diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 9ea62fb..d3ab61d 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -46,7 +46,7 @@ jobs: run: | # Check if commit message indicates a version bump COMMIT_MSG=$(git log -1 --pretty=%B) - if [[ "$COMMIT_MSG" == *"bump version"* ]]; then + if [[ "$COMMIT_MSG" =~ [Bb][Uu][Mm][Pp]\ [Vv][Ee][Rr][Ss][Ii][Oo][Nn] ]]; then echo "should_release=true" >> $GITHUB_OUTPUT echo "Version bump detected, should create release" else @@ -66,14 +66,16 @@ jobs: uses: actions/checkout@v4 - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: v${{ needs.check-version-bump.outputs.version }} - name: Release v${{ needs.check-version-bump.outputs.version }} + release_name: Release v${{ needs.check-version-bump.outputs.version }} body: ${{ needs.check-version-bump.outputs.changelog }} draft: false prerelease: false - token: ${{ secrets.GITHUB_TOKEN }} - name: Output Release Status run: echo "Release for v${{ needs.check-version-bump.outputs.version }} created successfully" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 514640c..e461fdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,94 +11,64 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install uv + uses: astral-sh/setup-uv@v3 with: - version: 1.7.1 - virtualenvs-create: true - virtualenvs-in-project: true - - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v3 - with: - path: .venv - key: venv-${{ runner.os }}-3.11-${{ hashFiles('**/poetry.lock') }} + enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Install dependencies - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' - run: poetry install --no-interaction --no-root - - - name: Install project run: | - poetry install --no-interaction - poetry run pip install -e . - + uv sync --all-extras --dev + - name: Check code formatting run: | - source .venv/bin/activate - poetry run ruff format . + uv run ruff format --check . - name: Run linter run: | - source .venv/bin/activate - poetry run ruff check . + uv run ruff check . - name: Type check run: | - source .venv/bin/activate - poetry run mypy commitloom tests + uv run mypy commitloom tests test: name: Test runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install uv + uses: astral-sh/setup-uv@v3 with: - version: 1.7.1 - virtualenvs-create: true - virtualenvs-in-project: true - - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v3 - with: - path: .venv - key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Install dependencies - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' - run: poetry install --no-interaction --no-root - - - name: Install project run: | - poetry install --no-interaction - poetry run pip install -e . + uv sync --all-extras --dev - name: Run tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | - source .venv/bin/activate - poetry run pytest --cov=commitloom --cov-report=xml + uv run pytest --cov=commitloom --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 @@ -110,22 +80,22 @@ jobs: name: Build Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install uv + uses: astral-sh/setup-uv@v3 with: - version: 1.7.1 - virtualenvs-create: true - virtualenvs-in-project: true + enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Build package - run: poetry build + run: | + uv build - name: Check dist contents run: | diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml new file mode 100644 index 0000000..a473012 --- /dev/null +++ b/.github/workflows/manual-publish.yml @@ -0,0 +1,75 @@ +name: Manual Publish to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (without v prefix, e.g. 1.2.5)' + required: true + type: string + +permissions: + contents: read + +jobs: + manual-publish: + runs-on: ubuntu-latest + environment: + name: pypi + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Verify version matches + run: | + VERSION=$(grep -m 1 "^version = " pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "Version in pyproject.toml: $VERSION" + echo "Version requested: ${{ github.event.inputs.version }}" + if [ "$VERSION" != "${{ github.event.inputs.version }}" ]; then + echo "::error::Version mismatch! pyproject.toml has $VERSION but requested ${{ github.event.inputs.version }}" + exit 1 + fi + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Build package (uv) + run: uv build + + - name: Verify distribution files + run: | + echo "Generated distribution files:" + ls -la dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} + packages-dir: dist/ + verbose: true + + - name: Verify publication + run: | + echo "Waiting for PyPI to index the package..." + sleep 30 + + # Use uv to verify the version is available + uv pip install commitloom==${{ github.event.inputs.version }} --no-deps + INSTALLED_VERSION=$(uv run python -c "import commitloom; print(commitloom.__version__)") + + echo "Installed version: $INSTALLED_VERSION" + if [ "$INSTALLED_VERSION" == "${{ github.event.inputs.version }}" ]; then + echo "βœ… Publication successful!" + else + echo "⚠️ Publication may have failed or not indexed yet" + fi \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9186007..2540747 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,27 +9,76 @@ name: Upload Python Package on: - release: - types: [published, created] + workflow_run: + workflows: ["Auto Release"] + types: + - completed permissions: contents: read jobs: + check-workflow: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + outputs: + version: ${{ steps.get-version.outputs.version }} + should_publish: ${{ steps.check-release.outputs.should_publish }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Get version from pyproject.toml + id: get-version + run: | + VERSION=$(grep -m 1 "^version = " pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Current version: $VERSION" + + - name: Check if release should be published + id: check-release + run: | + # Check if most recent commit was a version bump + COMMIT_MSG=$(git log -1 --pretty=%B) + if [[ "$COMMIT_MSG" =~ [Bb][Uu][Mm][Pp]\ [Vv][Ee][Rr][Ss][Ii][Oo][Nn] ]]; then + echo "should_publish=true" >> $GITHUB_OUTPUT + echo "Version bump detected, should publish to PyPI" + else + echo "should_publish=false" >> $GITHUB_OUTPUT + echo "Not a version bump commit, skipping PyPI publishing" + fi + + - name: Debug workflow info + run: | + echo "Auto-Release workflow completed successfully" + echo "Version to publish: ${{ steps.get-version.outputs.version }}" + echo "Should publish: ${{ steps.check-release.outputs.should_publish }}" + release-build: runs-on: ubuntu-latest + needs: check-workflow + if: ${{ needs.check-workflow.outputs.should_publish == 'true' }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} - uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true - name: Build release distributions run: | - python -m pip install build - python -m build + uv build - name: Upload distributions uses: actions/upload-artifact@v4 @@ -40,7 +89,9 @@ jobs: pypi-publish: runs-on: ubuntu-latest needs: + - check-workflow - release-build + if: ${{ needs.check-workflow.outputs.should_publish == 'true' }} environment: name: pypi @@ -55,4 +106,9 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_TOKEN }} - packages-dir: dist/ \ No newline at end of file + packages-dir: dist/ + verbose: true + + - name: Publish success + run: | + echo "βœ… Successfully published version ${{ needs.check-workflow.outputs.version }} to PyPI" \ No newline at end of file diff --git a/.gitignore b/.gitignore index a241100..fb10db5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ build/ develop-eggs/ dist/ downloads/ +docs/ eggs/ .eggs/ lib/ @@ -48,6 +49,7 @@ htmlcov/ .coverage.* .cache nosetests.xml +CLAUDE.md coverage.xml *.cover *.py,cover diff --git a/CHANGELOG.md b/CHANGELOG.md index f048e3b..f165b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,205 @@ # Changelog +## [1.7.0] - 2025-08-22 + +### πŸ› Bug Fixes +- **Resolved missing implementation pairing**: Test groups now pull in their corresponding implementation files instead of duplicating tests in isolation. + +### πŸš€ Improvements +- **Smarter dependency detection**: Smart grouping reads import statements with cached file access to uncover relationships between changed files. +- **Dependency-aware grouping**: File groups now surface downstream dependencies so commit authors understand supporting changes at a glance. +- **Prioritized test handling**: Ensured test changes are processed before other change types to prevent duplicated files across groups. + +### πŸ§ͺ Tests +- Added regression coverage for dependency extraction and dependency-enriched grouping flows. + +## [1.6.2] - 2025-08-21 + +### πŸ› Bug Fixes +- **Fixed duplicate debug logging**: Removed redundant `setup_logging()` calls that caused "Debug mode enabled" to appear twice +- **Cleaner CLI output**: Debug mode message now appears only once when using `-d/--debug` flag + +### πŸš€ Improvements +- Streamlined logging initialization process +- Better separation of concerns in CLI setup +- Maintained all existing functionality with cleaner output + +## [1.6.1] - 2025-08-21 + +### πŸ› Bug Fixes +- **Fixed duplicate logging**: Removed redundant logger calls causing messages to appear 2-3 times +- **Fixed metrics JSON parsing**: Better handling of corrupted or missing metrics files +- **Fixed MyPy type errors**: Added proper type checks for Response objects and type hints +- **Reduced output verbosity**: Simplified smart grouping output to be more concise + +### πŸš€ Improvements +- Cleaner console output without debug noise +- Silent handling of first-run file creation +- More concise smart grouping summaries +- Better error handling for API responses + +## [1.6.0] - 2025-08-21 + +### ✨ Features +- **Smart File Grouping**: Intelligent semantic analysis for grouping related files in commits + - Detects relationships between test files and their implementations + - Identifies component pairs (e.g., .tsx and .css files) + - Groups files by change type (feature, fix, test, docs, etc.) + - Analyzes file dependencies and imports + - CLI option `-s/--smart-grouping` (enabled by default) + +### πŸš€ Improvements +- **Migration from Poetry to UV**: Complete build system overhaul + - 10-100x faster dependency installation + - Simplified configuration using PEP 621 standard + - Improved CI/CD pipeline performance + - Better cache management + - Updated all GitHub Actions workflows + +### πŸ“¦ Build System +- Migrated from Poetry to UV package manager +- Updated pyproject.toml to PEP 621 format +- Added Dockerfile with UV support +- Updated CI/CD workflows for UV compatibility + +### πŸ“š Documentation +- Updated CONTRIBUTING.md with UV instructions +- Added comprehensive tests for smart grouping feature +- Improved code coverage to 74% + +### πŸ§ͺ Tests +- Added comprehensive test suite for smart grouping +- All 133 tests passing +- Code coverage increased from 68% to 74% + +## [1.5.6] - 2025-08-21 + +### ✨ Features +- polish commit flow and AI service + +### πŸ› Bug Fixes +- explicit response check in API retries + +### πŸ§ͺ Tests +- improve coverage for new features + +## [1.5.5] - 2025-06-15 + + +### πŸ› Bug Fixes +- add debug option to commit command and improve CLI argument parsing +- update poetry.lock file +- sync version in __init__.py and improve release script + +### πŸ“¦ Build System +- bump version to 1.5.5 +- trigger release workflow for version 1.5.4 +- republish version 1.5.4 to PyPI + +### πŸ”§ Chores +- cleanup trigger file + +## [1.5.4] - 2025-06-15 + + +### ✨ Features +- suggest new branch for large commits + +### πŸ› Bug Fixes +- remove debug prints + +### πŸ“¦ Build System +- bump version to 1.5.4 + +### πŸ”„ Other Changes +- Merge pull request #3 from Arakiss/codex/identificar-caracterΓ­sticas-a-mejorar-o-agregar +- Merge pull request #2 from Arakiss/codex/find-more-potential-bugs-to-fix +- Merge pull request #1 from Arakiss/codex/fix-bug +- fix metrics file parsing +- bump version 1.5.3 + +## [1.4.0] - 2025-04-17 + + +### ✨ Features +- set gpt-4.1-mini as default model and add 2025 OpenAI models/pricing + +### πŸ“¦ Build System +- bump version to 1.4.0 +- bump version to 1.3.0 + +## [1.2.10] - 2025-03-06 + + +### πŸ“¦ Build System +- bump version to 1.2.10 + +## [1.2.9] - 2025-03-06 + + +### πŸ“¦ Build System +- bump version to 1.2.9 + +### πŸ”„ Other Changes +- ✨ feat: add debug options for commit command +- πŸ”– chore: bump version to 1.2.8 + +## [1.2.8] - 2025-03-06 + + +### πŸ› Bug Fixes +- correct GitFile parameter types in tests + +### πŸ’„ Styling +- fix blank line whitespace issues +- fix line length issues and format cli_handler.py + +### πŸ“¦ Build System +- bump version to 1.2.8 +- bump version to 1.2.7 and fix metrics storage +- bump version to 1.2.6 and connect release workflows +- bump version to 1.2.5 and fix release creation +- bump version to 1.2.4 and fix release workflow +- bump version to 1.2.3 + +### πŸ‘· CI +- make linting and test coverage requirements more flexible +- add manual PyPI publish workflow and update version +- enable verbose output in PyPI publish workflow + +### πŸ”„ Other Changes +- ✨ feat: enhance metrics calculations and logging +- ✨ feat: enhance CLI with model selection and help + +## [1.2.7] - 2025-03-05 +### πŸ› οΈ Fixes +- Fix JSON handling in metrics storage +- Make statistics display more robust against malformed data +- Add safety checks for repository and model usage data + + + +## [1.2.6] - 2025-03-05 +### πŸ› οΈ Fixes +- Connect Auto Release and Package Publishing workflows +- Automate PyPI package publishing after GitHub release +- Fix workflow integration between GitHub release and PyPI publish + +## [1.2.5] - 2025-03-05 +### πŸ› οΈ Fixes +- Use actions/create-release instead of softprops/action-gh-release to ensure proper release event triggering + +## [1.2.4] - 2025-03-05 +### πŸ› οΈ Fixes +- Fix GitHub release workflow to trigger PyPI publishing correctly +- Add "released" type to the publish workflow trigger +- Ensure GitHub releases are properly formatted + +## [1.2.3] - 2025-03-05 +### πŸ› οΈ Fixes +- Fix auto-release workflow to correctly detect version bump commits +- Add verbose output to PyPI publish workflow for better diagnostics + ## [1.2.2] - 2025-03-05 ### πŸ› οΈ Fixes - Update publish workflow to trigger on both published and created releases @@ -284,4 +484,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Comprehensive test suite - Full type hints support -[0.1.0]: https://github.com/Arakiss/commitloom/releases/tag/v0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/Arakiss/commitloom/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25b0fd7..da79125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,19 +49,19 @@ If you have a suggestion for the project, I'd love to hear it! Enhancement sugge cd commitloom ``` -2. Install Poetry (if not already installed): +2. Install uv (if not already installed): ```bash - curl -sSL https://install.python-poetry.org | python3 - + curl -LsSf https://astral.sh/uv/install.sh | sh ``` 3. Install dependencies: ```bash - poetry install + uv sync --all-extras --dev ``` -4. Set up pre-commit hooks: +4. Set up pre-commit hooks (if available): ```bash - poetry run pre-commit install + uv run pre-commit install ``` ## Style Guide @@ -98,8 +98,8 @@ def process_data(input_data: List[str], max_items: Optional[int] = None) -> List - All new features should include tests - Maintain or improve test coverage -- Run tests with: `poetry run pytest` -- Check coverage with: `poetry run pytest --cov` +- Run tests with: `uv run pytest` +- Check coverage with: `uv run pytest --cov` ## Documentation diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d71fff5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Multi-stage build for optimal image size +FROM python:3.11-slim as builder + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml ./ + +# Create virtual environment and install dependencies +RUN uv venv .venv && \ + . .venv/bin/activate && \ + uv pip install -e . + +# Final stage +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Copy application code +COPY commitloom/ ./commitloom/ +COPY pyproject.toml ./ + +# Set environment variables +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +# Create a non-root user +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +# Entry point +ENTRYPOINT ["python", "-m", "commitloom"] \ No newline at end of file diff --git a/README.md b/README.md index d5445e4..87e373f 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,16 @@ I built CommitLoom to solve these challenges by: ## πŸš€ Quick Start -1. Install CommitLoom via pip: +1. Install CommitLoom via pip or UV: ```bash +# Using pip pip install commitloom + +# Using UV (faster alternative) +uv add commitloom +# or for global installation +uvx commitloom ``` 2. Set up your OpenAI API key: @@ -58,13 +64,47 @@ loom -y # Non-interactive mode ## ✨ Features - πŸ€– **AI-Powered Analysis**: Intelligently analyzes your changes and generates structured, semantic commit messages -- 🧡 **Smart Batching**: Weaves multiple changes into coherent, logical commits +- 🧠 **Smart File Grouping**: Advanced semantic analysis to group related files intelligently based on functionality, relationships, and change types +- 🧡 **Smart Batching**: Weaves multiple changes into coherent, logical commits using intelligent grouping algorithms - πŸ“Š **Complexity Analysis**: Identifies when commits are getting too large or complex +- 🌿 **Branch Suggestions**: Offers to create a new branch for very large commits - πŸ’° **Cost Control**: Built-in token and cost estimation to keep API usage efficient - πŸ“ˆ **Usage Metrics**: Track your usage, cost savings, and productivity gains with built-in metrics - πŸ” **Binary Support**: Special handling for binary files with size and type detection +- ⚑ **UV Support**: Compatible with UV package manager for faster dependency management - 🎨 **Beautiful CLI**: Rich, colorful interface with clear insights and warnings +## 🧠 Smart File Grouping + +CommitLoom v1.6.0+ includes advanced semantic analysis for intelligent file grouping. Instead of simple directory-based batching, it now: + +### How It Works +- **Change Type Detection**: Automatically identifies the type of changes (features, fixes, tests, docs, refactoring, etc.) +- **File Relationship Analysis**: Detects relationships between files: + - Test files and their corresponding implementation files + - Component files that work together (e.g., component + styles + tests) + - Configuration files and their dependent modules + - Documentation files and related code +- **Semantic Grouping**: Groups files based on functionality rather than just directory structure +- **Confidence Scoring**: Each grouping decision is scored for reliability + +### Benefits +- **Better Commit Organization**: Related changes are grouped together logically +- **Cleaner History**: More meaningful commit messages that reflect actual feature boundaries +- **Reduced Context Switching**: Files that belong together are committed together +- **Intelligent Defaults**: Works automatically but can be disabled with `--no-smart-grouping` + +### Example +```bash +# Before: Files grouped by directory +Commit 1: src/components/Button.tsx, src/components/Input.tsx +Commit 2: tests/Button.test.tsx, tests/Input.test.tsx + +# After: Files grouped by functionality +Commit 1: src/components/Button.tsx, tests/Button.test.tsx, docs/Button.md +Commit 2: src/components/Input.tsx, tests/Input.test.tsx, docs/Input.md +``` + ## πŸ“– Project History CommitLoom evolved from my personal script that I was tired of copying across different projects. Its predecessor, GitMuse, was my experiment with local models like Llama through Ollama, but I couldn't achieve the consistent, high-quality results I needed. The rise of cost-effective OpenAI models, particularly gpt-4o-mini, made it possible for me to create a more reliable and powerful tool. @@ -76,6 +116,15 @@ Key improvements over GitMuse: - Enhanced error handling and user experience - Improved binary file handling +### Recent Major Updates + +**v1.6.0+ (2024)**: Introduced intelligent file grouping and performance improvements: +- **Smart File Grouping**: Advanced semantic analysis for better commit organization +- **UV Package Manager Support**: Migrated from Poetry to UV for 10-100x faster dependency management +- **Enhanced CLI**: New `-s/--smart-grouping` and `--no-smart-grouping` options +- **Improved Error Handling**: Better JSON parsing and metrics collection +- **Performance Optimizations**: Reduced logging verbosity and duplicate messages + ## βš™οΈ Configuration CommitLoom offers multiple ways to configure your API key and settings: @@ -101,17 +150,26 @@ cl [command] [options] - `-y, --yes`: Auto-confirm all prompts (non-interactive mode) - `-c, --combine`: Combine all changes into a single commit +- `-s, --smart-grouping`: Enable intelligent file grouping (default: enabled) +- `--no-smart-grouping`: Disable smart grouping and use simple batching - `-d, --debug`: Enable debug logging +- `-m, --model`: Specify the AI model to use (e.g., gpt-4.1-mini) #### Usage Examples ```bash -# Basic usage (interactive mode) +# Basic usage (interactive mode with smart grouping) loom # Non-interactive mode with combined commits loom -y -c +# Use smart grouping with specific model +loom -s -m gpt-4.1 + +# Disable smart grouping for simple batching +loom --no-smart-grouping + # View usage statistics loom stats @@ -176,18 +234,25 @@ Configuration files are searched in this order: ### πŸ€– Model Configuration -CommitLoom supports various OpenAI models with different cost implications: +CommitLoom supports any OpenAI model for commit message generation. You can specify any model name (e.g., `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, etc.) using the `MODEL_NAME` or `COMMITLOOM_MODEL` environment variable, or with the `-m`/`--model` CLI option. + +| Model | Description | Input (per 1M tokens) | Output (per 1M tokens) | Best for | +|-----------------|------------------------------------|-----------------------|------------------------|-------------------------| +| gpt-4.1 | Highest quality, 1M ctx, multimodal| $2.00 | $8.00 | Final docs, critical | +| gpt-4.1-mini | Default, best cost/quality | $0.40 | $1.60 | Most use cases | +| gpt-4.1-nano | Fastest, cheapest | $0.10 | $0.40 | Drafts, previews | +| gpt-4o-mini | Legacy, cost-efficient | $0.15 | $0.60 | Legacy/compatibility | +| gpt-4o | Legacy, powerful | $2.50 | $10.00 | Legacy/compatibility | +| gpt-3.5-turbo | Legacy, fine-tuned | $3.00 | $6.00 | Training data | +| gpt-4o-2024-05-13| Legacy, previous version | $5.00 | $15.00 | Legacy support | + +> **Default model:** `gpt-4.1-mini` (best balance for documentation and code) -| Model | Description | Cost per 1M tokens (Input/Output) | Best for | -|-------|-------------|----------------------------------|----------| -| gpt-4o-mini | Default, optimized for commits | $0.15/$0.60 | Most use cases | -| gpt-4o | Latest model, powerful | $2.50/$10.00 | Complex analysis | -| gpt-4o-2024-05-13 | Previous version | $5.00/$15.00 | Legacy support | -| gpt-3.5-turbo | Fine-tuned version | $3.00/$6.00 | Training data | +> **Warning:** If you use a model that is not in the above list, CommitLoom will still work, but cost estimation and token pricing will not be available for that model. You will see a warning in the CLI, and cost will be reported as zero. To add cost support for a new model, update the `model_costs` dictionary in `commitloom/config/settings.py`. -You can change the model by setting the `MODEL_NAME` environment variable. The default `gpt-4o-mini` model is recommended as it provides the best balance of cost and quality for commit message generation. It's OpenAI's most cost-efficient small model that's smarter and cheaper than GPT-3.5 Turbo. +You can change the model by setting the `MODEL_NAME` environment variable. The default `gpt-4.1-mini` model is recommended as it provides the best balance of cost and quality for commit message generation. It's OpenAI's most cost-efficient small model that's smarter and cheaper than GPT-3.5 Turbo. -> Note: Prices are based on OpenAI's official pricing (https://openai.com/api/pricing/). Batch API usage can provide a 50% discount but responses will be returned within 24 hours. +> Note: Prices are based on OpenAI's official pricing (https://openai.com/pricing/). Batch API usage can provide a 50% discount but responses will be returned within 24 hours. ## ❓ FAQ @@ -201,14 +266,14 @@ While local models like Llama are impressive, my experience with GitMuse showed ### How much will it cost to use CommitLoom? -With the default gpt-4o-mini model, costs are very low: -- Input: $0.15 per million tokens -- Output: $0.60 per million tokens +With the default gpt-4.1-mini model, costs are very low: +- Input: $0.40 per million tokens +- Output: $1.60 per million tokens For perspective, a typical commit analysis: - Uses ~1,000-2,000 tokens -- Costs less than $0.002 (0.2 cents) -- That's about 500 commits for $1 +- Costs less than $0.004 (0.4 cents) +- That's about 250 commits for $1 This makes it one of the most cost-effective tools in its category, especially when compared to the time saved and quality of commit messages generated. @@ -242,19 +307,34 @@ For detailed documentation on the metrics system, see the [Usage Metrics Documen CommitLoom automatically: 1. Analyzes the size and complexity of changes -2. Warns about potentially oversized commits -3. Suggests splitting changes when appropriate -4. Maintains context across split commits +2. Uses smart grouping to organize related files together +3. Warns about potentially oversized commits +4. Suggests splitting changes when appropriate +5. Maintains context across split commits +6. Optionally creates a new branch when commits are very large + +### What is smart grouping and how does it work? + +Smart grouping is CommitLoom's advanced semantic analysis feature that intelligently organizes your changed files: + +- **Detects relationships**: Groups test files with their implementation files, components with their styles, etc. +- **Understands change types**: Identifies whether changes are features, fixes, documentation, tests, or refactoring +- **Semantic analysis**: Goes beyond directory structure to understand what files actually work together +- **Automatic by default**: Enabled automatically in v1.6.0+, but can be disabled with `--no-smart-grouping` + +This results in more logical commits where related files are grouped together, making your git history cleaner and more meaningful. ## πŸ› οΈ Development Status -- βœ… **CI/CD**: Automated testing, linting, and publishing +- βœ… **CI/CD**: Automated testing, linting, and publishing with GitHub Actions +- βœ… **Package Management**: Migrated to UV for faster dependency resolution and builds - βœ… **Code Quality**: - Ruff for linting and formatting - MyPy for static type checking - - 70%+ test coverage + - 70%+ test coverage with pytest +- βœ… **Smart Features**: Advanced semantic analysis and intelligent file grouping - βœ… **Distribution**: Available on PyPI and GitHub Releases -- βœ… **Documentation**: Comprehensive README and type hints +- βœ… **Documentation**: Comprehensive README with feature examples and type hints - βœ… **Maintenance**: Actively maintained and accepting contributions ## 🀝 Contributing diff --git a/commitloom/__init__.py b/commitloom/__init__.py index 8576752..d2fdb19 100644 --- a/commitloom/__init__.py +++ b/commitloom/__init__.py @@ -5,7 +5,7 @@ from .core.git import GitError, GitFile, GitOperations from .services.ai_service import AIService, CommitSuggestion, TokenUsage -__version__ = "0.1.0" +__version__ = "1.7.0" __author__ = "Petru Arakiss" __email__ = "petruarakiss@gmail.com" diff --git a/commitloom/__main__.py b/commitloom/__main__.py index fbe6a04..482402f 100644 --- a/commitloom/__main__.py +++ b/commitloom/__main__.py @@ -9,15 +9,12 @@ # Load environment variables before any imports env_path = os.path.join(os.path.dirname(__file__), "..", ".env") -print(f"Loading .env from: {os.path.abspath(env_path)}") load_dotenv(dotenv_path=env_path) -# Debug: Check if API key is loaded -api_key = os.getenv("OPENAI_API_KEY") -print(f"API Key loaded: {'Yes' if api_key else 'No'}") - +from . import __version__ from .cli import console from .cli.cli_handler import CommitLoom +from .config.settings import config def handle_error(error: BaseException) -> None: @@ -30,32 +27,72 @@ def handle_error(error: BaseException) -> None: @click.group() @click.option("-d", "--debug", is_flag=True, help="Enable debug logging") +@click.option( + "-v", + "--version", + is_flag=True, + callback=lambda ctx, param, value: value and print(f"CommitLoom, version {__version__}") or exit(0) + if value + else None, + help="Show the version and exit.", +) @click.pass_context -def cli(ctx, debug: bool) -> None: +def cli(ctx, debug: bool, version: bool = False) -> None: """Create structured git commits with AI-generated messages.""" ctx.ensure_object(dict) - ctx.obj["DEBUG"] = debug - - if debug: + + # Check for debug mode in config file or environment variable + debug_env = os.getenv("DEBUG_MODE", "").lower() in ("true", "1", "yes") + ctx.obj["DEBUG"] = debug or debug_env + + if debug or debug_env: console.setup_logging(debug=True) @cli.command(help="Generate an AI-powered commit message and commit your changes") @click.option("-y", "--yes", is_flag=True, help="Skip all confirmation prompts") @click.option("-c", "--combine", is_flag=True, help="Combine all changes into a single commit") +@click.option("-d", "--debug", is_flag=True, help="Enable debug logging") +@click.option( + "-s", + "--smart-grouping/--no-smart-grouping", + default=True, + help="Enable/disable intelligent file grouping (default: enabled)", +) +@click.option( + "-m", + "--model", + type=str, # Permitir cualquier string + help=f"Specify any OpenAI model to use (default: {config.default_model})", +) @click.pass_context -def commit(ctx, yes: bool, combine: bool) -> None: +def commit(ctx, yes: bool, combine: bool, debug: bool, smart_grouping: bool, model: str | None) -> None: """Generate commit message and commit changes.""" - debug = ctx.obj.get("DEBUG", False) - + # Use debug from either local flag or global context + debug = debug or ctx.obj.get("DEBUG", False) + + # Logging is already configured in the main callback + try: - # Use test_mode=True when running tests (detected by pytest) test_mode = "pytest" in sys.modules - # Only pass API key if not in test mode and it exists api_key = None if test_mode else os.getenv("OPENAI_API_KEY") - - # Initialize with test_mode loom = CommitLoom(test_mode=test_mode, api_key=api_key if api_key else None) + + # Configure smart grouping + loom.use_smart_grouping = smart_grouping + + # ValidaciΓ³n personalizada para modelos OpenAI + if model: + if not model.startswith("gpt-"): + console.print_warning( + f"Model '{model}' does not appear to be a valid OpenAI model (should start with 'gpt-')." + ) + if model not in config.model_costs: + console.print_warning( + f"Model '{model}' is not in the known cost list. Cost estimation will be unavailable or inaccurate." + ) + os.environ["COMMITLOOM_MODEL"] = model + console.print_info(f"Using model: {model}") loom.run(auto_commit=yes, combine_commits=combine, debug=debug) except (KeyboardInterrupt, Exception) as e: handle_error(e) @@ -67,31 +104,107 @@ def commit(ctx, yes: bool, combine: bool) -> None: def stats(ctx) -> None: """Show usage statistics.""" debug = ctx.obj.get("DEBUG", False) - + try: # Create a CommitLoom instance and run the stats command loom = CommitLoom(test_mode=True) # Test mode to avoid API key requirement - if debug: - console.setup_logging(debug=True) + # Logging is already configured in the main callback loom.stats_command() except (KeyboardInterrupt, Exception) as e: handle_error(e) sys.exit(1) +@cli.command(help="Display detailed help information") +def help() -> None: + """Display detailed help information about CommitLoom.""" + help_text = f""" +[bold cyan]CommitLoom v{__version__}[/bold cyan] +[italic]Weave perfect git commits with AI-powered intelligence[/italic] + +[bold]Basic Usage:[/bold] + loom Run the default commit command + loom commit Generate commit message for staged changes + loom commit -y Skip confirmation prompts + loom commit -c Combine all changes into a single commit + loom commit -s Enable smart grouping (default) + loom commit --no-smart-grouping Disable smart grouping + loom commit -m MODEL Specify any OpenAI model to use + loom stats Show usage statistics + loom --version Display version information + loom help Show this help message + +[bold]Available Models:[/bold] + {", ".join(config.model_costs.keys())} + Default: {config.default_model} + (You can use any OpenAI model name, but cost estimation is only available for the above models.) + +[bold]Environment Setup:[/bold] + 1. Set OPENAI_API_KEY in your environment or in a .env file + 2. Stage your changes with 'git add' + 3. Run 'loom' to generate and apply commit messages + +[bold]Documentation:[/bold] + Full documentation: https://github.com/Arakiss/commitloom#readme + """ + console.console.print(help_text) + + # For backwards compatibility, default to commit command if no subcommand provided def main() -> None: """Entry point for the CLI.""" - # Check if the first argument is a known command, if not, insert 'commit' - known_commands = ['commit', 'stats'] - - if len(sys.argv) > 1 and not sys.argv[1].startswith('-') and sys.argv[1] not in known_commands: - sys.argv.insert(1, 'commit') - - # If no arguments provided, add 'commit' as the default command + known_commands = ["commit", "stats", "help"] + # These are options for the main CLI group + global_options = ["-v", "--version", "--help"] + # These are debug options that should include commit command + debug_options = ["-d", "--debug"] + # These are options specific to the commit command + commit_options = [ + "-y", + "--yes", + "-c", + "--combine", + "-m", + "--model", + "-s", + "--smart-grouping", + "--no-smart-grouping", + ] + + # If no arguments, simply add the default commit command if len(sys.argv) == 1: - sys.argv.append('commit') - + sys.argv.insert(1, "commit") + cli(obj={}) + return + + # Check if we have debug option anywhere in the arguments + has_debug = any(arg in debug_options for arg in sys.argv[1:]) + + # Check the first argument + first_arg = sys.argv[1] + + # If it's already a known command, no need to modify + if first_arg in known_commands: + cli(obj={}) + return + + # If it's a global option without debug, don't insert commit + if first_arg in global_options and not has_debug: + cli(obj={}) + return + + # If we have debug option anywhere, or commit-specific options, add commit command + if has_debug or first_arg in commit_options or any(arg in commit_options for arg in sys.argv[1:]): + # Insert 'commit' at the beginning of options + sys.argv.insert(1, "commit") + cli(obj={}) + return + + # For any other non-option argument that's not a known command, + # assume it's meant for the commit command + if not first_arg.startswith("-"): + sys.argv.insert(1, "commit") + cli(obj={}) diff --git a/commitloom/cli/cli_handler.py b/commitloom/cli/cli_handler.py index 78a8da7..d46c245 100644 --- a/commitloom/cli/cli_handler.py +++ b/commitloom/cli/cli_handler.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """Main CLI handler module for CommitLoom.""" -import logging import os import subprocess import sys +from datetime import datetime from dotenv import load_dotenv -from ..core.analyzer import CommitAnalyzer +from ..core.analyzer import CommitAnalysis, CommitAnalyzer from ..core.git import GitError, GitFile, GitOperations +from ..core.smart_grouping import SmartGrouper from ..services.ai_service import AIService from ..services.metrics import metrics_manager # noqa from . import console @@ -17,14 +18,8 @@ env_path = os.path.join(os.path.dirname(__file__), "..", "..", ".env") load_dotenv(dotenv_path=env_path) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(levelname)s: %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) - -logger = logging.getLogger(__name__) +# Logging is configured by console module +# logger = logging.getLogger(__name__) # Minimum number of files to activate batch processing BATCH_THRESHOLD = 3 @@ -45,20 +40,34 @@ def __init__(self, test_mode: bool = False, api_key: str | None = None): self.repo_path = os.path.basename(os.getcwd()) except Exception: self.repo_path = "unknown_repo" - + self.git = GitOperations() self.analyzer = CommitAnalyzer() self.ai_service = AIService(api_key=api_key, test_mode=test_mode) + self.smart_grouper = SmartGrouper() self.auto_commit = False self.combine_commits = False self.console = console + self.use_smart_grouping = True # Flag to enable/disable smart grouping + + def _maybe_create_branch(self, analysis: CommitAnalysis) -> None: + """Offer to create a new branch if the commit is complex.""" + if not analysis.is_complex: + return + branch_name = f"loom-large-{datetime.now().strftime('%Y%m%d_%H%M%S')}" + if console.confirm_branch_creation(branch_name): + try: + self.git.create_and_checkout_branch(branch_name) + console.print_info(f"Switched to new branch {branch_name}") + except GitError as e: + console.print_error(str(e)) def _process_single_commit(self, files: list[GitFile]) -> None: """Process files as a single commit.""" try: # Start tracking metrics metrics_manager.start_commit_tracking(repository=self.repo_path) - + # Stage files file_paths = [f.path for f in files] self.git.stage_files(file_paths) @@ -69,6 +78,7 @@ def _process_single_commit(self, files: list[GitFile]) -> None: # Print analysis console.print_warnings(analysis) + self._maybe_create_branch(analysis) try: # Generate commit message @@ -92,7 +102,7 @@ def _process_single_commit(self, files: list[GitFile]) -> None: prompt_tokens=usage.prompt_tokens, completion_tokens=usage.completion_tokens, cost_in_eur=usage.total_cost, - model_used=self.ai_service.model + model_used=self.ai_service.model, ) sys.exit(0) @@ -100,7 +110,7 @@ def _process_single_commit(self, files: list[GitFile]) -> None: commit_success = self.git.create_commit(suggestion.title, suggestion.format_body()) if commit_success: console.print_success("Changes committed successfully!") - + # Record metrics metrics_manager.finish_commit_tracking( files_changed=len(files), @@ -108,12 +118,12 @@ def _process_single_commit(self, files: list[GitFile]) -> None: prompt_tokens=usage.prompt_tokens, completion_tokens=usage.completion_tokens, cost_in_eur=usage.total_cost, - model_used=self.ai_service.model + model_used=self.ai_service.model, ) else: console.print_warning("No changes were committed. Files may already be committed.") self.git.reset_staged_changes() - + # Record metrics with 0 files metrics_manager.finish_commit_tracking( files_changed=0, @@ -121,7 +131,7 @@ def _process_single_commit(self, files: list[GitFile]) -> None: prompt_tokens=usage.prompt_tokens, completion_tokens=usage.completion_tokens, cost_in_eur=usage.total_cost, - model_used=self.ai_service.model + model_used=self.ai_service.model, ) sys.exit(0) @@ -144,7 +154,7 @@ def _handle_batch( try: # Start tracking metrics metrics_manager.start_commit_tracking(repository=self.repo_path) - + # Stage files file_paths = [f.path for f in batch] self.git.stage_files(file_paths) @@ -173,7 +183,7 @@ def _handle_batch( if not commit_success: console.print_warning("No changes were committed. Files may already be committed.") self.git.reset_staged_changes() - + # Record metrics with 0 files metrics_manager.finish_commit_tracking( files_changed=0, @@ -184,7 +194,7 @@ def _handle_batch( model_used=self.ai_service.model, batch_processing=True, batch_number=batch_num, - batch_total=total_batches + batch_total=total_batches, ) return None @@ -198,7 +208,7 @@ def _handle_batch( model_used=self.ai_service.model, batch_processing=True, batch_number=batch_num, - batch_total=total_batches + batch_total=total_batches, ) console.print_batch_complete(batch_num, total_batches) @@ -224,9 +234,7 @@ def _create_batches(self, changed_files: list[GitFile]) -> list[list[GitFile]]: invalid_files = [] for file in changed_files: - if hasattr(self.git, "should_ignore_file") and self.git.should_ignore_file( - file.path - ): + if self.git.should_ignore_file(file.path): invalid_files.append(file) console.print_warning(f"Ignoring file: {file.path}") else: @@ -236,19 +244,50 @@ def _create_batches(self, changed_files: list[GitFile]) -> list[list[GitFile]]: console.print_warning("No valid files to process.") return [] - # Create batches from valid files - batches = [] - batch_size = BATCH_THRESHOLD - for i in range(0, len(valid_files), batch_size): - batch = valid_files[i : i + batch_size] - batches.append(batch) - - return batches + # Use smart grouping if enabled + if self.use_smart_grouping: + return self._create_smart_batches(valid_files) + else: + # Fallback to basic grouping + return self._create_basic_batches(valid_files) except subprocess.CalledProcessError as e: console.print_error(f"Error getting git status: {e}") return [] + def _create_basic_batches(self, valid_files: list[GitFile]) -> list[list[GitFile]]: + """Create basic batches using the old grouping logic.""" + # Group files by top-level directory for smarter batching + grouped: dict[str, list[GitFile]] = {} + for f in valid_files: + parts = f.path.split(os.sep) + top_dir = parts[0] if len(parts) > 1 else "root" + grouped.setdefault(top_dir, []).append(f) + + batches = [] + batch_size = BATCH_THRESHOLD + for group_files in grouped.values(): + for i in range(0, len(group_files), batch_size): + batches.append(group_files[i : i + batch_size]) + + return batches + + def _create_smart_batches(self, valid_files: list[GitFile]) -> list[list[GitFile]]: + """Create intelligent batches using semantic analysis.""" + # Use the smart grouper to analyze files + file_groups = self.smart_grouper.analyze_files(valid_files) + + if not file_groups: + console.print_warning("Smart grouping produced no groups, falling back to basic grouping") + return self._create_basic_batches(valid_files) + + # Print concise group summary + console.print_info(f"Smart grouping created {len(file_groups)} groups based on file relationships") + + # Convert FileGroup objects to lists of GitFile + batches = [group.files for group in file_groups] + return batches + def _create_combined_commit(self, batches: list[dict]) -> None: """Create a combined commit from multiple batches.""" try: @@ -268,18 +307,12 @@ def _create_combined_commit(self, batches: list[dict]) -> None: # Create combined commit message title = "πŸ“¦ chore: combine multiple changes" - body = "\n\n".join( - [ - title, - "\n".join( - f"{data['emoji']} {category}:" for category, data in all_changes.items() - ), - "\n".join( - f"- {change}" for data in all_changes.values() for change in data["changes"] - ), - " ".join(summary_points), - ] - ) + body_parts = [ + "\n".join(f"{data['emoji']} {category}:" for category, data in all_changes.items()), + "\n".join(f"- {change}" for data in all_changes.values() for change in data["changes"]), + " ".join(summary_points), + ] + body = "\n\n".join(part for part in body_parts if part) # Stage and commit all files self.git.stage_files(all_files) @@ -344,63 +377,73 @@ def stats_command(self) -> None: """Display usage statistics.""" # Get usage statistics stats = metrics_manager.get_statistics() - + console.console.print("\n[bold blue]πŸ“Š CommitLoom Usage Statistics[/bold blue]") - + # Display basic stats console.console.print("\n[bold cyan]Basic Statistics:[/bold cyan]") - console.console.print(f" β€’ Total commits generated: {stats['total_commits']:,}") - console.console.print(f" β€’ Total tokens used: {stats['total_tokens']:,}") - console.console.print(f" β€’ Total cost: €{stats['total_cost_in_eur']:.4f}") - console.console.print(f" β€’ Total files processed: {stats['total_files_processed']:,}") - + console.console.print(f" β€’ Total commits generated: {stats.get('total_commits', 0):,}") + console.console.print(f" β€’ Total tokens used: {stats.get('total_tokens', 0):,}") + cost = stats.get("total_cost_in_eur", 0.0) + console.console.print(f" β€’ Total cost: €{cost:.4f}") + files = stats.get("total_files_processed", 0) + console.console.print(f" β€’ Total files processed: {files:,}") + # Display time saved if available - if 'time_saved_formatted' in stats: + if "time_saved_formatted" in stats: console.console.print(f" β€’ Total time saved: {stats['time_saved_formatted']}") - + # Display activity period if available - if 'first_used_at' in stats and stats['first_used_at'] and 'days_active' in stats: - console.console.print(f" β€’ Active since: {stats['first_used_at'].split('T')[0]}") + if "first_used_at" in stats and stats["first_used_at"] and "days_active" in stats: + first_used = stats["first_used_at"] + has_t = isinstance(first_used, str) and "T" in first_used + date_part = first_used.split("T")[0] if has_t else first_used + console.console.print(f" β€’ Active since: {date_part}") console.console.print(f" β€’ Days active: {stats['days_active']}") - - if 'avg_commits_per_day' in stats: - console.console.print(f" β€’ Average commits per day: {stats['avg_commits_per_day']:.2f}") - console.console.print(f" β€’ Average cost per day: €{stats['avg_cost_per_day']:.4f}") - + + if "avg_commits_per_day" in stats: + avg_commits = stats["avg_commits_per_day"] + console.console.print(f" β€’ Average commits per day: {avg_commits:.2f}") + avg_cost = stats.get("avg_cost_per_day", 0.0) + console.console.print(f" β€’ Average cost per day: €{avg_cost:.4f}") + # Display repository stats if available - if stats['repositories']: + repositories = stats.get("repositories", {}) + if repositories and isinstance(repositories, dict): console.console.print("\n[bold cyan]Repository Activity:[/bold cyan]") - console.console.print(f" β€’ Most active repository: {stats['most_active_repository']}") - console.console.print(f" β€’ Repositories used: {len(stats['repositories'])}") - + if "most_active_repository" in stats and stats["most_active_repository"]: + most_active = stats["most_active_repository"] + console.console.print(f" β€’ Most active repository: {most_active}") + console.console.print(f" β€’ Repositories used: {len(repositories)}") + # Display model usage if available - if stats['model_usage']: + model_usage = stats.get("model_usage", {}) + if model_usage and isinstance(model_usage, dict): console.console.print("\n[bold cyan]Model Usage:[/bold cyan]") - for model, count in stats['model_usage'].items(): + for model, count in model_usage.items(): console.console.print(f" β€’ {model}: {count} commits") - + # Display batch vs single commits console.console.print("\n[bold cyan]Processing Methods:[/bold cyan]") - console.console.print(f" β€’ Batch commits: {stats['batch_commits']}") - console.console.print(f" β€’ Single commits: {stats['single_commits']}") - + console.console.print(f" β€’ Batch commits: {stats.get('batch_commits', 0)}") + console.console.print(f" β€’ Single commits: {stats.get('single_commits', 0)}") + # Get more detailed stats if commits exist - if stats['total_commits'] > 0: + if stats.get("total_commits", 0) > 0: model_stats = metrics_manager.get_model_usage_stats() if model_stats: console.console.print("\n[bold cyan]Detailed Model Stats:[/bold cyan]") for model, model_data in model_stats.items(): console.console.print(f" β€’ {model}:") - console.console.print(f" - Total tokens: {model_data['tokens']:,}") - console.console.print(f" - Total cost: €{model_data['cost']:.4f}") - console.console.print(f" - Avg tokens per commit: {model_data['avg_tokens_per_commit']:.1f}") - - def run( - self, auto_commit: bool = False, combine_commits: bool = False, debug: bool = False - ) -> None: + console.console.print(f" - Total tokens: {model_data.get('tokens', 0):,}") + cost = model_data.get("cost", 0.0) + console.console.print(f" - Total cost: €{cost:.4f}") + avg_tokens = model_data.get("avg_tokens_per_commit", 0.0) + console.console.print(f" - Avg tokens per commit: {avg_tokens:.1f}") + + def run(self, auto_commit: bool = False, combine_commits: bool = False, debug: bool = False) -> None: """Run the commit process.""" - if debug: - self.console.setup_logging(debug) + # Logging is already configured in the main CLI callback # Set auto-confirm mode based on auto_commit flag console.set_auto_confirm(auto_commit) diff --git a/commitloom/cli/console.py b/commitloom/cli/console.py index bcda06b..88723f0 100644 --- a/commitloom/cli/console.py +++ b/commitloom/cli/console.py @@ -41,7 +41,10 @@ def set_auto_confirm(value: bool) -> None: def setup_logging(debug: bool = False): """Configure logging with optional debug mode.""" level = logging.DEBUG if debug else logging.INFO - + + # Clear existing handlers to avoid duplicates + logger.handlers.clear() + # Configure rich handler rich_handler = RichHandler(rich_tracebacks=True, markup=True, show_time=debug, show_path=debug) rich_handler.setLevel(level) @@ -51,7 +54,7 @@ def setup_logging(debug: bool = False): logger.addHandler(rich_handler) if debug: - logger.debug("Debug mode enabled") + console.print("[dim]Debug mode enabled[/dim]") def print_debug(message: str, exc_info: bool = False) -> None: @@ -66,25 +69,21 @@ def print_debug(message: str, exc_info: bool = False) -> None: def print_info(message: str) -> None: """Print info message.""" - logger.info(f"ℹ️ {message}") console.print(f"\n[bold blue]ℹ️ {message}[/bold blue]") def print_warning(message: str) -> None: """Print warning message.""" - logger.warning(f"⚠️ {message}") console.print(f"\n[bold yellow]⚠️ {message}[/bold yellow]") def print_error(message: str) -> None: """Print error message.""" - logger.error(f"❌ {message}") console.print(f"\n[bold red]❌ {message}[/bold red]") def print_success(message: str) -> None: """Print success message.""" - logger.info(f"βœ… {message}") console.print(f"\n[bold green]βœ… {message}[/bold green]") @@ -146,9 +145,7 @@ def print_batch_start(batch_num: int, total_batches: int, files: list[GitFile]) def print_batch_complete(batch_num: int, total_batches: int) -> None: """Print completion message for a batch.""" - console.print( - f"\n[bold green]βœ… Batch {batch_num}/{total_batches} completed successfully[/bold green]" - ) + console.print(f"\n[bold green]βœ… Batch {batch_num}/{total_batches} completed successfully[/bold green]") def print_batch_summary(total_files: int, total_batches: int, batch_size: int = 5) -> None: @@ -222,15 +219,24 @@ def confirm_batch_continue() -> bool: return False +def confirm_branch_creation(branch_name: str) -> bool: + """Ask user to confirm creation of a new branch for large commits.""" + if _auto_confirm: + return True + try: + prompt = f"Create a new branch '{branch_name}' for these large changes?" + return Confirm.ask(f"\n{prompt}") + except Exception: + return False + + def select_commit_strategy() -> str: """Ask user how they want to handle multiple commits.""" if _auto_confirm: return "individual" console.print("\n[bold blue]πŸ€” How would you like to handle the commits?[/bold blue]") try: - return Prompt.ask( - "Choose strategy", choices=["individual", "combined"], default="individual" - ) + return Prompt.ask("Choose strategy", choices=["individual", "combined"], default="individual") except Exception: return "individual" diff --git a/commitloom/config/settings.py b/commitloom/config/settings.py index 0c0e18d..b4a9d51 100644 --- a/commitloom/config/settings.py +++ b/commitloom/config/settings.py @@ -17,7 +17,7 @@ def find_env_file() -> Path | None: search_paths = [ Path.cwd() / ".env", Path(__file__).parent.parent.parent / ".env", - Path.home() / ".commitloom" / ".env" + Path.home() / ".commitloom" / ".env", ] for path in search_paths: @@ -25,20 +25,25 @@ def find_env_file() -> Path | None: return path return None + # Try to load environment variables from the first .env file found env_file = find_env_file() if env_file: load_dotenv(dotenv_path=env_file) + @dataclass(frozen=True) class ModelCosts: """Cost configuration for AI models.""" + input: float output: float + @dataclass(frozen=True) class Config: """Main configuration settings.""" + token_limit: int max_files_threshold: int cost_warning_threshold: float @@ -52,16 +57,23 @@ class Config: def from_env(cls) -> "Config": """Create configuration from environment variables.""" # Try to get API key from multiple sources - api_key = ( - os.getenv("OPENAI_API_KEY") or - os.getenv("COMMITLOOM_API_KEY") - ) + api_key = os.getenv("OPENAI_API_KEY") or os.getenv("COMMITLOOM_API_KEY") if not api_key: config_file = Path.home() / ".commitloom" / "config" if config_file.exists(): with open(config_file) as f: - api_key = f.read().strip() + for line in f: + line = line.strip() + if line and not line.startswith("#"): + if line.startswith("OPENAI_API_KEY="): + api_key = line.split("=", 1)[1].strip().strip("\"'") + os.environ["OPENAI_API_KEY"] = api_key + break + elif line.startswith("COMMITLOOM_API_KEY="): + api_key = line.split("=", 1)[1].strip().strip("\"'") + os.environ["COMMITLOOM_API_KEY"] = api_key + break if not api_key: raise ValueError( @@ -74,22 +86,12 @@ def from_env(cls) -> "Config": "5. Store your API key in ~/.commitloom/config" ) - token_limit = int(os.getenv( - "COMMITLOOM_TOKEN_LIMIT", - os.getenv("TOKEN_LIMIT", "120000") - )) - max_files = int(os.getenv( - "COMMITLOOM_MAX_FILES", - os.getenv("MAX_FILES_THRESHOLD", "5") - )) - cost_warning = float(os.getenv( - "COMMITLOOM_COST_WARNING", - os.getenv("COST_WARNING_THRESHOLD", "0.05") - )) - default_model = os.getenv( - "COMMITLOOM_MODEL", - os.getenv("MODEL_NAME", "gpt-4o-mini") + token_limit = int(os.getenv("COMMITLOOM_TOKEN_LIMIT", os.getenv("TOKEN_LIMIT", "120000"))) + max_files = int(os.getenv("COMMITLOOM_MAX_FILES", os.getenv("MAX_FILES_THRESHOLD", "5"))) + cost_warning = float( + os.getenv("COMMITLOOM_COST_WARNING", os.getenv("COST_WARNING_THRESHOLD", "0.05")) ) + default_model = os.getenv("COMMITLOOM_MODEL", os.getenv("MODEL_NAME", "gpt-4.1-mini")) return cls( token_limit=token_limit, @@ -116,6 +118,20 @@ def from_env(cls) -> "Config": "*.min.css", ], model_costs={ + # Nuevos modelos recomendados 2025 + "gpt-4.1": ModelCosts( + input=0.00200, # $2.00 por 1M tokens + output=0.00800, # $8.00 por 1M tokens + ), + "gpt-4.1-mini": ModelCosts( + input=0.00040, # $0.40 por 1M tokens + output=0.00160, # $1.60 por 1M tokens + ), + "gpt-4.1-nano": ModelCosts( + input=0.00010, # $0.10 por 1M tokens + output=0.00040, # $0.40 por 1M tokens + ), + # Modelos legacy "gpt-4o-mini": ModelCosts( input=0.00015, output=0.00060, @@ -136,5 +152,6 @@ def from_env(cls) -> "Config": api_key=api_key, ) + # Global configuration instance config = Config.from_env() diff --git a/commitloom/core/analyzer.py b/commitloom/core/analyzer.py index b496e04..7efddc8 100644 --- a/commitloom/core/analyzer.py +++ b/commitloom/core/analyzer.py @@ -58,9 +58,12 @@ def estimate_tokens_and_cost(text: str, model: str = config.default_model) -> tu Tuple of (estimated_tokens, estimated_cost) """ estimated_tokens = len(text) // config.token_estimation_ratio - cost_per_token = config.model_costs[model].input / 1_000_000 + if model in config.model_costs: + cost_per_token = config.model_costs[model].input / 1_000_000 + else: + print(f"[WARNING] Cost estimation is not available for model '{model}'.") + cost_per_token = 0.0 estimated_cost = estimated_tokens * cost_per_token - return estimated_tokens, estimated_cost @staticmethod @@ -170,9 +173,9 @@ def format_cost_for_humans(cost: float) -> str: if cost >= 1.0: return f"€{cost:.2f}" elif cost >= 0.01: - return f"{cost*100:.2f}Β’" + return f"{cost * 100:.2f}Β’" else: - return "0.10Β’" # For very small costs, show as 0.10Β’ + return f"{cost * 100:.2f}Β’" @staticmethod def get_cost_context(total_cost: float) -> str: diff --git a/commitloom/core/batch.py b/commitloom/core/batch.py index 2a8cb35..111408e 100644 --- a/commitloom/core/batch.py +++ b/commitloom/core/batch.py @@ -33,8 +33,7 @@ def process_files(self, files: list[str]) -> None: # Split files into batches batches = [ - files[i : i + self.config.batch_size] - for i in range(0, len(files), self.config.batch_size) + files[i : i + self.config.batch_size] for i in range(0, len(files), self.config.batch_size) ] # Process each batch diff --git a/commitloom/core/git.py b/commitloom/core/git.py index 3bfc11e..62be737 100644 --- a/commitloom/core/git.py +++ b/commitloom/core/git.py @@ -4,6 +4,9 @@ import os import subprocess from dataclasses import dataclass +from fnmatch import fnmatch + +from ..config.settings import config logger = logging.getLogger(__name__) @@ -38,6 +41,14 @@ def is_renamed(self) -> bool: class GitOperations: """Basic git operations handler.""" + @staticmethod + def should_ignore_file(path: str) -> bool: + """Check if a file should be ignored based on configured patterns.""" + for pattern in config.ignored_patterns: + if fnmatch(path, pattern): + return True + return False + @staticmethod def _handle_git_output(result: subprocess.CompletedProcess, context: str = "") -> None: """Handle git command output and log messages.""" @@ -59,9 +70,7 @@ def _is_binary_file(path: str) -> tuple[bool, int | None, str | None]: size = os.path.getsize(path) # Get file hash - result = subprocess.run( - ["git", "hash-object", path], capture_output=True, text=True, check=True - ) + result = subprocess.run(["git", "hash-object", path], capture_output=True, text=True, check=True) file_hash = result.stdout.strip() # Check if file is binary using git's internal mechanism @@ -157,16 +166,12 @@ def get_staged_files() -> list[GitFile]: # First character is staged status, second is unstaged if status[0] != " " and status[0] != "?": is_binary, size, file_hash = GitOperations._is_binary_file(path_info) - files.append( - GitFile(path=path_info, status=status[0], size=size, hash=file_hash) - ) + files.append(GitFile(path=path_info, status=status[0], size=size, hash=file_hash)) if status[1] != " " and status[1] != "?": # Only add if not already added with staged status if not any(f.path == path_info for f in files): is_binary, size, file_hash = GitOperations._is_binary_file(path_info) - files.append( - GitFile(path=path_info, status=status[1], size=size, hash=file_hash) - ) + files.append(GitFile(path=path_info, status=status[1], size=size, hash=file_hash)) return files @@ -262,9 +267,7 @@ def stash_save(message: str = "") -> None: def stash_pop() -> None: """Pop most recent stash.""" try: - result = subprocess.run( - ["git", "stash", "pop"], capture_output=True, text=True, check=True - ) + result = subprocess.run(["git", "stash", "pop"], capture_output=True, text=True, check=True) GitOperations._handle_git_output(result, "during stash pop") except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else str(e) @@ -284,3 +287,18 @@ def unstage_file(file: str) -> None: except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else str(e) raise GitError(f"Failed to unstage file: {error_msg}") + + @staticmethod + def create_and_checkout_branch(branch: str) -> None: + """Create and switch to a new branch.""" + try: + result = subprocess.run( + ["git", "checkout", "-b", branch], + capture_output=True, + text=True, + check=True, + ) + GitOperations._handle_git_output(result, f"while creating branch {branch}") + except subprocess.CalledProcessError as e: + error_msg = e.stderr if e.stderr else str(e) + raise GitError(f"Failed to create branch '{branch}': {error_msg}") diff --git a/commitloom/core/smart_grouping.py b/commitloom/core/smart_grouping.py new file mode 100644 index 0000000..8f768b0 --- /dev/null +++ b/commitloom/core/smart_grouping.py @@ -0,0 +1,803 @@ +"""Smart grouping module for intelligent file batching based on semantic relationships.""" + +import re +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from .git import GitFile + + +class ChangeType(Enum): + """Types of changes detected in files.""" + + FEATURE = "feature" + FIX = "fix" + TEST = "test" + DOCS = "docs" + REFACTOR = "refactor" + STYLE = "style" + CHORE = "chore" + CONFIG = "config" + BUILD = "build" + PERF = "perf" + + +@dataclass +class FileRelationship: + """Represents a relationship between two files.""" + + file1: str + file2: str + relationship_type: str + strength: float # 0.0 to 1.0 + + +@dataclass +class FileGroup: + """A group of files that should be committed together.""" + + files: list[GitFile] + change_type: ChangeType + reason: str + confidence: float # 0.0 to 1.0 + dependencies: list[str] = field(default_factory=list) + + +class SmartGrouper: + """Intelligent file grouping based on semantic analysis.""" + + MAX_FILE_SIZE_FOR_ANALYSIS = 200_000 + + # Patterns for detecting change types + CHANGE_TYPE_PATTERNS = { + ChangeType.TEST: [ + r"test[s]?/", + r"test_.*\.py$", + r".*_test\.py$", + r".*\.test\.[jt]sx?$", + r".*\.spec\.[jt]sx?$", + r"__tests__/", + ], + ChangeType.DOCS: [ + r"\.md$", + r"\.rst$", + r"docs?/", + r"README", + r"CHANGELOG", + r"LICENSE", + r"(? list[FileGroup]: + """ + Analyze files and create intelligent groups. + + Args: + files: List of changed files to analyze + + Returns: + List of file groups for committing + """ + if not files: + return [] + + self.file_contents_cache.clear() + + # Step 1: Detect change types for each file + file_types = self._detect_change_types(files) + + # Step 2: Analyze relationships between files + self.relationships = self._analyze_relationships(files) + + # Step 3: Detect dependencies + dependencies = self._detect_dependencies(files) + + # Step 4: Create initial groups by change type + groups_by_type = self._group_by_change_type(files, file_types) + + # Step 5: Refine groups based on relationships and dependencies + refined_groups = self._refine_groups(groups_by_type, dependencies) + + # Step 6: Split large groups if necessary + final_groups = self._split_large_groups(refined_groups) + + self._enrich_groups_with_dependencies(final_groups, dependencies) + + return final_groups + + def _detect_change_types(self, files: list[GitFile]) -> dict[str, ChangeType]: + """ + Detect the type of change for each file. + + Args: + files: List of files to analyze + + Returns: + Dictionary mapping file paths to change types + """ + file_types = {} + + for file in files: + change_type = self._detect_single_file_type(file.path) + file_types[file.path] = change_type + + return file_types + + def _detect_single_file_type(self, file_path: str) -> ChangeType: + """ + Detect the change type for a single file. + + Args: + file_path: Path to the file + + Returns: + The detected change type + """ + # Check against patterns + for change_type, patterns in self.CHANGE_TYPE_PATTERNS.items(): + for pattern in patterns: + if self._matches_pattern(pattern, file_path): + return change_type + + # Check file extension for common source files + ext = Path(file_path).suffix.lower() + source_extensions = { + ".py", + ".js", + ".jsx", + ".ts", + ".tsx", + ".java", + ".go", + ".cpp", + ".c", + ".h", + ".hpp", + ".rs", + ".rb", + ".php", + ".swift", + ".kt", + ".scala", + ".cs", + ".vb", + ".f90", + } + + if ext in source_extensions: + # Try to determine if it's a feature or fix based on path + if "fix" in file_path.lower() or "bug" in file_path.lower(): + return ChangeType.FIX + elif "feature" in file_path.lower() or "feat" in file_path.lower(): + return ChangeType.FEATURE + else: + # Default to refactor for source files + return ChangeType.REFACTOR + + # Default to chore + return ChangeType.CHORE + + def _analyze_relationships(self, files: list[GitFile]) -> list[FileRelationship]: + """ + Analyze relationships between files. + + Args: + files: List of files to analyze + + Returns: + List of file relationships + """ + relationships = [] + + for i, file1 in enumerate(files): + for file2 in files[i + 1 :]: + # Check for various relationship types + rel = self._find_relationship(file1, file2) + if rel: + relationships.append(rel) + + return relationships + + def _find_relationship(self, file1: GitFile, file2: GitFile) -> FileRelationship | None: + """ + Find relationship between two files. + + Args: + file1: First file + file2: Second file + + Returns: + FileRelationship if found, None otherwise + """ + path1 = Path(file1.path) + path2 = Path(file2.path) + + # Test and implementation relationship + if self._is_test_implementation_pair(path1, path2): + return FileRelationship(file1.path, file2.path, "test-implementation", strength=1.0) + + # Component relationship (e.g., .tsx and .css files with same name) + # Check this before same-directory to give it priority + if path1.stem == path2.stem and path1.parent == path2.parent and path1.suffix != path2.suffix: + # Check if they're likely component pairs (different extensions but same name) + component_extensions = { + ".tsx", + ".jsx", + ".ts", + ".js", + ".css", + ".scss", + ".sass", + ".less", + ".module.css", + } + if path1.suffix in component_extensions or path2.suffix in component_extensions: + return FileRelationship(file1.path, file2.path, "component-pair", strength=0.9) + + # Similar naming (check before same directory) + if self._has_similar_naming(path1, path2): + return FileRelationship(file1.path, file2.path, "similar-naming", strength=0.6) + + # Same directory relationship + if path1.parent == path2.parent: + return FileRelationship(file1.path, file2.path, "same-directory", strength=0.7) + + # Parent-child directory relationship + if self._is_parent_child_directory(path1, path2): + return FileRelationship(file1.path, file2.path, "directory-hierarchy", strength=0.5) + + # Similar naming (e.g., user_service.py and user_model.py) + if self._has_similar_naming(path1, path2): + return FileRelationship(file1.path, file2.path, "similar-naming", strength=0.6) + + return None + + def _is_test_implementation_pair(self, path1: Path, path2: Path) -> bool: + """Check if two files form a test-implementation pair.""" + # Check if one is test and other is not + is_test1 = self._is_test_file(str(path1)) + is_test2 = self._is_test_file(str(path2)) + + if is_test1 == is_test2: + return False # Both test or both not test + + # Check if they have similar names + test_path = path1 if is_test1 else path2 + impl_path = path2 if is_test1 else path1 + + # Remove test markers from filename + test_name = test_path.stem + test_name = re.sub(r"(test_|_test|\.test|\.spec)", "", test_name) + + impl_name = impl_path.stem + + return test_name == impl_name or test_name in impl_name or impl_name in test_name + + def _is_test_file(self, file_path: str) -> bool: + """Check if a file is a test file.""" + for pattern in self.CHANGE_TYPE_PATTERNS[ChangeType.TEST]: + if self._matches_pattern(pattern, file_path): + return True + return False + + def _is_parent_child_directory(self, path1: Path, path2: Path) -> bool: + """Check if paths are in parent-child directory relationship.""" + try: + return path1.parent in path2.parents or path2.parent in path1.parents + except ValueError: + return False + + def _has_similar_naming(self, path1: Path, path2: Path) -> bool: + """Check if two files have similar naming patterns.""" + # Extract base names without extensions + name1 = path1.stem.lower() + name2 = path2.stem.lower() + + # Split by common separators + parts1 = [p for p in re.split(r"[_\-.]", name1) if p] # Filter empty parts + parts2 = [p for p in re.split(r"[_\-.]", name2) if p] # Filter empty parts + + # Check for common parts + common_parts = set(parts1) & set(parts2) + if not common_parts: + return False + + # Calculate similarity ratio + total_parts = len(set(parts1) | set(parts2)) + common_ratio = len(common_parts) / total_parts if total_parts > 0 else 0 + + # More lenient threshold for similar naming + return common_ratio >= 0.3 # Changed from 0.5 to 0.3 + + def _detect_dependencies(self, files: list[GitFile]) -> dict[str, list[str]]: + """ + Detect dependencies between files based on imports. + + Args: + files: List of files to analyze + + Returns: + Dictionary mapping file paths to their dependencies + """ + dependencies = defaultdict(list) + + for file in files: + if file.is_binary: + continue + + ext = Path(file.path).suffix.lower() + language = self._get_language_from_extension(ext) + + if not language or language not in self.IMPORT_PATTERNS: + continue + + raw_imports = self._extract_imports(file.path, language) + + if not raw_imports: + continue + + normalized_imports = [self._normalize_import_path(imp) for imp in raw_imports] + + matched_dependencies: set[str] = set() + for imp in normalized_imports: + if not imp: + continue + for other_file in files: + if other_file.path == file.path: + continue + if self._import_matches_file(imp, other_file.path): + matched_dependencies.add(other_file.path) + + if matched_dependencies: + dependencies[file.path].extend(sorted(matched_dependencies)) + + return {path: deps for path, deps in dependencies.items() if deps} + + def _get_language_from_extension(self, ext: str) -> str | None: + """Get programming language from file extension.""" + extension_map = { + ".py": "python", + ".js": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".java": "java", + ".go": "go", + } + return extension_map.get(ext) + + def _extract_imports(self, file_path: str, language: str) -> list[str]: + """ + Extract import statements from a file. + + Args: + file_path: Path to the file + language: Programming language + + Returns: + List of imported modules/files + """ + imports: list[str] = [] + + try: + content = self._get_file_contents(file_path) + except OSError: + return [] + + if not content: + return [] + + for pattern in self.IMPORT_PATTERNS.get(language, []): + for match in re.findall(pattern, content): + if isinstance(match, tuple): + imports.extend([m for m in match if m]) + elif match: + imports.append(match) + + return imports + + def _import_matches_file(self, import_path: str, file_path: str) -> bool: + """Check if an import path matches a file path.""" + # Normalize paths + import_parts = import_path.replace(".", "/").split("/") + file_path_obj = Path(file_path) + + # Remove file extension for matching + file_stem = file_path_obj.stem + file_parts = list(file_path_obj.parent.parts) + [file_stem] + + # Check if import parts match any subsequence in file parts + import_str = "/".join(import_parts) + file_str = "/".join(file_parts) + + # Check for exact match or if import is contained in file path + if import_str in file_str: + return True + + # Check if the last part of import matches the file name + if import_parts and import_parts[-1] == file_stem: + return True + + return False + + def _group_by_change_type( + self, files: list[GitFile], file_types: dict[str, ChangeType] + ) -> dict[ChangeType, list[GitFile]]: + """ + Group files by their change type. + + Args: + files: List of files + file_types: Mapping of file paths to change types + + Returns: + Dictionary mapping change types to lists of files + """ + groups = defaultdict(list) + + for file in files: + change_type = file_types.get(file.path, ChangeType.CHORE) + groups[change_type].append(file) + + return dict(groups) + + def _refine_groups( + self, groups_by_type: dict[ChangeType, list[GitFile]], dependencies: dict[str, list[str]] + ) -> list[FileGroup]: + """ + Refine groups based on relationships and dependencies. + + Args: + groups_by_type: Initial groups by change type + dependencies: File dependencies + + Returns: + Refined list of file groups + """ + refined_groups = [] + assigned_paths: set[str] = set() + + file_lookup = {file.path: file for file_list in groups_by_type.values() for file in file_list} + + original_order = {change_type: index for index, change_type in enumerate(groups_by_type.keys())} + sorted_change_types = sorted( + groups_by_type.keys(), + key=lambda ct: (self._change_type_priority(ct), original_order.get(ct, 0)), + ) + + for change_type in sorted_change_types: + files = groups_by_type.get(change_type, []) + if not files: + continue + + available_files = [file for file in files if file.path not in assigned_paths] + + if not available_files: + continue + + if change_type == ChangeType.TEST: + test_groups = self._group_tests_with_implementations( + available_files, file_lookup, dependencies, assigned_paths + ) + refined_groups.extend(test_groups) + elif len(available_files) <= 3: + group = FileGroup( + files=available_files, + change_type=change_type, + reason=f"All {change_type.value} changes", + confidence=0.8, + ) + assigned_paths.update(file.path for file in available_files) + refined_groups.append(group) + else: + subgroups = self._split_by_module(available_files, change_type) + for subgroup in subgroups: + assigned_paths.update(file.path for file in subgroup.files) + refined_groups.extend(subgroups) + + return refined_groups + + def _group_tests_with_implementations( + self, + test_files: list[GitFile], + file_lookup: dict[str, GitFile], + dependencies: dict[str, list[str]], + assigned_paths: set[str], + ) -> list[FileGroup]: + """Group test files with their corresponding implementations.""" + groups: list[FileGroup] = [] + test_paths = {file.path for file in test_files} + + implementation_to_tests: dict[str, set[str]] = defaultdict(set) + for rel in self.relationships: + if rel.relationship_type != "test-implementation": + continue + + test_path, impl_path = self._identify_test_and_implementation(rel.file1, rel.file2) + if not test_path or not impl_path: + continue + + if test_path not in test_paths: + continue + + if impl_path not in file_lookup: + continue + + implementation_to_tests[impl_path].add(test_path) + + for impl_path, tests in implementation_to_tests.items(): + if impl_path in assigned_paths: + continue + + candidate_tests = [test for test in sorted(tests) if test not in assigned_paths] + if not candidate_tests: + continue + + group_paths: set[str] = {impl_path} + group_paths.update(candidate_tests) + + group_files = [file_lookup[path] for path in sorted(group_paths)] + if not group_files: + continue + + assigned_paths.update(group_paths) + + reason = "Test with linked implementation" if len(candidate_tests) == 1 else "Test suite with implementation" + confidence = 0.9 if len(candidate_tests) == 1 else 0.95 + + groups.append( + FileGroup( + files=group_files, + change_type=ChangeType.TEST, + reason=reason, + confidence=confidence, + ) + ) + + for test_file in test_files: + if test_file.path in assigned_paths: + continue + + related_paths: set[str] = {test_file.path} + for dependency in dependencies.get(test_file.path, []): + if dependency in assigned_paths: + continue + if dependency in file_lookup: + related_paths.add(dependency) + + group_files = [file_lookup[path] for path in sorted(related_paths) if path in file_lookup] + if not group_files: + continue + + assigned_paths.update(related_paths) + + reason = "Isolated test change" if len(group_files) == 1 else "Test with supporting files" + confidence = 0.7 if len(group_files) == 1 else 0.78 + + groups.append( + FileGroup( + files=group_files, + change_type=ChangeType.TEST, + reason=reason, + confidence=confidence, + ) + ) + + return groups + + def _split_by_module(self, files: list[GitFile], change_type: ChangeType) -> list[FileGroup]: + """Split files into groups by module or directory.""" + module_groups = defaultdict(list) + + for file in files: + # Group by top-level directory or module + parts = Path(file.path).parts + if len(parts) > 1: + module = parts[0] + else: + module = "root" + module_groups[module].append(file) + + groups = [] + for module, module_files in module_groups.items(): + group = FileGroup( + files=module_files, + change_type=change_type, + reason=f"{change_type.value} changes in {module} module", + confidence=0.7, + ) + groups.append(group) + + return groups + + def _split_large_groups(self, groups: list[FileGroup]) -> list[FileGroup]: + """ + Split large groups into smaller, manageable chunks. + + Args: + groups: List of file groups + + Returns: + List of file groups with large groups split + """ + final_groups = [] + max_files_per_group = 5 # Configurable threshold + + for group in groups: + if len(group.files) <= max_files_per_group: + final_groups.append(group) + else: + # Split the group + for i in range(0, len(group.files), max_files_per_group): + chunk = group.files[i : i + max_files_per_group] + split_group = FileGroup( + files=chunk, + change_type=group.change_type, + reason=f"{group.reason} (part {i // max_files_per_group + 1})", + confidence=group.confidence * 0.9, # Slightly lower confidence for splits + ) + final_groups.append(split_group) + + return final_groups + + def get_group_summary(self, group: FileGroup) -> str: + """ + Get a human-readable summary of a file group. + + Args: + group: The file group + + Returns: + Summary string + """ + file_list = ", ".join(f.path for f in group.files) + return ( + f"Group: {group.change_type.value}\n" + f"Reason: {group.reason}\n" + f"Confidence: {group.confidence:.1%}\n" + f"Files: {file_list}\n" + f"Dependencies: {', '.join(group.dependencies) if group.dependencies else 'None'}" + ) + + @classmethod + def _change_type_priority(cls, change_type: ChangeType) -> int: + """Get processing priority for a change type.""" + return cls.CHANGE_TYPE_PRIORITY.get(change_type, 5) + + def _identify_test_and_implementation(self, path1: str, path2: str) -> tuple[str | None, str | None]: + """Identify which path corresponds to the test and which to the implementation.""" + is_test1 = self._is_test_file(path1) + is_test2 = self._is_test_file(path2) + + if is_test1 and not is_test2: + return path1, path2 + if is_test2 and not is_test1: + return path2, path1 + + return None, None + + def _normalize_import_path(self, import_path: str) -> str: + """Normalize import paths for comparison.""" + normalized = import_path.strip().strip("\"'") + normalized = normalized.lstrip("./") + return normalized + + def _get_file_contents(self, file_path: str) -> str: + """Retrieve file contents with caching and safety checks.""" + if file_path in self.file_contents_cache: + return self.file_contents_cache[file_path] + + path = Path(file_path) + try: + if not path.exists() or path.is_dir(): + self.file_contents_cache[file_path] = "" + return "" + + if path.stat().st_size > self.MAX_FILE_SIZE_FOR_ANALYSIS: + self.file_contents_cache[file_path] = "" + return "" + + content = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + content = "" + + self.file_contents_cache[file_path] = content + return content + + def _enrich_groups_with_dependencies( + self, groups: list[FileGroup], dependencies: dict[str, list[str]] + ) -> None: + """Populate dependency information for each group.""" + for group in groups: + group_paths = {file.path for file in group.files} + dependency_set = { + dep + for file in group.files + for dep in dependencies.get(file.path, []) + if dep not in group_paths + } + + group.dependencies = sorted(dependency_set) + + def _matches_pattern(self, pattern: str, file_path: str) -> bool: + """Match change-type patterns against either full paths or file names.""" + target = file_path if "/" in pattern else Path(file_path).name + return re.search(pattern, target, re.IGNORECASE) is not None diff --git a/commitloom/services/ai_service.py b/commitloom/services/ai_service.py index 3b73ace..0552a45 100644 --- a/commitloom/services/ai_service.py +++ b/commitloom/services/ai_service.py @@ -2,6 +2,8 @@ import json from dataclasses import dataclass +import os +import time import requests @@ -21,17 +23,20 @@ class TokenUsage: total_cost: float @classmethod - def from_api_usage( - cls, usage: dict[str, int], model: str = config.default_model - ) -> "TokenUsage": + def from_api_usage(cls, usage: dict[str, int], model: str = config.default_model) -> "TokenUsage": """Create TokenUsage from API response usage data.""" prompt_tokens = usage["prompt_tokens"] completion_tokens = usage["completion_tokens"] total_tokens = usage["total_tokens"] - # Calculate costs - input_cost = (prompt_tokens / 1_000_000) * config.model_costs[model].input - output_cost = (completion_tokens / 1_000_000) * config.model_costs[model].output + # Si el modelo no estΓ‘ en la lista, coste 0 y advertencia + if model in config.model_costs: + input_cost = (prompt_tokens / 1_000) * config.model_costs[model].input + output_cost = (completion_tokens / 1_000) * config.model_costs[model].output + else: + input_cost = 0.0 + output_cost = 0.0 + print(f"[WARNING] Cost estimation is not available for model '{model}'.") total_cost = input_cost + output_cost return cls( @@ -80,12 +85,14 @@ def __init__(self, api_key: str | None = None, test_mode: bool = False): raise ValueError("API key is required") self.api_key = api_key or config.api_key self.test_mode = test_mode - self.model_name = config.default_model + # Permitir override por variable de entorno + self.model_name = os.getenv("COMMITLOOM_MODEL", config.default_model) + self.session = requests.Session() @property def model(self) -> str: """Get the model name. - + Returns: The model name from config. """ @@ -99,14 +106,15 @@ def token_usage_from_api_usage(cls, usage: dict[str, int]) -> TokenUsage: def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str: """Generate the prompt for the AI model.""" files_summary = ", ".join(f.path for f in changed_files) + has_binary = any(f.is_binary for f in changed_files) + binary_files = ", ".join(f.path for f in changed_files if f.is_binary) + text_files = [f for f in changed_files if not f.is_binary] - # Check if we're dealing with binary files - if diff.startswith("Binary files changed:"): + if has_binary and not text_files: return ( "Generate a structured commit message for the following binary file changes.\n" "You must respond ONLY with a valid JSON object.\n\n" - f"Files changed: {files_summary}\n\n" - f"{diff}\n\n" + f"Files changed: {binary_files}\n\n" "Requirements:\n" "1. Title: Maximum 50 characters, starting with an appropriate " "gitemoji (πŸ“ for data files), followed by the semantic commit " @@ -121,18 +129,22 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str: ' "emoji": "πŸ“",\n' ' "changes": [\n' ' "Updated binary files with new data",\n' - ' "Files affected: example.bin"\n' + f' "Files affected: {binary_files}"\n' " ]\n" " }\n" " },\n" - ' "summary": "Updated binary files with new data"\n' + f' "summary": "Updated binary files: {binary_files}"\n' "}" ) - return ( + prompt = ( "Generate a structured commit message for the following git diff.\n" "You must respond ONLY with a valid JSON object.\n\n" f"Files changed: {files_summary}\n\n" + ) + if binary_files: + prompt += f"Binary files: {binary_files}\n\n" + prompt += ( "```\n" f"{diff}\n" "```\n\n" @@ -165,6 +177,7 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str: ' "summary": "Added new feature X with configuration updates"\n' "}" ) + return prompt def generate_commit_message( self, diff: str, changed_files: list[GitFile] @@ -199,43 +212,63 @@ def generate_commit_message( } data = { - "model": config.default_model, + "model": self.model_name, "messages": [{"role": "user", "content": prompt}], "response_format": {"type": "json_object"}, "max_tokens": 1000, "temperature": 0.7, } - try: - response = requests.post( - "https://api.openai.com/v1/chat/completions", - headers=headers, - json=data, - timeout=30, - ) - - if response.status_code == 400: - error_data = response.json() - error_message = error_data.get("error", {}).get("message", "Unknown error") - raise ValueError(f"API Error: {error_message}") - - response.raise_for_status() - response_data = response.json() - content = response_data["choices"][0]["message"]["content"] - usage = response_data["usage"] - + last_exception: requests.exceptions.RequestException | None = None + response: requests.Response | None = None + for attempt in range(3): try: - commit_data = json.loads(content) - return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage) - except json.JSONDecodeError as e: - raise ValueError(f"Failed to parse AI response: {str(e)}") from e - - except requests.exceptions.RequestException as e: - if hasattr(e, "response") and e.response is not None and hasattr(e.response, "text"): - error_message = e.response.text + response = self.session.post( + "https://api.openai.com/v1/chat/completions", + headers=headers, + json=data, + timeout=30, + ) + if response.status_code >= 500: + raise requests.exceptions.RequestException( + f"Server error: {response.status_code}", response=response + ) + break + except requests.exceptions.RequestException as e: + last_exception = e + if attempt == 2: + break + time.sleep(2**attempt) + + if last_exception and (response is None or response.status_code >= 500): + if ( + hasattr(last_exception, "response") + and last_exception.response is not None + and hasattr(last_exception.response, "text") + ): + error_message = last_exception.response.text else: - error_message = str(e) - raise ValueError(f"API Request failed: {error_message}") from e + error_message = str(last_exception) + raise ValueError(f"API Request failed: {error_message}") from last_exception + + if response is None: + raise ValueError("No response received from API") + + if response.status_code == 400: + error_data = response.json() + error_message = error_data.get("error", {}).get("message", "Unknown error") + raise ValueError(f"API Error: {error_message}") + + response.raise_for_status() + response_data = response.json() + content = response_data["choices"][0]["message"]["content"] + usage = response_data["usage"] + + try: + commit_data = json.loads(content) + return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse AI response: {str(e)}") from e @staticmethod def format_commit_message(commit_data: CommitSuggestion) -> str: diff --git a/commitloom/services/metrics.py b/commitloom/services/metrics.py index 8ceac7c..ad936a5 100644 --- a/commitloom/services/metrics.py +++ b/commitloom/services/metrics.py @@ -84,17 +84,36 @@ def _load_statistics(self) -> UsageStatistics: try: with open(self._stats_file) as f: data = json.load(f) + # Ensure valid JSON structure + if not isinstance(data, dict): + logger.debug("Invalid statistics file format, resetting") + return UsageStatistics() stats = UsageStatistics(**data) return stats - except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: - logger.warning(f"Failed to load statistics, creating new file: {str(e)}") + except FileNotFoundError: + # Normal for first run, don't log + return UsageStatistics() + except (json.JSONDecodeError, KeyError) as e: + # Only log for actual corruption + logger.debug(f"Statistics file corrupted, resetting: {str(e)}") return UsageStatistics() def _save_statistics(self) -> None: """Save usage statistics to file.""" try: + # Ensure valid data structure before saving + stats_dict = asdict(self._statistics) + + # Fix any potential problematic values + if "repositories" in stats_dict and not isinstance(stats_dict["repositories"], dict): + stats_dict["repositories"] = {} + + if "model_usage" in stats_dict and not isinstance(stats_dict["model_usage"], dict): + stats_dict["model_usage"] = {} + with open(self._stats_file, "w") as f: - json.dump(asdict(self._statistics), f, indent=2) + json.dump(stats_dict, f, indent=2) + except (OSError, TypeError) as e: logger.warning(f"Failed to save statistics: {str(e)}") @@ -107,8 +126,15 @@ def _save_metrics(self, metrics: CommitMetrics) -> None: try: with open(self._metrics_file) as f: metrics_list = json.load(f) - except (json.JSONDecodeError, FileNotFoundError) as e: - logger.warning(f"Failed to load metrics, creating new file: {str(e)}") + if not isinstance(metrics_list, list): + logger.debug("Invalid metrics file format, resetting") + metrics_list = [] + except FileNotFoundError: + # Normal for first run, don't log + metrics_list = [] + except json.JSONDecodeError as e: + # Only log for actual corruption + logger.debug(f"Metrics file corrupted, resetting: {str(e)}") metrics_list = [] # Add new metrics and save @@ -253,14 +279,22 @@ def get_statistics(self) -> dict[str, Any]: try: first = datetime.fromisoformat(stats["first_used_at"]) last = datetime.fromisoformat(stats["last_used_at"]) - days_active = (last - first).days + 1 + + # Calculate days active (at least 1) + days_active = max(1, (last.date() - first.date()).days + 1) stats["days_active"] = days_active if days_active > 0: + # Calculate average commits per day stats["avg_commits_per_day"] = stats["total_commits"] / days_active + + # Calculate average cost per day (ensure it's not zero) stats["avg_cost_per_day"] = stats["total_cost_in_eur"] / days_active except (ValueError, TypeError): - pass + # Default values if calculation fails + stats["days_active"] = 1 + stats["avg_commits_per_day"] = stats["total_commits"] + stats["avg_cost_per_day"] = stats["total_cost_in_eur"] return stats @@ -286,10 +320,12 @@ def get_recent_metrics(self, days: int = 30) -> list[dict[str, Any]]: cutoff_date = datetime.now() - timedelta(days=days) cutoff_str = cutoff_date.isoformat() - metrics_list = [m for m in all_metrics if m.get("timestamp", "") >= cutoff_str] + for metric in all_metrics: + if "timestamp" in metric and metric["timestamp"] >= cutoff_str: + metrics_list.append(metric) return metrics_list - except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: + except (json.JSONDecodeError, FileNotFoundError) as e: logger.warning(f"Failed to load metrics: {str(e)}") return [] @@ -359,17 +395,19 @@ def get_model_usage_stats(self) -> dict[str, dict[str, Any]]: } model_stats[model_name]["commits"] += 1 - model_stats[model_name]["tokens"] += metric.get("tokens_used", 0) - model_stats[model_name]["cost"] += metric.get("cost_in_eur", 0.0) + tokens = metric.get("tokens_used", 0) + model_stats[model_name]["tokens"] += tokens + cost = metric.get("cost_in_eur", 0.0) + model_stats[model_name]["cost"] += cost # Calculate averages - for _, stats in model_stats.items(): + for model, stats in model_stats.items(): if stats["commits"] > 0: stats["avg_tokens_per_commit"] = stats["tokens"] / stats["commits"] return model_stats except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: - logger.warning(f"Failed to load metrics for model usage stats: {str(e)}") + logger.warning(f"Failed to load metrics for model stats: {str(e)}") return {} def get_repository_stats(self) -> dict[str, dict[str, Any]]: @@ -430,12 +468,14 @@ def _format_timedelta(td: timedelta) -> str: parts.append(f"{days} day{'s' if days != 1 else ''}") if hours > 0: parts.append(f"{hours} hour{'s' if hours != 1 else ''}") - if minutes > 0: + if minutes > 0 or (days == 0 and hours == 0): parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") - if seconds > 0 and not parts: # Only show seconds if no larger units - parts.append(f"{seconds} second{'s' if seconds != 1 else ''}") - return ", ".join(parts) + # Always include at least one unit (default to minutes if everything is 0) + if not parts: + parts.append("0 minutes") + + return " ".join(parts) # Singleton instance diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 4ad5d6c..0000000 --- a/poetry.lock +++ /dev/null @@ -1,587 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.6.9" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb"}, - {file = "coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710"}, - {file = "coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa"}, - {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1"}, - {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec"}, - {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3"}, - {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5"}, - {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073"}, - {file = "coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198"}, - {file = "coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717"}, - {file = "coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9"}, - {file = "coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c"}, - {file = "coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7"}, - {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9"}, - {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4"}, - {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1"}, - {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b"}, - {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3"}, - {file = "coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0"}, - {file = "coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b"}, - {file = "coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8"}, - {file = "coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a"}, - {file = "coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015"}, - {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3"}, - {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae"}, - {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4"}, - {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6"}, - {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f"}, - {file = "coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692"}, - {file = "coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97"}, - {file = "coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664"}, - {file = "coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c"}, - {file = "coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014"}, - {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00"}, - {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d"}, - {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a"}, - {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077"}, - {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb"}, - {file = "coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba"}, - {file = "coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1"}, - {file = "coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419"}, - {file = "coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a"}, - {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4"}, - {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae"}, - {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030"}, - {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be"}, - {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e"}, - {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9"}, - {file = "coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b"}, - {file = "coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611"}, - {file = "coverage-7.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902"}, - {file = "coverage-7.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be"}, - {file = "coverage-7.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599"}, - {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08"}, - {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464"}, - {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845"}, - {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf"}, - {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678"}, - {file = "coverage-7.6.9-cp39-cp39-win32.whl", hash = "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6"}, - {file = "coverage-7.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4"}, - {file = "coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b"}, - {file = "coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d"}, -] - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mypy" -version = "1.13.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, - {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, - {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, - {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, - {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, - {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, - {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, - {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, - {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, - {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, - {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, - {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, - {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, - {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, - {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, - {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, - {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, - {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, - {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, - {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, - {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, - {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -typing-extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pytest" -version = "8.3.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.23.8" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, - {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, -] - -[package.dependencies] -pytest = ">=7.0.0,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "6.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, - {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, -] - -[package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-mock" -version = "3.14.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rich" -version = "13.9.4" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "ruff" -version = "0.1.15" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, - {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, - {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, - {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, - {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, -] - -[[package]] -name = "types-requests" -version = "2.32.0.20241016" -description = "Typing stubs for requests" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, - {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, -] - -[package.dependencies] -urllib3 = ">=2" - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.11" -content-hash = "f4217a1bffe1d5e4241dfcbc17e31abc3e503b90662f0a4f7b15fd76da2d7053" diff --git a/pyproject.toml b/pyproject.toml index b3ad75d..c5627db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ -[tool.poetry] +[project] name = "commitloom" -version = "1.2.2" +version = "1.7.0" description = "Weave perfect git commits with AI-powered intelligence" -authors = ["Petru Arakiss "] +authors = [ + { name = "Petru Arakiss", email = "petruarakiss@gmail.com" } +] readme = "README.md" -license = "MIT" -homepage = "https://github.com/Arakiss/commitloom" -repository = "https://github.com/Arakiss/commitloom" -documentation = "https://github.com/Arakiss/commitloom#readme" +license = { text = "MIT" } +requires-python = ">=3.10" keywords = [ "git", "commit", @@ -24,37 +24,45 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Version Control :: Git", "Typing :: Typed" ] -packages = [{ include = "commitloom" }] - -[tool.poetry.scripts] -loom = "commitloom.__main__:main" +dependencies = [ + "python-dotenv>=1.0.1", + "rich>=13.9.4", + "requests>=2.32.3", + "click>=8.1.7", +] -[tool.poetry.urls] +[project.urls] +Homepage = "https://github.com/Arakiss/commitloom" +Repository = "https://github.com/Arakiss/commitloom" +Documentation = "https://github.com/Arakiss/commitloom#readme" "Bug Tracker" = "https://github.com/Arakiss/commitloom/issues" -[tool.poetry.dependencies] -python = "^3.11" -python-dotenv = "^1.0.1" -rich = "^13.9.4" -requests = "^2.32.3" -click = "^8.1.7" - -[tool.poetry.group.dev.dependencies] -pytest = "^8.3.4" -pytest-cov = "^6.0.0" -pytest-asyncio = "^0.23.8" -ruff = "^0.1.6" -mypy = "^1.7.1" -types-requests = "^2.32.0" -pytest-mock = "^3.14.0" +[project.scripts] +loom = "commitloom.__main__:main" [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["commitloom"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.3.4", + "pytest-cov>=6.0.0", + "pytest-asyncio>=0.23.8", + "ruff>=0.1.6", + "mypy>=1.7.1", + "types-requests>=2.32.0", + "pytest-mock>=3.14.0", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -62,7 +70,7 @@ python_files = ["test_*.py"] addopts = """ --cov=commitloom --cov-report=term-missing - --cov-fail-under=70 + --cov-fail-under=68 --strict-markers --strict-config """ @@ -90,16 +98,16 @@ exclude_lines = [ "def __repr__", "@abstractmethod", ] -fail_under = 70 +fail_under = 68 [tool.ruff] -line-length = 100 +line-length = 110 indent-width = 4 target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "B", "UP"] -ignore = ["E402"] +ignore = ["E402", "E501", "I001", "F841", "B007"] [tool.ruff.lint.per-file-ignores] "commitloom/cli/cli_handler.py" = ["C901"] @@ -110,5 +118,16 @@ quote-style = "double" indent-style = "space" line-ending = "auto" +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +disallow_incomplete_defs = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +no_implicit_optional = true +warn_redundant_casts = true +warn_return_any = false +warn_unused_ignores = true + [tool.ruff.lint.isort] -known-first-party = ["commitloom"] +known-first-party = ["commitloom"] \ No newline at end of file diff --git a/release.py b/release.py index d8d2c9c..e4dc158 100755 --- a/release.py +++ b/release.py @@ -166,14 +166,34 @@ def create_github_release(version: str, dry_run: bool = False) -> None: else: print(f"Would create tag: {tag}") +def update_init_version(new_version: str) -> None: + """Update version in __init__.py file.""" + init_file = Path("commitloom/__init__.py") + + with open(init_file) as f: + content = f.read() + + # Update the version line + updated_content = re.sub( + r'__version__ = "[^"]*"', + f'__version__ = "{new_version}"', + content + ) + + with open(init_file, "w") as f: + f.write(updated_content) + def create_version_commits(new_version: str) -> None: """Create granular commits for version changes.""" - # 1. Update version in pyproject.toml - run_command('git add pyproject.toml') + # 1. Update version in __init__.py + update_init_version(new_version) + + # 2. Add both version files and commit + run_command('git add pyproject.toml commitloom/__init__.py') run_command(f'git commit -m "build: bump version to {new_version}"') print("βœ… Committed version bump") - # 2. Update changelog + # 3. Update changelog update_changelog(new_version) run_command('git add CHANGELOG.md') run_command(f'git commit -m "docs: update changelog for {new_version}"') diff --git a/tests/test_ai_service.py b/tests/test_ai_service.py index c906d49..b25bf6e 100644 --- a/tests/test_ai_service.py +++ b/tests/test_ai_service.py @@ -42,17 +42,26 @@ def test_generate_prompt_text_files(ai_service, mock_git_file): def test_generate_prompt_binary_files(ai_service, mock_git_file): """Test prompt generation for binary files.""" - files = [mock_git_file("image.png", size=1024)] - diff = "Binary files changed" + files = [mock_git_file("image.png", size=1024, hash_="abc123")] + prompt = ai_service.generate_prompt("", files) + assert "image.png" in prompt + assert "binary file changes" in prompt - prompt = ai_service.generate_prompt(diff, files) +def test_generate_prompt_mixed_files(ai_service, mock_git_file): + """Prompt should mention both binary and text changes.""" + files = [ + mock_git_file("image.png", size=1024, hash_="abc123"), + mock_git_file("test.py"), + ] + diff = "diff content" + prompt = ai_service.generate_prompt(diff, files) assert "image.png" in prompt - assert "Binary files changed" in prompt + assert "test.py" in prompt + assert "Binary files" in prompt -@patch("requests.post") -def test_generate_commit_message_success(mock_post, ai_service, mock_git_file): +def test_generate_commit_message_success(ai_service, mock_git_file): """Test successful commit message generation.""" mock_response = { "choices": [ @@ -76,7 +85,7 @@ def test_generate_commit_message_success(mock_post, ai_service, mock_git_file): "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } - mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response) + ai_service.session.post = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response)) suggestion, usage = ai_service.generate_commit_message("test diff", [mock_git_file("test.py")]) @@ -85,11 +94,10 @@ def test_generate_commit_message_success(mock_post, ai_service, mock_git_file): assert usage.total_tokens == 150 -@patch("requests.post") -def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file): +def test_generate_commit_message_api_error(ai_service, mock_git_file): """Test handling of API errors.""" - mock_post.return_value = MagicMock( - status_code=400, json=lambda: {"error": {"message": "API Error"}} + ai_service.session.post = MagicMock( + return_value=MagicMock(status_code=400, json=lambda: {"error": {"message": "API Error"}}) ) with pytest.raises(ValueError) as exc_info: @@ -98,15 +106,14 @@ def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file) assert "API Error" in str(exc_info.value) -@patch("requests.post") -def test_generate_commit_message_invalid_json(mock_post, ai_service, mock_git_file): +def test_generate_commit_message_invalid_json(ai_service, mock_git_file): """Test handling of invalid JSON response.""" mock_response = { "choices": [{"message": {"content": "Invalid JSON"}}], "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } - mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response) + ai_service.session.post = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response)) with pytest.raises(ValueError) as exc_info: ai_service.generate_commit_message("test diff", [mock_git_file("test.py")]) @@ -114,10 +121,9 @@ def test_generate_commit_message_invalid_json(mock_post, ai_service, mock_git_fi assert "Failed to parse AI response" in str(exc_info.value) -@patch("requests.post") -def test_generate_commit_message_network_error(mock_post, ai_service, mock_git_file): +def test_generate_commit_message_network_error(ai_service, mock_git_file): """Test handling of network errors.""" - mock_post.side_effect = requests.exceptions.RequestException("Network Error") + ai_service.session.post = MagicMock(side_effect=requests.exceptions.RequestException("Network Error")) with pytest.raises(ValueError) as exc_info: ai_service.generate_commit_message("test diff", [mock_git_file("test.py")]) @@ -125,6 +131,41 @@ def test_generate_commit_message_network_error(mock_post, ai_service, mock_git_f assert "Network Error" in str(exc_info.value) +@patch("time.sleep", return_value=None) +def test_generate_commit_message_retries(mock_sleep, ai_service, mock_git_file): + """Temporary failures should be retried.""" + mock_response = { + "choices": [ + { + "message": { + "content": json.dumps( + { + "title": "✨ feat: retry success", + "body": { + "Features": { + "emoji": "✨", + "changes": ["Added new functionality"], + } + }, + "summary": "Added new feature", + } + ) + } + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ai_service.session.post = MagicMock( + side_effect=[ + requests.exceptions.RequestException("temp"), + MagicMock(status_code=200, json=lambda: mock_response), + ] + ) + suggestion, _ = ai_service.generate_commit_message("diff", [mock_git_file("test.py")]) + assert suggestion.title == "✨ feat: retry success" + assert ai_service.session.post.call_count == 2 + + def test_format_commit_message(): """Test commit message formatting.""" suggestion = CommitSuggestion( @@ -146,3 +187,13 @@ def test_ai_service_missing_api_key(): AIService(api_key=None) assert "API key is required" in str(exc_info.value) + + +@patch("time.sleep", return_value=None) +def test_generate_commit_message_retries_exhausted(mock_sleep, ai_service, mock_git_file): + """Should raise error after exhausting all retries.""" + ai_service.session.post = MagicMock(side_effect=requests.exceptions.RequestException("temp")) + with pytest.raises(ValueError) as exc_info: + ai_service.generate_commit_message("diff", [mock_git_file("test.py")]) + assert "API Request failed" in str(exc_info.value) + assert ai_service.session.post.call_count == 3 diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index a16860d..55085a7 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -1,6 +1,5 @@ """Tests for commit analyzer module.""" - import pytest from commitloom.config.settings import config @@ -175,6 +174,7 @@ def test_analyze_diff_complexity_git_format(analyzer, mock_git_file): def test_format_cost_for_humans(): """Test cost formatting.""" + assert CommitAnalyzer.format_cost_for_humans(0.0001) == "0.01Β’" assert CommitAnalyzer.format_cost_for_humans(0.001) == "0.10Β’" assert CommitAnalyzer.format_cost_for_humans(0.01) == "1.00Β’" assert CommitAnalyzer.format_cost_for_humans(0.1) == "10.00Β’" @@ -188,3 +188,24 @@ def test_get_cost_context(): assert "moderate" in CommitAnalyzer.get_cost_context(0.05) assert "expensive" in CommitAnalyzer.get_cost_context(0.1) assert "very expensive" in CommitAnalyzer.get_cost_context(1.0) + + +def test_estimate_tokens_and_cost_unknown_model(capsys): + """Fallback to zero cost for unknown model.""" + tokens, cost = CommitAnalyzer.estimate_tokens_and_cost("test", model="unknown") + captured = capsys.readouterr() + assert "Cost estimation is not available" in captured.out + assert tokens >= 0 + assert cost == 0 + + +def test_analyze_diff_complexity_moderate_cost(analyzer, mock_git_file): + """Should warn about moderate cost without marking complex.""" + tokens_for_six_cents = int((0.06 * 1_000_000) / config.model_costs[config.default_model].input) + diff = "diff --git a/mod.py b/mod.py\n" + ( + "+" + "x" * tokens_for_six_cents * config.token_estimation_ratio + "\n" + ) + files = [mock_git_file("mod.py")] + analysis = analyzer.analyze_diff_complexity(diff, files) + assert any("moderate" in str(w) for w in analysis.warnings) + assert analysis.is_complex diff --git a/tests/test_batch_processing.py b/tests/test_batch_processing.py index 27b3368..8f46dc4 100644 --- a/tests/test_batch_processing.py +++ b/tests/test_batch_processing.py @@ -30,9 +30,10 @@ def mock_token_usage(): @pytest.fixture def mock_deps(): """Fixture for mocked dependencies.""" - with patch("commitloom.core.batch.GitOperations", autospec=True) as mock_git, patch( - "commitloom.cli.cli_handler.CommitAnalyzer", autospec=True - ) as mock_analyzer: + with ( + patch("commitloom.core.batch.GitOperations", autospec=True) as mock_git, + patch("commitloom.cli.cli_handler.CommitAnalyzer", autospec=True) as mock_analyzer, + ): mock_git_instance = mock_git.return_value mock_git_instance.stage_files = MagicMock() mock_git_instance.reset_staged_changes = MagicMock() diff --git a/tests/test_cli_handler.py b/tests/test_cli_handler.py index 648fd05..6a378d5 100644 --- a/tests/test_cli_handler.py +++ b/tests/test_cli_handler.py @@ -6,6 +6,7 @@ import pytest from commitloom.cli.cli_handler import CommitLoom +from commitloom.core.analyzer import CommitAnalysis from commitloom.core.git import GitError, GitFile from commitloom.services.ai_service import TokenUsage @@ -37,10 +38,10 @@ def cli(mock_ai_service, mock_token_usage): """Fixture for CommitLoom instance.""" instance = CommitLoom(test_mode=True) # Mock git operations - instance.git.stage_files = MagicMock() - instance.git.reset_staged_changes = MagicMock() - instance.git.get_diff = MagicMock(return_value="test diff") - instance.ai_service.generate_commit_message = MagicMock( + instance.git.stage_files = MagicMock() # type: ignore + instance.git.reset_staged_changes = MagicMock() # type: ignore + instance.git.get_diff = MagicMock(return_value="test diff") # type: ignore + instance.ai_service.generate_commit_message = MagicMock( # type: ignore return_value=( MagicMock(title="test", body={}, format_body=lambda: "test"), mock_token_usage, @@ -71,7 +72,7 @@ def test_handle_commit_git_error(cli): def test_handle_commit_success(cli): """Test successful commit.""" - mock_file = GitFile("test.py", "A", 100, "abc123") + mock_file = GitFile("test.py", "A", old_path=None, size=100, hash="abc123") cli.git.get_staged_files = MagicMock(return_value=[mock_file]) cli.git.create_commit = MagicMock(return_value=True) @@ -82,7 +83,7 @@ def test_handle_commit_success(cli): def test_handle_commit_complex_changes(cli): """Test handling complex changes.""" - mock_files = [GitFile(f"test{i}.py", "A", 100, "abc123") for i in range(4)] + mock_files = [GitFile(f"test{i}.py", "A", old_path=None, size=100, hash="abc123") for i in range(4)] cli.git.get_staged_files = MagicMock(return_value=mock_files) cli.git.create_commit = MagicMock(return_value=True) @@ -93,7 +94,7 @@ def test_handle_commit_complex_changes(cli): def test_handle_commit_user_abort(cli): """Test user aborting commit.""" - mock_file = GitFile("test.py", "A", 100, "abc123") + mock_file = GitFile("test.py", "A", old_path=None, size=100, hash="abc123") cli.git.get_staged_files = MagicMock(return_value=[mock_file]) with patch("commitloom.cli.cli_handler.console") as mock_console: mock_console.confirm_action.return_value = False @@ -107,7 +108,7 @@ def test_handle_commit_user_abort(cli): def test_handle_commit_with_flags(cli): """Test commit with various flags.""" - mock_file = GitFile("test.py", "A", 100, "abc123") + mock_file = GitFile("test.py", "A", old_path=None, size=100, hash="abc123") cli.git.get_staged_files = MagicMock(return_value=[mock_file]) cli.git.create_commit = MagicMock(return_value=True) @@ -118,7 +119,7 @@ def test_handle_commit_with_flags(cli): def test_handle_commit_api_error(cli): """Test handling API error.""" - mock_file = GitFile("test.py", "A", 100, "abc123") + mock_file = GitFile("test.py", "A", old_path=None, size=100, hash="abc123") cli.git.get_staged_files = MagicMock(return_value=[mock_file]) cli.ai_service.generate_commit_message = MagicMock(side_effect=Exception("API error")) @@ -131,13 +132,10 @@ def test_handle_commit_api_error(cli): def test_create_batches_with_ignored_files(cli): """Test batch creation with ignored files.""" mock_files = [ - GitFile("test.py", "A", 100, "abc123"), - GitFile("node_modules/test.js", "A", 100, "def456"), - GitFile("test2.py", "A", 100, "ghi789"), + GitFile("test.py", "A", old_path=None, size=100, hash="abc123"), + GitFile("node_modules/test.js", "A", old_path=None, size=100, hash="def456"), + GitFile("test2.py", "A", old_path=None, size=100, hash="ghi789"), ] - cli.git.get_staged_files = MagicMock(return_value=mock_files) - cli.git.should_ignore_file = MagicMock(side_effect=lambda path: "node_modules" in path) - batches = cli._create_batches(mock_files) assert len(batches) == 1 @@ -156,7 +154,7 @@ def test_create_batches_git_error(cli): def test_handle_batch_no_changes(cli): """Test handling batch with no changes.""" - mock_files = [GitFile("test.py", "A", 100, "abc123")] + mock_files = [GitFile("test.py", "A", old_path=None, size=100, hash="abc123")] cli.git.create_commit = MagicMock(return_value=False) result = cli._handle_batch(mock_files, 1, 1) @@ -168,7 +166,7 @@ def test_create_combined_commit_success(cli): """Test successful creation of combined commit.""" batches = [ { - "files": [GitFile("test1.py", "A", 100, "abc123")], + "files": [GitFile("test1.py", "A", old_path=None, size=100, hash="abc123")], "commit_data": MagicMock( title="test1", body={"feat": {"emoji": "✨", "changes": ["change1"]}}, @@ -176,7 +174,7 @@ def test_create_combined_commit_success(cli): ), }, { - "files": [GitFile("test2.py", "A", 100, "def456")], + "files": [GitFile("test2.py", "A", old_path=None, size=100, hash="def456")], "commit_data": MagicMock( title="test2", body={"fix": {"emoji": "πŸ›", "changes": ["change2"]}}, @@ -185,17 +183,18 @@ def test_create_combined_commit_success(cli): }, ] cli.git.create_commit = MagicMock(return_value=True) - cli._create_combined_commit(batches) - cli.git.create_commit.assert_called_once() + args, _ = cli.git.create_commit.call_args + assert args[0] == "πŸ“¦ chore: combine multiple changes" + assert not args[1].startswith("πŸ“¦ chore: combine multiple changes") def test_create_combined_commit_no_changes(cli): """Test combined commit with no changes.""" batches = [ { - "files": [GitFile("test1.py", "A", 100, "abc123")], + "files": [GitFile("test1.py", "A", old_path=None, size=100, hash="abc123")], "commit_data": MagicMock( title="test1", body={"feat": {"emoji": "✨", "changes": ["change1"]}}, @@ -223,7 +222,7 @@ def test_debug_mode(cli): def test_process_files_in_batches_error(cli): """Test error handling in batch processing.""" - mock_files = [GitFile(f"test{i}.py", "A", 100, "abc123") for i in range(4)] + mock_files = [GitFile(f"test{i}.py", "A", old_path=None, size=100, hash="abc123") for i in range(4)] cli.git.get_diff = MagicMock(side_effect=GitError("Git error")) with pytest.raises(SystemExit) as exc: @@ -234,7 +233,7 @@ def test_process_files_in_batches_error(cli): def test_handle_batch_value_error(cli): """Test handling value error in batch processing.""" - mock_files = [GitFile("test.py", "A", 100, "abc123")] + mock_files = [GitFile("test.py", "A", old_path=None, size=100, hash="abc123")] cli.git.get_diff = MagicMock(side_effect=ValueError("Invalid value")) result = cli._handle_batch(mock_files, 1, 1) @@ -244,9 +243,55 @@ def test_handle_batch_value_error(cli): def test_handle_batch_git_error(cli): """Test handling git error in batch processing.""" - mock_files = [GitFile("test.py", "A", 100, "abc123")] + mock_files = [GitFile("test.py", "A", old_path=None, size=100, hash="abc123")] cli.git.get_diff = MagicMock(side_effect=GitError("Git error")) result = cli._handle_batch(mock_files, 1, 1) assert result is None + + +def test_maybe_create_branch(cli): + """Ensure branch is created when commit is complex.""" + analysis = CommitAnalysis( + estimated_tokens=2000, + estimated_cost=0.2, + num_files=10, + warnings=[], + is_complex=True, + ) + cli.git.create_and_checkout_branch = MagicMock() + with patch("commitloom.cli.cli_handler.console") as mock_console: + mock_console.confirm_branch_creation.return_value = True + cli._maybe_create_branch(analysis) + cli.git.create_and_checkout_branch.assert_called_once() + + +def test_maybe_create_branch_not_complex(cli): + """Ensure no branch is created when commit is simple.""" + analysis = CommitAnalysis( + estimated_tokens=10, + estimated_cost=0.0, + num_files=1, + warnings=[], + is_complex=False, + ) + cli.git.create_and_checkout_branch = MagicMock() + with patch("commitloom.cli.cli_handler.console") as mock_console: + mock_console.confirm_branch_creation.return_value = True + cli._maybe_create_branch(analysis) + cli.git.create_and_checkout_branch.assert_not_called() + + +def test_process_single_commit_maybe_create_branch_once(cli, mock_git_file): + """_maybe_create_branch should be invoked only once.""" + cli.auto_commit = True + cli._maybe_create_branch = MagicMock() + cli.git.create_commit = MagicMock(return_value=True) + file = mock_git_file("test.py") + with ( + patch("commitloom.cli.cli_handler.metrics_manager.start_commit_tracking"), + patch("commitloom.cli.cli_handler.metrics_manager.finish_commit_tracking"), + ): + cli._process_single_commit([file]) + cli._maybe_create_branch.assert_called_once() diff --git a/tests/test_commit_loom.py b/tests/test_commit_loom.py index 267df60..110d36e 100644 --- a/tests/test_commit_loom.py +++ b/tests/test_commit_loom.py @@ -26,11 +26,12 @@ def mock_token_usage(): @pytest.fixture def mock_deps(): """Fixture for mocked dependencies.""" - with patch("commitloom.cli.cli_handler.GitOperations", autospec=True) as mock_git, patch( - "commitloom.cli.cli_handler.CommitAnalyzer", autospec=True - ) as mock_analyzer, patch( - "commitloom.cli.cli_handler.AIService", autospec=True - ) as mock_ai, patch("commitloom.cli.cli_handler.load_dotenv"): + with ( + patch("commitloom.cli.cli_handler.GitOperations", autospec=True) as mock_git, + patch("commitloom.cli.cli_handler.CommitAnalyzer", autospec=True) as mock_analyzer, + patch("commitloom.cli.cli_handler.AIService", autospec=True) as mock_ai, + patch("commitloom.cli.cli_handler.load_dotenv"), + ): mock_git_instance = mock_git.return_value mock_git_instance.stage_files = MagicMock() mock_git_instance.reset_staged_changes = MagicMock() diff --git a/tests/test_git/test_commits.py b/tests/test_git/test_commits.py index 85c30c2..ba754da 100644 --- a/tests/test_git/test_commits.py +++ b/tests/test_git/test_commits.py @@ -24,9 +24,7 @@ def test_create_commit_success(mock_run, git_operations): MagicMock(returncode=0, stdout="", stderr=""), ] - result = git_operations.create_commit( - title="test: add new feature", message="Detailed commit message" - ) + result = git_operations.create_commit(title="test: add new feature", message="Detailed commit message") assert result is True mock_run.assert_any_call( @@ -55,9 +53,7 @@ def test_create_commit_failure(mock_run, git_operations): ] with pytest.raises(GitError) as exc_info: - git_operations.create_commit( - title="test: add new feature", message="Detailed commit message" - ) + git_operations.create_commit(title="test: add new feature", message="Detailed commit message") assert "Failed to create commit" in str(exc_info.value) diff --git a/tests/test_git/test_files.py b/tests/test_git/test_files.py index bedac5a..ee52c33 100644 --- a/tests/test_git/test_files.py +++ b/tests/test_git/test_files.py @@ -38,9 +38,7 @@ def test_get_diff_binary_files(mock_run, git_operations, mock_git_file): returncode=0, ) - diff = git_operations.get_diff( - [mock_git_file("image.png", size=1024, hash_="abc123")] - ) + diff = git_operations.get_diff([mock_git_file("image.png", size=1024, hash_="abc123")]) assert "Binary files" in diff @@ -99,6 +97,4 @@ def test_stage_files_with_info(mock_logger, mock_run, git_operations): git_operations.stage_files(["file1.py"]) # Verify info was logged - mock_logger.info.assert_called_once_with( - "Git message while staging %s: %s", "file1.py", "Updating index" - ) + mock_logger.info.assert_called_once_with("Git message while staging %s: %s", "file1.py", "Updating index") diff --git a/tests/test_git/test_operations.py b/tests/test_git/test_operations.py index 815599d..f62d9d1 100644 --- a/tests/test_git/test_operations.py +++ b/tests/test_git/test_operations.py @@ -14,6 +14,12 @@ def git_operations(): return GitOperations() +def test_should_ignore_file(git_operations): + """Files matching ignored patterns should be skipped.""" + assert git_operations.should_ignore_file("node_modules/test.js") + assert not git_operations.should_ignore_file("src/app.py") + + @patch("subprocess.run") def test_get_staged_files_success(mock_run, git_operations, mock_git_file): """Test successful retrieval of staged files.""" @@ -88,7 +94,7 @@ def test_get_staged_files_ignores_untracked(mock_run, git_operations): def test_get_staged_files_with_spaces(git_operations): """Test getting staged files with spaces in paths.""" - mock_output = "M path with spaces/file.py\n" "A another path/with spaces.py\n" + mock_output = "M path with spaces/file.py\nA another path/with spaces.py\n" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=mock_output, stderr="", returncode=0) files = git_operations.get_staged_files() @@ -100,9 +106,7 @@ def test_get_staged_files_with_spaces(git_operations): def test_get_staged_files_with_special_chars(git_operations): """Test getting staged files with special characters.""" - mock_output = ( - "M path/with-dashes.py\n" "A path/with_underscores.py\n" "M path/with.dots.py\n" - ) + mock_output = "M path/with-dashes.py\nA path/with_underscores.py\nM path/with.dots.py\n" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=mock_output, stderr="", returncode=0) files = git_operations.get_staged_files() @@ -115,7 +119,7 @@ def test_get_staged_files_with_special_chars(git_operations): def test_get_staged_files_with_unicode(git_operations): """Test getting staged files with unicode characters.""" - mock_output = "M path/with/Γ©moji/πŸš€.py\n" "A path/with/Γ‘ccents/file.py\n" + mock_output = "M path/with/Γ©moji/πŸš€.py\nA path/with/Γ‘ccents/file.py\n" with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=mock_output, stderr="", returncode=0) files = git_operations.get_staged_files() @@ -139,7 +143,7 @@ def test_get_staged_files_with_warnings(git_operations): def test_get_staged_files_with_binary_detection(git_operations): """Test getting staged files with binary file detection.""" - mock_output = "M text.py\n" "M image.png\n" + mock_output = "M text.py\nM image.png\n" def mock_run_side_effect(*args, **kwargs): if args[0][0] == "git" and args[0][1] == "status": @@ -185,9 +189,7 @@ def test_get_staged_files_with_complex_renames(git_operations): def test_get_staged_files_with_submodules(git_operations): """Test getting staged files with submodule changes.""" - mock_output = ( - "M regular_file.py\n" "M submodule\n" # Submodule change - ) + mock_output = "M regular_file.py\nM submodule\n" # Submodule change with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=mock_output, stderr="", returncode=0) files = git_operations.get_staged_files() diff --git a/tests/test_main.py b/tests/test_main.py index b109624..f94e2e7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -39,9 +39,7 @@ def test_basic_run(self, runner, mock_loom): assert result.exit_code == 0 mock_commit_loom.assert_called_once_with(test_mode=True, api_key=None) - mock_loom.run.assert_called_once_with( - auto_commit=False, combine_commits=False, debug=False - ) + mock_loom.run.assert_called_once_with(auto_commit=False, combine_commits=False, debug=False) def test_all_flags(self, runner, mock_loom): """Test run with all flags enabled.""" diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..e2a9344 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,46 @@ +import json +from datetime import timedelta + +from commitloom.services.metrics import metrics_manager, CommitMetrics + + +def test_save_metrics_with_invalid_file(tmp_path, monkeypatch): + metrics_file = tmp_path / "metrics.json" + metrics_file.write_text("{}") # invalid structure (dict instead of list) + monkeypatch.setattr(metrics_manager, "_metrics_file", metrics_file) + + metric = CommitMetrics(files_changed=1) + # Should not raise even though existing file is invalid + metrics_manager._save_metrics(metric) + + data = json.loads(metrics_file.read_text()) + assert isinstance(data, list) + assert data[0]["files_changed"] == 1 + + +def test_format_timedelta_outputs(): + td = timedelta(days=1, hours=2, minutes=30) + result = metrics_manager._format_timedelta(td) + assert "1 day" in result + assert "2 hours" in result + assert "30 minutes" in result + + +def test_get_statistics(tmp_path, monkeypatch): + metrics_file = tmp_path / "metrics.json" + stats_file = tmp_path / "stats.json" + monkeypatch.setattr(metrics_manager, "_metrics_file", metrics_file) + monkeypatch.setattr(metrics_manager, "_stats_file", stats_file) + + metrics_manager.start_commit_tracking("repo") + metrics_manager.finish_commit_tracking( + files_changed=1, + tokens_used=10, + prompt_tokens=5, + completion_tokens=5, + cost_in_eur=0.01, + model_used="gpt-test", + ) + + stats = metrics_manager.get_statistics() + assert stats["total_commits"] >= 1 diff --git a/tests/test_smart_grouping.py b/tests/test_smart_grouping.py new file mode 100644 index 0000000..e292de0 --- /dev/null +++ b/tests/test_smart_grouping.py @@ -0,0 +1,369 @@ +"""Tests for the smart grouping module.""" + +import pytest +from pathlib import Path + +from commitloom.core.smart_grouping import SmartGrouper, ChangeType, FileGroup, FileRelationship +from commitloom.core.git import GitFile + + +class TestSmartGrouper: + """Test suite for SmartGrouper class.""" + + @pytest.fixture + def grouper(self): + """Create a SmartGrouper instance for testing.""" + return SmartGrouper() + + @pytest.fixture + def sample_files(self): + """Create sample GitFile objects for testing.""" + return [ + GitFile("src/main.py", "M"), + GitFile("tests/test_main.py", "M"), + GitFile("src/utils.py", "M"), + GitFile("docs/README.md", "M"), + GitFile("package.json", "M"), + GitFile("src/components/Button.tsx", "M"), + GitFile("src/components/Button.css", "M"), + GitFile("tests/test_utils.py", "A"), + GitFile(".gitignore", "M"), + GitFile("src/api/user_service.py", "M"), + GitFile("src/api/user_model.py", "M"), + ] + + def test_detect_change_types(self, grouper, sample_files): + """Test that change types are correctly detected.""" + file_types = grouper._detect_change_types(sample_files) + + # Check test files + assert file_types["tests/test_main.py"] == ChangeType.TEST + assert file_types["tests/test_utils.py"] == ChangeType.TEST + + # Check documentation files + assert file_types["docs/README.md"] == ChangeType.DOCS + + # Check config files + assert file_types["package.json"] == ChangeType.BUILD + assert file_types[".gitignore"] == ChangeType.CONFIG + + # Check source files (should be REFACTOR by default) + assert file_types["src/main.py"] == ChangeType.REFACTOR + assert file_types["src/utils.py"] == ChangeType.REFACTOR + + def test_detect_single_file_type(self, grouper): + """Test single file type detection.""" + # Test files + assert grouper._detect_single_file_type("tests/test_something.py") == ChangeType.TEST + assert grouper._detect_single_file_type("src/__tests__/component.test.js") == ChangeType.TEST + assert grouper._detect_single_file_type("spec/model.spec.ts") == ChangeType.TEST + + # Documentation files + assert grouper._detect_single_file_type("README.md") == ChangeType.DOCS + assert grouper._detect_single_file_type("docs/guide.md") == ChangeType.DOCS + assert grouper._detect_single_file_type("CHANGELOG.md") == ChangeType.DOCS + + # Config files + assert grouper._detect_single_file_type("config.yaml") == ChangeType.CONFIG + assert grouper._detect_single_file_type("Dockerfile") == ChangeType.CONFIG + assert grouper._detect_single_file_type(".env") == ChangeType.CONFIG + + # Build files + assert grouper._detect_single_file_type("package.json") == ChangeType.BUILD + assert grouper._detect_single_file_type("requirements.txt") == ChangeType.BUILD + assert grouper._detect_single_file_type("pyproject.toml") == ChangeType.BUILD + + # Style files + assert grouper._detect_single_file_type("styles/main.css") == ChangeType.STYLE + assert grouper._detect_single_file_type("app.scss") == ChangeType.STYLE + + # Source files with hints + assert grouper._detect_single_file_type("src/fix_bug.py") == ChangeType.FIX + assert grouper._detect_single_file_type("feature_login.js") == ChangeType.FEATURE + + # Default case + assert grouper._detect_single_file_type("random.xyz") == ChangeType.CHORE + + def test_find_relationship_test_implementation_pair(self, grouper): + """Test detection of test-implementation pairs.""" + file1 = GitFile("src/calculator.py", "M") + file2 = GitFile("tests/test_calculator.py", "M") + + rel = grouper._find_relationship(file1, file2) + assert rel is not None + assert rel.relationship_type == "test-implementation" + assert rel.strength == 1.0 + + def test_find_relationship_same_directory(self, grouper): + """Test detection of same directory relationship.""" + file1 = GitFile("src/models/user.py", "M") + file2 = GitFile("src/models/post.py", "M") + + rel = grouper._find_relationship(file1, file2) + assert rel is not None + assert rel.relationship_type == "same-directory" + assert rel.strength == 0.7 + + def test_find_relationship_component_pair(self, grouper): + """Test detection of component pairs (e.g., .tsx and .css with same name).""" + file1 = GitFile("src/Button.tsx", "M") + file2 = GitFile("src/Button.css", "M") + + rel = grouper._find_relationship(file1, file2) + assert rel is not None + assert rel.relationship_type == "component-pair" + assert rel.strength == 0.9 + + def test_find_relationship_similar_naming(self, grouper): + """Test detection of similar naming patterns.""" + file1 = GitFile("src/user_service.py", "M") + file2 = GitFile("src/user_model.py", "M") + + rel = grouper._find_relationship(file1, file2) + assert rel is not None + assert rel.relationship_type == "similar-naming" + assert rel.strength == 0.6 + + def test_is_test_implementation_pair(self, grouper): + """Test the test-implementation pair detection logic.""" + # Valid pairs + assert grouper._is_test_implementation_pair(Path("src/utils.py"), Path("tests/test_utils.py")) + assert grouper._is_test_implementation_pair(Path("lib/parser.js"), Path("tests/parser.test.js")) + assert grouper._is_test_implementation_pair(Path("app/model.ts"), Path("app/model.spec.ts")) + + # Invalid pairs (both tests) + assert not grouper._is_test_implementation_pair(Path("tests/test_a.py"), Path("tests/test_b.py")) + + # Invalid pairs (both implementations) + assert not grouper._is_test_implementation_pair(Path("src/a.py"), Path("src/b.py")) + + # Invalid pairs (unrelated names) + assert not grouper._is_test_implementation_pair(Path("src/utils.py"), Path("tests/test_parser.py")) + + def test_has_similar_naming(self, grouper): + """Test similar naming detection.""" + # Similar names + assert grouper._has_similar_naming(Path("user_service.py"), Path("user_model.py")) + assert grouper._has_similar_naming(Path("auth-handler.js"), Path("auth-validator.js")) + + # Not similar + assert not grouper._has_similar_naming(Path("user.py"), Path("post.py")) + assert not grouper._has_similar_naming(Path("main.js"), Path("utils.js")) + + def test_group_by_change_type(self, grouper, sample_files): + """Test grouping files by change type.""" + file_types = grouper._detect_change_types(sample_files) + groups = grouper._group_by_change_type(sample_files, file_types) + + # Check that groups are created correctly + assert ChangeType.TEST in groups + assert len(groups[ChangeType.TEST]) == 2 # test_main.py and test_utils.py + + assert ChangeType.DOCS in groups + assert len(groups[ChangeType.DOCS]) == 1 # README.md + + assert ChangeType.BUILD in groups + assert len(groups[ChangeType.BUILD]) == 1 # package.json + + assert ChangeType.CONFIG in groups + assert len(groups[ChangeType.CONFIG]) == 1 # .gitignore + + def test_split_large_groups(self, grouper): + """Test that large groups are split correctly.""" + # Create a large group of files + large_group = FileGroup( + files=[GitFile(f"src/file{i}.py", "M") for i in range(12)], + change_type=ChangeType.REFACTOR, + reason="Large refactoring", + confidence=0.8, + ) + + # Split the group + split_groups = grouper._split_large_groups([large_group]) + + # Check that the group was split + assert len(split_groups) > 1 + # Each group should have at most 5 files + for group in split_groups: + assert len(group.files) <= 5 + # Total files should be preserved + total_files = sum(len(g.files) for g in split_groups) + assert total_files == 12 + + def test_split_small_groups_unchanged(self, grouper): + """Test that small groups are not split.""" + # Create a small group + small_group = FileGroup( + files=[GitFile(f"src/file{i}.py", "M") for i in range(3)], + change_type=ChangeType.FEATURE, + reason="Small feature", + confidence=0.9, + ) + + # Try to split (should remain unchanged) + result = grouper._split_large_groups([small_group]) + + assert len(result) == 1 + assert result[0] == small_group + + def test_analyze_files_empty_input(self, grouper): + """Test analyzing empty file list.""" + result = grouper.analyze_files([]) + assert result == [] + + def test_analyze_files_complete_flow(self, grouper): + """Test complete analysis flow with sample files.""" + files = [ + GitFile("src/calculator.py", "M"), + GitFile("tests/test_calculator.py", "M"), + GitFile("docs/calculator.md", "M"), + GitFile("src/utils.py", "M"), + GitFile("package.json", "M"), + ] + + groups = grouper.analyze_files(files) + + # Should create groups + assert len(groups) > 0 + + # Each group should have files + for group in groups: + assert len(group.files) > 0 + assert group.change_type is not None + assert group.reason != "" + assert 0 <= group.confidence <= 1 + + def test_get_group_summary(self, grouper): + """Test group summary generation.""" + group = FileGroup( + files=[GitFile("src/main.py", "M"), GitFile("src/utils.py", "M")], + change_type=ChangeType.FEATURE, + reason="New feature implementation", + confidence=0.85, + dependencies=["src/config.py", "src/database.py"], + ) + + summary = grouper.get_group_summary(group) + + assert "feature" in summary.lower() + assert "New feature implementation" in summary + assert "85" in summary # 85% confidence + assert "src/main.py" in summary + assert "src/utils.py" in summary + assert "src/config.py" in summary + assert "src/database.py" in summary + + def test_refine_groups_with_tests(self, grouper): + """Test that refine_groups properly groups tests with implementations.""" + # Create test and implementation files + test_files = [ + GitFile("src/calculator.py", "M"), + GitFile("tests/test_calculator.py", "M"), + ] + + # Set up relationships + grouper.relationships = [ + FileRelationship("tests/test_calculator.py", "src/calculator.py", "test-implementation", 1.0) + ] + + # Create initial groups by type + groups_by_type = { + ChangeType.TEST: [test_files[1]], # test_calculator.py + ChangeType.REFACTOR: [test_files[0]], # calculator.py + } + + refined = grouper._refine_groups(groups_by_type, {}) + + # Should create groups that keep test and implementation together + assert len(refined) > 0 + + # Find the test group + test_groups = [g for g in refined if g.change_type == ChangeType.TEST] + assert len(test_groups) == 1 + + test_group = test_groups[0] + paths = {file.path for file in test_group.files} + assert paths == {"src/calculator.py", "tests/test_calculator.py"} + assert test_group.reason == "Test with linked implementation" + assert test_group.confidence == pytest.approx(0.9) + + def test_get_language_from_extension(self, grouper): + """Test language detection from file extension.""" + assert grouper._get_language_from_extension(".py") == "python" + assert grouper._get_language_from_extension(".js") == "javascript" + assert grouper._get_language_from_extension(".jsx") == "javascript" + assert grouper._get_language_from_extension(".ts") == "typescript" + assert grouper._get_language_from_extension(".tsx") == "typescript" + assert grouper._get_language_from_extension(".java") == "java" + assert grouper._get_language_from_extension(".go") == "go" + assert grouper._get_language_from_extension(".xyz") is None + + def test_import_matches_file(self, grouper): + """Test import path matching.""" + # Test various import patterns + assert grouper._import_matches_file("utils", "src/utils.py") + assert grouper._import_matches_file("models.user", "src/models/user.py") + assert grouper._import_matches_file("api/handler", "api/handler.js") + + # Non-matching cases + assert not grouper._import_matches_file("utils", "src/main.py") + assert not grouper._import_matches_file("models.user", "src/models/post.py") + + def test_detect_dependencies_reads_imports(self, grouper, tmp_path): + """Ensure dependency detection parses import statements.""" + module_dir = tmp_path / "pkg" + module_dir.mkdir() + + main_file = module_dir / "main.py" + helper_file = module_dir / "helper.py" + utils_file = module_dir / "utils.py" + + main_file.write_text("import helper\nfrom .utils import loader\n", encoding="utf-8") + helper_file.write_text("VALUE = 1\n", encoding="utf-8") + utils_file.write_text("def loader():\n return True\n", encoding="utf-8") + + files = [ + GitFile(str(main_file), "M"), + GitFile(str(helper_file), "M"), + GitFile(str(utils_file), "M"), + ] + + dependencies = grouper._detect_dependencies(files) + + assert str(main_file) in dependencies + assert str(helper_file) in dependencies[str(main_file)] + assert str(utils_file) in dependencies[str(main_file)] + + def test_analyze_files_enriches_dependencies(self, grouper, tmp_path): + """Integration test verifying dependency enrichment in final groups.""" + src_dir = tmp_path / "pkg" + tests_dir = tmp_path / "tests" + src_dir.mkdir() + tests_dir.mkdir() + + impl_path = src_dir / "service.py" + helper_path = src_dir / "helper.py" + test_path = tests_dir / "test_service.py" + + impl_path.write_text("from pkg import helper\n", encoding="utf-8") + helper_path.write_text("VALUE = 1\n", encoding="utf-8") + test_path.write_text("from pkg import service\n", encoding="utf-8") + + files = [ + GitFile(str(impl_path), "M"), + GitFile(str(helper_path), "M"), + GitFile(str(test_path), "M"), + ] + + groups = grouper.analyze_files(files) + + test_groups = [group for group in groups if group.change_type == ChangeType.TEST] + assert test_groups, "Expected at least one test-focused group" + + test_group = test_groups[0] + paths = {file.path for file in test_group.files} + + assert str(test_path) in paths + assert str(impl_path) in paths + assert str(helper_path) not in paths + assert str(helper_path) in test_group.dependencies diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4223489 --- /dev/null +++ b/uv.lock @@ -0,0 +1,567 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[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 = "commitloom" +version = "1.6.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.7" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "rich", specifier = ">=13.9.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.7.1" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.23.8" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "ruff", specifier = ">=0.1.6" }, + { name = "types-requests", specifier = ">=2.32.0" }, +] + +[[package]] +name = "coverage" +version = "7.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/4e/08b493f1f1d8a5182df0044acc970799b58a8d289608e0d891a03e9d269a/coverage-7.10.4.tar.gz", hash = "sha256:25f5130af6c8e7297fd14634955ba9e1697f47143f289e2a23284177c0061d27", size = 823798, upload-time = "2025-08-17T00:26:43.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/f4/350759710db50362685f922259c140592dba15eb4e2325656a98413864d9/coverage-7.10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d92d6edb0ccafd20c6fbf9891ca720b39c2a6a4b4a6f9cf323ca2c986f33e475", size = 216403, upload-time = "2025-08-17T00:24:19.083Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/e467c2bb4d5ecfd166bfd22c405cce4c50de2763ba1d78e2729c59539a42/coverage-7.10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7202da14dc0236884fcc45665ffb2d79d4991a53fbdf152ab22f69f70923cc22", size = 216802, upload-time = "2025-08-17T00:24:21.824Z" }, + { url = "https://files.pythonhosted.org/packages/62/ab/2accdd1ccfe63b890e5eb39118f63c155202df287798364868a2884a50af/coverage-7.10.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ada418633ae24ec8d0fcad5efe6fc7aa3c62497c6ed86589e57844ad04365674", size = 243558, upload-time = "2025-08-17T00:24:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/43/04/c14c33d0cfc0f4db6b3504d01a47f4c798563d932a836fd5f2dbc0521d3d/coverage-7.10.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b828e33eca6c3322adda3b5884456f98c435182a44917ded05005adfa1415500", size = 245370, upload-time = "2025-08-17T00:24:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/99/71/147053061f1f51c1d3b3d040c3cb26876964a3a0dca0765d2441411ca568/coverage-7.10.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:802793ba397afcfdbe9f91f89d65ae88b958d95edc8caf948e1f47d8b6b2b606", size = 247228, upload-time = "2025-08-17T00:24:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/cc/92/7ef882205d4d4eb502e6154ee7122c1a1b1ce3f29d0166921e0fb550a5d3/coverage-7.10.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d0b23512338c54101d3bf7a1ab107d9d75abda1d5f69bc0887fd079253e4c27e", size = 245270, upload-time = "2025-08-17T00:24:27.424Z" }, + { url = "https://files.pythonhosted.org/packages/ab/3d/297a20603abcc6c7d89d801286eb477b0b861f3c5a4222730f1c9837be3e/coverage-7.10.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f36b7dcf72d06a8c5e2dd3aca02be2b1b5db5f86404627dff834396efce958f2", size = 243287, upload-time = "2025-08-17T00:24:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/65/f9/b04111438f41f1ddd5dc88706d5f8064ae5bb962203c49fe417fa23a362d/coverage-7.10.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fce316c367a1dc2c411821365592eeb335ff1781956d87a0410eae248188ba51", size = 244164, upload-time = "2025-08-17T00:24:30.393Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/c7d9eb7a9ea66cf92d069077719fb2b07782dcd7050b01a9b88766b52154/coverage-7.10.4-cp310-cp310-win32.whl", hash = "sha256:8c5dab29fc8070b3766b5fc85f8d89b19634584429a2da6d42da5edfadaf32ae", size = 218917, upload-time = "2025-08-17T00:24:31.67Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/4d9d3b81f5a836b31a7428b8a25e6d490d4dca5ff2952492af130153c35c/coverage-7.10.4-cp310-cp310-win_amd64.whl", hash = "sha256:4b0d114616f0fccb529a1817457d5fb52a10e106f86c5fb3b0bd0d45d0d69b93", size = 219822, upload-time = "2025-08-17T00:24:32.89Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ba/2c9817e62018e7d480d14f684c160b3038df9ff69c5af7d80e97d143e4d1/coverage-7.10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:05d5f98ec893d4a2abc8bc5f046f2f4367404e7e5d5d18b83de8fde1093ebc4f", size = 216514, upload-time = "2025-08-17T00:24:34.188Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/093412a959a6b6261446221ba9fb23bb63f661a5de70b5d130763c87f916/coverage-7.10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9267efd28f8994b750d171e58e481e3bbd69e44baed540e4c789f8e368b24b88", size = 216914, upload-time = "2025-08-17T00:24:35.881Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1f/2fdf4a71cfe93b07eae845ebf763267539a7d8b7e16b062f959d56d7e433/coverage-7.10.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4456a039fdc1a89ea60823d0330f1ac6f97b0dbe9e2b6fb4873e889584b085fb", size = 247308, upload-time = "2025-08-17T00:24:37.61Z" }, + { url = "https://files.pythonhosted.org/packages/ba/16/33f6cded458e84f008b9f6bc379609a6a1eda7bffe349153b9960803fc11/coverage-7.10.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c2bfbd2a9f7e68a21c5bd191be94bfdb2691ac40d325bac9ef3ae45ff5c753d9", size = 249241, upload-time = "2025-08-17T00:24:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/84/98/9c18e47c889be58339ff2157c63b91a219272503ee32b49d926eea2337f2/coverage-7.10.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab7765f10ae1df7e7fe37de9e64b5a269b812ee22e2da3f84f97b1c7732a0d8", size = 251346, upload-time = "2025-08-17T00:24:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/6d/07/00a6c0d53e9a22d36d8e95ddd049b860eef8f4b9fd299f7ce34d8e323356/coverage-7.10.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a09b13695166236e171ec1627ff8434b9a9bae47528d0ba9d944c912d33b3d2", size = 249037, upload-time = "2025-08-17T00:24:41.904Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0e/1e1b944d6a6483d07bab5ef6ce063fcf3d0cc555a16a8c05ebaab11f5607/coverage-7.10.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5c9e75dfdc0167d5675e9804f04a56b2cf47fb83a524654297000b578b8adcb7", size = 247090, upload-time = "2025-08-17T00:24:43.193Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/2ce5ab8a728b8e25ced077111581290ffaef9efaf860a28e25435ab925cf/coverage-7.10.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c751261bfe6481caba15ec005a194cb60aad06f29235a74c24f18546d8377df0", size = 247732, upload-time = "2025-08-17T00:24:44.906Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/706c4a24f42c1c5f3a2ca56637ab1270f84d9e75355160dc34d5e39bb5b7/coverage-7.10.4-cp311-cp311-win32.whl", hash = "sha256:051c7c9e765f003c2ff6e8c81ccea28a70fb5b0142671e4e3ede7cebd45c80af", size = 218961, upload-time = "2025-08-17T00:24:46.241Z" }, + { url = "https://files.pythonhosted.org/packages/e8/aa/6b9ea06e0290bf1cf2a2765bba89d561c5c563b4e9db8298bf83699c8b67/coverage-7.10.4-cp311-cp311-win_amd64.whl", hash = "sha256:1a647b152f10be08fb771ae4a1421dbff66141e3d8ab27d543b5eb9ea5af8e52", size = 219851, upload-time = "2025-08-17T00:24:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/8b/be/f0dc9ad50ee183369e643cd7ed8f2ef5c491bc20b4c3387cbed97dd6e0d1/coverage-7.10.4-cp311-cp311-win_arm64.whl", hash = "sha256:b09b9e4e1de0d406ca9f19a371c2beefe3193b542f64a6dd40cfcf435b7d6aa0", size = 218530, upload-time = "2025-08-17T00:24:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/781c9e4dd57cabda2a28e2ce5b00b6be416015265851060945a5ed4bd85e/coverage-7.10.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a1f0264abcabd4853d4cb9b3d164adbf1565da7dab1da1669e93f3ea60162d79", size = 216706, upload-time = "2025-08-17T00:24:51.528Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8c/51255202ca03d2e7b664770289f80db6f47b05138e06cce112b3957d5dfd/coverage-7.10.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:536cbe6b118a4df231b11af3e0f974a72a095182ff8ec5f4868c931e8043ef3e", size = 216939, upload-time = "2025-08-17T00:24:53.171Z" }, + { url = "https://files.pythonhosted.org/packages/06/7f/df11131483698660f94d3c847dc76461369782d7a7644fcd72ac90da8fd0/coverage-7.10.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9a4c0d84134797b7bf3f080599d0cd501471f6c98b715405166860d79cfaa97e", size = 248429, upload-time = "2025-08-17T00:24:54.934Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fa/13ac5eda7300e160bf98f082e75f5c5b4189bf3a883dd1ee42dbedfdc617/coverage-7.10.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c155fc0f9cee8c9803ea0ad153ab6a3b956baa5d4cd993405dc0b45b2a0b9e0", size = 251178, upload-time = "2025-08-17T00:24:56.353Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/f63b56a58ad0bec68a840e7be6b7ed9d6f6288d790760647bb88f5fea41e/coverage-7.10.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5f2ab6e451d4b07855d8bcf063adf11e199bff421a4ba57f5bb95b7444ca62", size = 252313, upload-time = "2025-08-17T00:24:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b6/79338f1ea27b01266f845afb4485976211264ab92407d1c307babe3592a7/coverage-7.10.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:685b67d99b945b0c221be0780c336b303a7753b3e0ec0d618c795aada25d5e7a", size = 250230, upload-time = "2025-08-17T00:24:59.293Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/3b24f1da3e0286a4dc5832427e1d448d5296f8287464b1ff4a222abeeeb5/coverage-7.10.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0c079027e50c2ae44da51c2e294596cbc9dbb58f7ca45b30651c7e411060fc23", size = 248351, upload-time = "2025-08-17T00:25:00.676Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/d59412f869e49dcc5b89398ef3146c8bfaec870b179cc344d27932e0554b/coverage-7.10.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3749aa72b93ce516f77cf5034d8e3c0dfd45c6e8a163a602ede2dc5f9a0bb927", size = 249788, upload-time = "2025-08-17T00:25:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/cc/52/04a3b733f40a0cc7c4a5b9b010844111dbf906df3e868b13e1ce7b39ac31/coverage-7.10.4-cp312-cp312-win32.whl", hash = "sha256:fecb97b3a52fa9bcd5a7375e72fae209088faf671d39fae67261f37772d5559a", size = 219131, upload-time = "2025-08-17T00:25:03.79Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/12909fc0b83888197b3ec43a4ac7753589591c08d00d9deda4158df2734e/coverage-7.10.4-cp312-cp312-win_amd64.whl", hash = "sha256:26de58f355626628a21fe6a70e1e1fad95702dafebfb0685280962ae1449f17b", size = 219939, upload-time = "2025-08-17T00:25:05.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/058bb3220fdd6821bada9685eadac2940429ab3c97025ce53549ff423cc1/coverage-7.10.4-cp312-cp312-win_arm64.whl", hash = "sha256:67e8885408f8325198862bc487038a4980c9277d753cb8812510927f2176437a", size = 218572, upload-time = "2025-08-17T00:25:06.897Z" }, + { url = "https://files.pythonhosted.org/packages/46/b0/4a3662de81f2ed792a4e425d59c4ae50d8dd1d844de252838c200beed65a/coverage-7.10.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b8e1d2015d5dfdbf964ecef12944c0c8c55b885bb5c0467ae8ef55e0e151233", size = 216735, upload-time = "2025-08-17T00:25:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e8/e2dcffea01921bfffc6170fb4406cffb763a3b43a047bbd7923566708193/coverage-7.10.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:25735c299439018d66eb2dccf54f625aceb78645687a05f9f848f6e6c751e169", size = 216982, upload-time = "2025-08-17T00:25:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/9d/59/cc89bb6ac869704d2781c2f5f7957d07097c77da0e8fdd4fd50dbf2ac9c0/coverage-7.10.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:715c06cb5eceac4d9b7cdf783ce04aa495f6aff657543fea75c30215b28ddb74", size = 247981, upload-time = "2025-08-17T00:25:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/aa/23/3da089aa177ceaf0d3f96754ebc1318597822e6387560914cc480086e730/coverage-7.10.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e017ac69fac9aacd7df6dc464c05833e834dc5b00c914d7af9a5249fcccf07ef", size = 250584, upload-time = "2025-08-17T00:25:13.483Z" }, + { url = "https://files.pythonhosted.org/packages/ad/82/e8693c368535b4e5fad05252a366a1794d481c79ae0333ed943472fd778d/coverage-7.10.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad180cc40b3fccb0f0e8c702d781492654ac2580d468e3ffc8065e38c6c2408", size = 251856, upload-time = "2025-08-17T00:25:15.27Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/8b9cb13292e602fa4135b10a26ac4ce169a7fc7c285ff08bedd42ff6acca/coverage-7.10.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:becbdcd14f685fada010a5f792bf0895675ecf7481304fe159f0cd3f289550bd", size = 250015, upload-time = "2025-08-17T00:25:16.759Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/e5903990ce089527cf1c4f88b702985bd65c61ac245923f1ff1257dbcc02/coverage-7.10.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0b485ca21e16a76f68060911f97ebbe3e0d891da1dbbce6af7ca1ab3f98b9097", size = 247908, upload-time = "2025-08-17T00:25:18.232Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/7d464f116df1df7fe340669af1ddbe1a371fc60f3082ff3dc837c4f1f2ab/coverage-7.10.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6c1d098ccfe8e1e0a1ed9a0249138899948afd2978cbf48eb1cc3fcd38469690", size = 249525, upload-time = "2025-08-17T00:25:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/722e0cdbf6c19e7235c2020837d4e00f3b07820fd012201a983238cc3a30/coverage-7.10.4-cp313-cp313-win32.whl", hash = "sha256:8630f8af2ca84b5c367c3df907b1706621abe06d6929f5045fd628968d421e6e", size = 219173, upload-time = "2025-08-17T00:25:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/97/7e/aa70366f8275955cd51fa1ed52a521c7fcebcc0fc279f53c8c1ee6006dfe/coverage-7.10.4-cp313-cp313-win_amd64.whl", hash = "sha256:f68835d31c421736be367d32f179e14ca932978293fe1b4c7a6a49b555dff5b2", size = 219969, upload-time = "2025-08-17T00:25:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/ac/96/c39d92d5aad8fec28d4606556bfc92b6fee0ab51e4a548d9b49fb15a777c/coverage-7.10.4-cp313-cp313-win_arm64.whl", hash = "sha256:6eaa61ff6724ca7ebc5326d1fae062d85e19b38dd922d50903702e6078370ae7", size = 218601, upload-time = "2025-08-17T00:25:25.295Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/34d549a6177bd80fa5db758cb6fd3057b7ad9296d8707d4ab7f480b0135f/coverage-7.10.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:702978108876bfb3d997604930b05fe769462cc3000150b0e607b7b444f2fd84", size = 217445, upload-time = "2025-08-17T00:25:27.129Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/433da866359bf39bf595f46d134ff2d6b4293aeea7f3328b6898733b0633/coverage-7.10.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e8f978e8c5521d9c8f2086ac60d931d583fab0a16f382f6eb89453fe998e2484", size = 217676, upload-time = "2025-08-17T00:25:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d7/2b99aa8737f7801fd95222c79a4ebc8c5dd4460d4bed7ef26b17a60c8d74/coverage-7.10.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:df0ac2ccfd19351411c45e43ab60932b74472e4648b0a9edf6a3b58846e246a9", size = 259002, upload-time = "2025-08-17T00:25:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/08/cf/86432b69d57debaef5abf19aae661ba8f4fcd2882fa762e14added4bd334/coverage-7.10.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73a0d1aaaa3796179f336448e1576a3de6fc95ff4f07c2d7251d4caf5d18cf8d", size = 261178, upload-time = "2025-08-17T00:25:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/23/78/85176593f4aa6e869cbed7a8098da3448a50e3fac5cb2ecba57729a5220d/coverage-7.10.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:873da6d0ed6b3ffc0bc01f2c7e3ad7e2023751c0d8d86c26fe7322c314b031dc", size = 263402, upload-time = "2025-08-17T00:25:33.339Z" }, + { url = "https://files.pythonhosted.org/packages/88/1d/57a27b6789b79abcac0cc5805b31320d7a97fa20f728a6a7c562db9a3733/coverage-7.10.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c6446c75b0e7dda5daa876a1c87b480b2b52affb972fedd6c22edf1aaf2e00ec", size = 260957, upload-time = "2025-08-17T00:25:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e5/3e5ddfd42835c6def6cd5b2bdb3348da2e34c08d9c1211e91a49e9fd709d/coverage-7.10.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6e73933e296634e520390c44758d553d3b573b321608118363e52113790633b9", size = 258718, upload-time = "2025-08-17T00:25:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0b/d364f0f7ef111615dc4e05a6ed02cac7b6f2ac169884aa57faeae9eb5fa0/coverage-7.10.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52073d4b08d2cb571234c8a71eb32af3c6923149cf644a51d5957ac128cf6aa4", size = 259848, upload-time = "2025-08-17T00:25:37.754Z" }, + { url = "https://files.pythonhosted.org/packages/10/c6/bbea60a3b309621162e53faf7fac740daaf083048ea22077418e1ecaba3f/coverage-7.10.4-cp313-cp313t-win32.whl", hash = "sha256:e24afb178f21f9ceb1aefbc73eb524769aa9b504a42b26857243f881af56880c", size = 219833, upload-time = "2025-08-17T00:25:39.252Z" }, + { url = "https://files.pythonhosted.org/packages/44/a5/f9f080d49cfb117ddffe672f21eab41bd23a46179a907820743afac7c021/coverage-7.10.4-cp313-cp313t-win_amd64.whl", hash = "sha256:be04507ff1ad206f4be3d156a674e3fb84bbb751ea1b23b142979ac9eebaa15f", size = 220897, upload-time = "2025-08-17T00:25:40.772Z" }, + { url = "https://files.pythonhosted.org/packages/46/89/49a3fc784fa73d707f603e586d84a18c2e7796707044e9d73d13260930b7/coverage-7.10.4-cp313-cp313t-win_arm64.whl", hash = "sha256:f3e3ff3f69d02b5dad67a6eac68cc9c71ae343b6328aae96e914f9f2f23a22e2", size = 219160, upload-time = "2025-08-17T00:25:42.229Z" }, + { url = "https://files.pythonhosted.org/packages/b5/22/525f84b4cbcff66024d29f6909d7ecde97223f998116d3677cfba0d115b5/coverage-7.10.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a59fe0af7dd7211ba595cf7e2867458381f7e5d7b4cffe46274e0b2f5b9f4eb4", size = 216717, upload-time = "2025-08-17T00:25:43.875Z" }, + { url = "https://files.pythonhosted.org/packages/a6/58/213577f77efe44333a416d4bcb251471e7f64b19b5886bb515561b5ce389/coverage-7.10.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a6c35c5b70f569ee38dc3350cd14fdd0347a8b389a18bb37538cc43e6f730e6", size = 216994, upload-time = "2025-08-17T00:25:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/17/85/34ac02d0985a09472f41b609a1d7babc32df87c726c7612dc93d30679b5a/coverage-7.10.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acb7baf49f513554c4af6ef8e2bd6e8ac74e6ea0c7386df8b3eb586d82ccccc4", size = 248038, upload-time = "2025-08-17T00:25:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/47/4f/2140305ec93642fdaf988f139813629cbb6d8efa661b30a04b6f7c67c31e/coverage-7.10.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a89afecec1ed12ac13ed203238b560cbfad3522bae37d91c102e690b8b1dc46c", size = 250575, upload-time = "2025-08-17T00:25:48.613Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/41b5784180b82a083c76aeba8f2c72ea1cb789e5382157b7dc852832aea2/coverage-7.10.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:480442727f464407d8ade6e677b7f21f3b96a9838ab541b9a28ce9e44123c14e", size = 251927, upload-time = "2025-08-17T00:25:50.881Z" }, + { url = "https://files.pythonhosted.org/packages/78/ca/c1dd063e50b71f5aea2ebb27a1c404e7b5ecf5714c8b5301f20e4e8831ac/coverage-7.10.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a89bf193707f4a17f1ed461504031074d87f035153239f16ce86dfb8f8c7ac76", size = 249930, upload-time = "2025-08-17T00:25:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/8d/66/d8907408612ffee100d731798e6090aedb3ba766ecf929df296c1a7ee4fb/coverage-7.10.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3ddd912c2fc440f0fb3229e764feec85669d5d80a988ff1b336a27d73f63c818", size = 247862, upload-time = "2025-08-17T00:25:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/53cd8ec8b1c9c52d8e22a25434785bfc2d1e70c0cfb4d278a1326c87f741/coverage-7.10.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a538944ee3a42265e61c7298aeba9ea43f31c01271cf028f437a7b4075592cf", size = 249360, upload-time = "2025-08-17T00:25:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/4f/75/5ec0a28ae4a0804124ea5a5becd2b0fa3adf30967ac656711fb5cdf67c60/coverage-7.10.4-cp314-cp314-win32.whl", hash = "sha256:fd2e6002be1c62476eb862b8514b1ba7e7684c50165f2a8d389e77da6c9a2ebd", size = 219449, upload-time = "2025-08-17T00:25:57.984Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ab/66e2ee085ec60672bf5250f11101ad8143b81f24989e8c0e575d16bb1e53/coverage-7.10.4-cp314-cp314-win_amd64.whl", hash = "sha256:ec113277f2b5cf188d95fb66a65c7431f2b9192ee7e6ec9b72b30bbfb53c244a", size = 220246, upload-time = "2025-08-17T00:25:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/00b448d385f149143190846217797d730b973c3c0ec2045a7e0f5db3a7d0/coverage-7.10.4-cp314-cp314-win_arm64.whl", hash = "sha256:9744954bfd387796c6a091b50d55ca7cac3d08767795b5eec69ad0f7dbf12d38", size = 218825, upload-time = "2025-08-17T00:26:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/55e20d3d1ce00b513efb6fd35f13899e1c6d4f76c6cbcc9851c7227cd469/coverage-7.10.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5af4829904dda6aabb54a23879f0f4412094ba9ef153aaa464e3c1b1c9bc98e6", size = 217462, upload-time = "2025-08-17T00:26:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/47/b3/aab1260df5876f5921e2c57519e73a6f6eeacc0ae451e109d44ee747563e/coverage-7.10.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7bba5ed85e034831fac761ae506c0644d24fd5594727e174b5a73aff343a7508", size = 217675, upload-time = "2025-08-17T00:26:04.606Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/1cfe2aa50c7026180989f0bfc242168ac7c8399ccc66eb816b171e0ab05e/coverage-7.10.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57d555b0719834b55ad35045de6cc80fc2b28e05adb6b03c98479f9553b387f", size = 259176, upload-time = "2025-08-17T00:26:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/9d/72/5882b6aeed3f9de7fc4049874fd7d24213bf1d06882f5c754c8a682606ec/coverage-7.10.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ba62c51a72048bb1ea72db265e6bd8beaabf9809cd2125bbb5306c6ce105f214", size = 261341, upload-time = "2025-08-17T00:26:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/1b/70/a0c76e3087596ae155f8e71a49c2c534c58b92aeacaf4d9d0cbbf2dde53b/coverage-7.10.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0acf0c62a6095f07e9db4ec365cc58c0ef5babb757e54745a1aa2ea2a2564af1", size = 263600, upload-time = "2025-08-17T00:26:11.045Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5f/27e4cd4505b9a3c05257fb7fc509acbc778c830c450cb4ace00bf2b7bda7/coverage-7.10.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1033bf0f763f5cf49ffe6594314b11027dcc1073ac590b415ea93463466deec", size = 261036, upload-time = "2025-08-17T00:26:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/02/d6/cf2ae3a7f90ab226ea765a104c4e76c5126f73c93a92eaea41e1dc6a1892/coverage-7.10.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92c29eff894832b6a40da1789b1f252305af921750b03ee4535919db9179453d", size = 258794, upload-time = "2025-08-17T00:26:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/39f222eab0d78aa2001cdb7852aa1140bba632db23a5cfd832218b496d6c/coverage-7.10.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:822c4c830989c2093527e92acd97be4638a44eb042b1bdc0e7a278d84a070bd3", size = 259946, upload-time = "2025-08-17T00:26:15.899Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/49d82acefe2fe7c777436a3097f928c7242a842538b190f66aac01f29321/coverage-7.10.4-cp314-cp314t-win32.whl", hash = "sha256:e694d855dac2e7cf194ba33653e4ba7aad7267a802a7b3fc4347d0517d5d65cd", size = 220226, upload-time = "2025-08-17T00:26:17.566Z" }, + { url = "https://files.pythonhosted.org/packages/06/b0/afb942b6b2fc30bdbc7b05b087beae11c2b0daaa08e160586cf012b6ad70/coverage-7.10.4-cp314-cp314t-win_amd64.whl", hash = "sha256:efcc54b38ef7d5bfa98050f220b415bc5bb3d432bd6350a861cf6da0ede2cdcd", size = 221346, upload-time = "2025-08-17T00:26:19.311Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/e0531c9d1525cb6eac5b5733c76f27f3053ee92665f83f8899516fea6e76/coverage-7.10.4-cp314-cp314t-win_arm64.whl", hash = "sha256:6f3a3496c0fa26bfac4ebc458747b778cff201c8ae94fa05e1391bab0dbc473c", size = 219368, upload-time = "2025-08-17T00:26:21.011Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/983efd23200921d9edb6bd40512e1aa04af553d7d5a171e50f9b2b45d109/coverage-7.10.4-py3-none-any.whl", hash = "sha256:065d75447228d05121e5c938ca8f0e91eed60a1eb2d1258d42d5084fecfc3302", size = 208365, upload-time = "2025-08-17T00:26:41.479Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[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 = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, +] + +[[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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076, upload-time = "2025-08-21T18:23:22.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161, upload-time = "2025-08-21T18:22:26.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884, upload-time = "2025-08-21T18:22:30.925Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754, upload-time = "2025-08-21T18:22:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276, upload-time = "2025-08-21T18:22:36.764Z" }, + { url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700, upload-time = "2025-08-21T18:22:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783, upload-time = "2025-08-21T18:22:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642, upload-time = "2025-08-21T18:22:45.612Z" }, + { url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107, upload-time = "2025-08-21T18:22:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521, upload-time = "2025-08-21T18:22:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528, upload-time = "2025-08-21T18:22:54.609Z" }, + { url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443, upload-time = "2025-08-21T18:22:57.413Z" }, + { url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759, upload-time = "2025-08-21T18:23:00.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463, upload-time = "2025-08-21T18:23:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603, upload-time = "2025-08-21T18:23:06.935Z" }, + { url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356, upload-time = "2025-08-21T18:23:10.225Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089, upload-time = "2025-08-21T18:23:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616, upload-time = "2025-08-21T18:23:17.422Z" }, + { url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225, upload-time = "2025-08-21T18:23:20.137Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250809" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +]