diff --git a/utilities/contributors/.dockerignore b/utilities/contributors/.dockerignore new file mode 100644 index 0000000..01090a1 --- /dev/null +++ b/utilities/contributors/.dockerignore @@ -0,0 +1,57 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Test files +test.json +*-test.json +*.log +tests/ + +# Documentation +README.md + +# Docker +Dockerfile +.dockerignore \ No newline at end of file diff --git a/utilities/contributors/.gitignore b/utilities/contributors/.gitignore new file mode 100644 index 0000000..5846601 --- /dev/null +++ b/utilities/contributors/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +*.json +.python-version +.venv/ +tests/data/* +!tests/data/.gitkeep \ No newline at end of file diff --git a/utilities/contributors/Dockerfile b/utilities/contributors/Dockerfile new file mode 100644 index 0000000..01801af --- /dev/null +++ b/utilities/contributors/Dockerfile @@ -0,0 +1,17 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +COPY pyproject.toml uv.lock /app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project --no-dev + +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install . --no-deps + +ENV PATH="/app/.venv/bin:$PATH" + +ENTRYPOINT ["semgrep-contributors"] diff --git a/utilities/contributors/README.md b/utilities/contributors/README.md index 690f764..7e2a2b6 100644 --- a/utilities/contributors/README.md +++ b/utilities/contributors/README.md @@ -1,351 +1,330 @@ -# github_recent_contributors.py -This script is meant to help estimate the number of contributors that are active within a Github organization over a period of time. +# Semgrep Contributors Tool -The script does not use exactly the same logic as Semgrep in determining active contributors but should be helpful in determining a rough estimate. +A unified command-line tool for counting and analyzing contributors across multiple Git platforms including GitHub, GitLab, Bitbucket, and Azure DevOps. -## Usage -You'll need to first export a [Github PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) into your environment for the script to use. - -Export your PAT as the variable `GITHUB_PERSONAL_ACCESS_TOKEN`. +## Overview -Example: -``` -export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_BunchOfSecretStuffGoesHere -``` +This tool helps estimate the number of active contributors within organizations across different Git platforms over a specified time period. It provides a consistent interface for all supported platforms and generates detailed reports with contributor statistics. -The Token will need the following scopes: -- repo -- read:org -- read:user -- user:email - -The script takes the following arguements: -- The name of the github organization -- The number of days to look over (we recommend 90 as a safe default) -- An output filename to store the details from the execution - -After you have the PAT in your environment, run this script like this: -``` -python3 github_recent_contributors.py r2c-cse 90 output.json -``` - -## Output -Example console output: -``` -Total commit authors in the last 90 days: 33 -Total members in r2c-cse: 16 -Total unique contributors from r2c-cse in the last 90 days: 5 -``` - -Example output file: -``` -{ - "organization": "r2c-cse", - "date": "2023-09-26", - "number_of_days_history": 90, - "org_members": [ - ... - ], - "commit_authors": [ - ... - ], - "commiting_members": [ - ... - ] -} -``` +## Features -# gitlab_contributor_count.py +- **Multi-platform support**: GitHub, GitLab, Bitbucket, and Azure DevOps +- **Unified CLI interface**: Single command with platform-specific subcommands +- **Flexible filtering**: Filter by specific repositories or groups +- **Detailed reporting**: JSON output with comprehensive contributor statistics +- **Rate limiting handling**: Built-in retry mechanisms for API rate limits +- **Environment variable support**: Secure API key management -## Description +## Installation -This Python script counts the number of unique contributors across all repositories in all groups of a GitLab Self-Managed instance. It uses the GitLab API to fetch repository and commit data for a specified time period. +### Prerequisites -## Features +- Python 3.12.8 or higher +- API access tokens for the platforms you want to analyze -- Retrieves all groups and their projects from a GitLab instance -- Fetches commits for each project within a specified time frame -- Counts unique contributors across all repositories -- Handles API rate limiting with an exponential backoff retry mechanism -- Provides detailed logging of the process -- Generates a CSV file with a summary of contributors per repository and overall unique contributors +### Setup -## Requirements +1. Clone this repository and navigate to the `utilities/contributors` directory +2. Install the package using uv: -- Python -- `requests` library +```bash +uv sync +``` -## Installation +### Docker -1. Clone this repository or download the script. -2. Install the required Python package: +You can also run the tool using Docker, which eliminates the need to install Python dependencies locally: -``` -pip install requests +```bash +# Build the Docker image +docker build -t semgrep-contributors . + +# Run with Docker (example for GitHub) +docker run --rm \ + -e GITHUB_API_KEY=your_token_here \ + -v $(pwd):/workspace \ + semgrep-contributors get-contributors github \ + --org-name your-org \ + --number-of-days 30 \ + --output-dir /workspace ``` -## Configuration +**Docker Options:** +- `--rm`: Automatically remove the container when it exits +- `-e`: Set environment variables for API keys +- `-v $(pwd):/workspace`: Mount current directory to share files between host and container +- `-w /workspace`: Set working directory (optional, for convenience) -Before running the script, you need to set up the following: +**Example for all platforms:** +```bash +# GitHub +docker run --rm -e GITHUB_API_KEY=your_token semgrep-contributors get-contributors github --org-name my-org -1. GitLab instance URL -2. Private token for API authentication -3. Number of days to look back for contributions +# GitLab +docker run --rm -e GITLAB_API_KEY=your_token semgrep-contributors get-contributors gitlab --hostname gitlab.company.com -Edit the following lines at the bottom of the script: +# Bitbucket +docker run --rm -e BITBUCKET_API_KEY=your_token -e BITBUCKET_WORKSPACE=my-workspace semgrep-contributors get-contributors bitbucket -```python -base_url = "https://your-gitlab-instance.com" # Replace with your GitLab instance URL -private_token = "your_private_token_here" # Replace with your actual private token -days = 3000 # Number of days to look back +# Azure DevOps +docker run --rm -e AZURE_DEVOPS_API_KEY=your_token -e AZURE_DEVOPS_ORGANIZATION=my-org semgrep-contributors get-contributors azure-devops ``` ## Usage -Run the script using Python: +The tool provides a unified CLI interface with platform-specific subcommands: -``` -python gitlab_contributor_count.py +```bash +semgrep-contributors [OPTIONS] COMMAND [ARGS]... ``` -## Output +### Common Options -The script generates two types of output: -- A log file named gitlab_contributor_count.log with detailed information about the process. -- A CSV file named contributor_summary.csv containing: - - A list of repositories with their contributor counts and names - - The total number of unique contributors across all repositories - +- `--debug`: Enable debug logging +- `--help`, `-h`: Show help information -## Logging -The script provides detailed logging at different levels: -- DEBUG: Detailed information about groups, projects, and commits retrieved -- INFO: Summary information about the process and results -- WARNING: Any issues encountered during execution (e.g., rate limiting) -- ERROR: Any errors that occur during the process +### Platform Commands -Logs are written to both the console and the gitlab_contributor_count.log file. +#### GitHub -## Troubleshooting -If you encounter any issues: -- Check the gitlab_contributor_count.log file for error messages. -- Ensure your GitLab instance URL and private token are correct. -- Verify that your GitLab account has the necessary permissions to access the groups and projects. -- If you're hitting rate limits frequently, try increasing the backoff time in the make_request method. +```bash +semgrep-contributors get-contributors github [OPTIONS] +``` +**Options:** +- `--api-key TEXT`: GitHub API key (or set `GITHUB_API_KEY` environment variable) +- `--org-name TEXT`: Name of the GitHub organization (required) +- `--number-of-days INTEGER`: Number of days to analyze (default: 30) +- `--output-dir TEXT`: Output directory for JSON files (optional). Files are automatically named as `{platform}-contributors-{date}.json` +- `--repo-file PATH`: File containing repository names to filter (optional) +- `--repositories TEXT`: Comma-separated list of repositories to analyze (optional) -# Bitbucket Contributor Counter +**Example:** +```bash +export GITHUB_API_KEY=ghp_your_token_here +semgrep-contributors get-contributors github --org-name r2c-cse --number-of-days 90 --output-dir . +``` -This script counts unique contributors across all repositories in a Bitbucket workspace for a specified time period. +#### GitLab -## Features - -- Counts unique contributors across all repositories in a workspace -- Configurable time period for analysis -- Handles rate limiting with exponential backoff -- Outputs results to both console and JSON file -- Uses environment variables for secure token management -- Automatic pagination handling for both repositories and commits -- Detailed logging of API requests and responses +```bash +semgrep-contributors get-contributors gitlab [OPTIONS] +``` -## Prerequisites +**Options:** +- `--api-key TEXT`: GitLab API key (or set `GITLAB_API_KEY` environment variable) +- `--number-of-days INTEGER`: Number of days to analyze (default: 30) +- `--output-dir TEXT`: Output JSON file path (optional) +- `--repo-file PATH`: File containing repository names to filter (optional) +- `--hostname TEXT`: GitLab instance hostname (default: gitlab.com) +- `--group TEXT`: GitLab group to analyze (optional) +- `--repositories TEXT`: Comma-separated list of repositories to analyze (optional) -- Python 3.x -- `requests` library -- Bitbucket access token with appropriate permissions +**Example:** +```bash +export GITLAB_API_KEY=glpat_your_token_here +semgrep-contributors get-contributors gitlab --hostname gitlab.company.com --group my-group --number-of-days 60 +``` -## Setup +#### Bitbucket -1. Install the required Python package: ```bash -pip install requests +semgrep-contributors get-contributors bitbucket [OPTIONS] ``` -2. Set up your Bitbucket access token as an environment variable: +**Options:** +- `--api-key TEXT`: Bitbucket API key (or set `BITBUCKET_API_KEY` environment variable) +- `--workspace TEXT`: Bitbucket workspace (or set `BITBUCKET_WORKSPACE` environment variable) +- `--number-of-days INTEGER`: Number of days to analyze (default: 30) +- `--output-dir TEXT`: Output directory for JSON files (optional). Files are automatically named as `{platform}-contributors-{date}.json` +- `--repo-file PATH`: File containing repository names to filter (optional) +- `--repositories TEXT`: Comma-separated list of repositories to analyze (optional) + +**Example:** ```bash -export BITBUCKET_ACCESS_TOKEN="your_token_here" +export BITBUCKET_API_KEY=your_token_here +export BITBUCKET_WORKSPACE=my-workspace +semgrep-contributors get-contributors bitbucket --number-of-days 45 --output-dir . ``` -## Configuration +#### Azure DevOps -The script has the following configurable constants at the top of the file: - -- `WORKSPACE`: Your Bitbucket workspace name -- `COMMITS_FROM_DAYS`: Number of days to look back for commits (default: 1000) +```bash +semgrep-contributors get-contributors azure-devops [OPTIONS] +``` -## Usage +**Options:** +- `--api-key TEXT`: Azure DevOps API key (or set `AZURE_DEVOPS_API_KEY` environment variable) +- `--organization TEXT`: Azure DevOps organization (or set `AZURE_DEVOPS_ORGANIZATION` environment variable) +- `--number-of-days INTEGER`: Number of days to analyze (default: 30) +- `--output-dir TEXT`: Output directory for JSON files (optional). Files are automatically named as `{platform}-contributors-{date}.json` +- `--repo-file PATH`: File containing repository names to filter (optional) +- `--repositories TEXT`: Comma-separated list of repositories to analyze (optional) -Run the script: +**Example:** ```bash -python bitbucket_contributor_count_retry.py +export AZURE_DEVOPS_API_KEY=your_pat_token_here +export AZURE_DEVOPS_ORGANIZATION=my-org +semgrep-contributors get-contributors azure-devops --number-of-days 30 --output-dir . ``` -The script will: -1. Fetch all repositories in the specified workspace (with pagination) -2. For each repository, fetch commits within the specified time period (with pagination) -3. Extract unique contributors from the commits -4. Output the results to the console -5. Save the results to a JSON file with timestamp - -## Pagination - -The script automatically handles pagination for: -- Repository listing: Fetches all repositories across multiple pages -- Commit history: Fetches all commits within the specified time period across multiple pages - -Debug logging will show: -- Number of repositories found per page -- Total number of repositories and pages -- Number of commits found per page for each repository -- Total number of commits and pages per repository - -## Output - -The script generates two types of output: - -1. Console output with logging information about: - - Repository processing progress - - Number of commits and pages per repository - - Number of unique contributors per repository - - Total unique contributors across all repositories - - API request/response details (in DEBUG mode) - -2. JSON file (`contributors_YYYYMMDD_HHMMSS.json`) containing: - - Workspace name - - Analysis period in days - - Total number of contributors - - Sorted list of all contributors - -## Logging - -The script uses Python's logging module with the following levels: -- DEBUG (default): Detailed API request/response information, pagination details -- INFO: Repository progress, counts, and results -- WARNING: Rate limit notifications - -To change the logging level, modify the `level` parameter in `logging.basicConfig()`: -```python -logging.basicConfig( - level=logging.INFO, # Change to INFO to see less detail - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -``` +## API Key Requirements -## Error Handling +### GitHub +- **Token Type**: Personal Access Token (PAT) +- **Required Scopes**: `repo`, `read:org`, `read:user`, `user:email` -The script includes error handling for: -- Missing access token -- API rate limiting (with automatic retry) -- Failed API requests -- Pagination issues +### GitLab +- **Token Type**: Personal Access Token +- **Required Scopes**: `read_api`, `read_user`, `read_repository` -## Security +### Bitbucket +- **Token Type**: App Password or Personal Access Token +- **Required Scopes**: `Repositories: Read`, `Pull requests: Read` -- Access token is stored in environment variables, not in the code -- API requests use secure HTTPS -- Token is passed securely in request headers +### Azure DevOps +- **Token Type**: Personal Access Token (PAT) +- **Required Scopes**: `Code (Read)`, `Graph (Read)`, `Git (Read)`, `Project and Team (Read)` -## Contributing -Contributions to improve the script are welcome. Please feel free to submit a Pull Request. +## Output Format +The tool generates JSON reports with the following structure: -# Azure DevOps Contributors Counter +### Base Report Structure +```json +{ + "date": "2024-01-15", + "number_of_days_history": 30, + "repository_stats": [ + { + "name": "repo-name", + "contributor_count": 5, + "contributors": ["user1@example.com", "user2@example.com"] + } + ], + "total_contributor_count": 15, + "total_repository_count": 10 +} +``` -A Python script to fetch and analyze unique contributors across all repositories in an Azure DevOps organization and project. +### Platform-Specific Extensions -## Description +**GitHub:** +```json +{ + "organization": "org-name", + "org_members": ["member1@example.com", "member2@example.com"], + "org_contributors": ["contributor1@example.com"], + "org_contributors_count": 1 +} +``` -This script connects to Azure DevOps and generates a report of all unique contributors who have made commits across repositories within a specified time period. It provides both a summary view and detailed breakdown by repository. +**GitLab:** +```json +{ + "all_contributors": ["contributor1@example.com", "contributor2@example.com"] +} +``` -## Features +**Bitbucket:** +```json +{ + "workspace": "workspace-name", + "all_contributors": ["contributor1@example.com", "contributor2@example.com"] +} +``` -- Fetches contributors from all repositories in a project -- Supports custom time period analysis -- Generates detailed JSON output -- Provides both summary and detailed contributor information -- Handles pagination for large repositories -- Includes error handling and connection testing +**Azure DevOps:** +```json +{ + "organization": "org-name", + "all_contributor_emails": ["contributor1@example.com", "contributor2@example.com"] +} +``` -## Prerequisites +## Repository Filtering -- Python 3.x -- `requests` library -- Azure DevOps Personal Access Token (PAT) +You can filter repositories using either: -### Required Azure DevOps Permissions +1. **Repository file**: Create a text file with one repository name per line +2. **Command line**: Provide a comma-separated list of repository names -Your PAT token needs the following permissions: -- Code (Read) -- Graph (Read) -- Git (Read) -- Project and Team (Read) +Example repository file (`repos.txt`): +``` +my-repo-1 +my-repo-2 +my-repo-3 +``` -## Installation +Usage: +```bash +semgrep-contributors get-contributors github --org-name my-org --repo-file repos.txt +# or +semgrep-contributors get-contributors github --org-name my-org --repositories "repo1,repo2,repo3" +``` -1. Clone this repository or download the script -2. Install the required dependencies: - ```bash - pip install requests - ``` +## Development -## Usage +### Project Structure +``` +src/contributors/ +├── cli.py # Main CLI interface +├── main.py # Entry point +├── commands/ # Command implementations +│ └── get_contributors.py +├── models/ # Pydantic data models +│ ├── reports.py +│ ├── github_models.py +│ ├── gitlab_models.py +│ ├── bitbucket_models.py +│ └── azure_devops_models.py +├── clients/ # API clients +└── reporters/ # Platform-specific reporters +``` -1. Set your Azure DevOps Personal Access Token as an environment variable: - ```bash - export AZURE_PERSONAL_ACCESS_TOKEN='your_pat_token_here' - ``` +### Running Tests +```bash +# Install test dependencies +uv sync --group dev -2. Run the script with the required parameters: - ```bash - python ado_contributor_count.py - ``` +# Run tests +pytest +``` -### Parameters +### Building +```bash +# Build the package +uv build -- `organization_name`: Your Azure DevOps organization name -- `project_name`: The project name within the organization -- `number_of_days`: Number of days to look back for contributors -- `output_filename`: JSON file to store the output +# Install in development mode +uv pip install -e . +``` -### Example +## Troubleshooting -```bash -python ado_contributor_count.py my-org my-project 30 contributors.json -``` +### Common Issues -## Output +1. **API Rate Limiting**: The tool includes automatic retry mechanisms, but you may need to wait if you hit rate limits frequently. -The script generates a JSON file with the following structure: +2. **Authentication Errors**: Ensure your API tokens have the correct permissions and are not expired. -```json -{ - "organization": "organization_name", - "project": "project_name", - "date": "YYYY-MM-DD", - "number_of_days_history": number_of_days, - "total_contributors": total_count, - "total_repositories": repo_count, - "repositories": [ - { - "name": "repo_name", - "contributors": contributor_count, - "contributor_emails": ["email1@example.com", "email2@example.com"] - } - ], - "all_contributor_emails": ["email1@example.com", "email2@example.com"] -} -``` +3. **Repository Access**: Verify that your API token has access to the repositories you're trying to analyze. -## Troubleshooting +4. **Debug Mode**: Use the `--debug` flag to get detailed logging information: + ```bash + semgrep-contributors --debug github --org-name my-org + ``` -If you encounter issues: +### Logging -1. Verify your PAT token is correct and not expired -2. Confirm the organization and project names are correct -3. Ensure your PAT token has all required permissions -4. Check if you can access the project in your browser -5. Review the error messages and debug output provided by the script +The tool provides different logging levels: +- **INFO** (default): Basic progress and summary information +- **DEBUG**: Detailed API request/response information and pagination details ## Contributing -Contributions to improve the script are welcome. Please feel free to submit a Pull Request. + +Contributions are welcome! Please feel free to submit issues and pull requests to improve the tool. + +## License + +This project is part of the Semgrep utilities and follows the same licensing terms. diff --git a/utilities/contributors/ado_contributor_count.py b/utilities/contributors/ado_contributor_count.py deleted file mode 100644 index d50d303..0000000 --- a/utilities/contributors/ado_contributor_count.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Azure DevOps Contributors Script ---------------------------------- -This script fetches and prints the unique contributors across all repositories -in a specified Azure DevOps organization and project. - -Requirements: -- Python 3.x -- requests library - -Azure DevOps Permissions Needed: or just use Full scope PAT -- Read access to Code -- Read access to Graph -- Read access to Git - -Before Running: -- Export your Azure Personal Access Token to the environment variable 'AZURE_PERSONAL_ACCESS_TOKEN' -- Pass in your Azure DevOps Organization Name and Project Name -""" -import requests -import os -from datetime import datetime, timedelta -import argparse -import json -import base64 - -class AzureDevOpsAPI: - def __init__(self, org_name, project_name, token): - self.org_name = org_name - self.project_name = project_name - - # Debug print - print(f"\nInitializing connection to Azure DevOps...") - print(f"Organization: {org_name}") - print(f"Project: {project_name}") - - # Ensure token is properly formatted and encoded - token = token.strip() # Remove any whitespace - if not token: - raise ValueError("Token is empty") - - # Debug token length (don't print the actual token) - print(f"Token length: {len(token)} characters") - - token_bytes = f":{token}".encode('ascii') - base64_token = base64.b64encode(token_bytes).decode('ascii') - - self.headers = { - 'Authorization': f'Basic {base64_token}', - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - self.base_url = f"https://dev.azure.com/{org_name}" - - # Test connection with a simple request first - self.test_connection() - - def test_connection(self): - """Test basic connectivity to Azure DevOps.""" - print("\nTesting Azure DevOps connection...") - - # Try to access the organization first - org_url = f"https://dev.azure.com/{self.org_name}/_apis/projects?api-version=7.1" - print(f"Testing organization access: {org_url}") - - try: - response = requests.get(org_url, headers=self.headers) - print(f"Organization access status code: {response.status_code}") - print(f"Response headers: {response.headers}") - print(f"Response content: {response.text[:200]}...") # Print first 200 chars - - if response.status_code != 200: - print("\nAuthentication Error Details:") - print(f"Full response: {response.text}") - print("\nPlease verify:") - print("1. Your PAT token is correct and not expired") - print("2. The organization name is correct") - print("3. Your PAT token has these permissions:") - print(" - Code (Read)") - print(" - Project and Team (Read)") - print(" - Graph (Read)") - print(f"\nTry accessing {self.base_url} in your browser to verify the organization name") - raise ValueError(f"Organization access failed with status code: {response.status_code}") - - except requests.exceptions.RequestException as e: - print(f"Connection error: {str(e)}") - raise - - def get_repositories(self): - """Fetch all repositories in the project.""" - repositories = [] - page = 1 - while True: - url = f"{self.base_url}/{self.project_name}/_apis/git/repositories" - params = { - 'api-version': '7.1', - '$top': 100, # Max items per page - '$skip': (page - 1) * 100 - } - - print(f"\nFetching repositories page {page}...") - - try: - response = requests.get(url, headers=self.headers, params=params) - print(f"Repository request status code: {response.status_code}") - - if response.status_code != 200: - print(f"Error response content: {response.text}") - raise ValueError(f"Error fetching repositories. Status code: {response.status_code}") - - data = response.json() - page_repos = data['value'] - repositories.extend(page_repos) - - # Check if we've got all repositories - if len(page_repos) < 100: - break - - page += 1 - - except requests.exceptions.RequestException as e: - print(f"Repository request error: {str(e)}") - raise - - print(f"Successfully found {len(repositories)} repositories") - return repositories - - def get_repository_commits(self, repo_id, since_date): - """Fetch commits for a specific repository since the given date.""" - all_commits = [] - page = 1 - - while True: - url = f"{self.base_url}/{self.project_name}/_apis/git/repositories/{repo_id}/commits" - params = { - 'api-version': '7.1', - 'searchCriteria.fromDate': since_date, - '$top': 100, # Max items per page - '$skip': (page - 1) * 100 - } - - response = requests.get(url, headers=self.headers, params=params) - - if response.status_code != 200: - print(f"Error fetching commits for repo {repo_id}. Status code: {response.status_code}") - return [] - - data = response.json() - page_commits = data['value'] - all_commits.extend(page_commits) - - # Check if we've got all commits - if len(page_commits) < 100: - break - - page += 1 - - # Add progress indicator for repositories with many commits - if page % 5 == 0: - print(f" Retrieved {len(all_commits)} commits so far...") - - return all_commits - - def get_commit_authors(self, repo_id, since_date): - """Get unique commit authors for a repository.""" - commits = self.get_repository_commits(repo_id, since_date) - authors = set() - for commit in commits: - if 'author' in commit and 'email' in commit['author']: - authors.add(commit['author']['email'].lower()) - print(f" Found {len(commits)} total commits from {len(authors)} unique authors") - return authors - -def report_contributors(org_name, project_name, number_of_days, output_file): - # Initialize Azure DevOps client - token = os.environ.get("AZURE_PERSONAL_ACCESS_TOKEN") - if not token: - raise ValueError("Please set your AZURE_PERSONAL_ACCESS_TOKEN as an environment variable.") - - print(f"Connecting to Azure DevOps organization: {org_name}") - print(f"Project: {project_name}") - - try: - azure_client = AzureDevOpsAPI(org_name, project_name, token) - - # Calculate date range using timezone-aware datetime - from datetime import timezone - since_date = (datetime.now(timezone.utc) - timedelta(days=number_of_days)).isoformat() - - # Get all repositories - repositories = azure_client.get_repositories() - print(f"\nFound {len(repositories)} repositories") - - # Track unique contributors across all repos - all_contributors = set() - repo_stats = [] - - # Process each repository - for repo in repositories: - print(f"\nProcessing repository: {repo['name']}") - authors = azure_client.get_commit_authors(repo['id'], since_date) - all_contributors.update(authors) - repo_stats.append({ - "name": repo['name'], - "contributors": len(authors), - "contributor_emails": list(authors) - }) - print(f"Found {len(authors)} contributors in this repository") - - # Prepare output data - output_data = { - "organization": org_name, - "project": project_name, - "date": datetime.today().date().strftime('%Y-%m-%d'), - "number_of_days_history": number_of_days, - "total_contributors": len(all_contributors), - "total_repositories": len(repositories), - "repositories": repo_stats, - "all_contributor_emails": list(sorted(all_contributors)) - } - - # Write to output file if specified - if output_file: - with open(output_file, 'w') as file: - json.dump(output_data, file, indent=2) - - # Print summary - print(f"\nContributor Report for {org_name}/{project_name}") - print(f"Period: Last {number_of_days} days") - print(f"Total repositories: {len(repositories)}") - print(f"Total unique contributors: {len(all_contributors)}") - print("\nRepository breakdown:") - for repo in repo_stats: - print(f"- {repo['name']}: {repo['contributors']} contributors") - print("\nAll contributor emails:") - for email in sorted(all_contributors): - print(f"- {email}") - - except Exception as e: - print(f"\nError: {str(e)}") - print("\nTroubleshooting steps:") - print("1. Verify your PAT token is correct and not expired") - print("2. Verify organization name and project name are correct") - print("3. Ensure your PAT token has these permissions:") - print(" - Code (Read)") - print(" - Git (Read)") - print("4. Check if you can access the project in browser") - raise - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description="Get unique contributors from Azure DevOps repositories.") - parser.add_argument("org_name", help="The name of the Azure DevOps organization.") - parser.add_argument("project_name", help="The name of the Azure DevOps project.") - parser.add_argument("number_of_days", type=int, help="Number of days to look over.") - parser.add_argument("output_filename", help="A file to log output.") - - args = parser.parse_args() - report_contributors(args.org_name, args.project_name, args.number_of_days, args.output_filename) diff --git a/utilities/contributors/bitbucket_contributor_count.py b/utilities/contributors/bitbucket_contributor_count.py deleted file mode 100644 index affa4db..0000000 --- a/utilities/contributors/bitbucket_contributor_count.py +++ /dev/null @@ -1,143 +0,0 @@ -import time -import requests -import json -import logging -import os -from datetime import datetime, timedelta - -# Configure logging -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) - -# Constants -BASE_URL = "https://api.bitbucket.org/2.0" -WORKSPACE = "r2c-examples" # Replace with your workspace name -COMMITS_FROM_DAYS = 1000 - -# Get access token from environment variable -ACCESS_TOKEN = os.getenv('BITBUCKET_ACCESS_TOKEN') -if not ACCESS_TOKEN: - logging.error("BITBUCKET_ACCESS_TOKEN environment variable is not set") - exit(1) - -def get_repositories(workspace, token): - """ Get all repositories in a workspace """ - url = f"{BASE_URL}/repositories/{workspace}" - headers = {'Authorization': f'Bearer {token}'} - params = { } - repositories = [] - page = 1 - - while url: - logging.debug(f"Fetching repositories page {page}") - response = make_request_with_retry(url, headers, params) - current_repos = [repo['full_name'] for repo in response.get('values', [])] - repositories.extend(current_repos) - logging.debug(f"Found {len(current_repos)} repositories on page {page}") - url = response.get('next', None) - page += 1 - - logging.info(f'Found total of {len(repositories)} repositories across {page-1} pages') - return repositories - -def get_commits(repository, since_date, token): - """ Get commits for a repository since a specific date """ - url = f"{BASE_URL}/repositories/{repository}/commits" - headers = {'Authorization': f'Bearer {token}'} - params = {'q': f'date > {since_date}'} - commits = [] - page_count = 0 - - while url: - page_count += 1 - response = make_request_with_retry(url, headers, params) - current_commits = response.get('values', []) - - # Check if any commit is older than since_date - for commit in current_commits: - commit_date = commit['date'] - if commit_date <= since_date: - # If we find a commit older than since_date, we can stop - return commits, page_count - commits.append(commit) - - url = response.get('next', None) - return commits, page_count - -def make_request_with_retry(url, headers, params, max_retries=5, initial_delay=2): - """ Make API request with retry on rate limit errors (status 429) """ - retries = 0 - delay = initial_delay - - while retries < max_retries: - response = requests.get(url, headers=headers) - - # Log status code and response content for debugging - logging.debug(f"Request URL: {url}") - logging.debug(f"Response Status Code: {response.status_code}") - - if response.status_code == 200: - return response.json() - elif response.status_code == 429: # Rate limit exceeded - # Check if 'Retry-After' header is present to determine delay - retry_after = int(response.headers.get('Retry-After', delay)) - logging.warning(f"Rate limit exceeded. Retrying after {retry_after} seconds...") - time.sleep(retry_after) - retries += 1 - delay = delay * 2 # Exponential backoff - else: - raise Exception(f"Failed to fetch data: {response.status_code} - {response.text}") - - raise Exception(f"Max retries exceeded for URL: {url}") - -def extract_contributors(commits): - """ Extract unique contributors from commits """ - contributors = set() - for commit in commits: - commit_date = commit['date'] - if commit_date > since_date: - author = commit['author']['user']['display_name'] if 'user' in commit['author'] else commit['author']['raw'] - contributors.add(author) - else: - logging.debug(f"Commit date {commit_date} is older than {since_date}. Skipping...") - return contributors - -# Main script -if __name__ == "__main__": - # Get repositories - repositories = get_repositories(WORKSPACE, ACCESS_TOKEN) - - # Calculate date COMMITS_FROM_DAYS days ago - since_date = (datetime.now() - timedelta(days=COMMITS_FROM_DAYS)).strftime('%Y-%m-%dT%H:%M:%S%z') - - # Fetch commits and extract contributors - all_contributors = set() - for repo in repositories: - logging.info(f'working on repo- {repo}') - commits, page_count = get_commits(repo, since_date, ACCESS_TOKEN) - logging.info(f'number of commits in this repo- {repo} is {len(commits)}') - logging.info(f'number of pages in the commits in this repo- {repo} is {page_count}') - contributors = extract_contributors(commits) - logging.info(f"Repository '{repo}' has {len(contributors)} unique contributors.") - all_contributors.update(contributors) - - # Print unique contributors - logging.info(f"Unique contributors in the last {COMMITS_FROM_DAYS} days:") - logging.info(f"Total contributors: {len(all_contributors)}") - for contributor in all_contributors: - logging.info(contributor) - - # Write contributors to JSON file - output_file = f"contributors_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - with open(output_file, 'w') as f: - json.dump({ - 'workspace': WORKSPACE, - 'period_days': COMMITS_FROM_DAYS, - 'total_contributors': len(all_contributors), - 'contributors': sorted(list(all_contributors)) - }, f, indent=2) - - logging.info(f"\nContributors list has been written to {output_file}") diff --git a/utilities/contributors/github_recent_contributors.py b/utilities/contributors/github_recent_contributors.py deleted file mode 100644 index 9810e91..0000000 --- a/utilities/contributors/github_recent_contributors.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -GitHub Recent Contributors Script ---------------------------------- -This script fetches and prints the unique contributors from the last 30 days -across all the repositories in a specified GitHub organization. - -Directions: -1. Save this script as `github_recent_contributors.py` in a directory of your choice. -2. Open a terminal and navigate to the directory where this script is saved. -3. Install the required Python library: `pip3 install requests` -4. Run the script with: `usage: python3 github_recent_contributors.py ORG_NAME NUMBER_OF_DAYS OUTPUT_FILENAME [--repos REPO1 REPO2 ...]` - -Requirements: -- Python 3.x -- A GitHub Personal Access Token - -GitHub Permissions Needed: -- repo (or public_repo for public repositories) -- read:org -- read:user -- user:email (optional, but recommended) - -Before Running: -- Export your Github Personal Access Token to the environment variable 'GITHUB_PERSONAL_ACCESS_TOKEN' -- Pass in your Github Org Name and the Number of Days over which you'd like to list contributors -- Ensure your token has the necessary scopes and permissions. - -Note: -- Keep your tokens secure and never expose them in client-side code or public repositories. -""" -import requests -import os -from datetime import datetime, timedelta, timezone, UTC -import argparse -import json - - -def get_repos(org_name, headers): - """Fetch all repositories for the given organization.""" - repos = [] - page = 1 # Start from page 1 - - while True: - response = requests.get( - f'https://api.github.com/orgs/{org_name}/repos', - headers=headers, - params={'per_page': 100, 'page': page} # Fetch 100 repos per page - ) - - if response.status_code != 200: - raise ValueError(f"Error fetching repositories for organization {org_name}. Status code: {response.status_code}") - - data = response.json() - - if not data: # If no more repositories, break the loop - break - repos.extend(data) # Add fetched repos to the list - page += 1 # Move to the next page - - return repos - - -def get_organization_members(org_name, headers): - """Fetch all members of the organization.""" - members = [] - page = 1 - while True: - response = requests.get( - f'https://api.github.com/orgs/{org_name}/members?page={page}', - headers=headers - ) - if response.status_code != 200: - break - members_page = response.json() - if not members_page: - break - members.extend(members_page) - page += 1 - return {member['login'] for member in members} - -def get_contributors(org_name, number_of_days, headers): - # init contributor set - unique_contributors = set() - unique_authors = set() - - if not repo_list: - print("No repo names provided. Will count contributors to all repos in the org [" + org_name + "]") - - # Fetch all repositories in the organization - repos = get_repos(org_name, headers) - print(f"Number of repos = {len(repos)}") - - # Date range calculation - since_date = (datetime.now(timezone.utc) - timedelta(days=number_of_days)).isoformat() - until_date = datetime.now(UTC).isoformat() - - # Loop through each repository in the organization - for repo in repos: - owner = repo['owner']['login'] - repo_name = repo['name'] - - if repo_list: - if repo_name in repo_list: - print(f"Processing repo: {repo_name}") - else: - #print(f"skipping: {repo_name}") - continue - - # Fetch commits for each repository in the given date range - response = requests.get( - f'https://api.github.com/repos/{owner}/{repo_name}/commits', - params={'since': since_date, 'until': until_date}, - headers=headers - ) - - commits = response.json() - - if isinstance(commits, list): - for commit in commits: - unique_contributors.add(commit['commit']['author']['name']) - if commit['author']: - unique_authors.add(commit['author']['login']) - else: - print(f"Repo: {repo_name} is empty.") - - return unique_contributors, unique_authors - -def report_contributors(org_name, number_of_days, output_file): - # init github auth - token = os.environ.get("GITHUB_PERSONAL_ACCESS_TOKEN") - if not token: - raise ValueError("Please set your GITHUB_PERSONAL_ACCESS_TOKEN as an environment variable.") - headers = {'Authorization': f'token {token}'} - - org_members = get_organization_members(org_name, headers) - unique_contributors, unique_authors = get_contributors(org_name, number_of_days, headers) - - if output_file: - output_data = { - "organization": org_name, - "date": datetime.today().date().strftime('%Y-%m-%d'), - "number_of_days_history": number_of_days, - "org_members": list(org_members), - "commit_authors": list(unique_authors), - "commiting_members": list(unique_authors & org_members) - } - - with open(output_file, 'w') as file: - json.dump(output_data, file) - - # Print unique contributors and their total count - print(f"Total commit authors in the last {number_of_days} days:", len(unique_authors)) - print(f"Total members in {org_name}:", len(org_members)) - print(f"Total unique contributors from {org_name} in the last {number_of_days} days:", len(unique_authors & org_members)) - -if __name__ == '__main__': - parser = argparse.ArgumentParser( - description="Get unique contributors from a GitHub organization.", - usage="%(prog)s ORG_NAME NUMBER_OF_DAYS OUTPUT_FILENAME [--repos REPO1 REPO2 ...]" - ) - parser.add_argument("org_name", help="The name of the GitHub organization.") - parser.add_argument("number_of_days", type=int, help="Number of days to look over.") - parser.add_argument("output_filename", help="A file to log output.") - parser.add_argument("--repos", nargs="+", help="List of repo names (optional). If omitted, all repos will be considered.") - args = parser.parse_args() - - repo_list = args.repos if args.repos else [] - if repo_list: - print(f"repo_list: {repo_list}") - report_contributors(args.org_name, args.number_of_days, args.output_filename) diff --git a/utilities/contributors/gitlab_contributor_count.py b/utilities/contributors/gitlab_contributor_count.py deleted file mode 100644 index 74c7092..0000000 --- a/utilities/contributors/gitlab_contributor_count.py +++ /dev/null @@ -1,123 +0,0 @@ -import requests -import time -import csv -from datetime import datetime, timedelta -from collections import defaultdict -import logging - -class GitLabContributorCounter: - def __init__(self, base_url, private_token, days): - self.base_url = base_url - self.headers = {"PRIVATE-TOKEN": private_token} - self.days = days - self.since_date = (datetime.now() - timedelta(days=days)).isoformat() - - # Set up logging - logging.basicConfig(level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler("gitlab_contributor_count.log"), - logging.StreamHandler() - ]) - self.logger = logging.getLogger(__name__) - - def get_all_groups(self): - groups = [] - page = 1 - while True: - response = self.make_request(f"{self.base_url}/api/v4/groups?page={page}&per_page=100") - if not response: - break - groups.extend(response) - page += 1 - self.logger.debug(f"Retrieved {len(groups)} groups") - return groups - - def get_all_projects(self, group): - projects = [] - page = 1 - while True: - response = self.make_request(f"{self.base_url}/api/v4/groups/{group['id']}/projects?page={page}&per_page=100") - if not response: - break - projects.extend(response) - page += 1 - self.logger.info(f"Retrieved {len(projects)} projects for group '{group['name']}' (ID: {group['id']})") - return projects - - def get_commits(self, project): - commits = [] - page = 1 - while True: - response = self.make_request(f"{self.base_url}/api/v4/projects/{project['id']}/repository/commits?since={self.since_date}&page={page}&per_page=100") - if not response: - break - commits.extend(response) - page += 1 - self.logger.info(f"Retrieved {len(commits)} commits for project '{project['name']}' (ID: {project['id']})") - return commits - - def make_request(self, url, retries=5): - for attempt in range(retries): - try: - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - if response.status_code == 429: # Rate limit exceeded - wait_time = 2 ** attempt # Exponential backoff - self.logger.warning(f"Rate limit exceeded. Waiting for {wait_time} seconds.") - time.sleep(wait_time) - else: - self.logger.error(f"Error making request: {e}") - return None - self.logger.error(f"Max retries reached for URL: {url}") - return None - - def count_contributors(self): - all_contributors = set() - repo_contributors = defaultdict(set) - - groups = self.get_all_groups() - for group in groups: - projects = self.get_all_projects(group) - for project in projects: - commits = self.get_commits(project) - for commit in commits: - contributor = commit['author_name'] - all_contributors.add(contributor) - repo_contributors[project['path_with_namespace']].add(contributor) - - self.logger.info(f"Repository: {project['path_with_namespace']} - Contributors: {len(repo_contributors[project['path_with_namespace']])}") - self.logger.info(f"Contributors for {project['path_with_namespace']}: {', '.join(repo_contributors[project['path_with_namespace']])}") - - self.logger.info(f"All contributors: {', '.join(all_contributors)}") - return all_contributors, repo_contributors - - def write_to_csv(self, all_contributors, repo_contributors): - with open('contributor_summary.csv', 'w', newline='') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(['Repository', 'Number of Contributors', 'Contributors']) - - for repo, contributors in repo_contributors.items(): - writer.writerow([repo, len(contributors), ', '.join(contributors)]) - self.logger.info(f"CSV Entry - Repository: {repo} - Contributors: {len(contributors)}") - - writer.writerow([]) - writer.writerow(['Total Unique Contributors', len(all_contributors)]) - writer.writerow(['All Contributors', ', '.join(all_contributors)]) - - def run(self): - self.logger.info(f"Starting contributor count for the last {self.days} days") - all_contributors, repo_contributors = self.count_contributors() - self.write_to_csv(all_contributors, repo_contributors) - self.logger.info(f"Total unique contributors in the last {self.days} days: {len(all_contributors)}") - self.logger.info(f"Results written to contributor_summary.csv") - -if __name__ == "__main__": - base_url = "" # Replace with your GitLab instance URL - private_token = "" # Replace with your actual private token - days = 90 # Number of days to look back - - counter = GitLabContributorCounter(base_url, private_token, days) - counter.run() diff --git a/utilities/contributors/pyproject.toml b/utilities/contributors/pyproject.toml new file mode 100644 index 0000000..c8239d4 --- /dev/null +++ b/utilities/contributors/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "contributors" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12.8" +dependencies = [ + "black>=25.1.0", + "click>=8.2.1", + "pydantic>=2.11.4", + "requests>=2.32.3", +] + +[project.scripts] +semgrep-contributors = "contributors.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[build-system] +requires = ["uv", "setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/utilities/contributors/src/contributors/__init__.py b/utilities/contributors/src/contributors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/src/contributors/cli.py b/utilities/contributors/src/contributors/cli.py new file mode 100644 index 0000000..428227b --- /dev/null +++ b/utilities/contributors/src/contributors/cli.py @@ -0,0 +1,19 @@ +import logging +import click +from contributors.commands.get_contributors import get_contributors + + +@click.group(name="semgrep-contributors") +@click.help_option("--help", "-h") +@click.option("--debug", is_flag=True, help="Enable debug mode") +@click.pass_context +def cli(ctx: click.Context, debug: bool) -> None: + level = logging.DEBUG if debug else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[logging.StreamHandler()], + ) + + +cli.add_command(cmd=get_contributors) diff --git a/utilities/contributors/src/contributors/clients/__init__.py b/utilities/contributors/src/contributors/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/src/contributors/clients/api_client.py b/utilities/contributors/src/contributors/clients/api_client.py new file mode 100644 index 0000000..cc5e41e --- /dev/null +++ b/utilities/contributors/src/contributors/clients/api_client.py @@ -0,0 +1,338 @@ +import logging +import requests +import time +import re +from urllib.parse import quote +from contributors.models.github_models import GhCommit, GhRepository +from contributors.models.bitbucket_models import BbRepository, BbCommit +from contributors.models.azure_devops_models import AdoProject, AdoRepository, AdoCommit +from contributors.models.gitlab_models import GlGroup, GlProject, GlCommit +from typing import Callable, Generator, Optional + +logger = logging.getLogger(__name__) + + +class ApiClient: + def __init__(self, base_url: str, headers: dict | None = None): + self.__base_url = base_url + self.__session = requests.Session() + if headers: + self.__session.headers.update(headers) + + def make_request( + self, + method: str, + url: str, + params: dict | None = None, + retry_func: Callable[[dict], int | None] | None = None, + json: dict | None = None, + ): + for i in range(9): + try: + request_url = ( + url if url.startswith("http") else f"{self.__base_url}{url}" + ) + response = self.__session.request( + method, request_url, params=params, json=json + ) + response.raise_for_status() + return response.json(), response.headers + except requests.HTTPError as e: + if e.response.status_code == 404: + return None, e.response.headers + if not (e.response.status_code >= 500 or e.response.status_code == 429): + logger.error(f"Unrecoverable error making requests: {e}") + return None, e.response.headers + logger.warning(f"Error making request: {e}") + + retry_time = None + if retry_func: + retry_time = retry_func(e.response.headers) + sleep_time = retry_time if retry_time is not None else 2**i + logger.info(f"Sleeping for {sleep_time} seconds.") + time.sleep(sleep_time) + return None, None + + +class GitHubClient(ApiClient): + def __init__(self, github_token: str): + super().__init__( + "https://api.github.com", + headers={ + "Authorization": f"Bearer {github_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + def __get_next_url(self, link_header: str) -> str | None: + if not link_header: + return None + next_pattern = r'(?<=<)([\S]*)(?=>; rel="next")' + match = re.search(next_pattern, link_header) + return match.group(1) if match else None + + def get_member_logins(self, org_name: str) -> Generator[str, None, None]: + url = f"/orgs/{org_name}/members" + params = {"per_page": 100} + + while url: + members_page, headers = self.make_request("GET", url, params) + if not members_page: + break + + for member in members_page: + login = member.get("login", "") + if login: + yield login + + url = self.__get_next_url(headers.get("Link", "")) + + def get_repositories(self, org_name: str) -> Generator[GhRepository, None, None]: + url = f"/orgs/{org_name}/repos" + params = {"per_page": 100} + + while url: + repositories_page, headers = self.make_request("GET", url, params) + if not repositories_page: + break + + for repo in repositories_page: + try: + yield GhRepository.model_validate(repo) + except Exception as e: + logger.warning( + f"Error validating repository {repo.get('name')}: {e}" + ) + + url = self.__get_next_url(headers.get("Link", "")) + + def get_commits( + self, repository: GhRepository, since_date: str, until_date: str + ) -> Generator[GhCommit, None, None]: + url = f"/repos/{repository.owner.login}/{repository.name}/commits" + params = { + "since": since_date, + "until": until_date, + "per_page": 100, + } + + while url: + commits_page, headers = self.make_request("GET", url, params) + if not commits_page: + break + + for commit in commits_page: + try: + yield GhCommit.model_validate(commit) + except Exception as e: + logger.warning(f"Error validating commit {commit.get('sha')}: {e}") + + url = self.__get_next_url(headers.get("Link", "")) + + +class GitLabClient(ApiClient): + def __init__(self, gitlab_token: str, hostname: str): + super().__init__( + f"https://{hostname}/api/v4", + headers={"PRIVATE-TOKEN": f"{gitlab_token}"}, + ) + + def get_group(self, group_name: str) -> GlGroup | None: + response, _ = self.make_request("GET", f"/groups/{quote(group_name)}") + if not response: + return None + return GlGroup.model_validate(response) + + def get_groups(self) -> Generator[GlGroup, None, None]: + page = 1 + while True: + response, _ = self.make_request( + "GET", + "/groups", + params={"page": page, "per_page": 100}, + ) + if not response: + break + + for group in response: + try: + yield GlGroup.model_validate(group) + except Exception as e: + logger.warning(f"Error validating group {group.get('name')}: {e}") + + page += 1 + + def get_projects(self, group: GlGroup) -> Generator[GlProject, None, None]: + page = 1 + while True: + response, _ = self.make_request( + "GET", + f"/groups/{group.id}/projects", + params={"page": page, "per_page": 100}, + ) + if not response: + break + + for project in response: + try: + yield GlProject.model_validate(project) + except Exception as e: + logger.warning( + f"Error validating project {project.get('name')}: {e}" + ) + + page += 1 + + def get_commits( + self, project: GlProject, since_date: str + ) -> Generator[GlCommit, None, None]: + page = 1 + while True: + response, _ = self.make_request( + "GET", + f"/projects/{project.id}/repository/commits", + params={"since": since_date, "page": page, "per_page": 100}, + ) + if not response: + break + + for commit in response: + try: + yield GlCommit.model_validate(commit) + except Exception as e: + logger.warning(f"Error validating commit {commit.get('id')}: {e}") + + page += 1 + + +class BitbucketClient(ApiClient): + def __init__(self, bitbucket_token: str, bitbucket_workspace: str): + super().__init__( + "https://api.bitbucket.org/2.0", + headers={"Authorization": f"Bearer {bitbucket_token}"}, + ) + self.__workspace = bitbucket_workspace + + def get_repositories(self) -> Generator[BbRepository, None, None]: + url = f"/repositories/{self.__workspace}" + + while url: + response, _ = self.make_request( + "GET", url, retry_func=lambda headers: headers.get("Retry-After", None) + ) + if not response: + break + + for repo in response.get("values", []): + try: + yield BbRepository.model_validate(repo) + except Exception as e: + logger.warning( + f"Error validating repository {repo.get('name')}: {e}" + ) + + url = response.get("next", None) + + def get_commits( + self, repository: BbRepository, since_date: str + ) -> Generator[BbCommit, None, None]: + url = f"/repositories/{self.__workspace}/{repository.name}/commits" + + while url: + response, _ = self.make_request( + "GET", url, retry_func=lambda headers: headers.get("Retry-After", None) + ) + if not response: + break + + for commit in response.get("values", []): + try: + bbCommit = BbCommit.model_validate(commit) + if bbCommit.date < since_date: + return + yield bbCommit + except Exception as e: + logger.warning( + f"Error validating commit {commit.get('hash', 'unknown')}: {e}" + ) + + url = response.get("next", None) + + +class AzureDevOpsClient(ApiClient): + def __init__(self, azure_devops_token: str, organization: str): + super().__init__( + f"https://dev.azure.com/{organization}", + headers={"Authorization": f"Bearer {azure_devops_token}"}, + ) + + def get_projects(self) -> Generator[AdoProject, None, None]: + url = f"/_apis/projects" + params = {"api-version": "7.1", "$top": 100, "stateFilter": "wellFormed"} + + while url: + response, _ = self.make_request("GET", url, params) + if not response: + break + + for project in response.get("value", []): + try: + yield AdoProject.model_validate(project) + except Exception as e: + logger.warning( + f"Error validating project {project.get('name')}: {e}" + ) + + continuation_token = response.get("continuationToken", None) + if continuation_token: + params["continuationToken"] = continuation_token + else: + break + + def get_repositories( + self, project: AdoProject + ) -> Generator[AdoRepository, None, None]: + url = f"/{project.id}/_apis/git/repositories" + response, _ = self.make_request("GET", url) + + if not response: + return + + for repo in response.get("value", []): + try: + yield AdoRepository.model_validate(repo) + except Exception as e: + logger.warning(f"Error validating repository {repo.get('name')}: {e}") + + def get_commits( + self, + repository: AdoRepository, + project: AdoProject, + since_date: Optional[str] = None, + ) -> Generator[AdoCommit, None, None]: + url = f"/{project.id}/_apis/git/repositories/{repository.id}/commitsbatch" + params = {"api-version": "7.1", "$top": 100} + + request_body = {} + if since_date: + request_body["fromDate"] = since_date + + while True: + response, _ = self.make_request("POST", url, params, json=request_body) + + if not response: + break + + for commit in response.get("value", []): + try: + yield AdoCommit.model_validate(commit) + except Exception as e: + logger.warning( + f"Error validating commit {commit.get('commitId')}: {e}" + ) + + if len(response.get("value", [])) < 100: + break + + params["$skip"] = params.get("$skip", 0) + 100 diff --git a/utilities/contributors/src/contributors/commands/__init__.py b/utilities/contributors/src/contributors/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/src/contributors/commands/get_contributors.py b/utilities/contributors/src/contributors/commands/get_contributors.py new file mode 100644 index 0000000..390e1d4 --- /dev/null +++ b/utilities/contributors/src/contributors/commands/get_contributors.py @@ -0,0 +1,264 @@ +from datetime import datetime +import logging +import os +import click + +from contributors.reporters.contributor_reporters import ( + AzureDevOpsContributorReporter, + BitbucketContributorReporter, + GhContributorReporter, + GlContributorReporter, +) + +logger = logging.getLogger(__name__) + + +@click.group() +def get_contributors(): + pass + + +@get_contributors.command(name="github") +@click.option( + "--api-key", help="GitHub API key.", envvar="GITHUB_API_KEY", required=True +) +@click.option( + "--org-name", + help="Name of the organization to get contributors for.", + envvar="GITHUB_ORG_NAME", + required=True, +) +@click.option( + "--number-of-days", + help="Number of days to get contributors for.", + type=click.INT, + default=30, +) +@click.option( + "--output-dir", + help="Directory to write the contributors to.", + required=False, + type=click.Path(exists=True, dir_okay=True), +) +@click.option( + "--repo-file", + help="Path to a file containing repository names to filter by, one per line (optional).", + type=click.Path(exists=True, dir_okay=False), + required=False, +) +@click.option( + "--repositories", + help="A comma separated list of repositories to get contributors for.", + required=False, + type=click.STRING, +) +def github(api_key, org_name, number_of_days, output_dir, repo_file, repositories): + logger.info(f"Creating contributors report for GitHub organization {org_name}.") + github_reporter = GhContributorReporter(api_key) + report = github_reporter.get_report( + org_name, + number_of_days, + __get_repository_filter(repo_file, repositories), + ) + if output_dir: + __write_json(report.model_dump_json(indent=2), output_dir, "github") + logger.info( + f"Total unique contributors: {report.total_contributor_count} in the last {number_of_days} days." + ) + logger.info(f"Total unique organization members: {len(report.org_members)}.") + logger.info( + f"Total unique organization contributors: {report.org_contributors_count} in the last {number_of_days} days." + ) + + +@get_contributors.command(name="gitlab") +@click.option( + "--api-key", help="GitLab API key.", envvar="GITLAB_API_KEY", required=True +) +@click.option( + "--number-of-days", + help="Number of days to get contributors for.", + type=click.INT, + default=30, +) +@click.option( + "--output-dir", + help="Directory to write the contributors to.", + required=False, + type=click.Path(exists=True, dir_okay=True), +) +@click.option( + "--repo-file", + help="Path to a file containing repository names to filter by, one per line (optional).", + type=click.Path(exists=True, dir_okay=False), + required=False, +) +@click.option( + "--hostname", + help="Hostname of the GitLab instance.", + required=False, + default="gitlab.com", +) +@click.option( + "--group", + help="GitLab group to get contributors for.", + type=click.STRING, + envvar="GITLAB_GROUP", + required=False, +) +@click.option( + "--repositories", + help="A comma separated list of repositories to get contributors for.", + required=False, + type=click.STRING, +) +def gitlab( + api_key, + number_of_days, + output_dir, + repo_file, + hostname, + group, + repositories, +): + logger.info(f"Creating contributors report for GitLab.") + gitlab_reporter = GlContributorReporter(api_key, hostname) + report = gitlab_reporter.get_report( + number_of_days, + group, + __get_repository_filter(repo_file, repositories), + ) + if output_dir: + __write_json(report.model_dump_json(indent=2), output_dir, "gitlab") + logger.info( + f"Total unique contributors: {report.total_contributor_count} in the last {number_of_days} days." + ) + + +@get_contributors.command(name="bitbucket") +@click.option( + "--api-key", help="Bitbucket API key.", envvar="BITBUCKET_API_KEY", required=True +) +@click.option( + "--workspace", + help="Bitbucket workspace.", + envvar="BITBUCKET_WORKSPACE", + required=True, +) +@click.option( + "--number-of-days", + help="Number of days to get contributors for.", + type=click.INT, + default=30, +) +@click.option( + "--output-dir", + help="Directory to write the contributors to.", + required=False, + type=click.Path(exists=True, dir_okay=True), +) +@click.option( + "--repo-file", + help="Path to a file containing repository names to filter by, one per line (optional).", + type=click.Path(exists=True, dir_okay=False), + required=False, +) +@click.option( + "--repositories", + help="A comma separated list of repositories to get contributors for.", + required=False, + type=click.STRING, +) +def bitbucket(api_key, workspace, number_of_days, output_dir, repo_file, repositories): + logger.info(f"Creating contributors report for Bitbucket workspace {workspace}.") + bitbucket_reporter = BitbucketContributorReporter(api_key, workspace) + report = bitbucket_reporter.get_report( + number_of_days, __get_repository_filter(repo_file, repositories) + ) + if output_dir: + __write_json(report.model_dump_json(indent=2), output_dir, "bitbucket") + logger.info( + f"Total unique contributors: {report.total_contributor_count} in the last {number_of_days} days." + ) + + +@get_contributors.command(name="azure-devops") +@click.option( + "--api-key", + help="Azure DevOps API key.", + envvar="AZURE_DEVOPS_API_KEY", + required=True, +) +@click.option( + "--organization", + help="Azure DevOps organization.", + envvar="AZURE_DEVOPS_ORGANIZATION", + required=True, +) +@click.option( + "--number-of-days", + help="Number of days to get contributors for.", + type=click.INT, + default=30, +) +@click.option( + "--output-dir", + help="Directory to write the contributors to.", + required=False, + type=click.Path(exists=True, dir_okay=True), +) +@click.option( + "--repo-file", + help="Path to a file containing repository names to filter by, one per line (optional).", + type=click.Path(exists=True, dir_okay=False), + required=False, +) +@click.option( + "--repositories", + help="A comma separated list of repositories to get contributors for.", + required=False, + type=click.STRING, +) +def azure_devops( + api_key, organization, number_of_days, output_dir, repo_file, repositories +): + logger.info( + f"Creating contributors report for Azure DevOps organization {organization}." + ) + azure_devops_reporter = AzureDevOpsContributorReporter(api_key, organization) + report = azure_devops_reporter.get_report( + number_of_days, __get_repository_filter(repo_file, repositories) + ) + if output_dir: + __write_json(report.model_dump_json(indent=2), output_dir, "azure-devops") + + logger.info( + f"Total unique contributors: {report.total_contributor_count} in the last {number_of_days} days." + ) + + +def __get_repository_filter( + repo_file: str | None, repositories: list[str] | None +) -> list[str]: + repo_names: set[str] = set() + if repositories: + repo_names.update([repo.strip() for repo in repositories.split(",")]) + if repo_file: + try: + with open(repo_file, "r") as f: + repo_names.update([line.strip() for line in f.readlines()]) + except FileNotFoundError: + logger.error(f"Repository file {repo_file} not found.") + raise click.ClickException(f"Repository file {repo_file} not found.") + return list(repo_names) + + +def __write_json(report: str, output_dir: str, filename: str): + with open( + os.path.join( + output_dir, + f"{filename}-contributors-{datetime.now().strftime('%Y-%m-%d')}.json", + ), + "w", + ) as f: + f.write(report) diff --git a/utilities/contributors/src/contributors/main.py b/utilities/contributors/src/contributors/main.py new file mode 100644 index 0000000..267d445 --- /dev/null +++ b/utilities/contributors/src/contributors/main.py @@ -0,0 +1,9 @@ +from contributors.cli import cli + + +def main(): + cli(prog_name="semgrep-contributors") + + +if __name__ == "__main__": + main() diff --git a/utilities/contributors/src/contributors/models/__init__.py b/utilities/contributors/src/contributors/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/src/contributors/models/azure_devops_models.py b/utilities/contributors/src/contributors/models/azure_devops_models.py new file mode 100644 index 0000000..344ea8c --- /dev/null +++ b/utilities/contributors/src/contributors/models/azure_devops_models.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional + + +class AdoProject(BaseModel): + name: str + id: str + + +class AdoRepository(BaseModel): + name: str + id: str + + +class AdoCommitAuthor(BaseModel): + name: str + email: str + date: str + display_name: Optional[str] = None + + +class AdoCommit(BaseModel): + commitId: str + author: AdoCommitAuthor + committer: AdoCommitAuthor diff --git a/utilities/contributors/src/contributors/models/bitbucket_models.py b/utilities/contributors/src/contributors/models/bitbucket_models.py new file mode 100644 index 0000000..fa701de --- /dev/null +++ b/utilities/contributors/src/contributors/models/bitbucket_models.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Optional + +from contributors.models.reports import BaseReport + + +class BbUser(BaseModel): + display_name: str + + +class BbAuthor(BaseModel): + raw: str + user: Optional[BbUser] = None + + +class BbRepository(BaseModel): + name: str + full_name: str + + +class BbCommit(BaseModel): + date: str + author: BbAuthor diff --git a/utilities/contributors/src/contributors/models/github_models.py b/utilities/contributors/src/contributors/models/github_models.py new file mode 100644 index 0000000..5aa24e4 --- /dev/null +++ b/utilities/contributors/src/contributors/models/github_models.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel + + +class GhOwner(BaseModel): + login: str + + +class GhRepository(BaseModel): + name: str + owner: GhOwner + full_name: str + + +class GhCommitAuthor(BaseModel): + name: str + + +class GhCommit(BaseModel): + author: GhCommitAuthor + + +class GhAuthor(BaseModel): + login: str | None + + +class GhCommit(BaseModel): + commit: GhCommit + author: GhAuthor | None diff --git a/utilities/contributors/src/contributors/models/gitlab_models.py b/utilities/contributors/src/contributors/models/gitlab_models.py new file mode 100644 index 0000000..745d5de --- /dev/null +++ b/utilities/contributors/src/contributors/models/gitlab_models.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class GlGroup(BaseModel): + name: str + id: int + + +class GlProject(BaseModel): + path_with_namespace: str + id: int + name: str + + +class GlCommit(BaseModel): + author_name: str diff --git a/utilities/contributors/src/contributors/models/reports.py b/utilities/contributors/src/contributors/models/reports.py new file mode 100644 index 0000000..ef21fa3 --- /dev/null +++ b/utilities/contributors/src/contributors/models/reports.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel + + +class RepositoryStats(BaseModel): + name: str + contributor_count: int + contributors: list[str] + + +class BaseReport(BaseModel): + date: str + number_of_days_history: int + repository_stats: list[RepositoryStats] + total_contributor_count: int + total_repository_count: int + + +class AdoReport(BaseReport): + organization: str + all_contributor_emails: list[str] + + +class BbReport(BaseReport): + workspace: str + all_contributors: list[str] + + +class GlReport(BaseReport): + all_contributors: list[str] + + +class GhReport(BaseReport): + organization: str + org_members: list[str] + org_contributors: list[str] + org_contributors_count: int diff --git a/utilities/contributors/src/contributors/reporters/__init__.py b/utilities/contributors/src/contributors/reporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/src/contributors/reporters/contributor_reporters.py b/utilities/contributors/src/contributors/reporters/contributor_reporters.py new file mode 100644 index 0000000..282fa4a --- /dev/null +++ b/utilities/contributors/src/contributors/reporters/contributor_reporters.py @@ -0,0 +1,277 @@ +from collections import defaultdict +from datetime import UTC, datetime, timedelta, timezone +from contributors.clients.api_client import ( + AzureDevOpsClient, + BitbucketClient, + GitHubClient, + GitLabClient, +) +from contributors.models.reports import ( + AdoReport, + BbReport, + GhReport, + GlReport, + RepositoryStats, +) +from contributors.models.gitlab_models import GlGroup +import logging + +logger = logging.getLogger(__name__) + + +class GhContributorReporter: + def __init__(self, api_key: str): + self.__github_client = GitHubClient(api_key) + + def get_report( + self, + org_name: str, + number_of_days: int, + repo_filter: list[str] | None = None, + ): + members = set(self.__github_client.get_member_logins(org_name)) + until_date = datetime.now(UTC).isoformat() + since_date = (datetime.now(UTC) - timedelta(days=number_of_days)).isoformat() + + unique_contributors = set() + unique_authors = set() + + repo_stats: list[RepositoryStats] = [] + for repository in self.__github_client.get_repositories(org_name): + if is_repository_filtered(repo_filter, repository.name): + logger.debug(f"Skipping repository: {repository.name}") + continue + + logger.debug(f"Processing repository: {repository.name}") + for commit in self.__github_client.get_commits( + repository, since_date, until_date + ): + unique_contributors.add(commit.commit.author.name) + if commit.author: + unique_authors.add(commit.author.login) + + repo_stats.append( + RepositoryStats( + name=repository.name, + contributor_count=len(unique_authors & members), + contributors=list(unique_authors & members), + ) + ) + + logger.debug( + f"Repository: {repository.name} - Contributors: {len(unique_authors)}" + ) + logger.debug( + f"Contributors for {repository.name}: {', '.join(unique_authors)}" + ) + + return GhReport( + organization=org_name, + date=datetime.today().date().strftime("%Y-%m-%d"), + number_of_days_history=number_of_days, + total_contributor_count=len(unique_authors), + total_repository_count=len(repo_stats), + repository_stats=repo_stats, + org_members=sorted(list(members)), + org_contributors=sorted(list(unique_authors & members)), + org_contributors_count=len(unique_authors & members), + ) + + +class GlContributorReporter: + def __init__(self, api_key: str, hostname: str): + self.__gitlab_client = GitLabClient(api_key, hostname) + + def get_report( + self, + number_of_days: int, + group: str | None = None, + repo_filter: list[str] | None = None, + ): + all_contributors = set() + repo_contributors = defaultdict(set) + since_date = (datetime.now(UTC) - timedelta(days=number_of_days)).isoformat() + + self.__process_groups( + group, since_date, all_contributors, repo_contributors, repo_filter + ) + + return GlReport( + date=datetime.today().date().strftime("%Y-%m-%d"), + number_of_days_history=number_of_days, + total_contributor_count=len(all_contributors), + total_repository_count=len(repo_contributors), + repository_stats=[ + RepositoryStats( + name=repo, + contributor_count=len(contributors), + contributors=list(contributors), + ) + for repo, contributors in repo_contributors.items() + ], + all_contributors=sorted(list(all_contributors)), + ) + + def __process_groups( + self, + group_name: str | None, + since_date: str, + all_contributors: set, + repo_contributors: dict, + repo_filter: list[str] | None = None, + ): + if group_name: + group = self.__gitlab_client.get_group(group_name) + if not group: + logger.warning(f"Group {group_name} not found.") + return + self.__process_single_group( + group, since_date, all_contributors, repo_contributors, repo_filter + ) + else: + for group in self.__gitlab_client.get_groups(): + self.__process_single_group( + group, since_date, all_contributors, repo_contributors, repo_filter + ) + + def __process_single_group( + self, + group: GlGroup, + since_date: str, + all_contributors: set, + repo_contributors: dict, + repo_filter: list[str] | None = None, + ): + for project in self.__gitlab_client.get_projects(group): + if is_repository_filtered(repo_filter, project.path_with_namespace): + logger.debug(f"Skipping repository: {project.path_with_namespace}") + continue + + for commit in self.__gitlab_client.get_commits(project, since_date): + all_contributors.add(commit.author_name) + repo_contributors[project.path_with_namespace].add(commit.author_name) + + logger.debug( + f"Repository: {project.path_with_namespace} - Contributors: {len(repo_contributors[project.path_with_namespace])}" + ) + logger.debug( + f"Contributors for {project.path_with_namespace}: {', '.join(repo_contributors[project.path_with_namespace])}" + ) + + +class BitbucketContributorReporter: + def __init__(self, api_key: str, workspace: str): + self.__bitbucket_client = BitbucketClient(api_key, workspace) + self.__workspace = workspace + + def get_report( + self, + number_of_days: int, + repo_filter: list[str] | None = None, + ): + since_date = (datetime.now() - timedelta(days=number_of_days)).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ) + + all_contributors = set() + repo_stats: list[RepositoryStats] = [] + + for repository in self.__bitbucket_client.get_repositories(): + if is_repository_filtered(repo_filter, repository.name): + logger.debug(f"Skipping repository: {repository.name}") + continue + + commit_count = 0 + authors = set() + for commit in self.__bitbucket_client.get_commits(repository, since_date): + commit_count += 1 + author = ( + commit.author.user.display_name + if commit.author.user + else commit.author.raw + ) + authors.add(author) + + logger.debug( + f"Found {commit_count} total commits from {len(authors)} unique authors in {repository.name}." + ) + + all_contributors.update(authors) + repo_stats.append( + RepositoryStats( + name=repository.name, + contributor_count=len(authors), + contributors=list(authors), + ) + ) + + return BbReport( + workspace=self.__workspace, + date=datetime.today().date().strftime("%Y-%m-%d"), + number_of_days_history=number_of_days, + total_contributor_count=len(all_contributors), + total_repository_count=len(repo_stats), + repository_stats=repo_stats, + all_contributors=sorted(list(all_contributors)), + ) + + +class AzureDevOpsContributorReporter: + def __init__(self, api_key: str, organization: str): + self.__azure_devops_client = AzureDevOpsClient(api_key, organization) + self.__organization = organization + + def get_report( + self, + number_of_days: int, + repo_filter: list[str] | None = None, + ): + since_date = ( + datetime.now(timezone.utc) - timedelta(days=number_of_days) + ).isoformat() + + all_contributors = set() + repo_stats: list[RepositoryStats] = [] + + for project in self.__azure_devops_client.get_projects(): + for repository in self.__azure_devops_client.get_repositories(project): + if is_repository_filtered(repo_filter, repository.name): + logger.debug(f"Skipping repository: {repository.name}") + continue + + commit_count = 0 + authors = set() + + for commit in self.__azure_devops_client.get_commits( + repository, project, since_date + ): + commit_count += 1 + if commit.author.email: + authors.add(commit.author.email.lower()) + + logger.debug( + f"Found {commit_count} total commits from {len(authors)} unique authors in {repository.name}." + ) + + all_contributors.update(authors) + repo_stats.append( + RepositoryStats( + name=repository.name, + contributor_count=len(authors), + contributors=list(authors), + ) + ) + + return AdoReport( + organization=self.__organization, + date=datetime.today().date().strftime("%Y-%m-%d"), + number_of_days_history=number_of_days, + total_contributor_count=len(all_contributors), + total_repository_count=len(repo_stats), + repository_stats=repo_stats, + all_contributor_emails=sorted(list(all_contributors)), + ) + + +def is_repository_filtered(repo_filter: list[str] | None, repository: str) -> bool: + return repo_filter and repository not in repo_filter diff --git a/utilities/contributors/tests/data/.gitkeep b/utilities/contributors/tests/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utilities/contributors/uv.lock b/utilities/contributors/uv.lock new file mode 100644 index 0000000..d41bfb4 --- /dev/null +++ b/utilities/contributors/uv.lock @@ -0,0 +1,267 @@ +version = 1 +revision = 2 +requires-python = ">=3.12.8" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contributors" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "black" }, + { name = "click" }, + { name = "pydantic" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "click", specifier = ">=8.2.1" }, + { name = "pydantic", specifier = ">=2.11.4" }, + { name = "requests", specifier = ">=2.32.3" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540, upload-time = "2025-04-29T20:38:55.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900, upload-time = "2025-04-29T20:38:52.724Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +]