diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e74aea4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Documentation Validation + +on: + push: + branches: + - "**" + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + validate-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install markdownlint-cli2 + run: npm install -g markdownlint-cli2 + + - name: Install markdown-link-check + run: npm install -g markdown-link-check + + - name: Lint markdown files + run: markdownlint-cli2 "README.md" "CONTRIBUTING.md" "tests/README.md" "docs/**/*.md" + continue-on-error: false + + - name: Check links in README.md + run: markdown-link-check README.md + continue-on-error: false + + - name: Check links in CONTRIBUTING.md + run: markdown-link-check CONTRIBUTING.md + continue-on-error: false + + - name: Check links in tests/README.md + run: markdown-link-check tests/README.md + continue-on-error: false + + - name: Check links in docs/RELEASING.md + run: markdown-link-check docs/RELEASING.md + continue-on-error: false + + - name: Check links in docs/SHELL_COMPLETION.md + run: markdown-link-check docs/SHELL_COMPLETION.md + continue-on-error: false + + - name: Check links in docs/WORKFLOWS.md + run: markdown-link-check docs/WORKFLOWS.md + continue-on-error: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3c11ae5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,41 @@ +name: Lint + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: uv pip install -e ".[dev]" --system + + - name: Run Ruff linting + run: uv run ruff check src/ + + - name: Run Ruff formatting check + run: uv run ruff format --check src/ + + - name: Run Mypy type checking + run: uv run mypy src/glpkg/ --strict diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6ac9f83 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + 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@v4 + + - name: Install build dependencies + run: uv pip install build --system + + - name: Install build tools for .pyz + run: uv pip install shiv --system + + - name: Build package distributions + run: python -m build + + - name: Build .pyz universal binary + run: bash scripts/build_pyz.sh --tool shiv --output-dir dist + + - name: List built artifacts + run: ls -la dist/ + + - name: Upload .pyz to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v1 + with: + files: dist/glpkg.pyz + fail_on_unmatched_files: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..85cc9e4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,90 @@ +name: Tests + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package in editable mode + run: uv pip install -e . --system + + - name: Install test dependencies + run: uv pip install -e ".[test]" --system + + - name: Run unit tests with coverage + run: uv run pytest tests/unit/ -m unit -v -n auto --cov=glpkg --cov-report=xml --cov-report=term + + - name: Upload coverage reports + if: matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.xml + htmlcov/ + retention-days: 7 + + - name: Check coverage threshold + if: matrix.python-version == '3.11' + run: | + # Extract coverage percentage from XML + COVERAGE=$(python -c " + import xml.etree.ElementTree as ET + tree = ET.parse('coverage.xml') + root = tree.getroot() + line_rate = float(root.get('line-rate', 0)) + print(f'{line_rate * 100:.2f}') + ") + echo "Coverage: ${COVERAGE}%" + + # Check if below 95% warning threshold + if (( $(echo "$COVERAGE < 95" | bc -l) )); then + echo "::warning::Coverage is below 95% target: ${COVERAGE}%" + fi + + # The --cov-fail-under=90 in pytest already handles the failure threshold + + - name: Run integration tests + if: ${{ secrets.GITLAB_TOKEN != '' && secrets.GITLAB_REPO != '' }} + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITLAB_PROJECT_PATH: ${{ secrets.GITLAB_REPO }} + RUN_INTEGRATION_TESTS: "1" + run: uv run pytest tests/integration/ -m integration -v -n auto --no-cov + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-results-${{ matrix.python-version }} + path: | + .pytest_cache/ + htmlcov/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56c1f27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Shiv/PEX universal binaries +*.pyz +*.pex + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# Linting +.ruff_cache/ + +# OS +.DS_Store +Thumbs.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a7ecf13 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + # File maintenance hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + files: ^src/ + - id: ruff-format + files: ^src/ + + # Mypy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [--strict] + files: ^src/gitlab_pkg_upload/ + additional_dependencies: + - python-gitlab + - rich + - GitPython + - tenacity + - argcomplete diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0a89783 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,151 @@ +# Contributing to glpkg + +Thank you for your interest in contributing to glpkg! This document provides +guidelines and instructions for development. + +## Getting Started + +### Prerequisites + +- Python 3.11 or higher +- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`) + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/your-org/glpkg.git +cd glpkg + +# Install all dependencies (including dev and test extras) +uv sync --all-extras +``` + +## Development Workflow + +1. Install pre-commit hooks: + + ```bash + uv run pre-commit install + ``` + +2. Create a feature branch: + + ```bash + git checkout -b feature/your-feature-name + ``` + +3. Make your changes +4. Run tests locally before committing +5. Commit your changes (pre-commit hooks run automatically) +6. Push and create a pull request + +## Running Tests + +### Unit Tests + +Fast tests that don't require external dependencies: + +```bash +uv run pytest tests/unit/ +``` + +### Integration Tests + +Require a GitLab token and opt-in via environment variable: + +```bash +export RUN_INTEGRATION_TESTS=1 +export GITLAB_TOKEN="your-token" +uv run pytest tests/integration/ -m integration +``` + +### All Tests with Coverage + +```bash +uv run pytest tests/ +``` + +### Parallel Execution + +Speed up test runs with parallel execution: + +```bash +uv run pytest tests/ -n auto +``` + +For detailed testing documentation, see [tests/README.md](tests/README.md). + +## Code Quality Checks + +Pre-commit hooks run automatically on every commit. To run checks manually: + +### Linting + +```bash +uv run ruff check src/ +``` + +### Type Checking + +```bash +uv run mypy src/ +``` + +### Formatting + +```bash +uv run ruff format src/ +``` + +### Run All Pre-commit Hooks + +```bash +uv run pre-commit run --all-files +``` + +## Coverage Requirements + +- **Target coverage**: 95% (warning threshold in CI) +- **Minimum coverage**: 90% (tests fail below this threshold) + +View coverage report locally: + +```bash +uv run pytest tests/unit/ --cov=glpkg --cov-report=html +open htmlcov/index.html # or xdg-open on Linux +``` + +## Pull Request Process + +1. Create a feature branch from `main` +2. Make changes with clear, descriptive commits +3. Ensure all tests pass and coverage meets requirements +4. Ensure pre-commit hooks pass +5. Push your branch and create a pull request +6. Address review feedback +7. Squash commits if requested by maintainers + +## Code Style Guidelines + +- Follow PEP 8 conventions (enforced by ruff) +- Use type hints (checked by mypy in strict mode) +- Write descriptive docstrings for public APIs +- Keep functions focused and testable +- Avoid over-engineering; keep solutions simple + +## Adding New Features + +When adding new features: + +1. Add unit tests in `tests/unit/` +2. Add integration tests in `tests/integration/` if the feature interacts + with external services +3. Update documentation in README.md or relevant docs +4. Add appropriate pytest markers: + - `@pytest.mark.unit` for unit tests + - `@pytest.mark.integration` for integration tests + +## Questions? + +If you have questions about contributing, please open an issue on GitHub. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ac0abb --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# glpkg + +![Tests](https://github.com/jetm/glpkg/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/jetm/glpkg/actions/workflows/lint.yml/badge.svg) +![Publish](https://github.com/jetm/glpkg/actions/workflows/publish.yml/badge.svg) +![Docs](https://github.com/jetm/glpkg/actions/workflows/docs.yml/badge.svg) +![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen) + +A CLI tool for uploading files to GitLab's Generic Package Registry. + +## Installation + +### From PyPI (Recommended) + +```bash +# Using uv (recommended) +uv pip install glpkg-cli + +# Or using pip +pip install glpkg-cli +``` + +After installation, the `glpkg` command is available in your PATH: + +```bash +# Verify installation +glpkg --version + +# View available commands +glpkg --help +``` + +### Universal Binary (.pyz) + +Download the pre-built universal binary from GitHub releases. +This is a self-contained executable that requires no installation - +just Python 3.11+. + +```bash +# Download the latest release +curl -L -o glpkg.pyz \ + https://github.com/your-org/glpkg/releases/latest/download/glpkg.pyz + +# Make it executable +chmod +x glpkg.pyz + +# Run directly +./glpkg.pyz --help + +# Or run with Python +python glpkg.pyz --help +``` + +Optionally, install the binary to a location in your PATH for easier access: + +```bash +# Install to ~/.local/bin (user-local) +mv glpkg.pyz ~/.local/bin/glpkg +chmod +x ~/.local/bin/glpkg + +# Or install system-wide (requires sudo) +sudo mv glpkg.pyz /usr/local/bin/glpkg +sudo chmod +x /usr/local/bin/glpkg + +# Now use it like a regular command +glpkg --help +``` + +### Development Installation + +```bash +# Clone the repository +git clone https://github.com/your-org/glpkg.git +cd glpkg + +# Install in development mode with uv +uv pip install -e . + +# Or run directly without installing +uv run glpkg --help +``` + +## Usage + +```bash +# Upload a single file +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file.tar.gz + +# Upload multiple files +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file1.tar.gz file2.zip + +# Upload with automatic project detection from git remote +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file.tar.gz + +# Specify project explicitly +glpkg upload --package-name my-package --package-version 1.0.0 \ + --project-path namespace/project --files file.tar.gz + +# Handle duplicates (skip, replace, or error) +glpkg upload --package-name my-package --package-version 1.0.0 \ + --duplicate-policy replace --files file.tar.gz + +# Verbose output with global flags +glpkg --verbose upload --package-name my-package \ + --package-version 1.0.0 --files file.tar.gz + +# JSON output for CI/CD pipelines +glpkg --json-output upload --package-name my-package \ + --package-version 1.0.0 --files file.tar.gz +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Required | +| --------------------- | -------------------------------------- | -------- | +| `GITLAB_TOKEN` | GitLab access token with api scope | Yes | +| `GITLAB_URL` | GitLab URL (default: gitlab.com) | No | +| `GITLAB_PROJECT_PATH` | Project path (e.g., `group/project`) | No | + +### Token Permissions + +Your GitLab token requires: + +- `api` scope for full API access +- Write access to the target project's Package Registry + +## Development + +For detailed contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md). + +### Quick Start + +```bash +# Clone and install dependencies +git clone https://github.com/your-org/glpkg.git +cd glpkg +uv sync --all-extras + +# Install pre-commit hooks +uv run pre-commit install + +# Run tests +uv run pytest tests/unit/ +``` + +### Documentation + +- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and guidelines +- [docs/SHELL_COMPLETION.md](docs/SHELL_COMPLETION.md) - Shell completion +- [docs/RELEASING.md](docs/RELEASING.md) - Release procedures +- [docs/WORKFLOWS.md](docs/WORKFLOWS.md) - GitHub Actions workflows +- [tests/README.md](tests/README.md) - Detailed testing documentation + +## Project Structure + +```text +glpkg/ +├── src/ +│ └── glpkg/ +│ ├── __init__.py +│ ├── cli/ +│ │ ├── __init__.py +│ │ ├── main.py # Main CLI entry point +│ │ └── upload.py # Upload subcommand implementation +│ ├── models.py # Data models +│ ├── uploader.py # Upload logic +│ ├── formatters.py # Output formatting +│ ├── duplicate_detector.py # Duplicate detection +│ └── validators.py # Input validation +├── tests/ +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests +│ └── utils/ # Test utilities +├── pyproject.toml # Project configuration +└── README.md # This file +``` + +## License + +MIT License diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..b00dae0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,259 @@ +# Release Procedures + +This document describes the release workflow for glpkg. + +## Overview + +- Project uses [semantic versioning](https://semver.org/) (major.minor.patch) +- Automated version management with [bump-my-version](https://github.com/callowayproject/bump-my-version) +- PyPI publishing via GitHub Actions on release creation +- Universal .pyz binaries built and attached to GitHub releases + +## Creating a Release + +### 1. Bump Version + +Use `bump-my-version` based on the type of changes: + +```bash +# Bug fixes: 0.1.0 → 0.1.1 +uv run bump-my-version bump patch + +# New features: 0.1.0 → 0.2.0 +uv run bump-my-version bump minor + +# Breaking changes: 0.1.0 → 1.0.0 +uv run bump-my-version bump major +``` + +### 2. Verify Changes + +Confirm the version was updated correctly: + +```bash +# Check version in both files +grep -r "version" pyproject.toml src/glpkg/__init__.py | head -5 + +# Verify git commit and tag were created +git log -1 --oneline +git tag -l | tail -3 +``` + +### 3. Push Changes + +Push the commit and tag to GitHub: + +```bash +git push && git push --tags +``` + +### 4. Create GitHub Release + +1. Navigate to the repository's Releases page +2. Click "Create a new release" +3. Select the version tag (e.g., `v0.2.0`) +4. Add release notes describing the changes +5. Click "Publish release" + +### 5. Automated Publishing + +The GitHub Actions workflow (`.github/workflows/publish.yml`) automatically: + +- Builds and publishes the package to PyPI +- Builds the .pyz universal binary +- Attaches the binary to the GitHub release + +## Building .pyz Locally + +For testing or local distribution, you can build the universal binary locally: + +```bash +# Build with Shiv (recommended) +./scripts/build_pyz.sh --tool shiv + +# Build with PEX +./scripts/build_pyz.sh --tool pex + +# Build with both tools +./scripts/build_pyz.sh --tool both + +# Test the binary +python dist/glpkg.pyz --version +``` + +See `scripts/build_pyz.sh` for build script details. + +## Manual Release (Without GitHub Actions) + +If you need to publish a release manually without relying on GitHub Actions: + +### 1. Get the Current Version + +```bash +# Extract version from pyproject.toml +VERSION=$(grep -m1 'version = ' pyproject.toml | cut -d'"' -f2) +echo "Version: ${VERSION}" + +# Or from Python +VERSION=$(uv run python -c "import glpkg; print(glpkg.__version__)") +echo "Version: ${VERSION}" +``` + +### 2. Build the Package + +```bash +# Install build tool if needed +uv pip install build + +# Build source distribution and wheel +uv run python -m build + +# Verify build artifacts +ls dist/ +# Should show: glpkg_cli-${VERSION}.tar.gz and glpkg_cli-${VERSION}-py3-none-any.whl +``` + +### 3. Publish to PyPI + +PyPI requires API token authentication (username/password is no longer supported). + +**Get an API token:** + +1. Log in to [PyPI](https://pypi.org/manage/account/) +2. Go to Account Settings → API tokens +3. Create a new token (scope: "Entire account" or project-specific) +4. Copy the token (starts with `pypi-`) + +**Upload with the token:** + +```bash +# Install twine if not already installed +uv pip install twine + +# Upload to PyPI using API token +TWINE_USERNAME=__token__ \ +TWINE_PASSWORD=pypi- \ +uv run twine upload dist/glpkg_cli-${VERSION}* +``` + +Alternatively, configure credentials in `~/.pypirc`: + +```ini +[pypi] +username = __token__ +password = pypi- +``` + +Then upload without environment variables: + +```bash +uv run twine upload dist/glpkg_cli-${VERSION}* +``` + +For more information, see: + +- [API Tokens][pypi-tokens] - Create a token for manual uploads +- [Trusted Publishers][pypi-trusted] - Configure GitHub Actions + +[pypi-tokens]: https://pypi.org/help/#apitoken +[pypi-trusted]: https://pypi.org/help/#trusted-publishers + +### 4. Build and Upload .pyz Binary + +```bash +# Build the .pyz binary +./scripts/build_pyz.sh --tool shiv + +# Verify the binary works +python dist/glpkg.pyz --version +``` + +Upload the .pyz binary to the GitHub release: + +```bash +# Using GitHub CLI (gh) +gh release upload v${VERSION} dist/glpkg.pyz + +# Or manually via GitHub web interface: +# 1. Go to https://github.com/your-org/glpkg/releases/tag/v${VERSION} +# 2. Click "Edit release" +# 3. Drag and drop dist/glpkg.pyz into the "Attach binaries" area +# 4. Click "Update release" +``` + +## Verification Steps + +After publishing a release: + +1. **Check PyPI**: Visit `https://pypi.org/project/glpkg-cli/` + +2. **Test PyPI installation**: + + ```bash + uv pip install glpkg-cli== + glpkg --version + ``` + +3. **Test .pyz binary**: + + ```bash + # Download from GitHub release + curl -L -o glpkg.pyz https://github.com/your-org/glpkg/releases/download/v/glpkg.pyz + python glpkg.pyz --version + ``` + +## Troubleshooting + +### PyPI Publish Fails + +- Verify `PYPI_API_TOKEN` secret is configured in GitHub repository settings +- Check that trusted publishing is configured on PyPI for this repository +- Review the GitHub Actions logs for specific error messages + +### .pyz Build Fails + +- Ensure `shiv` or `pex` is installed: `uv pip install shiv pex` +- Check build logs in GitHub Actions for dependency issues +- Try building locally to reproduce the issue + +### Version Mismatch + +Preview changes before bumping: + +```bash +uv run bump-my-version bump --dry-run --verbose patch +``` + +### Tag Already Exists + +If you need to recreate a tag: + +```bash +# Delete local tag +git tag -d v + +# Delete remote tag +git push origin :refs/tags/v + +# Re-run bump-my-version or create tag manually +git tag v +git push --tags +``` + +## Release Checklist + +Before creating a release, verify: + +- [ ] All tests passing on main branch +- [ ] Coverage meets 90% minimum threshold +- [ ] CHANGELOG or release notes prepared +- [ ] Version bumped with bump-my-version +- [ ] Changes and tags pushed to GitHub +- [ ] GitHub release created with release notes + +After release: + +- [ ] PyPI package published successfully +- [ ] .pyz binary attached to release +- [ ] Installation verified from PyPI +- [ ] .pyz binary verified diff --git a/docs/SHELL_COMPLETION.md b/docs/SHELL_COMPLETION.md new file mode 100644 index 0000000..91ae170 --- /dev/null +++ b/docs/SHELL_COMPLETION.md @@ -0,0 +1,173 @@ +# Shell Completion Setup + +glpkg supports shell completion for bash and zsh, providing tab-completion +for commands, options, and arguments. + +## Overview + +- Completion powered by [argcomplete](https://github.com/kislyuk/argcomplete) +- Supports bash and zsh shells +- Provides tab-completion for subcommands, flags, and option values + +## Installation + +### Automatic Installation (Recommended) + +Use the built-in completion installer: + +```bash +# For Bash +glpkg --install-completion bash + +# For Zsh +glpkg --install-completion zsh +``` + +Follow the activation instructions printed after installation. + +### Manual Installation + +#### Bash + +1. Generate the completion script: + + ```bash + glpkg --install-completion bash + ``` + + Or manually create the file at `~/.bash_completion.d/glpkg` + +2. Add to your `~/.bashrc`: + + ```bash + source ~/.bash_completion.d/glpkg + ``` + +3. Reload your shell: + + ```bash + source ~/.bashrc + ``` + + Or restart your terminal. + +#### Zsh + +1. Generate the completion script: + + ```bash + glpkg --install-completion zsh + ``` + + Or manually create the file at `~/.zsh/completion/_glpkg` + +2. Add to your `~/.zshrc` (before `compinit`): + + ```zsh + fpath=(~/.zsh/completion $fpath) + ``` + +3. Reload completions: + + ```zsh + autoload -Uz compinit && compinit + ``` + + Or restart your terminal. + +## Usage + +Once installed, use Tab to complete commands and options: + +```bash +# Show available commands +glpkg + +# Show options for upload command +glpkg upload -- + +# Complete long option names +glpkg upload --pack # completes to --package-name +``` + +## Verification + +Test that completion is working: + +1. Open a new terminal or reload your shell +2. Type `glpkg` followed by a space and press Tab twice +3. You should see available commands and options + +If completion works, you'll see suggestions like: + +```text +upload --help --verbose --json-output +``` + +## Troubleshooting + +### Completion Not Working + +1. **Verify installation**: Check that the completion script exists + + ```bash + # Bash + cat ~/.bash_completion.d/glpkg + + # Zsh + cat ~/.zsh/completion/_glpkg + ``` + +2. **Verify shell configuration**: Ensure your shell config sources the completion + + ```bash + # Check bashrc + grep -n "bash_completion" ~/.bashrc + + # Check zshrc + grep -n "completion" ~/.zshrc + ``` + +3. **Restart shell**: Close and reopen your terminal, or source your config file + +### Permission Denied + +If you encounter permission errors during installation: + +```bash +# Create directory with appropriate permissions +mkdir -p ~/.bash_completion.d +chmod 755 ~/.bash_completion.d + +# Or for zsh +mkdir -p ~/.zsh/completion +chmod 755 ~/.zsh/completion +``` + +Then re-run the installation command. + +### Zsh Completion Not Loading + +Ensure `fpath` is updated before `compinit` in your `.zshrc`: + +```zsh +# This must come BEFORE compinit +fpath=(~/.zsh/completion $fpath) + +# Then initialize completions +autoload -Uz compinit && compinit +``` + +If you've modified your `.zshrc`, rebuild the completion cache: + +```zsh +rm -f ~/.zcompdump +compinit +``` + +## Technical Details + +- **Implementation**: `src/glpkg/cli/completion.py` +- **Bash completion path**: `~/.bash_completion.d/glpkg` +- **Zsh completion path**: `~/.zsh/completion/_glpkg` +- **Library**: argcomplete for completion generation diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md new file mode 100644 index 0000000..ada3491 --- /dev/null +++ b/docs/WORKFLOWS.md @@ -0,0 +1,230 @@ +# GitHub Workflows Documentation + +This document provides comprehensive documentation for all GitHub Actions +workflows used in the glpkg project. These workflows form the CI/CD pipeline +that ensures code quality, runs tests, validates documentation, and handles +package publishing. + +## Table of Contents + +- [Test Workflow](#test-workflow) +- [Lint Workflow](#lint-workflow) +- [Publish Workflow](#publish-workflow) +- [Documentation Workflow](#documentation-workflow) +- [Workflow Status Badges](#workflow-status-badges) +- [Common Debugging Steps](#common-debugging-steps) +- [Future Improvements](#future-improvements) + +## Test Workflow + +**File:** `.github/workflows/test.yml` + +Runs unit and integration tests across Python 3.11-3.13 to ensure code +correctness and maintain test coverage standards. + +**Triggers:** + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +**Key Features:** + +- Matrix testing across Python versions 3.11, 3.12, and 3.13 +- Unit tests with pytest-cov providing coverage reporting +- Coverage thresholds: 95% warning level, 90% fail threshold +- Integration tests run conditionally when secrets are configured +- Coverage report upload for Python 3.11 runs +- Uses `uv` for fast, reliable dependency management + +**Secrets:** + +| Secret | Required | Description | +| -------------- | -------- | ---------------------------------------- | +| `GITLAB_TOKEN` | Optional | GitLab API token for integration tests | +| `GITLAB_REPO` | Optional | GitLab repository path for integration | + +Integration tests are skipped if these secrets are not configured. + +**Artifacts:** + +- `coverage.xml` - XML coverage report for CI integrations +- `htmlcov/` - HTML coverage report for detailed inspection +- `pytest-results` - Test result files + +**Debugging:** + +- Coverage threshold failures: Check the "Check coverage threshold" step + output for specific coverage percentages +- Integration tests skipped: Verify `GITLAB_TOKEN` and `GITLAB_REPO` + secrets are configured in repository settings +- Manual test runs: Use `workflow_dispatch` from Actions tab +- Detailed coverage: Download the `htmlcov` artifact +- Local reproduction: + `uv run pytest tests/unit/ --cov=src/glpkg --cov-report=term-missing` + +## Lint Workflow + +**File:** `.github/workflows/lint.yml` + +Performs code quality checks using ruff for linting/formatting and mypy for +type checking to maintain consistent code standards. + +**Triggers:** + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +**Key Features:** + +- Ruff linting: `ruff check src/` for code style and error detection +- Ruff formatting: `ruff format --check src/` for consistent formatting +- Mypy type checking: Strict mode (`--strict`) for comprehensive type safety +- Uses Python 3.11 and `uv` for consistency + +**Secrets:** None required. + +**Debugging:** + +- Ruff lint errors: Run `uv run ruff check src/ --fix` to auto-fix issues +- Ruff format errors: Run `uv run ruff format src/` to auto-format code +- Mypy errors: Run `uv run mypy src/glpkg/ --strict` locally +- Configuration: Check `pyproject.toml` for ruff and mypy settings +- Ignore patterns: Add `# noqa` comments or configure in `pyproject.toml` + +## Publish Workflow + +**File:** `.github/workflows/publish.yml` + +Builds and publishes the package to PyPI and creates GitHub release assets +including the universal `.pyz` binary. + +**Triggers:** + +- GitHub release published +- Manual dispatch via `workflow_dispatch` + +**Key Features:** + +- Package building: Creates wheel and sdist with `python -m build` +- Universal binary: Builds `.pyz` file using shiv for standalone execution +- GitHub release assets: Uploads `.pyz` binary to release assets +- PyPI publishing: Publishes to PyPI using token authentication +- Package name: Published as `glpkg-cli` on PyPI + +**Secrets:** + +| Secret | Required | Description | +| ---------------- | -------- | ------------------------------------- | +| `PYPI_API_TOKEN` | Yes | PyPI API token with upload permission | + +**Artifacts:** + +- `dist/` directory containing: + - `*.whl` - Wheel package + - `*.tar.gz` - Source distribution + - `*.pyz` - Universal binary + +**Debugging:** + +- Test .pyz build locally: + `bash scripts/build_pyz.sh --tool shiv --output-dir dist` +- Verify package builds: + `uv pip install build --system && python -m build` +- PyPI token issues: Ensure token has "Upload packages" permission +- Build script issues: Review `scripts/build_pyz.sh` for shiv configuration +- Version conflicts: Check version in `pyproject.toml` doesn't exist on PyPI +- Local testing: Install built wheel with `pip install dist/*.whl` + +## Documentation Workflow + +**File:** `.github/workflows/docs.yml` + +Validates markdown files for proper formatting and checks that all links +are functional. + +**Triggers:** + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +**Key Features:** + +- Markdown linting with markdownlint-cli2 for consistent formatting +- Link checking with markdown-link-check for broken URL detection +- Validated files: + - `README.md` + - `CONTRIBUTING.md` + - `tests/README.md` + - `docs/RELEASING.md` + - `docs/SHELL_COMPLETION.md` + - `docs/WORKFLOWS.md` + +**Secrets:** None required. + +**Debugging:** + +- Markdown lint errors: Install markdownlint-cli2 locally and run on files +- Broken links: Verify URLs are accessible +- Custom rules: Add `.markdownlint.json` to configure linting rules +- Link check config: Add `.markdown-link-check.json` for custom behavior +- False positives: Some internal links may fail in CI but work locally + +## Workflow Status Badges + +Add these badges to your README.md to display workflow status: + +```markdown +![Tests](https://github.com/OWNER/REPO/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/OWNER/REPO/actions/workflows/lint.yml/badge.svg) +![Publish](https://github.com/OWNER/REPO/actions/workflows/publish.yml/badge.svg) +![Docs](https://github.com/OWNER/REPO/actions/workflows/docs.yml/badge.svg) +``` + +Replace `OWNER/REPO` with your actual GitHub repository path. + +## Common Debugging Steps + +1. **Check workflow logs**: Navigate to Actions tab and select failed run +2. **Manual testing**: Use `workflow_dispatch` to trigger manually +3. **Local reproduction**: Run same commands locally using `uv` +4. **Review configuration**: Check `pyproject.toml` for tool settings +5. **Secret verification**: Ensure secrets are configured in Settings +6. **Branch protection**: Verify rules aren't blocking execution +7. **Permissions**: Check `GITHUB_TOKEN` has necessary permissions + +## Future Improvements + +### Integration Test Secret Checking + +The current condition `${{ secrets.GITLAB_TOKEN != '' }}` may not work as +expected since GitHub Actions doesn't expose secret values in expressions. +Consider these alternatives: + +- Use a dedicated job with conditional execution based on repository context +- Use environment-based checks with separate environments +- Restrict integration tests to specific branches + +### PyPI Trusted Publishing + +The current workflow uses token-based authentication (`PYPI_API_TOKEN`). +Consider migrating to PyPI trusted publishing (OIDC) for enhanced security. + +**Benefits:** + +- Eliminates need for long-lived API tokens +- No secret rotation required +- Stronger authentication through GitHub's OIDC provider + +**Migration steps:** + +1. Configure the PyPI project for trusted publishing in PyPI settings +2. Add the GitHub repository as a trusted publisher +3. Update workflow to use `pypa/gh-action-pypi-publish` with OIDC +4. Remove the `PYPI_API_TOKEN` secret after verification + +See [PyPI Trusted Publishing documentation][pypi-trusted] for details. + +[pypi-trusted]: https://docs.pypi.org/trusted-publishers/ diff --git a/gitlab-pkg-upload.py b/gitlab-pkg-upload.py deleted file mode 100755 index eefd9b5..0000000 --- a/gitlab-pkg-upload.py +++ /dev/null @@ -1,1622 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "python-gitlab>=4.0.0", -# "rich>=13.0.0", -# "GitPython>=3.1.0", -# ] -# /// - -""" -GitLab Generic Package Upload Script - -A standalone uv-compatible Python script that uploads single or multiple files to GitLab's -generic package registry with SHA256 checksum validation, retry logic, and rich progress output. -Supports uploading multiple files from explicit file lists or directories. -Features copy-paste friendly URL output to avoid terminal truncation. - -Filename Restrictions: - GitLab's Generic Package Registry API has limitations on filename characters. - Only ASCII characters are supported: letters (a-z, A-Z), digits (0-9), dots (.), - hyphens (-), underscores (_), and forward slashes (/) for directory paths. - - Files with non-ASCII characters or unsupported special characters will be rejected - with an error. Ensure filenames use only ASCII-safe characters before uploading. -""" - -import argparse -import hashlib -import json -import logging -import sys -import time -from dataclasses import dataclass -from enum import Enum -from pathlib import Path -from typing import Optional - -from gitlab.exceptions import GitlabAuthenticationError -from gitlab_common import ( - GitAutoDetector, - ProjectResolver, - enhance_error_message, - get_gitlab_token, - handle_network_error_with_retry, - setup_logging, - validate_configuration, - validate_project_input, -) -from rich.console import Console -from rich.status import Status - -from gitlab import Gitlab - -# Constants -DEFAULT_GITLAB_URL = "https://gitlab.com" -MAX_RETRIES = 3 -RETRY_DELAYS = [1, 2, 4] # Exponential backoff in seconds -RATE_LIMIT_RETRY_DELAY = 60 # Seconds to wait for rate limit reset - -# Setup rich console and logging -console = Console() -setup_logging(console=console) -logger = logging.getLogger(__name__) - - -@dataclass -class FileFingerprint: - """Represents a unique identifier for files to enable accurate duplicate detection.""" - - source_path: str - target_filename: str - sha256_checksum: str - file_size: int - timestamp: float - - -@dataclass -class RemoteFile: - """Represents a file that exists in the GitLab package registry.""" - - file_id: int - filename: str - sha256_checksum: Optional[str] - file_size: int - download_url: str - package_name: str - version: str - - -class DuplicatePolicy(Enum): - """Defines how the system should handle detected duplicates.""" - - SKIP = "skip" # Skip uploading duplicates (default) - REPLACE = "replace" # Delete existing and upload new - ERROR = "error" # Fail with error on duplicates - - -@dataclass -class UploadResult: - """Enhanced upload result structure with duplicate detection information.""" - - source_path: str - target_filename: str - success: bool - result: str # URL on success, error message on failure - was_duplicate: bool = False - duplicate_action: Optional[str] = None # "skipped", "replaced", "error" - existing_url: Optional[str] = None - - -class DuplicateDetector: - """Core component responsible for detecting duplicates both locally (within session) and remotely (in GitLab registry).""" - - def __init__(self, gitlab_client: Gitlab, project_id: int): - """ - Initialize DuplicateDetector with GitLab client and project ID. - - Args: - gitlab_client: Authenticated GitLab client - project_id: GitLab project ID - """ - self.gl = gitlab_client - self.project_id = project_id - self.session_registry: dict[str, FileFingerprint] = {} - - def check_session_duplicate( - self, file_path: Path, target_filename: str - ) -> Optional[FileFingerprint]: - """ - Check if file was already processed in current session. - - Args: - file_path: Path to the source file - target_filename: Target filename in registry - - Returns: - FileFingerprint if duplicate found, None otherwise - """ - logger.debug(f"Checking session duplicate for: {target_filename}") - - # Check if target filename already exists in session registry - if target_filename in self.session_registry: - existing_fingerprint = self.session_registry[target_filename] - logger.debug(f"Found existing session entry for {target_filename}") - - # Calculate checksum of current file to compare - current_checksum = calculate_sha256(file_path) - - # Compare checksums to determine if it's truly a duplicate - if existing_fingerprint.sha256_checksum == current_checksum: - logger.info( - f"Session duplicate detected: {target_filename} (checksum: {current_checksum})" - ) - logger.info( - f"Original source: {existing_fingerprint.source_path}, Current source: {file_path}" - ) - return existing_fingerprint - else: - logger.warning( - f"Same target filename {target_filename} but different content detected" - ) - logger.warning( - f"Existing checksum: {existing_fingerprint.sha256_checksum}, Current checksum: {current_checksum}" - ) - else: - logger.debug(f"No session duplicate found for {target_filename}") - - return None - - def check_remote_duplicate( - self, package_name: str, version: str, filename: str, checksum: str - ) -> Optional[RemoteFile]: - """ - Check if file exists in GitLab registry with enhanced retry logic. - - Args: - package_name: Package name in registry - version: Package version - filename: Target filename - checksum: SHA256 checksum to compare - - Returns: - RemoteFile if duplicate found, None otherwise - """ - logger.info( - f"Starting remote duplicate check for {filename} in {package_name} v{version}" - ) - logger.debug(f"Local checksum to compare: {checksum}") - - def _check_remote_duplicate(): - """Internal function to check remote duplicate.""" - project = self.gl.projects.get(self.project_id) - packages = project.packages.list(package_name=package_name, get_all=True) - - # Find the target package version - target_package = next((p for p in packages if p.version == version), None) - - if not target_package: - logger.debug( - f"Package {package_name} v{version} not found - no remote duplicate" - ) - return None - - logger.debug( - f"Found package {package_name} v{version} (ID: {target_package.id})" - ) - - # Get package files - package_obj = project.packages.get(target_package.id) - package_files = package_obj.package_files.list(get_all=True) - - logger.debug(f"Found {len(package_files)} files in package") - - # Find files with matching filename - matching_files = [f for f in package_files if f.file_name == filename] - - if not matching_files: - logger.debug( - f"No files named {filename} found in remote package - no duplicate" - ) - return None - - logger.debug( - f"Found {len(matching_files)} file(s) with matching filename {filename}" - ) - - # Check for checksum matches - for pkg_file in matching_files: - remote_sha256 = getattr(pkg_file, "file_sha256", None) - - if remote_sha256: - logger.debug( - f"Comparing checksums - Remote: {remote_sha256}, Local: {checksum}" - ) - if remote_sha256.lower() == checksum.lower(): - logger.info( - f"Remote duplicate detected: {filename} (checksum: {checksum})" - ) - logger.info( - f"Remote file ID: {pkg_file.id}, Size: {getattr(pkg_file, 'size', 'unknown')}" - ) - - # Generate download URL - download_url = ( - f"{self.gl.api_url.replace('/api/v4', '')}/api/v4/projects/{self.project_id}/packages/generic/" - f"{package_name}/{version}/{filename}" - ) - - return RemoteFile( - file_id=pkg_file.id, - filename=filename, - sha256_checksum=remote_sha256, - file_size=getattr(pkg_file, "size", 0), - download_url=download_url, - package_name=package_name, - version=version, - ) - else: - logger.debug( - f"File {filename} exists but checksum differs (remote: {remote_sha256}, local: {checksum})" - ) - else: - # Handle incomplete metadata gracefully - use file size as fallback - logger.warning( - f"Remote checksum not available for {filename}, using file size comparison" - ) - logger.debug( - f"Cannot verify duplicate without checksum for {filename}" - ) - - logger.debug( - f"No matching checksums found for {filename} - no remote duplicate" - ) - return None - - try: - return handle_network_error_with_retry( - operation_name=f"Remote duplicate check for {filename}", - operation_func=_check_remote_duplicate, - ) - except Exception as e: - logger.error(f"Remote duplicate check failed for {filename}: {e}") - logger.warning(f"Proceeding without duplicate detection for {filename}") - return None - - def register_file(self, file_path: Path, target_filename: str, checksum: str): - """ - Register file as processed in current session. - - Args: - file_path: Path to the source file - target_filename: Target filename in registry - checksum: SHA256 checksum of the file - """ - file_stats = file_path.stat() - - fingerprint = FileFingerprint( - source_path=str(file_path), - target_filename=target_filename, - sha256_checksum=checksum, - file_size=file_stats.st_size, - timestamp=time.time(), - ) - - self.session_registry[target_filename] = fingerprint - logger.info( - f"Registered file in session: {target_filename} (checksum: {checksum})" - ) - logger.debug( - f"Session registry now contains {len(self.session_registry)} file(s)" - ) - - -def validate_filename_ascii(filename: str) -> tuple[bool, str]: - """ - Validate that a filename contains only ASCII characters supported by GitLab Generic Package Registry. - - GitLab's API restricts filenames to ASCII-safe characters only. This function checks - if the provided filename complies with these restrictions. - - Args: - filename: Target filename to validate - - Returns: - Tuple of (is_valid, error_message) where: - - is_valid: True if filename is valid, False otherwise - - error_message: Empty string if valid, detailed error message if invalid - - Examples: - Valid filenames: "package.tar.gz", "my-file_v1.0.bin", "subdir/file.txt" - Invalid filenames: "café.tar.gz", "文件.bin", "file™.txt" - """ - logger.debug(f"Validating filename for ASCII compliance: {filename}") - - # Check if filename is ASCII - if not filename.isascii(): - logger.info(f"Filename validation failed: {filename}") - error_message = ( - f"GitLab Generic Package Registry does not support non-ASCII characters in filenames.\n" - f"Problematic filename: '{filename}'\n" - f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), underscores (_), and forward slashes (/) for directory paths.\n" - f"Please rename the file to use only ASCII-safe characters before uploading." - ) - return False, error_message - - # Additional validation: check for allowed characters only - # Allowed: letters, digits, dots, hyphens, underscores, forward slashes - import re - - allowed_pattern = re.compile(r"^[a-zA-Z0-9._/-]+$") - - if not allowed_pattern.match(filename): - logger.info(f"Filename validation failed: {filename}") - error_message = ( - f"GitLab Generic Package Registry does not support special characters in filenames.\n" - f"Problematic filename: '{filename}'\n" - f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), underscores (_), and forward slashes (/) for directory paths.\n" - f"Please rename the file to use only ASCII-safe characters before uploading." - ) - return False, error_message - - logger.debug(f"Filename validation passed: {filename}") - return True, "" - - -def calculate_sha256(file_path: Path) -> str: - """ - Calculate SHA256 checksum of a file. - - Args: - file_path: Path to the file - - Returns: - Hexadecimal SHA256 digest string - """ - sha256_hash = hashlib.sha256() - - with open(file_path, "rb") as f: - # Read in chunks for memory efficiency - for chunk in iter(lambda: f.read(8192), b""): - sha256_hash.update(chunk) - - checksum = sha256_hash.hexdigest() - logger.info(f"Calculated SHA256 checksum: {checksum}") - return checksum - - -def collect_files_to_upload( - args: argparse.Namespace, -) -> tuple[list[tuple[Path, str]], list[dict]]: - """ - Collect files to upload based on input mode (--files or --directory). - - Validates that all filenames contain only ASCII characters supported by GitLab. - - Args: - args: Parsed command-line arguments - - Returns: - Tuple of (valid_files, file_errors) where: - - valid_files: List of tuples containing (source_path, target_filename) - - file_errors: List of dicts with error information for invalid files - - Raises: - ValueError: If file paths are invalid, file mappings are malformed, - or filenames contain non-ASCII characters - FileNotFoundError: If specified directory does not exist - """ - files_to_upload: list[tuple[Path, str]] = [] - file_errors: list[dict] = [] - - if args.files: - # Parse file mappings if provided - file_mappings: dict[str, str] = {} - if args.file_mapping: - for mapping in args.file_mapping: - if mapping.count(":") != 1: - raise ValueError( - f"Invalid file mapping format '{mapping}'. " - "Expected format: 'local.bin:remote.bin'" - ) - local_name, remote_name = mapping.split(":", 1) - file_mappings[local_name] = remote_name - - # Validate that file mappings reference files in the --files list - if file_mappings: - files_set = {Path(f).name for f in args.files} - for local_name in file_mappings.keys(): - if local_name not in files_set: - raise ValueError( - f"File mapping references '{local_name}' which is not in --files list" - ) - - # Process each file - collect errors instead of raising immediately - for file_path_str in args.files: - source_path = Path(file_path_str) - - # Validate file existence and type - if not source_path.exists(): - target_filename = file_mappings.get(source_path.name, source_path.name) - file_errors.append( - { - "source_path": str(source_path), - "target_filename": target_filename, - "error_message": f"File not found: {source_path}", - "error_type": "FileNotFoundError", - } - ) - logger.error(f"File not found: {source_path}") - continue - - if not source_path.is_file(): - target_filename = file_mappings.get(source_path.name, source_path.name) - file_errors.append( - { - "source_path": str(source_path), - "target_filename": target_filename, - "error_message": f"Path is not a file: {source_path}", - "error_type": "ValueError", - } - ) - logger.error(f"Path is not a file: {source_path}") - continue - - # Apply mapping if exists, otherwise use original filename - target_filename = file_mappings.get(source_path.name, source_path.name) - - # Validate filename for GitLab API compatibility - is_valid, error_message = validate_filename_ascii(target_filename) - if not is_valid: - raise ValueError(error_message) - - files_to_upload.append((source_path, target_filename)) - - elif args.directory: - directory_path = Path(args.directory) - if not directory_path.exists(): - raise FileNotFoundError(f"Directory not found: {directory_path}") - if not directory_path.is_dir(): - raise ValueError(f"Path is not a directory: {directory_path}") - - # Collect only top-level files (not subdirectories) - for item in directory_path.iterdir(): - if item.is_file(): - # Validate filename for GitLab API compatibility - is_valid, error_message = validate_filename_ascii(item.name) - if not is_valid: - raise ValueError(error_message) - files_to_upload.append((item, item.name)) - - if not files_to_upload: - logger.warning(f"No files found in directory: {directory_path}") - - # Check for duplicate target filenames - target_filenames = [target for _, target in files_to_upload] - duplicates = [name for name in target_filenames if target_filenames.count(name) > 1] - if duplicates: - unique_duplicates = list(set(duplicates)) - raise ValueError( - f"Duplicate target filenames detected: {', '.join(unique_duplicates)}" - ) - - return files_to_upload, file_errors - - -def format_results_as_json( - package_name: str, - version: str, - successful_uploads: list[UploadResult], - skipped_duplicates: list[UploadResult], - failed_uploads: list[UploadResult], -) -> dict: - """ - Convert upload results into a JSON-serializable dictionary. - - Args: - package_name: Package name in registry - version: Package version - successful_uploads: List of successful upload results - skipped_duplicates: List of skipped duplicate results - failed_uploads: List of failed upload results - - Returns: - Dictionary with structured results ready for JSON serialization - """ - # Calculate statistics - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - total_processed = ( - len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) - ) - - # Determine overall success status - success = len(failed_uploads) == 0 - exit_code = 0 if success else 1 - - # Helper function to convert UploadResult to dict - def result_to_dict(result: UploadResult, is_skipped: bool = False) -> dict: - """Convert an UploadResult to a dictionary for JSON output.""" - # Determine download URL based on result type - if result.success: - if is_skipped: - download_url = result.existing_url or result.result - else: - download_url = result.result - else: - download_url = None - - return { - "source_path": result.source_path, - "target_filename": result.target_filename, - "download_url": download_url, - "checksum": None, # Checksum not currently stored in UploadResult - "was_duplicate": result.was_duplicate, - "duplicate_action": result.duplicate_action, - "existing_url": result.existing_url, - "error_message": result.result if not result.success else None, - } - - # Build the result structure - result_dict = { - "success": success, - "exit_code": exit_code, - "package_name": package_name, - "version": version, - "successful_uploads": [result_to_dict(r) for r in successful_uploads], - "skipped_duplicates": [ - result_to_dict(r, is_skipped=True) for r in skipped_duplicates - ], - "failed_uploads": [result_to_dict(r) for r in failed_uploads], - "statistics": { - "total_processed": total_processed, - "new_uploads": new_uploads_count, - "replaced_duplicates": replaced_count, - "skipped_duplicates": len(skipped_duplicates), - "failed_uploads": len(failed_uploads), - }, - } - - return result_dict - - -def handle_duplicate( - policy: DuplicatePolicy, - remote_file: RemoteFile, - detector: DuplicateDetector, - gl: Gitlab, - project_id: int, -) -> tuple[str, str, int]: - """ - Handle duplicate file according to policy. - - Args: - policy: Duplicate handling policy - remote_file: Remote file that was found as duplicate - detector: DuplicateDetector instance - gl: GitLab client - project_id: Project ID - - Returns: - Tuple of (action, result, deleted_count) where action is "skipped"/"replaced"/"error", - result is URL or error message, and deleted_count is the number of files deleted - - Raises: - ValueError: If policy is ERROR and duplicate is found - """ - logger.info( - f"Handling duplicate file {remote_file.filename} with policy: {policy.value}" - ) - logger.debug( - f"Remote file details - ID: {remote_file.file_id}, Size: {remote_file.file_size}, Checksum: {remote_file.sha256_checksum}" - ) - - if policy == DuplicatePolicy.SKIP: - logger.info( - f"Policy decision: SKIP - Skipping duplicate file: {remote_file.filename}" - ) - logger.info(f"Using existing file URL: {remote_file.download_url}") - return "skipped", remote_file.download_url, 0 - - elif policy == DuplicatePolicy.REPLACE: - logger.info( - f"Policy decision: REPLACE - Replacing duplicate file: {remote_file.filename}" - ) - logger.info("Deleting existing file(s) before upload") - deleted_count = delete_existing_files( - gl=gl, - project_id=project_id, - package_name=remote_file.package_name, - version=remote_file.version, - filename=remote_file.filename, - ) - logger.info(f"Deleted {deleted_count} existing file(s), proceeding with upload") - return "replaced", "proceed_with_upload", deleted_count - - elif policy == DuplicatePolicy.ERROR: - error_msg = f"Policy decision: ERROR - Duplicate file detected: {remote_file.filename} (checksum: {remote_file.sha256_checksum})" - logger.error(error_msg) - logger.error(f"Existing file URL: {remote_file.download_url}") - raise ValueError(error_msg) - - else: - error_msg = f"Unknown duplicate policy: {policy}" - logger.error(error_msg) - raise ValueError(error_msg) - - -def delete_existing_files( - gl: Gitlab, - project_id: int, - package_name: str, - version: str, - filename: str, -) -> int: - """ - Delete existing files with the same name from the package. - - This enables replacing files in the registry rather than creating duplicates. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - package_name: Package name in registry - version: Package version - filename: Filename to delete - - Returns: - Number of files deleted - """ - deleted_count = 0 - - try: - project = gl.projects.get(project_id) - packages = project.packages.list(package_name=package_name, get_all=True) - - target_package = next((p for p in packages if p.version == version), None) - - if not target_package: - logger.debug( - f"Package {package_name} v{version} not found, nothing to delete" - ) - return 0 - - # Get package files - package_obj = project.packages.get(target_package.id) - package_files = package_obj.package_files.list(get_all=True) - - # Find all files matching the target filename - matching_files = [f for f in package_files if f.file_name == filename] - - if not matching_files: - logger.debug(f"No existing files named {filename} found") - return 0 - - # Delete all matching files - for file_obj in matching_files: - try: - logger.info(f"Deleting existing file: {filename} (ID: {file_obj.id})") - file_obj.delete() - deleted_count += 1 - except Exception as e: - logger.warning( - f"Failed to delete file {filename} (ID: {file_obj.id}): {e}" - ) - - if deleted_count > 0: - logger.info(f"Deleted {deleted_count} existing file(s) named {filename}") - - except Exception as e: - logger.warning(f"Error checking for existing files: {e}") - - return deleted_count - - -def upload_file_with_retry( - gl: Gitlab, - project_id: int, - file_path: Path, - package_name: str, - version: str, - target_filename: str, - max_retries: int = MAX_RETRIES, -) -> bool: - """ - Upload file to GitLab generic package registry with enhanced retry logic. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - file_path: Path to file to upload - package_name: Package name in registry - version: Package version - target_filename: Target filename in registry - max_retries: Maximum number of retry attempts - - Returns: - True if upload succeeded, False otherwise - """ - - def _upload_file(): - """Internal function to upload file.""" - project = gl.projects.get(project_id) - - # Use spinner with elapsed time since GitLab API doesn't provide upload progress - file_size_mb = file_path.stat().st_size / (1024 * 1024) - start_time = time.time() - - with Status( - f"[bold blue]Uploading {target_filename} ({file_size_mb:.2f} MB)...[/bold blue]", - console=console, - spinner="dots", - ): - # Upload file to generic packages - project.generic_packages.upload( - package_name=package_name, - package_version=version, - file_name=target_filename, - path=file_path.as_posix(), - ) - - elapsed = time.time() - start_time - speed_mbps = file_size_mb / elapsed if elapsed > 0 else 0 - console.print( - f"[green]✓[/green] Uploaded {target_filename} " - f"({file_size_mb:.2f} MB in {elapsed:.1f}s, {speed_mbps:.2f} MB/s)" - ) - - logger.info(f"Upload successful: {target_filename}") - return True - - try: - return handle_network_error_with_retry( - operation_name=f"File upload for {target_filename}", - operation_func=_upload_file, - max_retries=max_retries, - ) - except Exception as e: - logger.error(f"Upload failed for {target_filename}: {e}") - return False - - -def validate_upload( - gl: Gitlab, - project_id: int, - package_name: str, - version: str, - filename: str, - expected_sha256: str, -) -> bool: - """ - Validate uploaded file by comparing checksums. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - package_name: Package name in registry - version: Package version - filename: Filename in registry - expected_sha256: Expected SHA256 checksum - - Returns: - True if validation succeeded, False otherwise - """ - try: - project = gl.projects.get(project_id) - - # Get package files - packages = project.packages.list(package_name=package_name, get_all=True) - - for package in packages: - if package.version == version: - # Get package files - package_obj = project.packages.get(package.id) - package_files = package_obj.package_files.list(get_all=True) - - # Debug: log all available filenames - available_files = [pkg_file.file_name for pkg_file in package_files] - logger.debug(f"Available files in package: {available_files}") - logger.debug(f"Looking for filename: {filename}") - - for pkg_file in package_files: - # Handle both direct filename match and path-based match - # GitLab might store subdirectory files differently - file_matches = ( - pkg_file.file_name == filename - or pkg_file.file_name.endswith(f"/{filename}") - or filename.endswith(f"/{pkg_file.file_name}") - or pkg_file.file_name.replace("/", "_") - == filename.replace("/", "_") - ) - - if file_matches: - # Check if SHA256 is available in the response - remote_sha256 = getattr(pkg_file, "file_sha256", None) - - if remote_sha256: - if remote_sha256.lower() == expected_sha256.lower(): - logger.info( - f"Checksum validation successful: {expected_sha256}" - ) - return True - else: - # Special handling for empty files - GitLab may compute checksums differently - if ( - expected_sha256.lower() - == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ): - logger.warning( - f"Empty file checksum mismatch (GitLab limitation). " - f"Local: {expected_sha256}, Remote: {remote_sha256}. " - f"Assuming upload success for empty file." - ) - return True - else: - logger.error( - f"Checksum mismatch! Local: {expected_sha256}, " - f"Remote: {remote_sha256}" - ) - return False - else: - logger.warning( - "Remote checksum not available in API response, " - "skipping validation" - ) - return True - - logger.warning( - f"Could not find uploaded file {filename} in package {package_name} " - f"version {version}" - ) - - # Special handling for files with subdirectories - GitLab Generic Package Registry - # may not support subdirectories in filenames properly - if "/" in filename: - logger.warning( - f"File '{filename}' contains subdirectory path. GitLab Generic Package Registry " - f"may not support subdirectories in filenames. Upload may have succeeded but " - f"validation failed. Consider using flat filenames without subdirectories." - ) - # For subdirectory files, we'll assume success if upload didn't fail - # This is a workaround for GitLab API limitations - return True - - return False - - except Exception as e: - logger.warning(f"Checksum validation failed: {e}") - return False - - -def process_single_file( - gl: Gitlab, - project_id: int, - source_path: Path, - target_filename: str, - package_name: str, - version: str, - gitlab_url: str, - detector: DuplicateDetector, - duplicate_policy: DuplicatePolicy, -) -> UploadResult: - """ - Process upload and validation for a single file with duplicate detection. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - source_path: Path to source file - target_filename: Target filename in registry - package_name: Package name in registry - version: Package version - gitlab_url: GitLab instance URL - detector: DuplicateDetector instance - duplicate_policy: How to handle duplicates - - Returns: - UploadResult with success status and details - """ - try: - logger.info(f"Processing file: {source_path.name} -> {target_filename}") - - # Check session duplicate before processing - session_duplicate = detector.check_session_duplicate( - source_path, target_filename - ) - if session_duplicate: - logger.info(f"Session duplicate detected for {target_filename}, skipping") - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result="Skipped - already processed in this session", - was_duplicate=True, - duplicate_action="skipped", - existing_url=None, - ) - - # Calculate local file checksum for new files - logger.info(f"Calculating checksum for {source_path.name}...") - local_checksum = calculate_sha256(source_path) - - # Initialize variable to track file deletions - files_deleted = 0 - - # Check remote duplicate before upload - remote_duplicate = detector.check_remote_duplicate( - package_name, version, target_filename, local_checksum - ) - - if remote_duplicate: - logger.info(f"Remote duplicate detected for {target_filename}") - try: - # Handle duplicates according to policy - action, result, deleted_count = handle_duplicate( - duplicate_policy, remote_duplicate, detector, gl, project_id - ) - - if action == "skipped": - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result=result, # This is the download URL - was_duplicate=True, - duplicate_action="skipped", - existing_url=result, - ) - elif action == "replaced": - # Track deletions and continue with upload after deletion - files_deleted = deleted_count - logger.info( - f"Proceeding with upload after replacing {target_filename}" - ) - # If action is "error", handle_duplicate will raise an exception - - except ValueError as e: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result=str(e), - was_duplicate=True, - duplicate_action="error", - ) - else: - # No remote duplicate found, delete existing files with same name for replacement - files_deleted = delete_existing_files( - gl=gl, - project_id=project_id, - package_name=package_name, - version=version, - filename=target_filename, - ) - - # Upload file with retry logic - upload_success = upload_file_with_retry( - gl=gl, - project_id=project_id, - file_path=source_path, - package_name=package_name, - version=version, - target_filename=target_filename, - ) - - if not upload_success: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result="Upload failed after all retry attempts", - ) - - # Validate upload - logger.info(f"Validating upload for {target_filename}...") - validation_success = validate_upload( - gl=gl, - project_id=project_id, - package_name=package_name, - version=version, - filename=target_filename, - expected_sha256=local_checksum, - ) - - if not validation_success: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result="Upload validation failed", - ) - - # Generate download URL - download_url = ( - f"{gitlab_url}/api/v4/projects/{project_id}/packages/generic/" - f"{package_name}/{version}/{target_filename}" - ) - - # Register successfully processed files - detector.register_file(source_path, target_filename, local_checksum) - - logger.info(f"Successfully uploaded {target_filename}") - - # Check if this was a replaced duplicate - # A file is considered replaced if either: - # 1. A remote duplicate was found (same name + same checksum), OR - # 2. Files were deleted (same name, possibly different checksum) - was_replaced_duplicate = (remote_duplicate is not None) or (files_deleted > 0) - replaced_existing_url = ( - remote_duplicate.download_url if remote_duplicate else None - ) - - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result=download_url, - was_duplicate=was_replaced_duplicate, - duplicate_action="replaced" if was_replaced_duplicate else None, - existing_url=replaced_existing_url, - ) - - except Exception as e: - error_msg = f"Unexpected error: {str(e)}" - logger.error(error_msg) - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result=error_msg, - ) - - -def main() -> None: - """Main function to handle argument parsing and orchestrate the multi-file upload process.""" - parser = argparse.ArgumentParser( - description="Upload single or multiple files to GitLab generic package registry with checksum validation and copy-paste friendly URL reporting. " - "Automatically detects GitLab project from Git repository when no project is specified." - ) - - # Required arguments - parser.add_argument( - "--package-name", - type=str, - required=True, - help="Package name in GitLab generic package registry", - ) - parser.add_argument("--version", type=str, required=True, help="Package version") - - # Mutually exclusive project specification group (now optional due to auto-detection) - project_group = parser.add_mutually_exclusive_group(required=False) - project_group.add_argument( - "--project-url", - type=str, - help="GitLab project URL (e.g., https://gitlab.com/namespace/project). If not specified, will attempt auto-detection from Git repository.", - ) - project_group.add_argument( - "--project-path", - type=str, - help="GitLab project path (e.g., namespace/project). If not specified, will attempt auto-detection from Git repository.", - ) - - # Mutually exclusive file input group - file_input_group = parser.add_mutually_exclusive_group(required=True) - file_input_group.add_argument( - "--files", - type=str, - nargs="+", - help="One or more file paths to upload (mutually exclusive with --directory)", - ) - file_input_group.add_argument( - "--directory", - type=str, - help="Directory path to upload all top-level files (mutually exclusive with --files)", - ) - - # Optional arguments - parser.add_argument( - "--file-mapping", - type=str, - action="append", - help="Map source file to target filename (format: local.bin:remote.bin). " - "Can be specified multiple times. Only valid with --files.", - ) - parser.add_argument( - "--token", - type=str, - default=None, - help="GitLab private token (takes precedence over GITLAB_TOKEN env var)", - ) - parser.add_argument( - "--gitlab-url", - type=str, - default=DEFAULT_GITLAB_URL, - help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", - ) - parser.add_argument( - "--duplicate-policy", - type=str, - choices=["skip", "replace", "error"], - default="skip", - help="How to handle duplicate files: skip (default), replace, or error", - ) - parser.add_argument( - "--json-output", - action="store_true", - help="Output results in JSON format for machine parsing (useful for testing and automation)", - ) - - args = parser.parse_args() - - # Validate that --file-mapping is only used with --files - if args.file_mapping and not args.files: - parser.error("--file-mapping can only be used with --files") - - logger.info(f"Package: {args.package_name}, Version: {args.version}") - - try: - # Validate configuration and dependencies - logger.info("Validating configuration and dependencies...") - validate_configuration( - token=args.token, - gitlab_url=args.gitlab_url, - require_git=not ( - args.project_url or args.project_path - ), # Git required only for auto-detection - working_directory=".", - ) - - # Collect files to upload - logger.info("Collecting files to upload...") - files_to_upload, file_errors = collect_files_to_upload(args) - - # Handle file validation errors - if file_errors: - if not files_to_upload: - # All files are invalid - exit with error - logger.error(f"All {len(file_errors)} file(s) failed validation") - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "package_name": args.package_name, - "version": args.version, - "successful_uploads": [], - "skipped_duplicates": [], - "failed_uploads": file_errors, - "error": f"All {len(file_errors)} file(s) failed validation", - "error_type": "FileValidationError", - "statistics": { - "total_processed": len(file_errors), - "new_uploads": 0, - "replaced_duplicates": 0, - "skipped_duplicates": 0, - "failed_uploads": len(file_errors), - }, - } - print(json.dumps(error_json, indent=2)) - else: - for error in file_errors: - logger.error(f"{error['error_message']}") - sys.exit(1) - else: - # Some files are valid - log warnings and continue - logger.warning( - f"{len(file_errors)} file(s) failed validation, continuing with {len(files_to_upload)} valid file(s)" - ) - for error in file_errors: - logger.warning(f"{error['error_message']}") - - if not files_to_upload: - logger.error("No files to upload") - sys.exit(1) - - logger.info(f"Found {len(files_to_upload)} file(s) to upload") - - # Get authentication token - token = get_gitlab_token(args.token) - - # Initialize GitLab client - logger.info(f"Connecting to GitLab at {args.gitlab_url}") - try: - gl = Gitlab(args.gitlab_url, private_token=token) - gl.auth() - logger.info("Authentication successful") - except GitlabAuthenticationError as e: - context = { - "project_path": "authentication", - "gitlab_url": args.gitlab_url, - "operation": "GitLab authentication", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - except Exception as e: - context = { - "project_path": "connection", - "gitlab_url": args.gitlab_url, - "operation": "GitLab connection", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - - # Determine project ID with precedence logic - project_id = None - resolved_gitlab_url = args.gitlab_url - - try: - # Validate and parse project input (URL or path) - input_gitlab_url, project_path = validate_project_input(args) - - if input_gitlab_url and project_path: - # Project URL or path provided - use dynamic project resolution - logger.info( - "Project URL/path provided - using dynamic project resolution" - ) - - # Update GitLab URL if different from CLI argument - if input_gitlab_url != args.gitlab_url: - logger.info( - f"Updating GitLab URL from {args.gitlab_url} to {input_gitlab_url}" - ) - resolved_gitlab_url = input_gitlab_url - # Re-initialize GitLab client with correct URL - try: - gl = Gitlab(input_gitlab_url, private_token=token) - gl.auth() - logger.info(f"Re-authenticated with {input_gitlab_url}") - except Exception as e: - context = { - "project_path": "authentication", - "gitlab_url": input_gitlab_url, - "operation": "GitLab re-authentication", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - - # Create ProjectResolver and resolve project ID - resolver = ProjectResolver(gl) - project_id = resolver.resolve_project_id(input_gitlab_url, project_path) - - # Validate project access - if not resolver.validate_project_access(project_id): - raise ValueError( - f"Access validation failed for project {project_path}" - ) - - logger.info( - f"Successfully resolved project ID: {project_id} for {project_path}" - ) - - else: - # No project URL/path provided and auto-detection failed - logger.error("Project specification required - auto-detection failed") - - # Try to provide more specific guidance based on what we found - detector = GitAutoDetector() - try: - repo = detector.find_git_repository() - if not repo: - # No Git repository found - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - "REASON: No Git repository found in current directory or parent directories.\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name\n\n" - "ALTERNATIVE: To enable auto-detection in the future:\n" - " 1. Initialize Git repository: git init\n" - " 2. Add GitLab remote: git remote add origin \n" - " 3. Or clone from GitLab: git clone \n\n" - "TROUBLESHOOTING:\n" - " • Ensure you're running from within a Git repository\n" - " • Check if .git directory exists: ls -la .git\n" - " • Verify Git is installed: git --version" - ) - else: - # Git repository found but no GitLab remotes - try: - detector.get_gitlab_remotes(repo) - # This should not happen since we're in the else block, but just in case - error_msg = ( - "No project could be determined - Git auto-detection failed unexpectedly.\n\n" - "Please specify project information using:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project" - ) - except ValueError as remote_error: - # get_gitlab_remotes raised a detailed error - use it - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - f"REASON: {str(remote_error)}\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name" - ) - except ValueError as git_error: - # find_git_repository raised a detailed error - use it - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - f"REASON: {str(git_error)}\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name" - ) - except Exception: - # Generic fallback error message - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - "REASON: Unable to detect GitLab project from Git repository.\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name\n\n" - "TROUBLESHOOTING Git auto-detection:\n" - " 1. Ensure you're in a Git repository: git status\n" - " 2. Check Git remotes: git remote -v\n" - " 3. Verify remotes point to GitLab: git remote get-url origin\n" - " 4. Add GitLab remote if missing: git remote add origin \n\n" - "SUPPORTED Git remote formats:\n" - " • HTTPS: https://gitlab.com/namespace/project.git\n" - " • SSH: git@gitlab.com:namespace/project.git\n" - " • Custom GitLab: https://gitlab.example.com/namespace/project.git" - ) - - raise ValueError(error_msg) - - except ValueError as e: - logger.error(f"Project resolution failed: {e}") - sys.exit(1) - - # Create detector instance with resolved project ID - detector = DuplicateDetector(gl, project_id) - duplicate_policy = DuplicatePolicy(args.duplicate_policy) - logger.info(f"Using duplicate handling policy: {duplicate_policy.value}") - - # Fetch project details to get human-readable path - project = gl.projects.get(project_id) - project_path = project.path_with_namespace - logger.info(f"Uploading to project {project_path}") - - # Initialize result lists to track duplicate status - successful_uploads: list[ - UploadResult - ] = [] # Successfully uploaded files (new uploads) - failed_uploads: list[UploadResult] = [] # Failed uploads - skipped_duplicates: list[UploadResult] = [] # Files skipped due to duplication - - # Process each file - for source_path, target_filename in files_to_upload: - result = process_single_file( - gl=gl, - project_id=project_id, - source_path=source_path, - target_filename=target_filename, - package_name=args.package_name, - version=args.version, - gitlab_url=resolved_gitlab_url, - detector=detector, - duplicate_policy=duplicate_policy, - ) - - # Categorize results based on success and duplicate status - if result.success: - if result.was_duplicate and result.duplicate_action == "skipped": - # Track skipped duplicates with existing URLs - skipped_duplicates.append(result) - logger.info( - f"Categorized as skipped duplicate: {result.target_filename}" - ) - else: - # Track successful uploads (including replaced duplicates) - successful_uploads.append(result) - logger.info( - f"Categorized as successful upload: {result.target_filename}" - ) - else: - # Track failed uploads (including duplicate policy errors) - failed_uploads.append(result) - logger.info(f"Categorized as failed upload: {result.target_filename}") - - # Output results based on format preference - if args.json_output: - # JSON output mode - merge file_errors into failed_uploads - all_failed_uploads = failed_uploads + [ - UploadResult( - source_path=error["source_path"], - target_filename=error["target_filename"], - success=False, - result=error["error_message"], - ) - for error in file_errors - ] - result_dict = format_results_as_json( - package_name=args.package_name, - version=args.version, - successful_uploads=successful_uploads, - skipped_duplicates=skipped_duplicates, - failed_uploads=all_failed_uploads, - ) - print(json.dumps(result_dict, indent=2)) - sys.exit(result_dict["exit_code"]) - else: - # Rich console output mode (existing code) - # Print summary table with enhanced duplicate detection reporting - console.print("\n[bold]Upload Summary[/bold]\n") - - if successful_uploads: - console.print("[bold green]✓ Successful Uploads[/bold green]\n") - for result in successful_uploads: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print( - f"[cyan]Download URL:[/cyan] [blue]{result.result}[/blue]" - ) - - # Show if this was a replaced duplicate - if result.was_duplicate and result.duplicate_action == "replaced": - console.print( - "[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]" - ) - if result.existing_url: - console.print( - f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]" - ) - - console.print() # Add blank line between entries - - if skipped_duplicates: - console.print("[bold yellow]⚠ Skipped Duplicates[/bold yellow]\n") - for result in skipped_duplicates: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print( - f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url or result.result}[/blue]" - ) - console.print(f"[cyan]Reason:[/cyan] {result.result}") - console.print() # Add blank line between entries - - if failed_uploads: - console.print("[bold red]✗ Failed Uploads[/bold red]\n") - for result in failed_uploads: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print(f"[cyan]Error:[/cyan] [red]{result.result}[/red]") - if result.was_duplicate: - console.print( - f"[cyan]Duplicate Action:[/cyan] {result.duplicate_action}" - ) - if result.existing_url: - console.print( - f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]" - ) - console.print() # Add blank line between entries - - # Print comprehensive statistics including duplicate detection - total_processed = ( - len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) - ) - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - - console.print("\n[bold]Duplicate Detection Statistics:[/bold]") - console.print(f"• New uploads: {new_uploads_count}") - console.print(f"• Replaced duplicates: {replaced_count}") - console.print(f"• Skipped duplicates: {len(skipped_duplicates)}") - console.print(f"• Failed uploads: {len(failed_uploads)}") - console.print(f"• Total processed: {total_processed}") - - # Print final status distinguishing uploaded vs skipped - console.print( - f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded " - f"({new_uploads_count} new, {replaced_count} replaced), " - f"{len(skipped_duplicates)} skipped duplicates, " - f"{len(failed_uploads)} failed out of {total_processed} total" - ) - - # Exit with appropriate code - if failed_uploads: - sys.exit(1) - else: - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - - console.print( - f"\n[bold green]✓[/bold green] All files processed successfully for {args.package_name} v{args.version}: " - f"{new_uploads_count} new uploads, {replaced_count} replaced duplicates, " - f"{len(skipped_duplicates)} skipped duplicates" - ) - sys.exit(0) - - except ValueError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": "ValueError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(str(e)) - sys.exit(1) - except FileNotFoundError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": "FileNotFoundError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(str(e)) - sys.exit(1) - except GitlabAuthenticationError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": f"GitLab authentication failed: {e}", - "error_type": "GitlabAuthenticationError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(f"GitLab authentication failed: {e}") - sys.exit(1) - except Exception as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": type(e).__name__, - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(f"Unexpected error: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ede4806 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,125 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "glpkg-cli" +version = "0.1.0" +description = "A CLI tool for uploading files to GitLab's Generic Package Registry" +authors = [{name = "Javier Tia"}] +license = "MIT" +requires-python = ">=3.11" +readme = "README.md" +keywords = ["gitlab", "package", "upload", "cli", "generic-package-registry"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "python-gitlab>=4.0.0", + "rich>=13.0.0", + "GitPython>=3.1.0", + "tenacity>=8.0.0", + "argcomplete>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "bump-my-version", + "mypy", + "pex>=2.0.0", + "pre-commit", + "ruff", + "shiv>=1.0.0", +] +test = [ + "pytest", + "pytest-xdist", + "pytest-timeout", + "pytest-sugar", + "pytest-instafail", + "pytest-cov", +] + +[project.scripts] +glpkg = "glpkg.cli.main:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "--cov=glpkg", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=90", + "--no-cov-on-fail", +] +markers = [ + "unit: Unit tests that don't require external dependencies", + "integration: Integration tests requiring GitLab API access", + "fast: Fast-running tests", + "slow: Slow-running tests", + "timeout: Timeout for test execution (provided by pytest-timeout)", +] +# Note: Target coverage is 95% (warning threshold in CI), fail threshold is 90% + +[tool.coverage.run] +source = ["src/glpkg"] +omit = ["tests/*", "*/tests/*"] + +[tool.coverage.report] +precision = 2 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + +[tool.bumpversion] +current_version = "0.1.0" +commit = true +tag = true +tag_name = "v{new_version}" +message = "Bump version: {current_version} → {new_version}" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] + +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = "version = \"{current_version}\"" +replace = "version = \"{new_version}\"" + +[[tool.bumpversion.files]] +filename = "src/glpkg/__init__.py" +search = "__version__ = \"{current_version}\"" +replace = "__version__ = \"{new_version}\"" diff --git a/run_tests.py b/run_tests.py index 4ab4470..de55a5f 100755 --- a/run_tests.py +++ b/run_tests.py @@ -1,44 +1,31 @@ -#!/usr/bin/env -S uv run --quiet --script -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "python-gitlab>=4.0.0", -# "requests>=2.25.0", -# "rich>=10.0.0", -# "pytest>=7.0.0", -# "pytest-xdist>=3.0.0", -# "pytest-timeout>=2.1.0", -# "pytest-sugar>=0.9.7", -# "pytest-instafail>=0.5.0", -# "GitPython>=3.1.0", -# ] -# /// - +#!/usr/bin/env python3 """ Test Runner Wrapper for GitLab Upload Script Tests -A standalone uv-compatible Python script that wraps pytest with convenient -command-line options and pass-through support for advanced pytest usage. +A convenience wrapper that delegates to `uv run pytest` for test execution. +All test dependencies are automatically managed by uv. Usage Examples: Basic (runs all available tests): ./run_tests.py + # or directly: uv run pytest tests/ Convenience commands: ./run_tests.py --unit # Run only unit tests ./run_tests.py --integration # Run integration tests (requires GITLAB_TOKEN) - ./run_tests.py --config # Run configuration validation tests ./run_tests.py --all # Run all test categories sequentially Individual test execution: - ./run_tests.py tests/test_unit_basic.py::test_import_gitlab_common + ./run_tests.py tests/unit/test_cli.py::test_parse_args + # or directly: uv run pytest tests/unit/test_cli.py::test_parse_args With pytest options: ./run_tests.py -v -k "test_import" tests/ - ./run_tests.py -v --tb=short tests/test_unit_basic.py + ./run_tests.py -v --tb=short tests/unit/ Parallel execution: ./run_tests.py -n auto tests/ + # or directly: uv run pytest tests/ -n auto Specific markers: ./run_tests.py -m "unit and not slow" @@ -46,7 +33,6 @@ Duration reporting: ./run_tests.py --durations=5 tests/ # Show 5 slowest tests ./run_tests.py --durations=0 tests/ # Show all test durations - ./run_tests.py --durations-min=2.0 tests/ # Only show tests >= 2 seconds Common pytest options: -v, --verbose Verbose output @@ -57,14 +43,13 @@ -n auto Run tests in parallel (requires pytest-xdist) --timeout=SECONDS Set test timeout --durations=N Show N slowest test durations (0 for all) - --durations-min=N Minimum duration in seconds to include in report --instafail Show failures instantly (enabled by default) -Progress Reporting: - - pytest-sugar provides real-time progress bars during execution - - pytest-instafail shows failures immediately as they occur - - --durations flag shows test timing information at the end - - Performance summary is displayed automatically after test completion +Note: + You can also run tests directly with `uv run pytest`: + uv run pytest tests/ # All tests + uv run pytest tests/unit/ # Unit tests only + uv run pytest tests/integration/ # Integration tests only """ import argparse @@ -74,22 +59,9 @@ import time from pathlib import Path -from rich.console import Console - -# Setup rich console for colored output -console = Console() - def format_duration(seconds: float) -> str: - """ - Format duration in seconds to human-readable format. - - Args: - seconds: Duration in seconds - - Returns: - Formatted duration string (e.g., "2m 30s", "45s", "1h 5m") - """ + """Format duration in seconds to human-readable format.""" if seconds < 60: return f"{seconds:.1f}s" elif seconds < 3600: @@ -102,25 +74,59 @@ def format_duration(seconds: float) -> str: return f"{hours}h {minutes}m" -def run_pytest( +def ensure_package_installed() -> bool: + """ + Ensure the glpkg package is installed in development mode. + + Returns: + True if package is available (already installed or successfully installed), + False if installation failed. + """ + try: + import glpkg # noqa: F401 + + return True + except ImportError: + pass + + print("Installing glpkg package in development mode...") + try: + result = subprocess.run( + ["uv", "pip", "install", "-e", "."], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + print(f"ERROR: Failed to install package: {result.stderr}") + return False + print("Package installed successfully.\n") + return True + except subprocess.TimeoutExpired: + print("ERROR: Package installation timed out") + return False + except Exception as e: + print(f"ERROR: Failed to install package: {e}") + return False + + +def run_uv_pytest( args: list[str], env: dict | None = None, timeout: int = 900, - show_duration_context: bool = False, ) -> tuple[int, float]: """ - Execute pytest with the given arguments. + Execute pytest via uv run with the given arguments. Args: args: List of pytest arguments env: Optional environment variables to pass to subprocess timeout: Timeout in seconds (default: 900) - show_duration_context: Whether to show duration context message Returns: - Tuple of (exit code from pytest execution, elapsed time in seconds) + Tuple of (exit code, elapsed time in seconds) """ - cmd = ["pytest"] + args + cmd = ["uv", "run", "pytest"] + args start_time = time.time() @@ -131,109 +137,31 @@ def run_pytest( timeout=timeout, ) elapsed = time.time() - start_time - - if show_duration_context: - console.print(f"\n[dim]Elapsed time: {format_duration(elapsed)}[/dim]") - return result.returncode, elapsed except subprocess.TimeoutExpired: elapsed = time.time() - start_time - console.print( - f"[bold red]ERROR:[/bold red] Tests timed out after {timeout} seconds" - ) + print(f"ERROR: Tests timed out after {timeout} seconds") return 1, elapsed except Exception as e: elapsed = time.time() - start_time - console.print(f"[bold red]ERROR:[/bold red] Failed to run tests: {e}") + print(f"ERROR: Failed to run tests: {e}") return 1, elapsed -def run_unit_tests() -> list[str]: - """ - Build pytest arguments for unit tests. - - Returns: - List of pytest arguments for unit tests - """ - return [ - "-m", - "unit or fast", - "-v", - "--tb=auto", - "--durations=10", - "--durations-min=1.0", - "tests/test_unit_basic.py", - ] - - -def run_integration_tests(gitlab_token: str) -> tuple[list[str], dict]: - """ - Build pytest arguments and environment for integration tests. - - Args: - gitlab_token: GitLab API token - - Returns: - Tuple of (pytest_args, environment_dict) - """ - env = os.environ.copy() - env["GITLAB_TOKEN"] = gitlab_token - - args = [ - "-m", - "integration", - "-v", - "--tb=auto", - "--durations=0", - "--durations-min=1.0", - "tests/", - ] - - return args, env - - -def run_configuration_tests() -> int: - """ - Run configuration validation tests (not pytest-based). - - Returns: - Exit code from configuration test execution - """ - console.print("\n[bold]Running configuration tests...[/bold]") - console.print("=" * 60) - - try: - result = subprocess.run( - ["python", "test_parallel_config.py"], - timeout=60, - ) - return result.returncode - - except subprocess.TimeoutExpired: - console.print("[bold red]ERROR:[/bold red] Configuration tests timed out") - return 1 - except Exception as e: - console.print( - f"[bold red]ERROR:[/bold red] Failed to run configuration tests: {e}" - ) - return 1 - - def main(): """Main function to handle argument parsing and test execution.""" # Check if any convenience flags are used - convenience_flags = {"--unit", "--integration", "--config", "--all", "--help", "-h"} + convenience_flags = {"--unit", "--integration", "--all", "--help", "-h"} has_convenience_flag = any(arg in convenience_flags for arg in sys.argv[1:]) if has_convenience_flag: # Use argparse for convenience flags parser = argparse.ArgumentParser( - description="Test runner wrapper for GitLab upload script tests", + description="Test runner wrapper for GitLab upload script tests. " + "Delegates to `uv run pytest` for execution.", epilog="Any additional arguments are passed directly to pytest. " - "Common pytest options: -v (verbose), -k (filter), -m (markers), " - "-x (exit on first failure), --tb=short (short traceback), " - "-n auto (parallel execution)", + "You can also run tests directly with `uv run pytest tests/`.", ) # Convenience command flags (mutually exclusive) @@ -248,11 +176,6 @@ def main(): action="store_true", help="Run only integration tests (requires GITLAB_TOKEN)", ) - command_group.add_argument( - "--config", - action="store_true", - help="Run configuration validation tests", - ) command_group.add_argument( "--all", action="store_true", @@ -266,20 +189,25 @@ def main(): class Args: unit = False integration = False - config = False all = False pytest_args = sys.argv[1:] args = Args() # Print header - console.print("[bold]GitLab Upload Script - Test Suite Runner[/bold]") - console.print("=" * 60) - console.print("Using uv for dependency management\n") - - # Change to the gitlab directory - gitlab_dir = Path(__file__).parent - os.chdir(gitlab_dir) + print("GitLab Package Upload - Test Suite Runner") + print("=" * 60) + print("Using uv run pytest for test execution\n") + + # Change to the project directory + project_dir = Path(__file__).parent + os.chdir(project_dir) + + # Ensure the package is installed before running tests + if not ensure_package_installed(): + print("\nFailed to install the glpkg package.") + print("Please install it manually with: uv pip install -e .") + return 1 # Get GitLab token from environment gitlab_token = os.environ.get("GITLAB_TOKEN") @@ -290,47 +218,48 @@ class Args: # Handle convenience commands if args.all: - console.print("[bold]Running all test categories sequentially...[/bold]\n") + print("Running all test categories sequentially...\n") overall_start = time.time() # Run unit tests - console.print("\n[bold cyan]1. Unit Tests[/bold cyan]") - console.print("=" * 60) - unit_args = run_unit_tests() - unit_result, unit_time = run_pytest( - unit_args, timeout=120, show_duration_context=True - ) + print("\n1. Unit Tests") + print("=" * 60) + unit_args = [ + "-v", + "--tb=auto", + "--durations=10", + "--durations-min=1.0", + "tests/unit/", + ] + unit_result, unit_time = run_uv_pytest(unit_args, timeout=120) results.append(("Unit Tests", unit_result == 0, unit_time)) - # Run configuration tests - console.print("\n[bold cyan]2. Configuration Tests[/bold cyan]") - console.print("=" * 60) - config_start = time.time() - config_result = run_configuration_tests() - config_time = time.time() - config_start - results.append(("Configuration Tests", config_result == 0, config_time)) - # Run integration tests if token available if gitlab_token: - console.print("\n[bold cyan]3. Integration Tests[/bold cyan]") - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) - integration_args, integration_env = run_integration_tests(gitlab_token) - integration_result, integration_time = run_pytest( + print("\n2. Integration Tests") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") + env = os.environ.copy() + env["GITLAB_TOKEN"] = gitlab_token + integration_args = [ + "-m", + "integration", + "-v", + "--tb=auto", + "--durations=0", + "--durations-min=1.0", + "tests/integration/", + ] + integration_result, integration_time = run_uv_pytest( integration_args, - env=integration_env, + env=env, timeout=900, - show_duration_context=True, ) results.append( ("Integration Tests", integration_result == 0, integration_time) ) else: - console.print( - "\n[bold yellow]⚠ Skipping integration tests (no GITLAB_TOKEN)[/bold yellow]" - ) + print("\nSkipping integration tests (no GITLAB_TOKEN)") results.append(("Integration Tests", None, 0)) overall_time = time.time() - overall_start @@ -339,138 +268,126 @@ class Args: exit_code = 0 if all(r[1] for r in results if r[1] is not None) else 1 elif args.unit: - console.print("[bold]Running unit tests (no external dependencies)...[/bold]") - console.print("=" * 60 + "\n") - unit_args = run_unit_tests() - exit_code, _ = run_pytest(unit_args, timeout=120, show_duration_context=True) + print("Running unit tests (no external dependencies)...") + print("=" * 60 + "\n") + unit_args = [ + "-v", + "--tb=auto", + "--durations=10", + "--durations-min=1.0", + "tests/unit/", + ] + exit_code, _ = run_uv_pytest(unit_args, timeout=120) elif args.integration: if not gitlab_token: - console.print( - "[bold red]ERROR:[/bold red] GITLAB_TOKEN environment variable not set" - ) - console.print("\nTo run integration tests:") - console.print(" export GITLAB_TOKEN=your_token") - console.print(" ./run_tests.py --integration") + print("ERROR: GITLAB_TOKEN environment variable not set") + print("\nTo run integration tests:") + print(" export GITLAB_TOKEN=your_token") + print(" ./run_tests.py --integration") + print("\nOr run directly with uv:") + print(" GITLAB_TOKEN=your_token uv run pytest tests/integration/ -m integration") return 1 - console.print( - "[bold]Running integration tests (requires GitLab API access)...[/bold]" - ) - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) - integration_args, integration_env = run_integration_tests(gitlab_token) - exit_code, _ = run_pytest( + print("Running integration tests (requires GitLab API access)...") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") + env = os.environ.copy() + env["GITLAB_TOKEN"] = gitlab_token + integration_args = [ + "-m", + "integration", + "-v", + "--tb=auto", + "--durations=0", + "--durations-min=1.0", + "tests/integration/", + ] + exit_code, _ = run_uv_pytest( integration_args, - env=integration_env, + env=env, timeout=900, - show_duration_context=True, ) - elif args.config: - exit_code = run_configuration_tests() - elif args.pytest_args: # Pass-through mode: run pytest with provided arguments - console.print("[bold]Running pytest with custom arguments...[/bold]") - console.print(f"Arguments: {' '.join(args.pytest_args)}") - console.print("=" * 60 + "\n") + print("Running pytest with custom arguments...") + print(f"Arguments: {' '.join(args.pytest_args)}") + print("=" * 60 + "\n") # Add duration flags if not already present enhanced_args = args.pytest_args.copy() if not any(arg.startswith("--durations") for arg in enhanced_args): enhanced_args.extend(["--durations=10", "--durations-min=1.0"]) - exit_code, _ = run_pytest(enhanced_args, show_duration_context=True) + exit_code, _ = run_uv_pytest(enhanced_args) else: # Default behavior: run all available tests based on token presence if gitlab_token: - console.print("[bold]Running all tests (unit + integration)...[/bold]") - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) + print("Running all tests (unit + integration)...") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") env = os.environ.copy() env["GITLAB_TOKEN"] = gitlab_token - exit_code, _ = run_pytest( + exit_code, _ = run_uv_pytest( ["-v", "--tb=short", "--durations=10", "--durations-min=1.0", "tests/"], env=env, timeout=900, - show_duration_context=True, ) else: - console.print( - "[bold]Running all available tests (unit tests only, no GITLAB_TOKEN)...[/bold]" - ) - console.print("=" * 60 + "\n") - exit_code, _ = run_pytest( + print("Running unit tests only (no GITLAB_TOKEN set)...") + print("=" * 60 + "\n") + exit_code, _ = run_uv_pytest( [ "-v", "--tb=short", "--durations=10", "--durations-min=1.0", - "tests/test_unit_basic.py", + "tests/unit/", ], timeout=180, - show_duration_context=True, ) # Print summary - console.print("\n" + "=" * 60) - console.print("[bold]TEST SUMMARY[/bold]") - console.print("=" * 60) + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) if args.all: # Detailed summary for --all mode for test_name, success, duration in results: duration_str = format_duration(duration) if duration > 0 else "N/A" if success is None: - console.print( - f"[yellow]⚠[/yellow] {test_name}: Skipped ({duration_str})" - ) + print(f" {test_name}: Skipped ({duration_str})") elif success: - console.print(f"[green]✅[/green] {test_name}: Passed ({duration_str})") + print(f" {test_name}: Passed ({duration_str})") else: - console.print(f"[red]❌[/red] {test_name}: Failed ({duration_str})") + print(f" {test_name}: Failed ({duration_str})") - console.print( - f"\n[dim]Total elapsed time: {format_duration(overall_time)}[/dim]" - ) + print(f"\nTotal elapsed time: {format_duration(overall_time)}") if exit_code == 0: - console.print("[bold green]✅ All tests passed![/bold green]") + print("\nAll tests passed!") else: - console.print("[bold red]❌ Some tests failed![/bold red]") + print("\nSome tests failed!") else: if exit_code == 0: - console.print("[bold green]✅ All tests passed![/bold green]") + print("All tests passed!") # Show helpful hints if not gitlab_token and not args.integration: - console.print( - "\n[dim]Note: Integration tests were skipped (no GITLAB_TOKEN)[/dim]" - ) - console.print("[dim]To run integration tests:[/dim]") - console.print("[dim] export GITLAB_TOKEN=your_token[/dim]") - console.print("[dim] ./run_tests.py --integration[/dim]") - - # Show usage hints - if not args.pytest_args: - console.print("\n[dim]Tip: Run with -v for verbose output[/dim]") - console.print("[dim]Tip: Use -n auto for parallel execution[/dim]") - console.print( - "[dim]Tip: Run ./run_tests.py --help for more options[/dim]" - ) + print("\nNote: Integration tests were skipped (no GITLAB_TOKEN)") + print("To run integration tests:") + print(" export GITLAB_TOKEN=your_token") + print(" uv run pytest tests/integration/ -m integration") else: - console.print("[bold red]❌ Some tests failed![/bold red]") - console.print("\n[dim]Tip: Run with -v for more details[/dim]") - console.print("[dim]Tip: Use -x to stop on first failure[/dim]") - console.print("[dim]Tip: Use --tb=short for shorter tracebacks[/dim]") + print("Some tests failed!") + print("\nTip: Run with -v for more details") + print("Tip: Use -x to stop on first failure") + print("Tip: Use --tb=short for shorter tracebacks") return exit_code diff --git a/scripts/build_pyz.sh b/scripts/build_pyz.sh new file mode 100755 index 0000000..4cd7de6 --- /dev/null +++ b/scripts/build_pyz.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# +# Build script for creating .pyz universal binaries using Shiv or PEX. +# +# Usage: +# ./scripts/build_pyz.sh [OPTIONS] +# +# Options: +# --tool [shiv|pex|both] Build tool to use (default: shiv) +# --output-dir DIR Output directory (default: dist) +# --help Show this help message +# +# Examples: +# ./scripts/build_pyz.sh # Build with Shiv to dist/ +# ./scripts/build_pyz.sh --tool pex # Build with PEX +# ./scripts/build_pyz.sh --tool both # Build with both tools +# ./scripts/build_pyz.sh --output-dir build # Output to build/ +# +# Platform Compatibility Notes: +# - .pyz files are platform-independent for pure Python packages +# - Dependencies with C extensions (e.g., some cryptography libraries) +# may require platform-specific builds +# - Tested on Linux, should work on macOS and Windows with Python 3.11+ +# + +set -euo pipefail + +# Default values +TOOL="shiv" +OUTPUT_DIR="dist" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +usage() { + head -30 "$0" | tail -28 | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tool) + TOOL="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --help|-h) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate tool option +if [[ ! "$TOOL" =~ ^(shiv|pex|both)$ ]]; then + log_error "Invalid tool: $TOOL. Must be 'shiv', 'pex', or 'both'." + exit 1 +fi + +# Create output directory +mkdir -p "${PROJECT_ROOT}/${OUTPUT_DIR}" + +build_with_shiv() { + log_info "Building .pyz with Shiv..." + + # Determine how to run shiv (uv run for local dev, direct for CI) + local shiv_cmd="shiv" + if command -v uv &> /dev/null && [[ -f "${PROJECT_ROOT}/uv.lock" ]]; then + # Check if shiv is available directly first (CI environment) + if ! command -v shiv &> /dev/null; then + shiv_cmd="uv run shiv" + fi + elif ! command -v shiv &> /dev/null; then + log_error "shiv is not installed. Install with: uv pip install shiv" + exit 1 + fi + + local output_file="${PROJECT_ROOT}/${OUTPUT_DIR}/glpkg.pyz" + local temp_dir + temp_dir=$(mktemp -d) + + # Cleanup on exit + trap "rm -rf ${temp_dir}" EXIT + + log_info "Installing package to temporary directory..." + uv pip install "${PROJECT_ROOT}" --target "${temp_dir}" --quiet + + log_info "Creating .pyz archive..." + ${shiv_cmd} \ + --site-packages "${temp_dir}" \ + --compressed \ + --console-script glpkg \ + --output-file "${output_file}" \ + --python "/usr/bin/env python3" + + chmod +x "${output_file}" + + local size + size=$(du -h "${output_file}" | cut -f1) + log_info "Successfully built: ${output_file} (${size})" +} + +build_with_pex() { + log_info "Building .pex with PEX..." + + # Determine how to run pex (uv run for local dev, direct for CI) + local pex_cmd="pex" + if command -v uv &> /dev/null && [[ -f "${PROJECT_ROOT}/uv.lock" ]]; then + # Check if pex is available directly first (CI environment) + if ! command -v pex &> /dev/null; then + pex_cmd="uv run pex" + fi + elif ! command -v pex &> /dev/null; then + log_error "pex is not installed. Install with: uv pip install pex" + exit 1 + fi + + local output_file="${PROJECT_ROOT}/${OUTPUT_DIR}/glpkg.pex" + + log_info "Creating .pex archive..." + ${pex_cmd} \ + "${PROJECT_ROOT}" \ + --console-script glpkg \ + --output-file "${output_file}" + + chmod +x "${output_file}" + + local size + size=$(du -h "${output_file}" | cut -f1) + log_info "Successfully built: ${output_file} (${size})" +} + +# Main execution +cd "${PROJECT_ROOT}" + +log_info "Project root: ${PROJECT_ROOT}" +log_info "Output directory: ${OUTPUT_DIR}" +log_info "Build tool: ${TOOL}" + +case $TOOL in + shiv) + build_with_shiv + ;; + pex) + build_with_pex + ;; + both) + build_with_shiv + # Reset trap for second build + trap - EXIT + build_with_pex + ;; +esac + +log_info "Build complete!" +echo "" +echo "To test the built binary:" +echo " python ${OUTPUT_DIR}/glpkg.pyz --version" +echo " python ${OUTPUT_DIR}/glpkg.pyz --help" diff --git a/src/glpkg/__init__.py b/src/glpkg/__init__.py new file mode 100644 index 0000000..0094553 --- /dev/null +++ b/src/glpkg/__init__.py @@ -0,0 +1,45 @@ +"""glpkg - GitLab Generic Package Upload Tool.""" + +__version__ = "0.1.0" + +# Export key models and exceptions for convenience +from .duplicate_detector import DuplicateDetector +from .models import ( + AuthenticationError, + ChecksumValidationError, + ConfigurationError, + DuplicatePolicy, + FileFingerprint, + FileValidationError, + GitLabUploadError, + GitRemoteInfo, + NetworkError, + ProjectInfo, + ProjectResolutionError, + ProjectResolutionResult, + RemoteFile, + UploadConfig, + UploadContext, + UploadResult, +) + +__all__ = [ + "__version__", + "DuplicateDetector", + "DuplicatePolicy", + "FileFingerprint", + "RemoteFile", + "UploadResult", + "ProjectInfo", + "ProjectResolutionResult", + "GitRemoteInfo", + "UploadConfig", + "UploadContext", + "GitLabUploadError", + "AuthenticationError", + "ConfigurationError", + "ProjectResolutionError", + "FileValidationError", + "NetworkError", + "ChecksumValidationError", +] diff --git a/src/glpkg/cli/__init__.py b/src/glpkg/cli/__init__.py new file mode 100644 index 0000000..c364c7e --- /dev/null +++ b/src/glpkg/cli/__init__.py @@ -0,0 +1,11 @@ +"""CLI package for glpkg. + +This package provides the command-line interface for glpkg, organized into: +- main: Global argument parsing, logging setup, and subcommand routing +- upload: Upload subcommand implementation +- completion: Shell completion installation for bash and zsh +""" + +from glpkg.cli.main import main + +__all__ = ["main"] diff --git a/src/glpkg/cli/completion.py b/src/glpkg/cli/completion.py new file mode 100644 index 0000000..e3f43aa --- /dev/null +++ b/src/glpkg/cli/completion.py @@ -0,0 +1,141 @@ +"""Shell completion installation for glpkg. + +This module provides functionality to generate and install shell completion +scripts for bash and zsh using argcomplete. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import argcomplete + +logger = logging.getLogger(__name__) + +SUPPORTED_SHELLS = ["bash", "zsh"] + +COMPLETION_PATHS = { + "bash": "~/.bash_completion.d/", + "zsh": "~/.zsh/completion/", +} + +COMPLETION_FILENAMES = { + "bash": "glpkg", + "zsh": "_glpkg", +} + + +def generate_completion_script(shell: str) -> str: + """Generate shell completion script for the specified shell. + + Args: + shell: The shell to generate completion for ('bash' or 'zsh'). + + Returns: + The generated completion script as a string. + + Raises: + ValueError: If the shell is not supported. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + # argcomplete.shellcode is not typed properly + result: str = argcomplete.shellcode( # type: ignore[attr-defined,no-untyped-call] + ["glpkg"], shell=shell + ) + return result + + +def get_completion_path(shell: str) -> Path: + """Get the completion directory path for the specified shell. + + Args: + shell: The shell to get the completion path for ('bash' or 'zsh'). + + Returns: + The expanded absolute path to the completion directory. + + Raises: + ValueError: If the shell is not supported. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + return Path(COMPLETION_PATHS[shell]).expanduser() + + +def install_completion(shell: str) -> None: + """Install shell completion for the specified shell. + + Generates the completion script and writes it to the appropriate + completion directory. Creates the directory if it doesn't exist. + + Args: + shell: The shell to install completion for ('bash' or 'zsh'). + + Raises: + ValueError: If the shell is not supported. + PermissionError: If there are insufficient permissions to write the file. + OSError: If there are other file system errors. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + script = generate_completion_script(shell) + completion_dir = get_completion_path(shell) + filename = COMPLETION_FILENAMES[shell] + completion_file = completion_dir / filename + + try: + completion_dir.mkdir(parents=True, exist_ok=True) + except PermissionError as e: + raise PermissionError( + f"Cannot create directory {completion_dir}: Permission denied. " + f"Try running with appropriate permissions or create the directory manually." + ) from e + except OSError as e: + raise OSError( + f"Cannot create directory {completion_dir}: {e}. Please check the path and try again." + ) from e + + try: + completion_file.write_text(script) + completion_file.chmod(0o644) + except PermissionError as e: + raise PermissionError( + f"Cannot write to {completion_file}: Permission denied. " + f"Try running with appropriate permissions." + ) from e + except OSError as e: + raise OSError( + f"Cannot write to {completion_file}: {e}. Please check the path and try again." + ) from e + + logger.info(f"Installed {shell} completion to {completion_file}") + + # Print activation instructions + print(f"Shell completion for {shell} installed to: {completion_file}") + print() + if shell == "bash": + print("To activate completion, add the following to your ~/.bashrc:") + print(f" source {completion_file}") + print() + print("Then restart your shell or run:") + print(" source ~/.bashrc") + elif shell == "zsh": + print("To activate completion, ensure ~/.zsh/completion is in your fpath.") + print("Add the following to your ~/.zshrc (before compinit):") + print(" fpath=(~/.zsh/completion $fpath)") + print() + print("Then run:") + print(" autoload -Uz compinit && compinit") + print() + print("Or restart your shell.") diff --git a/src/glpkg/cli/main.py b/src/glpkg/cli/main.py new file mode 100644 index 0000000..43d4732 --- /dev/null +++ b/src/glpkg/cli/main.py @@ -0,0 +1,347 @@ +# PYTHON_ARGCOMPLETE_OK +"""Main CLI entry point with subcommand routing for glpkg. + +This module provides the command-line interface framework for glpkg, +including global argument parsing, logging configuration, and subcommand +routing via argparse subparsers. + +Supported global flags: + --verbose Enable verbose output + --quiet Suppress non-essential output + --debug Enable debug output + --token GitLab API token + --gitlab-url GitLab instance URL + --json-output Output results as JSON + --plain Force plain text output (no colors) + --version Display version number +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import argcomplete +from rich.console import Console +from rich.logging import RichHandler + +# Module-level logger +logger = logging.getLogger(__name__) + + +def get_version() -> str: + """Get the package version from pyproject.toml. + + Returns: + Version string from pyproject.toml, or 'unknown' if not found. + """ + try: + # Try to find pyproject.toml relative to this module + module_path = Path(__file__).parent + # Check in package location (installed) + pyproject_paths = [ + module_path.parent.parent.parent / "pyproject.toml", # Development layout + module_path.parent / "pyproject.toml", # Alternate location + ] + + for pyproject_path in pyproject_paths: + if pyproject_path.exists(): + content = pyproject_path.read_text() + # Simple parsing - look for version = "x.y.z" + for line in content.splitlines(): + line = line.strip() + if line.startswith("version") and "=" in line: + # Extract version value + _, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + return value + + # Fallback: try importlib.metadata (for installed packages) + try: + from importlib.metadata import version as get_pkg_version + + return get_pkg_version("glpkg") + except Exception: + pass + + return "unknown" + except Exception: + return "unknown" + + +def determine_verbosity(args: argparse.Namespace) -> str: + """Determine verbosity level from parsed arguments. + + Checks verbosity flags in priority order: debug > verbose > quiet > normal. + + Args: + args: Parsed argument namespace from argparse. + + Returns: + One of: 'debug', 'verbose', 'quiet', or 'normal'. + """ + if getattr(args, "debug", False): + return "debug" + elif getattr(args, "verbose", False): + return "verbose" + elif getattr(args, "quiet", False): + return "quiet" + else: + return "normal" + + +def setup_logging(args: argparse.Namespace) -> None: + """Configure logging based on verbosity flags. + + Sets up Python's root logger with RichHandler for enhanced console output. + When --json-output is enabled, logs go to stderr to keep stdout clean for JSON. + + Args: + args: Parsed argument namespace containing verbosity flags. + """ + verbosity = determine_verbosity(args) + + # Determine log level based on verbosity + log_levels = { + "debug": logging.DEBUG, + "verbose": logging.INFO, + "quiet": logging.WARNING, + "normal": logging.INFO, + } + level = log_levels.get(verbosity, logging.INFO) + + # Use stderr when json_output is enabled to keep stdout clean for JSON + stream = sys.stderr if getattr(args, "json_output", False) else sys.stdout + + # Configure RichHandler with appropriate settings + rich_handler = RichHandler( + console=Console(file=stream), + show_time=True, + show_path=False, + markup=True, + rich_tracebacks=True, + ) + + # Configure root logger + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[rich_handler], + force=True, # Reconfigure if already configured + ) + + stream = "stderr" if getattr(args, "json_output", False) else "stdout" + logger.debug(f"Logging configured: level={verbosity}, stream={stream}") + + +def validate_global_flags(args: argparse.Namespace) -> None: + """Validate global flag combinations and detect conflicts. + + Checks for: + - Conflicting verbosity flags (--verbose, --quiet, --debug) + + Args: + args: Parsed argument namespace from argparse. + + Raises: + SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. + """ + errors: list[str] = [] + + # Check for conflicting verbosity flags + verbosity_flags = [] + if getattr(args, "verbose", False): + verbosity_flags.append("--verbose") + if getattr(args, "quiet", False): + verbosity_flags.append("--quiet") + if getattr(args, "debug", False): + verbosity_flags.append("--debug") + if len(verbosity_flags) > 1: + errors.append( + f"Cannot specify multiple verbosity flags: {', '.join(verbosity_flags)}. " + "Choose one of --verbose, --quiet, or --debug." + ) + + # Report all errors + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + print( + "\nUse --help for usage information.", + file=sys.stderr, + ) + sys.exit(3) # ConfigurationError exit code + + +def create_argument_parser() -> argparse.ArgumentParser: + """Create and configure the main argument parser with subparsers. + + Returns: + Configured ArgumentParser instance with subparsers for subcommands. + """ + from glpkg.validators import DEFAULT_GITLAB_URL + + parser = argparse.ArgumentParser( + prog="glpkg", + description=( + "Upload files to GitLab's Generic Package Registry.\n\n" + "This tool uploads one or more files to a GitLab project's package registry, " + "with support for duplicate detection, retry handling, and various output formats." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + # Upload files to a package + %(prog)s upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload with verbose output + %(prog)s --verbose upload --package-name myapp --package-version 1.0.0 --files file.bin + + # JSON output for CI/CD pipelines + %(prog)s --json-output upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz + +For subcommand help: + %(prog)s upload --help +""", + ) + + # Global arguments + global_group = parser.add_argument_group("global options") + global_group.add_argument( + "--gitlab-url", + type=str, + default=DEFAULT_GITLAB_URL, + metavar="URL", + help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", + ) + global_group.add_argument( + "--token", + type=str, + metavar="TOKEN", + help="GitLab API token (or set GITLAB_TOKEN environment variable)", + ) + + # Verbosity flags (mutual exclusion validated in validate_global_flags for exit code 3) + verbosity_group = parser.add_argument_group( + "verbosity", + "Control output verbosity (mutually exclusive)", + ) + verbosity_group.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output with detailed progress information", + ) + verbosity_group.add_argument( + "--quiet", + action="store_true", + help="Suppress non-essential output (only show errors and final summary)", + ) + verbosity_group.add_argument( + "--debug", + action="store_true", + help="Enable debug output with full diagnostic information", + ) + + # Output format flags + output_group = parser.add_argument_group("output format") + output_group.add_argument( + "--json-output", + action="store_true", + help="Output results as JSON (useful for CI/CD pipelines and scripting)", + ) + output_group.add_argument( + "--plain", + action="store_true", + help="Force plain text output without colors or formatting", + ) + + # Version flag + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {get_version()}", + help="Display version number and exit", + ) + + # Shell completion installation + parser.add_argument( + "--install-completion", + type=str, + choices=["bash", "zsh"], + metavar="SHELL", + help="Install shell completion for the specified shell (bash or zsh)", + ) + + # Create subparsers + subparsers = parser.add_subparsers( + title="commands", + description="Available subcommands", + dest="command", + metavar="", + ) + + # Register subcommands + from glpkg.cli.upload import register_upload_command + + register_upload_command(subparsers) + + return parser + + +def main(argv: list[str] | None = None) -> None: + """Main entry point for the glpkg CLI. + + Parses command-line arguments, validates configuration, and routes + to the appropriate subcommand handler. + + Args: + argv: Command-line arguments. If None, uses sys.argv[1:]. + """ + parser = create_argument_parser() + + # Enable shell completion via argcomplete + argcomplete.autocomplete(parser) + + # Parse arguments + args = parser.parse_args(argv) + + # Handle --install-completion before checking for subcommand + if args.install_completion: + from glpkg.cli.completion import install_completion + + try: + install_completion(args.install_completion) + sys.exit(0) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(3) # ConfigurationError + except PermissionError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(5) # FileValidationError + except OSError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(5) # FileValidationError + + # If no subcommand is provided, show help and exit + if args.command is None: + parser.print_help() + sys.exit(0) + + # Validate global flag combinations + validate_global_flags(args) + + # Configure logging based on verbosity flags + setup_logging(args) + + # Execute the subcommand handler + if hasattr(args, "func"): + args.func(args) + else: + parser.print_help() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/glpkg/cli/upload.py b/src/glpkg/cli/upload.py new file mode 100644 index 0000000..d2497c8 --- /dev/null +++ b/src/glpkg/cli/upload.py @@ -0,0 +1,1054 @@ +"""Upload subcommand for glpkg CLI. + +This module provides the upload subcommand implementation, including: +- GitAutoDetector: Auto-detect GitLab project from Git repository +- ProjectResolver: Resolve GitLab project ID from project path +- UploadContextBuilder: Build upload context with all required components +- Upload command registration and execution logic + +Supported upload-specific flags: + Required: + --package-name Package name in the registry + --package-version Package version + + File input (mutually exclusive): + --files List of files to upload + --directory Directory containing files to upload + + Project specification: + --project-url Full GitLab project URL + --project-path Project path (namespace/project) + + Duplicate handling: + --duplicate-policy How to handle duplicates: skip, replace, error + + File mapping: + --file-mapping Rename files during upload (source:target format) + + Operational: + --dry-run Preview actions without executing + --fail-fast Stop on first failure + --retry Number of retry attempts +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from typing import TYPE_CHECKING, Optional + +import git +from gitlab import Gitlab +from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError + +from glpkg.cli.main import determine_verbosity +from glpkg.duplicate_detector import DuplicateDetector +from glpkg.formatters import OutputFormatter +from glpkg.models import ( + AuthenticationError, + ConfigurationError, + DuplicatePolicy, + GitLabUploadError, + GitRemoteInfo, + ProjectInfo, + ProjectResolutionError, + UploadConfig, + UploadContext, + enhance_error_message, +) +from glpkg.uploader import upload_files +from glpkg.validators import ( + collect_files, + get_gitlab_token, + normalize_gitlab_url, + parse_git_url, +) + +if TYPE_CHECKING: + pass + +# Module-level logger +logger = logging.getLogger(__name__) + +# Exception exit code mapping for standard Python exceptions +EXCEPTION_EXIT_CODE_MAP: dict[type, int] = { + FileNotFoundError: 5, # File validation failure + PermissionError: 5, # File validation failure + ValueError: 3, # Configuration error + ConnectionError: 6, # Network error + TimeoutError: 6, # Network error +} + + +class GitAutoDetector: + """Auto-detect GitLab project from Git repository. + + This class handles Git repository discovery and remote parsing to + automatically detect GitLab project information from the current + working directory. + + Attributes: + working_directory: Directory to search for Git repository. + """ + + def __init__(self, working_directory: str = ".") -> None: + """Initialize GitAutoDetector. + + Args: + working_directory: Directory to search for Git repository. + Parent directories are also searched. + """ + self.working_directory = working_directory + + def find_git_repository(self) -> Optional[git.Repo]: + """Find Git repository in working directory or parent directories. + + Returns: + Git repository object if found, None if no repository exists. + + Raises: + ProjectResolutionError: If repository access fails due to + permissions, corruption, or other errors. + """ + try: + repo = git.Repo(self.working_directory, search_parent_directories=True) + logger.debug(f"Found Git repository at: {repo.working_dir}") + return repo + except git.InvalidGitRepositoryError: + logger.debug(f"No Git repository found in {self.working_directory}") + return None + except PermissionError as e: + raise ProjectResolutionError( + f"Permission denied accessing Git repository in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions:\n" + f" ls -la {self.working_directory}\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + except git.GitCommandError as e: + raise ProjectResolutionError( + f"Git command error in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check repository status:\n" + " git status\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + except OSError as e: + raise ProjectResolutionError( + f"OS error accessing Git repository in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify directory exists:\n" + f" ls -la {self.working_directory}\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + def _looks_like_gitlab_url(self, url: str) -> bool: + """Check if URL appears to be a GitLab instance. + + Args: + url: URL to check. + + Returns: + True if URL contains GitLab-related keywords, False otherwise. + """ + url_lower = url.lower() + gitlab_indicators = ["gitlab.com", "gitlab.", ".gitlab.", "git.lab"] + return any(indicator in url_lower for indicator in gitlab_indicators) + + def _is_known_non_gitlab_host(self, host: str) -> bool: + """Check if host is a known non-GitLab service. + + Args: + host: Hostname to check (e.g., 'github.com', 'bitbucket.org'). + + Returns: + True if host is a known non-GitLab service, False otherwise. + """ + host_lower = host.lower() + # Known non-GitLab Git hosting services + non_gitlab_hosts = [ + "github.com", + "github.", + ".github.", + "bitbucket.org", + "bitbucket.", + ".bitbucket.", + "codeberg.org", + "codeberg.", + "sr.ht", + "sourcehut.", + "gitea.com", + "gitea.", + "gogs.", + "azure.com", + "dev.azure.com", + "visualstudio.com", + ] + return any(indicator in host_lower for indicator in non_gitlab_hosts) + + def parse_git_url(self, remote_url: str) -> Optional[tuple[str, str]]: + """Parse a Git remote URL to extract GitLab instance URL and project path. + + Args: + remote_url: Git remote URL (HTTPS or SSH format). + + Returns: + Tuple of (gitlab_url, project_path) if successful, None if URL + is not a GitLab URL or cannot be parsed. + + Raises: + ProjectResolutionError: If URL looks like GitLab but format is unrecognized. + """ + try: + gitlab_url, project_path = parse_git_url(remote_url) + + # Check if this is a known non-GitLab host (GitHub, Bitbucket, etc.) + if self._is_known_non_gitlab_host(gitlab_url): + logger.debug(f"Ignoring known non-GitLab host: {gitlab_url}") + return None + + # Validate this is a GitLab instance + if self._looks_like_gitlab_url(gitlab_url): + logger.debug(f"Parsed GitLab URL: {gitlab_url}, project: {project_path}") + return gitlab_url, project_path + + # URL parsed but doesn't look like GitLab - return it anyway + # (could be self-hosted GitLab without 'gitlab' in hostname) + logger.debug( + f"URL parsed but doesn't contain 'gitlab': {gitlab_url}, project: {project_path}" + ) + return gitlab_url, project_path + + except Exception as e: + # Check if URL looks like it should be GitLab + if self._looks_like_gitlab_url(remote_url): + raise ProjectResolutionError( + f"URL appears to be GitLab but format is unrecognized: '{remote_url}'\n\n" + f"Parse error: {e}\n\n" + "SOLUTION:\n" + "Supported Git URL formats:\n" + " - HTTPS: https://gitlab.com/namespace/project.git\n" + " - SSH: git@gitlab.com:namespace/project.git\n\n" + "Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + # Not GitLab URL - return None + logger.debug(f"Could not parse URL as GitLab: {remote_url}") + return None + + def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: + """Extract GitLab remotes from repository. + + Iterates through repository remotes, identifies GitLab instances, + and prioritizes 'origin' remote when multiple GitLab remotes exist. + + Args: + repo: Git repository to extract remotes from. + + Returns: + List of GitRemoteInfo objects for GitLab remotes, sorted with + 'origin' first if present. + + Raises: + ProjectResolutionError: If no remotes found or no GitLab remotes detected. + """ + remotes = list(repo.remotes) + + if not remotes: + raise ProjectResolutionError( + "No Git remotes configured in repository.\n\n" + "SOLUTION:\n" + "1. Add a Git remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + gitlab_remotes: list[GitRemoteInfo] = [] + all_remote_urls: list[str] = [] + + for remote in remotes: + # Get all URLs for this remote + urls = list(remote.urls) + all_remote_urls.extend(urls) + + for url in urls: + parsed = self.parse_git_url(url) + if parsed: + gitlab_url, project_path = parsed + gitlab_remotes.append( + GitRemoteInfo( + name=remote.name, + url=url, + gitlab_url=gitlab_url, + project_path=project_path, + ) + ) + logger.debug(f"Found GitLab remote '{remote.name}': {project_path}") + break # Only use first valid URL per remote + + if not gitlab_remotes: + remote_list = "\n".join(f" - {url}" for url in all_remote_urls) + raise ProjectResolutionError( + f"No GitLab remotes found in repository.\n\n" + f"Found remotes:\n{remote_list}\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "Supported GitLab URL formats:\n" + " - HTTPS: https://gitlab.com/namespace/project.git\n" + " - SSH: git@gitlab.com:namespace/project.git" + ) + + # Prioritize 'origin' remote + gitlab_remotes.sort(key=lambda r: (0 if r.name == "origin" else 1, r.name)) + + if len(gitlab_remotes) > 1: + logger.info( + f"Multiple GitLab remotes found, using '{gitlab_remotes[0].name}': " + f"{gitlab_remotes[0].project_path}" + ) + + return gitlab_remotes + + +class ProjectResolver: + """Resolve GitLab project ID from project path. + + This class handles GitLab API interactions to resolve project paths + to project IDs and validate project access. + + Attributes: + gl: Authenticated GitLab client. + project_cache: Cache of resolved project IDs. + """ + + def __init__(self, gitlab_client: Gitlab) -> None: + """Initialize ProjectResolver. + + Args: + gitlab_client: Authenticated GitLab client instance. + """ + self.gl = gitlab_client + self.project_cache: dict[str, int] = {} + + def parse_project_url(self, url: str) -> ProjectInfo: + """Parse a GitLab project URL into components. + + Args: + url: Full GitLab project URL. + + Returns: + ProjectInfo object with parsed components. + + Raises: + ProjectResolutionError: If URL format is invalid. + """ + try: + gitlab_url, project_path = normalize_gitlab_url(url) + except Exception as e: + raise ProjectResolutionError( + f"Invalid GitLab project URL: '{url}'\n\n" + f"Error: {e}\n\n" + "SOLUTION:\n" + "Expected URL format: https://gitlab.com/namespace/project\n\n" + "Examples:\n" + " - https://gitlab.com/mycompany/my-project\n" + " - https://gitlab.example.com/group/subgroup/project" + ) + + # Split project_path into namespace and project_name + path_parts = project_path.split("/") + namespace = "/".join(path_parts[:-1]) + project_name = path_parts[-1] + + return ProjectInfo( + gitlab_url=gitlab_url, + namespace=namespace, + project_name=project_name, + project_path=project_path, + original_url=url, + ) + + def resolve_project_id(self, gitlab_url: str, project_path: str) -> int: + """Resolve project path to project ID via GitLab API. + + Uses caching to avoid redundant API calls for the same project. + + Args: + gitlab_url: GitLab instance URL. + project_path: Project path (namespace/project). + + Returns: + Project ID. + + Raises: + ProjectResolutionError: If project cannot be found or accessed. + """ + cache_key = f"{gitlab_url}/{project_path}" + + # Check cache first + if cache_key in self.project_cache: + logger.debug(f"Using cached project ID for {project_path}") + return self.project_cache[cache_key] + + context = { + "project_path": project_path, + "gitlab_url": gitlab_url, + "operation": "project resolution", + } + + try: + logger.debug(f"Resolving project ID for: {project_path}") + project = self.gl.projects.get(project_path) + project_id: int = project.id + + # Cache the result + self.project_cache[cache_key] = project_id + logger.info(f"Resolved project '{project_path}' to ID {project_id}") + + return project_id + + except GitlabGetError as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + except GitlabAuthenticationError as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + except Exception as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + + def validate_project_access(self, project_id: int) -> bool: + """Validate that the project is accessible. + + Args: + project_id: Project ID to validate. + + Returns: + True if project is accessible, False otherwise. + """ + try: + project = self.gl.projects.get(project_id) + # Check if we can access basic project attributes + _ = project.name + _ = project.path_with_namespace + logger.debug(f"Project access validated: {project.path_with_namespace}") + return True + except Exception as e: + logger.warning(f"Project access validation failed for ID {project_id}: {e}") + return False + + +class UploadContextBuilder: + """Builder for safely initializing upload context with all required components. + + This class follows the builder pattern to create an UploadContext with + all dependencies properly initialized and validated. + """ + + def __init__(self) -> None: + """Initialize UploadContextBuilder.""" + pass + + def build( + self, + args: argparse.Namespace, + gl: Gitlab, + project_id: int, + project_path: str, + gitlab_url: str, + token: str, + ) -> UploadContext: + """Build an UploadContext from parsed arguments and resolved project info. + + Args: + args: Parsed argument namespace from argparse. + gl: Authenticated GitLab client. + project_id: Resolved GitLab project ID. + project_path: Resolved project path (namespace/project). + gitlab_url: GitLab instance URL. + token: Resolved GitLab API token (from CLI or environment). + + Returns: + Fully initialized UploadContext ready for upload operations. + + Raises: + ConfigurationError: If context building fails due to configuration issues. + """ + try: + # Determine verbosity level + verbosity = determine_verbosity(args) + + # Create UploadConfig from parsed arguments + config = UploadConfig( + package_name=args.package_name, + version=args.package_version, + duplicate_policy=args.duplicate_policy, + retry_count=args.retry, + verbosity=verbosity, + dry_run=args.dry_run, + fail_fast=args.fail_fast, + json_output=getattr(args, "json_output", False), + plain_output=getattr(args, "plain", False), + gitlab_url=gitlab_url, + token=token, # Resolved token (from CLI or environment) + ) + + logger.debug( + f"Created UploadConfig: package={config.package_name}, version={config.version}" + ) + + # Initialize DuplicateDetector + detector = DuplicateDetector(gl, project_id) + logger.debug(f"Initialized DuplicateDetector for project ID {project_id}") + + # Create and return UploadContext + context = UploadContext( + gl=gl, + config=config, + detector=detector, + project_id=project_id, + project_path=project_path, + ) + + logger.info(f"Built upload context for {project_path} (ID: {project_id})") + logger.debug( + f"Context details: package={config.package_name}, version={config.version}, " + f"duplicate_policy={config.duplicate_policy.value}, dry_run={config.dry_run}" + ) + + return context + + except Exception as e: + raise ConfigurationError( + f"Failed to build upload context: {e}\n\n" + "SOLUTION:\n" + " - Verify all required arguments are provided\n" + " - Check that project ID is valid\n" + " - Ensure GitLab client is properly authenticated" + ) + + +def auto_detect_project() -> tuple[str, str]: + """Auto-detect GitLab project from git repository. + + Searches for a Git repository in the current directory and parent + directories, then extracts GitLab project information from git remotes. + + Returns: + Tuple of (gitlab_url, project_path). + + Raises: + ProjectResolutionError: If auto-detection fails. + """ + detector = GitAutoDetector() + + # Find git repository + repo = detector.find_git_repository() + if repo is None: + raise ProjectResolutionError( + "No Git repository found in current directory or parent directories.\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize a repository if needed:\n" + " git init\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + # Get GitLab remotes + gitlab_remotes = detector.get_gitlab_remotes(repo) + + # Select first (prioritized) remote + selected_remote = gitlab_remotes[0] + gitlab_url = selected_remote.gitlab_url + project_path = selected_remote.project_path + + logger.info(f"Auto-detected project: {project_path} from {gitlab_url}") + + return gitlab_url, project_path + + +def resolve_project_manually( + project_url: str | None, + project_path: str | None, + gitlab_url: str, +) -> tuple[str, str]: + """Resolve project from manual specification. + + Args: + project_url: Full GitLab project URL (mutually exclusive with project_path). + project_path: Project path in namespace/project format. + gitlab_url: GitLab instance URL (used with project_path). + + Returns: + Tuple of (gitlab_url, project_path). + + Raises: + ProjectResolutionError: If project specification is invalid. + """ + if project_url: + # Parse full project URL + try: + resolved_gitlab_url, resolved_project_path = normalize_gitlab_url(project_url) + logger.info(f"Using project URL: {project_url}") + return resolved_gitlab_url, resolved_project_path + except Exception as e: + raise ProjectResolutionError( + f"Invalid project URL: '{project_url}'\n\n" + f"Error: {e}\n\n" + "SOLUTION:\n" + "Expected URL format: https://gitlab.com/namespace/project\n\n" + "Examples:\n" + " - https://gitlab.com/mycompany/my-project\n" + " - https://gitlab.example.com/group/subgroup/project" + ) + + elif project_path: + # Validate project path format + path = project_path.strip().strip("/") + + if "/" not in path: + raise ProjectResolutionError( + f"Invalid project path format: '{project_path}'\n\n" + "Project path must contain at least namespace/project.\n\n" + "SOLUTION:\n" + "Examples of valid project paths:\n" + " - mycompany/my-project\n" + " - group/subgroup/project-name\n" + " - username/personal-project" + ) + + # Validate path components + path_parts = path.split("/") + if len(path_parts) < 2 or not all(path_parts[:2]): + raise ProjectResolutionError( + f"Invalid project path: '{project_path}'\n\n" + "Path must contain at least namespace and project name.\n\n" + "SOLUTION:\n" + "Examples of valid project paths:\n" + " - mycompany/my-project\n" + " - group/subgroup/project-name" + ) + + logger.info(f"Using project path: {path} at {gitlab_url}") + return gitlab_url, path + + else: + raise ProjectResolutionError( + "No project specification provided.\n\n" + "SOLUTION:\n" + "Use one of the following:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project --gitlab-url https://gitlab.com" + ) + + +def validate_upload_flags(args: argparse.Namespace) -> None: + """Validate upload-specific flag combinations and detect conflicts. + + Checks for: + - Required arguments (--package-name and --package-version) + - Conflicting file input (--files and --directory) + - Conflicting project specification (--project-url with --project-path) + - File input requirement (--files or --directory must be provided) + - File mapping constraint (--file-mapping only valid with --files) + + Args: + args: Parsed argument namespace from argparse. + + Raises: + SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. + """ + errors: list[str] = [] + + # Check required arguments for upload runs + if not args.package_name: + errors.append( + "--package-name is required. Specify the package name in the GitLab registry." + ) + if not args.package_version: + errors.append("--package-version is required. Specify the package version.") + + # Check for conflicting file input flags + if args.files and args.directory: + errors.append( + "Cannot specify both --files and --directory. " + "Use --files for explicit file list or --directory to upload " + "all files from a directory." + ) + + # Check for conflicting project specification + if args.project_url and args.project_path: + errors.append( + "Cannot specify both --project-url and --project-path. " + "Use --project-url for full URLs or --project-path with --gitlab-url." + ) + + # Check that file input is provided + if not args.files and not args.directory: + errors.append( + "Either --files or --directory must be provided. " + "Use --files for explicit file list or --directory to upload " + "all files from a directory." + ) + + # Check that file-mapping is only used with --files + if args.file_mapping and args.directory: + errors.append( + "--file-mapping can only be used with --files, not with --directory. " + "File mappings require explicit file specification." + ) + + # Check retry value is non-negative + if args.retry < 0: + errors.append(f"--retry must be a non-negative integer, got {args.retry}.") + + # Report all errors + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + print( + "\nUse 'glpkg upload --help' for usage information.", + file=sys.stderr, + ) + sys.exit(3) # ConfigurationError exit code + + +def register_upload_command( + subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", +) -> None: + """Register the upload subcommand with the main argument parser. + + Args: + subparsers: Subparsers action from the main argument parser. + """ + upload_parser = subparsers.add_parser( + "upload", + help="Upload files to GitLab's Generic Package Registry", + description=( + "Upload one or more files to a GitLab project's package registry.\n\n" + "Supports automatic project detection from Git remotes, duplicate handling,\n" + "file renaming, and retry logic for reliability." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + # Upload a single file + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload multiple files + glpkg upload --package-name myapp --package-version 1.0.0 --files file1.bin file2.bin + + # Upload all files from a directory + glpkg upload --package-name myapp --package-version 1.0.0 --directory dist/ + + # With file renaming (source:target format) + glpkg upload --package-name myapp --package-version 1.0.0 \\ + --files local.tar.gz --file-mapping local.tar.gz:app-1.0.0.tar.gz + + # Skip duplicates (default behavior) + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy skip + + # Replace existing files + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy replace + + # Dry run with verbose output + glpkg --verbose upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --dry-run + + # Specify project explicitly + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --project-url https://gitlab.com/mygroup/myproject + + # Use custom GitLab instance + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --project-path mygroup/myproject + +Environment variables: + GITLAB_TOKEN GitLab API token (alternative to --token) +""", + ) + + # Required arguments + required_group = upload_parser.add_argument_group("required arguments") + required_group.add_argument( + "--package-name", + type=str, + metavar="NAME", + help="Package name in the GitLab registry (e.g., 'myapp', 'my-library')", + ) + required_group.add_argument( + "--package-version", + type=str, + metavar="VERSION", + help="Package version (e.g., '1.0.0', '2.3.1-beta')", + ) + + # File input arguments + file_input_group = upload_parser.add_argument_group( + "file input (one required)", + "Specify files to upload using either --files or --directory", + ) + file_input_group.add_argument( + "--files", + nargs="+", + type=str, + metavar="FILE", + help="List of files to upload (e.g., --files file1.tar.gz file2.tar.gz)", + ) + file_input_group.add_argument( + "--directory", + type=str, + metavar="DIR", + help="Directory containing files to upload (uploads all top-level files)", + ) + + # Project specification arguments + project_group = upload_parser.add_argument_group( + "project specification", + "Specify the target GitLab project (auto-detected from Git remote if not provided)", + ) + project_group.add_argument( + "--project-url", + type=str, + metavar="URL", + help="Full GitLab project URL (e.g., 'https://gitlab.com/namespace/project')", + ) + project_group.add_argument( + "--project-path", + type=str, + metavar="PATH", + help="Project path in namespace/project format (e.g., 'mygroup/myproject')", + ) + + # Duplicate handling + upload_parser.add_argument( + "--duplicate-policy", + type=str, + choices=["skip", "replace", "error"], + default="skip", + metavar="POLICY", + help=( + "How to handle duplicate files: " + "'skip' (default) - skip uploading, " + "'replace' - delete existing and upload new, " + "'error' - fail with error" + ), + ) + + # File mapping + upload_parser.add_argument( + "--file-mapping", + action="append", + type=str, + metavar="SOURCE:TARGET", + help=( + "Rename files during upload using source:target format. " + "Can be specified multiple times (e.g., --file-mapping local.bin:remote.bin). " + "Only valid with --files, not --directory." + ), + ) + + # Operational flags + operational_group = upload_parser.add_argument_group("operational options") + operational_group.add_argument( + "--dry-run", + action="store_true", + help="Preview actions without executing uploads (shows what would be done)", + ) + operational_group.add_argument( + "--fail-fast", + action="store_true", + help="Stop immediately on first upload failure (default: continue with remaining files)", + ) + operational_group.add_argument( + "--retry", + type=int, + default=0, + metavar="N", + help="Number of retry attempts for failed uploads (default: 0)", + ) + + # Set the handler function + upload_parser.set_defaults(func=execute_upload) + + +def execute_upload(args: argparse.Namespace) -> None: + """Execute the upload subcommand. + + This is the main handler for the upload subcommand, orchestrating: + 1. Flag validation + 2. Project resolution (auto-detect or manual) + 3. GitLab authentication + 4. Context building + 5. File collection + 6. Upload execution + 7. Result formatting + + Args: + args: Parsed argument namespace from argparse. + """ + # Validate upload-specific flags + validate_upload_flags(args) + + # Convert duplicate_policy string to enum + args.duplicate_policy = DuplicatePolicy(args.duplicate_policy) + + # Project resolution + try: + if args.project_url or args.project_path: + # Manual specification + gitlab_url, project_path = resolve_project_manually( + project_url=args.project_url, + project_path=args.project_path, + gitlab_url=args.gitlab_url, + ) + else: + # Auto-detection + gitlab_url, project_path = auto_detect_project() + + # Authenticate with GitLab + token = get_gitlab_token(args.token) + gl = Gitlab(gitlab_url, private_token=token) + gl.auth() + + # Resolve project ID + resolver = ProjectResolver(gl) + project_id = resolver.resolve_project_id(gitlab_url, project_path) + + # Validate access + if not resolver.validate_project_access(project_id): + raise ProjectResolutionError( + f"Cannot access project {project_path}. Verify you have appropriate permissions." + ) + + # Log success + logger.info(f"Successfully resolved project: {project_path} (ID: {project_id})") + + # Context building + builder = UploadContextBuilder() + context = builder.build( + args=args, + gl=gl, + project_id=project_id, + project_path=project_path, + gitlab_url=gitlab_url, + token=token, + ) + logger.debug(f"Upload context built successfully for {project_path}") + + except AuthenticationError as e: + logger.error(f"Authentication failed: {e}") + sys.exit(e.exit_code) + except ConfigurationError as e: + logger.error(f"Configuration error: {e}") + sys.exit(e.exit_code) + except ProjectResolutionError as e: + logger.error(f"Project resolution failed: {e}") + sys.exit(e.exit_code) + except GitLabUploadError as e: + logger.error(f"GitLab error: {e}") + sys.exit(e.exit_code) + except FileNotFoundError as e: + logger.error(f"File not found: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(FileNotFoundError, 1)) + except PermissionError as e: + logger.error(f"Permission denied: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(PermissionError, 1)) + except ValueError as e: + logger.error(f"Value error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ValueError, 1)) + except ConnectionError as e: + logger.error(f"Connection error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ConnectionError, 1)) + except TimeoutError as e: + logger.error(f"Timeout error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(TimeoutError, 1)) + except Exception as e: + logger.error(f"Unexpected error during project resolution: {e}") + sys.exit(1) + + # Upload orchestration + try: + # Step 1: Collect files to upload + files_to_upload, file_errors = collect_files( + files=args.files, + directory=args.directory, + file_mappings=args.file_mapping, + ) + + # Handle file collection errors + if file_errors: + for error in file_errors: + logger.error( + f"File validation error for {error['source_path']}: {error['error_message']}" + ) + if args.fail_fast: + logger.error("Fail-fast enabled, stopping due to file validation errors") + sys.exit(5) + + # Check if we have any valid files to upload + if not files_to_upload: + logger.error("No valid files to upload") + sys.exit(5) + + logger.info(f"Collected {len(files_to_upload)} files to upload") + + # Step 2: Execute uploads + results = upload_files(context, files_to_upload) + + # Step 3: Format and display results + formatter = OutputFormatter(context.config) + formatter.format_output( + results, + context.config.package_name, + context.config.version, + ) + + # Step 4: Determine exit code based on results + failed_count = sum(1 for r in results if not r.success) + if failed_count > 0: + sys.exit(1) + else: + sys.exit(0) + + except FileNotFoundError as e: + logger.error(f"File not found: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(FileNotFoundError, 1)) + except PermissionError as e: + logger.error(f"Permission denied: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(PermissionError, 1)) + except ValueError as e: + logger.error(f"Value error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ValueError, 1)) + except ConnectionError as e: + logger.error(f"Connection error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ConnectionError, 1)) + except TimeoutError as e: + logger.error(f"Timeout error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(TimeoutError, 1)) + except GitLabUploadError as e: + logger.error(f"Upload error: {e}") + sys.exit(e.exit_code) + except Exception as e: + logger.error(f"Unexpected error during upload: {e}") + sys.exit(1) diff --git a/src/glpkg/duplicate_detector.py b/src/glpkg/duplicate_detector.py new file mode 100644 index 0000000..3100b36 --- /dev/null +++ b/src/glpkg/duplicate_detector.py @@ -0,0 +1,266 @@ +"""Duplicate detection for gitlab-pkg-upload.""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional, TypeVar + +from .models import FileFingerprint, RemoteFile + +if TYPE_CHECKING: + from gitlab import Gitlab + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +def calculate_sha256(file_path: Path) -> str: + """ + Calculate SHA256 checksum of a file. + + Args: + file_path: Path to the file + + Returns: + SHA256 checksum as hex string + """ + import hashlib + + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + +def handle_network_error_with_retry( + operation_name: str, + operation_func: Callable[[], T], + max_retries: int = 3, + retry_delays: list[int] | None = None, +) -> T: + """ + Execute an operation with retry logic for network errors. + + Args: + operation_name: Name of the operation for logging + operation_func: Function to execute + max_retries: Maximum number of retries + retry_delays: List of delays between retries + + Returns: + Result of operation_func + + Raises: + Exception: If all retries are exhausted + """ + if retry_delays is None: + retry_delays = [1, 2, 4] + + last_exception = None + for attempt in range(max_retries + 1): + try: + return operation_func() + except Exception as e: + last_exception = e + if attempt < max_retries: + delay = retry_delays[min(attempt, len(retry_delays) - 1)] + logger.warning( + f"{operation_name} failed (attempt {attempt + 1}/{max_retries + 1}): {e}" + ) + logger.info(f"Retrying in {delay} seconds...") + time.sleep(delay) + else: + logger.error(f"{operation_name} failed after {max_retries + 1} attempts: {e}") + + assert last_exception is not None + raise last_exception + + +class DuplicateDetector: + """Detect duplicates locally (within session) and remotely (in GitLab).""" + + def __init__(self, gitlab_client: Gitlab, project_id: int): + """ + Initialize DuplicateDetector with GitLab client and project ID. + + Args: + gitlab_client: Authenticated GitLab client + project_id: GitLab project ID + """ + self.gl = gitlab_client + self.project_id = project_id + self.session_registry: dict[str, FileFingerprint] = {} + + def check_session_duplicate( + self, file_path: Path, target_filename: str + ) -> Optional[FileFingerprint]: + """ + Check if file was already processed in current session. + + Args: + file_path: Path to the source file + target_filename: Target filename in registry + + Returns: + FileFingerprint if duplicate found, None otherwise + """ + logger.debug(f"Checking session duplicate for: {target_filename}") + + # Check if target filename already exists in session registry + if target_filename in self.session_registry: + existing_fingerprint = self.session_registry[target_filename] + logger.debug(f"Found existing session entry for {target_filename}") + + # Calculate checksum of current file to compare + current_checksum = calculate_sha256(file_path) + + # Compare checksums to determine if it's truly a duplicate + if existing_fingerprint.sha256_checksum == current_checksum: + logger.info( + f"Session duplicate detected: {target_filename} (checksum: {current_checksum})" + ) + logger.info( + f"Original source: {existing_fingerprint.source_path}, " + f"Current source: {file_path}" + ) + return existing_fingerprint + else: + logger.warning( + f"Same target filename {target_filename} but different content detected" + ) + logger.warning( + f"Existing checksum: {existing_fingerprint.sha256_checksum}, " + f"Current checksum: {current_checksum}" + ) + else: + logger.debug(f"No session duplicate found for {target_filename}") + + return None + + def check_remote_duplicate( + self, package_name: str, version: str, filename: str, checksum: str + ) -> Optional[RemoteFile]: + """ + Check if file exists in GitLab registry with enhanced retry logic. + + Args: + package_name: Package name in registry + version: Package version + filename: Target filename + checksum: SHA256 checksum to compare + + Returns: + RemoteFile if duplicate found, None otherwise + """ + logger.info(f"Starting remote duplicate check for {filename} in {package_name} v{version}") + logger.debug(f"Local checksum to compare: {checksum}") + + def _check_remote_duplicate() -> Optional[RemoteFile]: + """Internal function to check remote duplicate.""" + project = self.gl.projects.get(self.project_id) + packages = project.packages.list(package_name=package_name, get_all=True) + + # Find the target package version + target_package = next((p for p in packages if p.version == version), None) + + if not target_package: + logger.debug(f"Package {package_name} v{version} not found - no remote duplicate") + return None + + logger.debug(f"Found package {package_name} v{version} (ID: {target_package.id})") + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + logger.debug(f"Found {len(package_files)} files in package") + + # Find files with matching filename + matching_files = [f for f in package_files if f.file_name == filename] + + if not matching_files: + logger.debug(f"No files named {filename} found in remote package - no duplicate") + return None + + logger.debug(f"Found {len(matching_files)} file(s) with matching filename {filename}") + + # Check for checksum matches + for pkg_file in matching_files: + remote_sha256 = getattr(pkg_file, "file_sha256", None) + + if remote_sha256: + logger.debug( + f"Comparing checksums - Remote: {remote_sha256}, Local: {checksum}" + ) + if remote_sha256.lower() == checksum.lower(): + logger.info(f"Remote duplicate detected: {filename} (checksum: {checksum})") + file_size = getattr(pkg_file, "size", "unknown") + logger.info(f"Remote file ID: {pkg_file.id}, Size: {file_size}") + + # Generate download URL + base_url = self.gl.api_url.replace("/api/v4", "") + download_url = ( + f"{base_url}/api/v4/projects/{self.project_id}" + f"/packages/generic/{package_name}/{version}/{filename}" + ) + + return RemoteFile( + file_id=pkg_file.id, + filename=filename, + sha256_checksum=remote_sha256, + file_size=getattr(pkg_file, "size", 0), + download_url=download_url, + package_name=package_name, + version=version, + ) + else: + logger.debug( + f"File {filename} exists but checksum differs " + f"(remote: {remote_sha256}, local: {checksum})" + ) + else: + # Handle incomplete metadata gracefully - use file size as fallback + logger.warning( + f"Remote checksum not available for {filename}, using file size comparison" + ) + logger.debug(f"Cannot verify duplicate without checksum for {filename}") + + logger.debug(f"No matching checksums found for {filename} - no remote duplicate") + return None + + try: + return handle_network_error_with_retry( + operation_name=f"Remote duplicate check for {filename}", + operation_func=_check_remote_duplicate, + ) + except Exception as e: + logger.error(f"Remote duplicate check failed for {filename}: {e}") + logger.warning(f"Proceeding without duplicate detection for {filename}") + return None + + def register_file(self, file_path: Path, target_filename: str, checksum: str) -> None: + """ + Register file as processed in current session. + + Args: + file_path: Path to the source file + target_filename: Target filename in registry + checksum: SHA256 checksum of the file + """ + file_stats = file_path.stat() + + fingerprint = FileFingerprint( + source_path=str(file_path), + target_filename=target_filename, + sha256_checksum=checksum, + file_size=file_stats.st_size, + timestamp=time.time(), + ) + + self.session_registry[target_filename] = fingerprint + logger.info(f"Registered file in session: {target_filename} (checksum: {checksum})") + logger.debug(f"Session registry now contains {len(self.session_registry)} file(s)") diff --git a/src/glpkg/formatters.py b/src/glpkg/formatters.py new file mode 100644 index 0000000..da74e37 --- /dev/null +++ b/src/glpkg/formatters.py @@ -0,0 +1,626 @@ +"""Output formatting module for gitlab-pkg-upload. + +Provides terminal capability detection and multiple output formats +(rich console, JSON, plain text) for upload results and error messages. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from typing import Any, Dict, List, Optional + +from rich.console import Console +from rich.status import Status + +from .models import GitLabUploadError, UploadConfig, UploadResult, enhance_error_message + +# Terminal Detection Functions + + +def detect_tty() -> bool: + """Detect if stdout is connected to a TTY terminal. + + Returns: + True if stdout is connected to a TTY terminal, False otherwise. + + Examples: + >>> detect_tty() # In a terminal + True + >>> detect_tty() # When piped to a file + False + """ + try: + if sys.stdout is None: + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + except Exception: + return False + + +def detect_color_support() -> bool: + """Detect if terminal supports color output based on environment variables and platform. + + Checks environment variables in order of precedence: + - NO_COLOR: if set (any value), returns False + - FORCE_COLOR: if set (any value), returns True + - COLORTERM: if set, returns True + - TERM: if contains "color" or equals "xterm-256color", returns True + + On Windows, also checks for ANSICON and WT_SESSION environment variables. + + Returns: + True if terminal supports color output, False otherwise. + + Examples: + >>> os.environ['FORCE_COLOR'] = '1' + >>> detect_color_support() + True + >>> os.environ['NO_COLOR'] = '1' + >>> detect_color_support() + False + """ + if not detect_tty(): + return False + + # NO_COLOR takes highest precedence + if os.environ.get("NO_COLOR") is not None: + return False + + # FORCE_COLOR overrides other checks + if os.environ.get("FORCE_COLOR") is not None: + return True + + # COLORTERM indicates color support + if os.environ.get("COLORTERM"): + return True + + # Check TERM variable + term = os.environ.get("TERM", "") + if "color" in term.lower() or term == "xterm-256color": + return True + + # Windows-specific checks + if sys.platform == "win32": + # Windows Terminal + if os.environ.get("WT_SESSION"): + return True + # ANSICON + if os.environ.get("ANSICON"): + return True + # ConEmu + if os.environ.get("ConEmuANSI") == "ON": + return True + + return False + + +def detect_unicode_support() -> bool: + """Detect if terminal supports Unicode characters. + + Checks: + - stdout encoding contains "utf" (case-insensitive) + - LANG or LC_ALL environment variables for UTF-8 indicators + - On Windows, checks if console encoding is UTF-8 + + Returns: + True if terminal supports Unicode, False otherwise. + + Examples: + >>> detect_unicode_support() # In a UTF-8 terminal + True + >>> detect_unicode_support() # In an ASCII-only terminal + False + """ + if not detect_tty(): + return False + + # Check stdout encoding + try: + encoding = getattr(sys.stdout, "encoding", None) + if encoding and "utf" in encoding.lower(): + return True + except Exception: + pass + + # Check locale environment variables + lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "") + if "utf" in lang.lower() or "UTF" in lang: + return True + + return False + + +# OutputFormatter Class + + +class OutputFormatter: + """Formats and outputs upload results based on configuration. + + Encapsulates formatting logic with methods for different output modes, + respecting the --plain flag override and terminal capabilities. + + Attributes: + config: Upload configuration with output preferences. + is_tty: Whether stdout is connected to a TTY. + supports_color: Whether terminal supports color output. + supports_unicode: Whether terminal supports Unicode characters. + console: Rich Console instance for formatted output. + """ + + _logger = logging.getLogger(__name__) + + def __init__(self, config: UploadConfig) -> None: + """Initialize OutputFormatter with configuration. + + Args: + config: Upload configuration containing output preferences + (json_output, plain_output flags). + + Examples: + >>> config = UploadConfig(plain_output=True, ...) + >>> formatter = OutputFormatter(config) + >>> formatter.is_tty + False + """ + self.config = config + + # Determine terminal capabilities + if config.plain_output: + # Plain output mode forces all capabilities to False + self.is_tty = False + self.supports_color = False + self.supports_unicode = False + else: + self.is_tty = detect_tty() + self.supports_color = detect_color_support() + self.supports_unicode = detect_unicode_support() + + # Initialize Rich Console with appropriate settings + self.console = Console( + force_terminal=self.is_tty and not config.plain_output, + no_color=not self.supports_color or config.plain_output, + legacy_windows=False, + ) + + def format_output(self, results: List[UploadResult], package_name: str, version: str) -> None: + """Format and output upload results based on configuration. + + Determines the appropriate output format based on config.json_output + and config.plain_output flags, then delegates to the appropriate + formatting method. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + + Examples: + >>> formatter.format_output(results, "my-package", "1.0.0") + """ + if self.config.json_output: + self._format_json_output(results, package_name, version) + elif self.config.plain_output or not self.is_tty: + self._format_plain_output(results, package_name, version) + else: + self._format_rich_output(results, package_name, version) + + def _format_rich_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output using rich console with colors and formatting. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Display Upload Summary Header + self.console.print("\n[bold]Upload Summary[/bold]\n") + + # Display Successful Uploads Section + if successful_uploads: + self.console.print("[bold green]✓ Successful Uploads[/bold green]\n") + for result in successful_uploads: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + self.console.print(f"[cyan]Download URL:[/cyan] [blue]{result.result}[/blue]") + if result.was_duplicate and result.duplicate_action == "replaced": + self.console.print( + "[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]" + ) + if result.existing_url is not None: + self.console.print( + f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]" + ) + self.console.print() + + # Display Skipped Duplicates Section + if skipped_duplicates: + self.console.print("[bold yellow]⚠ Skipped Duplicates[/bold yellow]\n") + for result in skipped_duplicates: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + existing_url = result.existing_url or result.result + self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{existing_url}[/blue]") + self.console.print(f"[cyan]Reason:[/cyan] {result.result}") + self.console.print() + + # Display Failed Uploads Section + if failed_uploads: + self.console.print("[bold red]✗ Failed Uploads[/bold red]\n") + for result in failed_uploads: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + self.console.print(f"[cyan]Error:[/cyan] [red]{result.result}[/red]") + if result.was_duplicate: + self.console.print(f"[cyan]Duplicate Action:[/cyan] {result.duplicate_action}") + if result.existing_url is not None: + self.console.print( + f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]" + ) + self.console.print() + + # Calculate and Display Statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum( + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" + ) + new_uploads_count = len(successful_uploads) - replaced_count + + self.console.print("\n[bold]Duplicate Detection Statistics:[/bold]") + self.console.print(f"• New uploads: {new_uploads_count}") + self.console.print(f"• Replaced duplicates: {replaced_count}") + self.console.print(f"• Skipped duplicates: {len(skipped_duplicates)}") + self.console.print(f"• Failed uploads: {len(failed_uploads)}") + self.console.print(f"• Total processed: {total_processed}") + + # Display Final Results Summary + self.console.print( + f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded " + f"({new_uploads_count} new, {replaced_count} replaced), " + f"{len(skipped_duplicates)} skipped duplicates, " + f"{len(failed_uploads)} failed out of {total_processed} total" + ) + + if not failed_uploads: + self.console.print( + f"\n[bold green]✓[/bold green] All files processed successfully " + f"for {package_name} v{version}: {new_uploads_count} new uploads, " + f"{replaced_count} replaced duplicates, " + f"{len(skipped_duplicates)} skipped duplicates" + ) + + def _format_json_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output as JSON structure. + + JSON output is printed to stdout for machine parsing. + Any logging goes to stderr via self._logger to maintain separation. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Build upload result objects for each category + successful_uploads_data = [] + for result in successful_uploads: + successful_uploads_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.result, + "checksum": None, # Reserved for future use + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + } + ) + + skipped_duplicates_data = [] + for result in skipped_duplicates: + skipped_duplicates_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.existing_url or result.result, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + } + ) + + failed_uploads_data = [] + for result in failed_uploads: + failed_uploads_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": None, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": result.result, + } + ) + + # Calculate statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum( + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" + ) + new_uploads_count = len(successful_uploads) - replaced_count + + statistics = { + "total_processed": total_processed, + "new_uploads": new_uploads_count, + "replaced_duplicates": replaced_count, + "skipped_duplicates": len(skipped_duplicates), + "failed_uploads": len(failed_uploads), + } + + # Build JSON structure + output_data: Dict[str, Any] = { + "success": len(failed_uploads) == 0, + "exit_code": 0 if len(failed_uploads) == 0 else 1, + "package_name": package_name, + "version": version, + "successful_uploads": successful_uploads_data, + "skipped_duplicates": skipped_duplicates_data, + "failed_uploads": failed_uploads_data, + "statistics": statistics, + } + + # Add top-level error fields when failures occur + if failed_uploads: + failed_count = len(failed_uploads) + if failed_count == 1: + output_data["error"] = failed_uploads[0].result + else: + output_data["error"] = f"{failed_count} file(s) failed to upload" + output_data["error_type"] = "UploadError" + + # Output JSON to stdout (not self.console.print to avoid rich formatting) + print(json.dumps(output_data, indent=2)) + + def _format_plain_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output as plain text without colors or special characters. + + Plain text output is printed to stdout using ASCII-only characters. + No color codes or ANSI escape sequences are used. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Display Upload Summary Header + print("\nUpload Summary\n") + + # Display Successful Uploads Section + if successful_uploads: + print("[OK] Successful Uploads\n") + for result in successful_uploads: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Download URL: {result.result}") + if result.was_duplicate and result.duplicate_action == "replaced": + print("Action: Replaced existing duplicate") + if result.existing_url is not None: + print(f"Previous URL: {result.existing_url}") + print() + + # Display Skipped Duplicates Section + if skipped_duplicates: + print("[SKIP] Skipped Duplicates\n") + for result in skipped_duplicates: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Existing URL: {result.existing_url or result.result}") + print(f"Reason: {result.result}") + print() + + # Display Failed Uploads Section + if failed_uploads: + print("[FAIL] Failed Uploads\n") + for result in failed_uploads: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Error: {result.result}") + if result.was_duplicate: + print(f"Duplicate Action: {result.duplicate_action}") + if result.existing_url is not None: + print(f"Existing URL: {result.existing_url}") + print() + + # Calculate statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum( + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" + ) + new_uploads_count = len(successful_uploads) - replaced_count + + # Display Statistics + print("Duplicate Detection Statistics:") + print(f"* New uploads: {new_uploads_count}") + print(f"* Replaced duplicates: {replaced_count}") + print(f"* Skipped duplicates: {len(skipped_duplicates)}") + print(f"* Failed uploads: {len(failed_uploads)}") + print(f"* Total processed: {total_processed}") + + # Display Final Results + print( + f"\nFinal Results: {len(successful_uploads)} uploaded " + f"({new_uploads_count} new, {replaced_count} replaced), " + f"{len(skipped_duplicates)} skipped duplicates, " + f"{len(failed_uploads)} failed out of {total_processed} total" + ) + + if not failed_uploads: + print( + f"\n[OK] All files processed successfully " + f"for {package_name} v{version}: {new_uploads_count} new uploads, " + f"{replaced_count} replaced duplicates, " + f"{len(skipped_duplicates)} skipped duplicates" + ) + + def create_progress_spinner(self, message: str) -> Status: + """Create a progress spinner for long-running operations. + + Args: + message: Message to display alongside the spinner. + + Returns: + A Rich Status object that can be used as a context manager. + + Examples: + >>> with formatter.create_progress_spinner("Uploading...") as status: + ... upload_file() + """ + if self.config.plain_output or not self.is_tty: + # Return a Status that does nothing for plain output + return Status(message, console=self.console, spinner="dots") + return Status(message, console=self.console, spinner="dots") + + +# Error Formatting Function + + +def format_error(error: Exception, context: Optional[Dict[str, Any]] = None) -> str: + """Format error messages with context for better debugging. + + Uses enhanced error messages from models.py when context is available. + + Args: + error: The exception to format. + context: Optional context dictionary with keys like: + - 'operation': Operation that failed + - 'project_path': Project path being accessed + - 'gitlab_url': GitLab instance URL + + Returns: + Formatted error string with error type, message, and context. + + Raises: + None - this function handles all errors gracefully. + + Examples: + >>> error = ValueError("Invalid input") + >>> format_error(error) + 'ERROR: ValueError\\nInvalid input' + + >>> context = {'operation': 'upload', 'project_path': 'group/project'} + >>> format_error(error, context) + 'ERROR: ValueError\\nInvalid input\\nOperation: upload\\nProject: group/project' + """ + error_type = type(error).__name__ + + # Build error message header + lines = [f"ERROR: {error_type}"] + + # Get exit code if it's a GitLabUploadError + if isinstance(error, GitLabUploadError): + lines.append(f"Exit code: {error.exit_code}") + + # Use enhanced error message if context is provided + if context: + enhanced_message = enhance_error_message(error, context) + lines.append(enhanced_message) + else: + lines.append(str(error)) + + return "\n".join(lines) + + +# Helper Functions + + +def get_formatter(config: UploadConfig) -> OutputFormatter: + """Factory function to create an OutputFormatter instance. + + Args: + config: Upload configuration containing output preferences. + + Returns: + Configured OutputFormatter instance. + + Examples: + >>> config = UploadConfig(...) + >>> formatter = get_formatter(config) + >>> formatter.format_output(results, "pkg", "1.0.0") + """ + return OutputFormatter(config) + + +def display_progress(formatter: OutputFormatter, message: str) -> Status: + """Convenience function that wraps formatter.create_progress_spinner(). + + Provides a standalone function interface for progress display, + maintaining backward compatibility if other modules expect a function + rather than a method. + + Args: + formatter: OutputFormatter instance to use for progress display. + message: Message to display alongside the spinner. + + Returns: + A Rich Status object that can be used as a context manager. + + Examples: + >>> formatter = get_formatter(config) + >>> with display_progress(formatter, "Uploading...") as status: + ... upload_file() + ... status.update("Processing...") + """ + return formatter.create_progress_spinner(message) diff --git a/src/glpkg/models.py b/src/glpkg/models.py new file mode 100644 index 0000000..ad75134 --- /dev/null +++ b/src/glpkg/models.py @@ -0,0 +1,372 @@ +"""Data models, enums, and exceptions for gitlab-pkg-upload.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from gitlab import Gitlab + + from glpkg.duplicate_detector import DuplicateDetector + + +# Enums + + +class DuplicatePolicy(Enum): + """Defines how the system should handle detected duplicates.""" + + SKIP = "skip" # Skip uploading duplicates (default) + REPLACE = "replace" # Delete existing and upload new + ERROR = "error" # Fail with error on duplicates + + +# Data Models - File Operations + + +@dataclass +class FileFingerprint: + """Represents a unique identifier for files to enable accurate duplicate detection.""" + + source_path: str + target_filename: str + sha256_checksum: str + file_size: int + timestamp: float + + +@dataclass +class RemoteFile: + """Represents a file that exists in the GitLab package registry.""" + + file_id: int + filename: str + sha256_checksum: Optional[str] + file_size: int + download_url: str + package_name: str + version: str + + +@dataclass +class UploadResult: + """Enhanced upload result structure with duplicate detection information.""" + + source_path: str + target_filename: str + success: bool + result: str # URL on success, error message on failure + was_duplicate: bool = False + duplicate_action: Optional[str] = None # "skipped", "replaced", "error" + existing_url: Optional[str] = None + + +# Data Models - Project Resolution + + +@dataclass +class ProjectInfo: + """Represents parsed project information from URLs.""" + + gitlab_url: str # Base GitLab instance URL + namespace: str # Project namespace/group + project_name: str # Project name + project_path: str # Full project path (namespace/project) + original_url: str # Original URL provided by user + + +@dataclass +class ProjectResolutionResult: + """Represents the result of project ID resolution.""" + + success: bool + project_id: Optional[int] + error_message: Optional[str] + project_info: Optional[ProjectInfo] + gitlab_url: str + + +@dataclass +class GitRemoteInfo: + """Represents Git remote information.""" + + name: str # Remote name (e.g., 'origin') + url: str # Remote URL + gitlab_url: str # Extracted GitLab instance URL + project_path: str # Extracted project path + + +# Data Models - Configuration + + +@dataclass +class UploadConfig: + """User configuration for upload operation.""" + + package_name: str + version: str + duplicate_policy: DuplicatePolicy + retry_count: int + verbosity: str # 'normal', 'verbose', 'quiet', 'debug' + dry_run: bool + fail_fast: bool + json_output: bool + plain_output: bool + gitlab_url: str + token: Optional[str] + + +@dataclass +class UploadContext: + """Runtime context for upload operations.""" + + gl: Gitlab + config: UploadConfig + detector: DuplicateDetector + project_id: int + project_path: str + + +# Exception Hierarchy + + +class GitLabUploadError(Exception): + """Base exception for GitLab upload errors.""" + + exit_code: int = 1 + + +class AuthenticationError(GitLabUploadError): + """Authentication failed.""" + + exit_code: int = 2 + + +class ConfigurationError(GitLabUploadError): + """Configuration error.""" + + exit_code: int = 3 + + +class ProjectResolutionError(GitLabUploadError): + """Project resolution failed.""" + + exit_code: int = 4 + + +class FileValidationError(GitLabUploadError): + """File validation failed.""" + + exit_code: int = 5 + + +class NetworkError(GitLabUploadError): + """Network error.""" + + exit_code: int = 6 + + +class ChecksumValidationError(GitLabUploadError): + """Checksum validation failed.""" + + exit_code: int = 7 + + +# Error Enhancement Functions + + +def handle_project_not_found_error(project_path: str, gitlab_url: str, original_error: str) -> str: + """ + Generate helpful error message for project not found errors. + + Args: + project_path: Project path that was not found + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with suggestions + """ + return ( + f"Project '{project_path}' not found at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Project path format is correct (should be: namespace/project-name)\n" + f" • Project exists and is accessible at {gitlab_url}\n" + f" • You have permission to view the project\n" + f" • GitLab instance URL is correct\n" + f" • Project is not private (if using public access)\n\n" + f"Examples of valid project paths:\n" + f" • mycompany/my-project\n" + f" • group/subgroup/project-name\n" + f" • username/personal-project\n\n" + f"You can verify the project exists by visiting:\n" + f" {gitlab_url}/{project_path}\n\n" + f"Original error: {original_error}" + ) + + +def handle_authentication_error(project_path: str, gitlab_url: str, original_error: str) -> str: + """ + Generate helpful error message for authentication failures. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Authentication failed for project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • GitLab token is valid and not expired\n" + f" • Token has appropriate permissions (minimum: 'read_api' scope)\n" + f" • Token is configured for the correct GitLab instance\n" + f" • You have access to the project '{project_path}'\n" + f" • Project is not private (if token lacks permissions)\n\n" + f"Token configuration:\n" + f" • Set GITLAB_TOKEN environment variable, or\n" + f" • Use --token command line argument\n\n" + f"To create a new token:\n" + f" 1. Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + f" 2. Create token with 'api' or 'read_api' scope\n" + f" 3. Set GITLAB_TOKEN environment variable\n\n" + f"Original error: {original_error}" + ) + + +def handle_permission_error( + project_path: str, gitlab_url: str, operation: str, original_error: str +) -> str: + """ + Generate helpful error message for permission errors. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + operation: Operation that failed (e.g., "upload", "read packages") + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Permission denied for {operation} in project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • You have the required permissions for this operation\n" + f" • Your GitLab token has sufficient scope (may need 'api' instead of 'read_api')\n" + f" • You are a member of the project with appropriate role\n" + f" • Project settings allow the requested operation\n\n" + f"Required permissions for {operation}:\n" + f" • Package uploads: Developer role or higher\n" + f" • Package downloads: Reporter role or higher\n" + f" • Project access: Guest role or higher\n\n" + f"To check your permissions:\n" + f" 1. Visit: {gitlab_url}/{project_path}/-/project_members\n" + f" 2. Verify your role and permissions\n" + f" 3. Contact project maintainer if access is needed\n\n" + f"Original error: {original_error}" + ) + + +def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> str: + """ + Generate helpful error message for network connectivity issues. + + Args: + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with troubleshooting steps + """ + return ( + f"Network connectivity issue with GitLab instance at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Internet connection is working\n" + f" • GitLab instance URL is correct and accessible\n" + f" • No firewall or proxy blocking the connection\n" + f" • GitLab instance is not experiencing downtime\n\n" + f"Troubleshooting steps:\n" + f" 1. Test connectivity: curl -I {gitlab_url}\n" + f" 2. Check GitLab status page (if available)\n" + f" 3. Try accessing {gitlab_url} in a web browser\n" + f" 4. Verify DNS resolution: nslookup " + f"{gitlab_url.replace('https://', '').replace('http://', '')}\n\n" + f"If using a corporate network:\n" + f" • Check proxy settings\n" + f" • Verify SSL certificate trust\n" + f" • Contact IT support if needed\n\n" + f"Original error: {original_error}" + ) + + +def enhance_error_message(error: Exception, context: dict[str, str]) -> str: + """ + Enhance error messages with context and helpful suggestions. + + Args: + error: Original exception + context: Context dictionary with keys like 'project_path', 'gitlab_url', 'operation' + + Returns: + Enhanced error message + """ + error_msg = str(error).lower() + original_error = str(error) + + project_path = context.get("project_path", "unknown") + gitlab_url = context.get("gitlab_url", "unknown") + operation = context.get("operation", "operation") + + # Handle specific error types + if "404" in error_msg or "not found" in error_msg: + return handle_project_not_found_error(project_path, gitlab_url, original_error) + + elif any(keyword in error_msg for keyword in ["401", "403", "authentication", "unauthorized"]): + if "permission" in error_msg or "forbidden" in error_msg: + return handle_permission_error(project_path, gitlab_url, operation, original_error) + else: + return handle_authentication_error(project_path, gitlab_url, original_error) + + elif any( + keyword in error_msg + for keyword in [ + "connection", + "network", + "timeout", + "unreachable", + "dns", + "resolve", + ] + ): + return handle_network_connectivity_error(gitlab_url, original_error) + + elif "rate limit" in error_msg or "too many requests" in error_msg: + return ( + f"GitLab API rate limit exceeded.\n\n" + f"Please wait a few minutes before retrying.\n" + f"Rate limits help ensure fair usage of GitLab resources.\n\n" + f"If you frequently hit rate limits:\n" + f" • Reduce the frequency of API calls\n" + f" • Consider using GitLab Premium for higher limits\n" + f" • Contact GitLab support for assistance\n\n" + f"Original error: {original_error}" + ) + + else: + # Generic enhancement with context + return ( + f"Operation failed: {operation}\n" + f"Project: {project_path}\n" + f"GitLab URL: {gitlab_url}\n\n" + f"Error details: {original_error}\n\n" + f"If this error persists:\n" + f" • Check GitLab instance status\n" + f" • Verify your network connection\n" + f" • Review your authentication and permissions\n" + f" • Contact support with the error details above" + ) diff --git a/src/glpkg/uploader.py b/src/glpkg/uploader.py new file mode 100644 index 0000000..d509059 --- /dev/null +++ b/src/glpkg/uploader.py @@ -0,0 +1,490 @@ +"""Upload orchestration for gitlab-pkg-upload using tenacity for retry handling.""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path + +from gitlab.exceptions import GitlabError +from tenacity import ( + retry, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from .duplicate_detector import calculate_sha256 +from .models import ( + ChecksumValidationError, + DuplicatePolicy, + RemoteFile, + UploadContext, + UploadResult, +) + +logger = logging.getLogger(__name__) + + +def is_transient_error(exception: BaseException) -> bool: + """ + Determine if an exception represents a transient error that should be retried. + + Transient errors include network issues, timeouts, rate limits, and server errors. + Permanent errors include authentication failures, validation errors, and not found errors. + + Args: + exception: The exception to classify + + Returns: + True if the error is transient and should be retried, False otherwise + """ + # Check for network-related exception types + if isinstance(exception, (ConnectionError, TimeoutError)): + logger.debug(f"Transient error detected (exception type): {type(exception).__name__}") + return True + + error_msg = str(exception).lower() + + # Permanent errors - do not retry + permanent_indicators = [ + "401", + "403", + "unauthorized", + "forbidden", + "400", + "422", + "validation", + "404", + "not found", + ] + for indicator in permanent_indicators: + if indicator in error_msg: + logger.debug(f"Permanent error detected (indicator: {indicator}): {exception}") + return False + + # Transient errors - should retry + transient_indicators = [ + "connection", + "timeout", + "network", + "unreachable", + "408", + "429", + "rate limit", + "500", + "502", + "503", + "504", + "service unavailable", + "bad gateway", + "gateway timeout", + ] + for indicator in transient_indicators: + if indicator in error_msg: + logger.debug(f"Transient error detected (indicator: {indicator}): {exception}") + return True + + # GitLab-specific transient errors + if isinstance(exception, GitlabError): + # Check response code if available + response_code = getattr(exception, "response_code", None) + if response_code: + if response_code >= 500 or response_code in (408, 429): + logger.debug(f"Transient GitLab error detected (response code: {response_code})") + return True + if response_code in (401, 403, 404, 400, 422): + logger.debug(f"Permanent GitLab error detected (response code: {response_code})") + return False + + # Default to not retrying unknown errors + logger.debug(f"Unknown error type, not retrying: {exception}") + return False + + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception(is_transient_error), +) +def upload_single_file(context: UploadContext, file: Path, target_filename: str) -> str: + """ + Upload a single file to the GitLab generic package registry. + + This function handles the actual upload operation with automatic retry + for transient errors using tenacity. + + Args: + context: Upload context containing GitLab client and configuration + file: Path to the local file to upload + target_filename: Target filename in the registry + + Returns: + Download URL for the uploaded file + + Raises: + GitlabError: If upload fails after all retry attempts + NetworkError: If network issues persist after retries + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would upload: {file} -> {target_filename}") + mock_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + return mock_url + + # Perform actual upload + start_time = time.time() + file_size_mb = file.stat().st_size / (1024 * 1024) + + logger.info(f"Uploading {target_filename} ({file_size_mb:.2f} MB)...") + logger.debug(f"Source path: {file}") + + project = context.gl.projects.get(context.project_id) + project.generic_packages.upload( + package_name=context.config.package_name, + package_version=context.config.version, + file_name=target_filename, + path=file.as_posix(), + ) + + elapsed_time = time.time() - start_time + logger.info(f"Uploaded {target_filename} ({file_size_mb:.2f} MB) in {elapsed_time:.2f}s") + + # Construct download URL + download_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + + return download_url + + +def validate_upload(context: UploadContext, filename: str, expected_sha256: str) -> bool: + """ + Validate that an uploaded file has the correct checksum in the registry. + + Args: + context: Upload context containing GitLab client and configuration + filename: The filename to validate in the registry + expected_sha256: Expected SHA256 checksum of the file + + Returns: + True if validation succeeds + + Raises: + ChecksumValidationError: If checksum mismatch detected + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would validate checksum for: {filename}") + return True + + logger.debug(f"Validating upload checksum for {filename}") + logger.debug(f"Expected SHA256: {expected_sha256}") + + project = context.gl.projects.get(context.project_id) + packages = project.packages.list(package_name=context.config.package_name, get_all=True) + + # Find the target package version + target_package = next((p for p in packages if p.version == context.config.version), None) + + if not target_package: + logger.error( + f"Package {context.config.package_name} v{context.config.version} " + "not found during validation" + ) + return False + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + # Find file matching filename (handle exact match and path variations) + matching_file = None + for pkg_file in package_files: + file_name = getattr(pkg_file, "file_name", "") + if file_name == filename or file_name.endswith(f"/{filename}"): + matching_file = pkg_file + break + + if not matching_file: + logger.error(f"File {filename} not found in registry during validation") + return False + + # Extract remote checksum + remote_sha256 = getattr(matching_file, "file_sha256", None) + + if not remote_sha256: + logger.warning(f"Remote checksum not available for {filename}, skipping validation") + return True + + # Handle special case for empty files + empty_file_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if expected_sha256.lower() == empty_file_sha256 and remote_sha256.lower() == empty_file_sha256: + logger.debug(f"Empty file checksum validated for {filename}") + return True + + # Compare checksums (case-insensitive) + if remote_sha256.lower() == expected_sha256.lower(): + logger.info(f"Checksum validated for {filename}") + logger.debug(f"Checksum: {expected_sha256}") + return True + + # Checksum mismatch - this is an error + error_msg = f"Checksum mismatch for {filename}: expected {expected_sha256}, got {remote_sha256}" + logger.error(error_msg) + raise ChecksumValidationError(error_msg) + + +def handle_duplicate(context: UploadContext, file: Path, remote: RemoteFile) -> tuple[str, str]: + """ + Handle a detected duplicate file based on the configured policy. + + Args: + context: Upload context containing GitLab client and configuration + file: Path to the local file that is a duplicate + remote: RemoteFile information about the existing file + + Returns: + Tuple of (action_taken, url_or_proceed_flag): + - ("skipped", download_url) - File was skipped, returns existing URL + - ("replaced", "proceed_with_upload") - File was deleted, proceed with upload + - ("error", ...) - Should not reach here, raises ValueError instead + + Raises: + ValueError: If policy is ERROR + """ + logger.info( + f"Duplicate detected for {file.name}: " + f"remote file {remote.filename} (checksum: {remote.sha256_checksum})" + ) + + policy = context.config.duplicate_policy + + if policy == DuplicatePolicy.SKIP: + logger.info(f"Skipping duplicate: {file.name} (policy: SKIP)") + return ("skipped", remote.download_url) + + elif policy == DuplicatePolicy.REPLACE: + logger.info(f"Replacing duplicate: {file.name} (policy: REPLACE)") + delete_file_from_registry(context, remote.filename) + return ("replaced", "proceed_with_upload") + + elif policy == DuplicatePolicy.ERROR: + error_msg = ( + f"Duplicate file detected: {file.name} " + f"(remote: {remote.filename}, checksum: {remote.sha256_checksum}). " + f"Use --duplicate-policy=skip or --duplicate-policy=replace to handle duplicates." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Should not reach here + raise ValueError(f"Unknown duplicate policy: {policy}") + + +def delete_file_from_registry(context: UploadContext, filename: str) -> int: + """ + Delete a file from the GitLab package registry. + + Args: + context: Upload context containing GitLab client and configuration + filename: The filename to delete from the registry + + Returns: + Number of files deleted + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would delete: {filename} from registry") + return 0 + + logger.debug(f"Deleting {filename} from registry") + + project = context.gl.projects.get(context.project_id) + packages = project.packages.list(package_name=context.config.package_name, get_all=True) + + # Find the target package version + target_package = next((p for p in packages if p.version == context.config.version), None) + + if not target_package: + logger.warning( + f"Package {context.config.package_name} v{context.config.version} not found, " + f"nothing to delete" + ) + return 0 + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + # Find and delete all files matching filename + deleted_count = 0 + for pkg_file in package_files: + file_name = getattr(pkg_file, "file_name", "") + if file_name == filename: + try: + pkg_file.delete() + deleted_count += 1 + logger.info(f"Deleted file from registry: {filename} (ID: {pkg_file.id})") + except Exception as e: + logger.warning(f"Failed to delete file {filename} (ID: {pkg_file.id}): {e}") + + if deleted_count == 0: + logger.warning(f"No files named {filename} found to delete") + + return deleted_count + + +def upload_files(context: UploadContext, files: list[tuple[Path, str]]) -> list[UploadResult]: + """ + Upload multiple files to the GitLab generic package registry. + + This is the main orchestration function that handles duplicate detection, + upload, validation, and registration for each file. + + Args: + context: Upload context containing GitLab client and configuration + files: List of (source_path, target_filename) tuples to upload + + Returns: + List of UploadResult objects for each file + """ + results: list[UploadResult] = [] + + for source_path, target_filename in files: + logger.debug(f"Processing file: {source_path} -> {target_filename}") + + try: + # Check for session duplicate + session_duplicate = context.detector.check_session_duplicate( + source_path, target_filename + ) + + if session_duplicate: + logger.info(f"Session duplicate detected for {target_filename}, skipping") + # Construct URL from session duplicate info + existing_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=existing_url, + was_duplicate=True, + duplicate_action="skipped", + existing_url=existing_url, + ) + ) + continue + + # Calculate checksum for remote duplicate check + checksum = calculate_sha256(source_path) + logger.debug(f"Calculated checksum for {source_path}: {checksum}") + + # Check for remote duplicate + remote_duplicate = context.detector.check_remote_duplicate( + context.config.package_name, + context.config.version, + target_filename, + checksum, + ) + + if remote_duplicate: + try: + action, result_value = handle_duplicate(context, source_path, remote_duplicate) + + if action == "skipped": + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=result_value, + was_duplicate=True, + duplicate_action="skipped", + existing_url=result_value, + ) + ) + continue + # If action is "replaced", proceed with upload below + + except ValueError as e: + # ERROR policy triggered + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=False, + result=str(e), + was_duplicate=True, + duplicate_action="error", + existing_url=remote_duplicate.download_url, + ) + ) + if context.config.fail_fast: + logger.error(f"Fail-fast enabled, stopping after error: {e}") + return results + continue + else: + # No checksum match found, but a file with the same name may exist + # with a different checksum. Delete it if policy is REPLACE to avoid + # upload conflicts or stale artifacts. + if context.config.duplicate_policy == DuplicatePolicy.REPLACE: + deleted_count = delete_file_from_registry(context, target_filename) + if deleted_count > 0: + logger.info( + f"Deleted {deleted_count} existing file(s) named {target_filename} " + f"with different checksum (policy: REPLACE)" + ) + + # Upload the file + download_url = upload_single_file(context, source_path, target_filename) + + # Validate the upload + validate_upload(context, target_filename, checksum) + + # Register file in session + context.detector.register_file(source_path, target_filename, checksum) + + # Create success result + was_duplicate = remote_duplicate is not None + duplicate_action = "replaced" if was_duplicate else None + + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=download_url, + was_duplicate=was_duplicate, + duplicate_action=duplicate_action, + existing_url=remote_duplicate.download_url if remote_duplicate else None, + ) + ) + + except Exception as e: + logger.error(f"Failed to upload {source_path} -> {target_filename}: {e}") + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=False, + result=str(e), + was_duplicate=False, + duplicate_action=None, + existing_url=None, + ) + ) + + if context.config.fail_fast: + logger.error(f"Fail-fast enabled, stopping after error: {e}") + return results + + return results diff --git a/src/glpkg/validators.py b/src/glpkg/validators.py new file mode 100644 index 0000000..2bc1b7e --- /dev/null +++ b/src/glpkg/validators.py @@ -0,0 +1,1281 @@ +"""Validation and utility functions for GitLab package uploads. + +This module provides comprehensive validation capabilities for the GitLab package +upload workflow including: + +- File validation (existence, readability, filename format) +- Git URL parsing and normalization +- Configuration validation (dependencies, tokens, Git installation) +- Git repository validation +- Project specification validation +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from glpkg.models import ConfigurationError, FileValidationError, ProjectResolutionError + +# Module-level logger +logger = logging.getLogger(__name__) + +# Constants +DEFAULT_GITLAB_URL = "https://gitlab.com" + + +def validate_filename(filename: str) -> None: + """ + Validate filename for GitLab Generic Package Registry compatibility. + + GitLab's API restricts filenames to ASCII-safe characters only. This function checks + if the provided filename complies with these restrictions. + + Args: + filename: Target filename to validate + + Raises: + FileValidationError: If filename contains non-ASCII or disallowed characters + + Examples: + Valid: "package.tar.gz", "my-file_v1.0.bin", "subdir/file.txt" + Invalid: "café.tar.gz", "文件.bin", "file™.txt" + """ + # Check if filename is ASCII + if not filename.isascii(): + raise FileValidationError( + f"GitLab Generic Package Registry does not support non-ASCII characters in filenames. " + f"Problematic filename: '{filename}'. " + f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), " + f"underscores (_), and forward slashes (/) for directory paths." + ) + + # Additional validation: check for allowed characters only + # Allowed: letters, digits, dots, hyphens, underscores, forward slashes + allowed_pattern = re.compile(r"^[a-zA-Z0-9._/-]+$") + + if not allowed_pattern.match(filename): + raise FileValidationError( + f"GitLab Generic Package Registry does not support special characters in filenames. " + f"Problematic filename: '{filename}'. " + f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), " + f"underscores (_), and forward slashes (/) for directory paths." + ) + + +def validate_file_exists(file_path: Path) -> None: + """ + Validate that file exists, is a regular file, and is readable. + + Args: + file_path: Path object pointing to the file to validate + + Raises: + FileValidationError: If file doesn't exist, is not a regular file, or is not readable + + Examples: + Valid: Path("package.tar.gz") (existing readable file) + Invalid: Path("nonexistent.bin"), Path("/some/directory"), + Path("unreadable.txt") (no permissions) + """ + # Check if path exists + if not file_path.exists(): + raise FileValidationError(f"File not found: {file_path}") + + # Check if path is a file + if not file_path.is_file(): + raise FileValidationError(f"Path is not a file: {file_path}") + + # Check if file is readable + try: + with open(file_path, "rb"): + pass + except (PermissionError, OSError): + raise FileValidationError(f"File is not readable: {file_path}. Check file permissions.") + + +def calculate_sha256(file_path: Path) -> str: + """ + Calculate SHA256 checksum of a file. + + Reads the file in chunks for memory efficiency, making it suitable + for large files. + + Args: + file_path: Path to the file to calculate checksum for + + Returns: + Hexadecimal SHA256 digest string (64 characters) + + Raises: + FileValidationError: If the file cannot be read + + Examples: + >>> checksum = calculate_sha256(Path("package.tar.gz")) + >>> print(checksum) + 'a1b2c3d4e5f6...' # 64-character hex string + """ + sha256_hash = hashlib.sha256() + + try: + with open(file_path, "rb") as f: + # Read in chunks for memory efficiency + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + except (IOError, OSError) as e: + raise FileValidationError( + f"Failed to read file for checksum calculation: {file_path}. Error: {e}" + ) + + return sha256_hash.hexdigest() + + +def parse_file_mapping(mappings: list[str], files: list[str]) -> dict[str, str]: + """ + Parse file mapping strings into a dictionary. + + File mappings allow renaming files during upload using the format + 'source:target' where source is the local filename and target is + the desired remote filename. + + Args: + mappings: List of mapping strings in 'source:target' format + files: List of file paths that mappings should reference + + Returns: + Dictionary mapping local filenames to remote filenames + + Raises: + ConfigurationError: If mapping format is invalid (not exactly one colon) + or if a local name in mapping doesn't exist in the files list + + Examples: + Valid: + >>> parse_file_mapping(["local.bin:remote.bin"], ["path/to/local.bin"]) + {'local.bin': 'remote.bin'} + + Invalid (wrong format): + >>> parse_file_mapping(["invalid_mapping"], ["file.bin"]) + ConfigurationError: Invalid file mapping format... + + Invalid (file not in list): + >>> parse_file_mapping(["missing.bin:remote.bin"], ["other.bin"]) + ConfigurationError: File mapping references 'missing.bin'... + """ + file_mappings: dict[str, str] = {} + + for mapping in mappings: + if mapping.count(":") != 1: + raise ConfigurationError( + f"Invalid file mapping format '{mapping}'. Expected format: 'local.bin:remote.bin'" + ) + local_name, remote_name = mapping.split(":", 1) + file_mappings[local_name] = remote_name + + # Validate that file mappings reference files in the files list + if file_mappings: + files_set = {Path(f).name for f in files} + for local_name in file_mappings.keys(): + if local_name not in files_set: + raise ConfigurationError( + f"File mapping references '{local_name}' which is not in the files list" + ) + + return file_mappings + + +def collect_files( + files: list[str] | None = None, + directory: str | None = None, + file_mappings: dict[str, str] | list[str] | None = None, +) -> tuple[list[tuple[Path, str]], list[dict[str, str]]]: + """ + Collect files to upload based on input mode (files list or directory). + + Supports two modes: + - Files mode: Explicitly list files to upload, with optional renaming via file_mappings + - Directory mode: Upload all files from a directory (top-level only) + + Validates that all filenames contain only ASCII characters supported by GitLab. + File validation errors are collected rather than raised immediately, allowing + batch processing to continue with valid files. + + Args: + files: List of file paths to upload (files mode) + directory: Directory path to upload files from (directory mode) + file_mappings: Optional dictionary mapping local filenames to remote filenames, + or a list of mapping strings in 'source:target' format. + Only applicable in files mode. + + Returns: + Tuple of (files_to_upload, file_errors) where: + - files_to_upload: List of tuples containing (source_path, target_filename) + - file_errors: List of dicts with keys: source_path, target_filename, + error_message, error_type + + Raises: + ConfigurationError: If directory doesn't exist, isn't a directory, + duplicate target filenames are detected, both files and directory + are provided, neither files nor directory is provided, or + file_mappings is an unsupported type. + + Examples: + Files mode: + >>> files_to_upload, errors = collect_files( + ... files=["path/to/file1.bin", "path/to/file2.bin"], + ... file_mappings={"file1.bin": "renamed.bin"} + ... ) + + Directory mode: + >>> files_to_upload, errors = collect_files(directory="/path/to/uploads") + """ + files_to_upload: list[tuple[Path, str]] = [] + file_errors: list[dict[str, str]] = [] + + # Validate mutually exclusive inputs + if files and directory: + raise ConfigurationError( + "Cannot specify both 'files' and 'directory'. They are mutually exclusive." + ) + if not files and not directory: + raise ConfigurationError("Either 'files' or 'directory' must be provided.") + + # Handle file_mappings type conversion + if file_mappings is None: + file_mappings = {} + elif isinstance(file_mappings, list): + # Convert list of mapping strings to dict via parse_file_mapping + file_mappings = parse_file_mapping(file_mappings, files or []) + elif not isinstance(file_mappings, dict): + raise ConfigurationError( + f"file_mappings must be a dict or list of strings, got {type(file_mappings).__name__}" + ) + + if files: + # Files mode: process each file explicitly + for file_path_str in files: + source_path = Path(file_path_str) + + # Determine target filename (apply mapping if exists) + target_filename = file_mappings.get(source_path.name, source_path.name) + + # Validate file existence and type + try: + validate_file_exists(source_path) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(source_path), + "target_filename": target_filename, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + # Validate filename for GitLab API compatibility + try: + validate_filename(target_filename) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(source_path), + "target_filename": target_filename, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + files_to_upload.append((source_path, target_filename)) + + elif directory: + # Directory mode: collect all top-level files + directory_path = Path(directory) + + if not directory_path.exists(): + raise ConfigurationError(f"Directory not found: {directory_path}") + if not directory_path.is_dir(): + raise ConfigurationError(f"Path is not a directory: {directory_path}") + + # Collect only top-level files (not subdirectories) + for item in directory_path.iterdir(): + if item.is_file(): + # Validate filename for GitLab API compatibility + try: + validate_filename(item.name) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(item), + "target_filename": item.name, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + files_to_upload.append((item, item.name)) + + if not files_to_upload and not file_errors: + # Log warning - no files found (caller may want to handle this) + pass + + # Check for duplicate target filenames + target_filenames = [target for _, target in files_to_upload] + duplicates = [name for name in target_filenames if target_filenames.count(name) > 1] + if duplicates: + unique_duplicates = list(set(duplicates)) + raise ConfigurationError( + f"Duplicate target filenames detected: {', '.join(unique_duplicates)}" + ) + + return files_to_upload, file_errors + + +def parse_git_url(url: str) -> tuple[str, str]: + """ + Parse a Git remote URL and extract GitLab instance URL and project path. + + Supports both HTTPS and SSH Git URL formats. Extracts the GitLab instance + base URL and the project path (namespace/project) from the remote URL. + + Args: + url: Git remote URL in HTTPS or SSH format + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ConfigurationError: If the URL format is invalid or cannot be parsed + + Examples: + HTTPS format: + >>> parse_git_url("https://gitlab.com/namespace/project.git") + ('https://gitlab.com', 'namespace/project') + + SSH format: + >>> parse_git_url("git@gitlab.com:namespace/project.git") + ('https://gitlab.com', 'namespace/project') + + Invalid format: + >>> parse_git_url("invalid-url") + ConfigurationError: Invalid Git URL format... + """ + if not url or not isinstance(url, str): + raise ConfigurationError( + "Git URL must be a non-empty string. " + "Expected formats: 'https://gitlab.com/namespace/project.git' or " + "'git@gitlab.com:namespace/project.git'" + ) + + url = url.strip() + + try: + # Detect URL format: SSH starts with 'git@', otherwise assume HTTPS + if url.startswith("git@"): + # SSH format: git@hostname:namespace/project.git + if ":" not in url: + raise ConfigurationError( + f"Invalid SSH Git URL format: '{url}'. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + # Split on first ':' to separate host from path + host_part, path_part = url.split(":", 1) + + # Extract hostname by removing 'git@' prefix + hostname = host_part[4:] # Remove 'git@' + if not hostname: + raise ConfigurationError( + f"Invalid SSH Git URL: missing hostname in '{url}'. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + # Process path: strip slashes, remove .git suffix + path = path_part.strip("/") + if path.endswith(".git"): + path = path[:-4] + + # Validate path has at least namespace/project + path_components = path.split("/") + if len(path_components) < 2 or not all(path_components[:2]): + raise ConfigurationError( + f"Invalid Git URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + gitlab_url = f"https://{hostname}" + project_path = "/".join(path_components) + + return gitlab_url, project_path + + else: + # HTTPS format: https://gitlab.com/namespace/project.git + parsed = urlparse(url) + + if parsed.scheme != "https": + raise ConfigurationError( + f"Invalid Git URL scheme: '{parsed.scheme}'. " + "Expected 'https' for HTTPS Git URLs. " + "Example: 'https://gitlab.com/namespace/project.git'" + ) + + if not parsed.netloc: + raise ConfigurationError( + f"Invalid Git URL: missing hostname in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project.git'" + ) + + # Process path: strip slashes, remove .git suffix + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[:-4] + + # Validate path has at least namespace/project + path_components = path.split("/") + if len(path_components) < 2 or not all(path_components[:2]): + raise ConfigurationError( + f"Invalid Git URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'https://gitlab.com/namespace/project.git'" + ) + + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + project_path = "/".join(path_components) + + return gitlab_url, project_path + + except ConfigurationError: + raise + except Exception as e: + raise ConfigurationError( + f"Failed to parse Git URL '{url}': {e}. " + "Expected formats: 'https://gitlab.com/namespace/project.git' or " + "'git@gitlab.com:namespace/project.git'" + ) + + +def normalize_gitlab_url(url: str) -> tuple[str, str]: + """ + Normalize a GitLab project URL by extracting instance URL and project path. + + Standardizes GitLab project URLs by parsing and validating the URL structure, + then returning the base instance URL and the project path. + + Args: + url: GitLab project URL (e.g., "https://gitlab.com/namespace/project") + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ConfigurationError: If the URL format is invalid, missing required components, + or uses an unsupported scheme + + Examples: + Valid URL: + >>> normalize_gitlab_url("https://gitlab.com/namespace/project") + ('https://gitlab.com', 'namespace/project') + + With trailing slash: + >>> normalize_gitlab_url("https://gitlab.com/namespace/project/") + ('https://gitlab.com', 'namespace/project') + + Invalid (missing project): + >>> normalize_gitlab_url("https://gitlab.com/namespace") + ConfigurationError: Invalid GitLab URL path... + """ + if not url or not isinstance(url, str): + raise ConfigurationError( + "GitLab URL must be a non-empty string. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Strip trailing slashes + url = url.rstrip("/") + + try: + parsed = urlparse(url) + except Exception as e: + raise ConfigurationError( + f"Failed to parse GitLab URL '{url}': {e}. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Validate scheme + if parsed.scheme not in ("http", "https"): + raise ConfigurationError( + f"Invalid GitLab URL scheme: '{parsed.scheme}'. " + "Expected 'http' or 'https'. " + "Example: 'https://gitlab.com/namespace/project'" + ) + + # Validate hostname + if not parsed.netloc: + raise ConfigurationError( + f"Invalid GitLab URL: missing hostname in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Extract and validate path + path = parsed.path.strip("/") + if not path: + raise ConfigurationError( + f"Invalid GitLab URL: missing project path in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Split path into components + path_components = path.split("/") + if len(path_components) < 2: + raise ConfigurationError( + f"Invalid GitLab URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + namespace = path_components[0] + project_name = path_components[1] + + # Validate namespace and project are non-empty + if not namespace: + raise ConfigurationError( + f"Invalid GitLab URL: empty namespace in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + if not project_name: + raise ConfigurationError( + f"Invalid GitLab URL: empty project name in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + project_path = f"{namespace}/{project_name}" + + return gitlab_url, project_path + + +def get_gitlab_token(cli_token: str | None = None) -> str: + """ + Retrieve GitLab API token from CLI argument or environment variable. + + Token sources are checked in priority order: + 1. CLI argument (cli_token parameter) + 2. GITLAB_TOKEN environment variable + + Args: + cli_token: Optional token provided via CLI argument. Takes precedence + over environment variable if provided. + + Returns: + GitLab API token string + + Raises: + ConfigurationError: If no token is found from any source + + Examples: + CLI token provided: + >>> get_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + 'glpat-xxxxxxxxxxxxxxxxxxxx' + + Environment variable (when cli_token is None): + >>> os.environ["GITLAB_TOKEN"] = "glpat-yyyyyyyyyyyyyyyyyyyy" + >>> get_gitlab_token() + 'glpat-yyyyyyyyyyyyyyyyyyyy' + + No token available: + >>> get_gitlab_token() + ConfigurationError: No GitLab token provided... + """ + # CLI argument takes precedence + if cli_token: + return cli_token + + # Check environment variable + env_token = os.environ.get("GITLAB_TOKEN") + if env_token: + return env_token + + # No token found + raise ConfigurationError( + "No GitLab token provided. Set GITLAB_TOKEN environment variable or use --token argument" + ) + + +def validate_dependencies() -> None: + """ + Validate that all required dependencies are available. + + Checks Python version (requires 3.11+) and required modules (gitlab, git, rich). + Provides detailed installation instructions for missing dependencies. + + Raises: + ConfigurationError: If Python version is insufficient or required modules + are missing, with specific installation instructions. + + Examples: + >>> validate_dependencies() # Success - all dependencies available + + >>> validate_dependencies() # Python version too low + ConfigurationError: Python 3.11 or higher is required... + + >>> validate_dependencies() # Missing module + ConfigurationError: Required dependencies are not available... + """ + logger.debug("Validating required dependencies...") + + # Check Python version + if sys.version_info < (3, 11): + raise ConfigurationError( + f"Python 3.11 or higher is required. Current version: {sys.version}\n\n" + "SOLUTION:\n" + "1. Install Python 3.11 or higher:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install python3.11\n" + " • macOS: brew install python@3.11\n" + " • Windows: Download from https://python.org/downloads/\n\n" + "2. Use pyenv to manage Python versions:\n" + " • Install pyenv: curl https://pyenv.run | bash\n" + " • Install Python: pyenv install 3.11\n" + " • Set local version: pyenv local 3.11\n\n" + "3. Use uv to run with correct Python version:\n" + " • Install uv: pip install uv\n" + " • Run command: uv run --python 3.11 glpkg\n\n" + "For more help, see: https://docs.python.org/3/installing/" + ) + + # Check required modules + required_modules = { + "gitlab": "python-gitlab>=4.0.0", + "git": "GitPython>=3.1.0", + "rich": "rich>=13.0.0", + } + + missing_modules = [] + for module_name, package_spec in required_modules.items(): + try: + __import__(module_name) + logger.debug(f"Module {module_name} available") + except ImportError: + missing_modules.append((module_name, package_spec)) + logger.debug(f"Module {module_name} not available") + + if missing_modules: + error_msg = "Required dependencies are not available:\n" + for module_name, package_spec in missing_modules: + error_msg += f" • {module_name} (install: {package_spec})\n" + + error_msg += ( + "\nSOLUTION:\n" + "1. If using uv (recommended):\n" + " • Install package: uv pip install -e .\n" + " • Run command: glpkg\n" + " • Or run directly: uv run glpkg\n\n" + "2. Manual installation with pip:\n" + ) + + for module_name, package_spec in missing_modules: + error_msg += f" pip install '{package_spec}'\n" + + error_msg += ( + "\n3. Install all at once:\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "4. Using virtual environment (recommended):\n" + " python -m venv venv\n" + " source venv/bin/activate # On Windows: venv\\Scripts\\activate\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "TROUBLESHOOTING:\n" + "• Check Python version: python --version\n" + "• Check pip version: pip --version\n" + "• Update pip: pip install --upgrade pip\n" + "• For corporate networks: pip install --trusted-host pypi.org " + "--trusted-host pypi.python.org\n\n" + "For more help: https://packaging.python.org/tutorials/installing-packages/" + ) + + raise ConfigurationError(error_msg) + + logger.debug("All required dependencies are available") + + +def validate_gitlab_token(token: str, gitlab_url: str = DEFAULT_GITLAB_URL) -> None: + """ + Validate GitLab token availability and basic validity. + + Performs basic format validation on the token including: + - Non-empty string check + - Minimum length validation (20+ characters) + - glpat- prefix token length validation (26+ characters) + + Args: + token: GitLab authentication token to validate + gitlab_url: GitLab instance URL for constructing help URLs + + Raises: + ConfigurationError: If token is empty, too short, or has invalid format, + with guidance on creating and configuring tokens. + + Examples: + Valid token: + >>> validate_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + # Success - no exception raised + + Empty token: + >>> validate_gitlab_token("") + ConfigurationError: GitLab token is required... + + Short token: + >>> validate_gitlab_token("short") + ConfigurationError: GitLab token appears to be invalid (too short)... + """ + logger.debug("Validating GitLab token...") + + if not token or not isinstance(token, str): + raise ConfigurationError( + "GitLab token is required but not provided.\n\n" + "SOLUTION:\n" + "1. Create a GitLab personal access token:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Click 'Add new token'\n" + " • Name: 'Package Upload Token'\n" + " • Scopes: Select 'api' (required for package operations)\n" + " • Expiration: Set appropriate date\n" + " • Click 'Create personal access token'\n" + " • Copy the generated token immediately\n\n" + "2. Set the token as environment variable:\n" + " export GITLAB_TOKEN='your-token-here'\n\n" + "3. Or use command line argument:\n" + " --token your-token-here\n\n" + "4. For CI/CD pipelines:\n" + " export GITLAB_TOKEN=$CI_JOB_TOKEN\n\n" + "IMPORTANT:\n" + "• Token must have 'api' scope (not just 'read_api')\n" + "• Token must not be expired\n" + "• Keep token secure and never commit to version control\n\n" + "TROUBLESHOOTING:\n" + "• Check token format: should be 20+ characters\n" + "• Verify token hasn't expired\n" + f"• Test token manually: " + f"curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Basic token format validation + token = token.strip() + if len(token) < 20: + raise ConfigurationError( + f"GitLab token appears to be invalid (too short: {len(token)} characters).\n\n" + "SOLUTION:\n" + "1. Verify you copied the complete token:\n" + " • GitLab personal access tokens are typically 20+ characters\n" + " • Ensure no whitespace or truncation occurred\n" + " • Check for copy/paste errors\n\n" + "2. Generate a new token if needed:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Create new token with 'api' scope\n" + " • Copy the complete token\n\n" + "3. Test token format:\n" + " echo $GITLAB_TOKEN | wc -c # Should be 20+ characters\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Check for common token format issues + if token.startswith("glpat-") and len(token) < 26: + raise ConfigurationError( + f"GitLab personal access token appears incomplete.\n" + f"Token length: {len(token)} characters (expected 26+ for glpat- tokens)\n\n" + "SOLUTION:\n" + "1. Verify complete token was copied:\n" + " • Personal access tokens start with 'glpat-' and are 26+ characters\n" + " • Check for truncation during copy/paste\n" + " • Ensure no line breaks or extra characters\n\n" + "2. Generate new token if corrupted:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Revoke old token if compromised\n" + " • Create new token with 'api' scope\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + logger.debug("GitLab token format validation passed") + + +def validate_git_installation() -> None: + """ + Validate that Git is installed and accessible. + + Runs 'git --version' to verify Git is available in the system PATH + and functioning correctly. + + Raises: + ConfigurationError: If Git is not installed, not in PATH, command times out, + or other unexpected errors occur, with platform-specific installation + instructions and troubleshooting steps. + + Examples: + Git installed: + >>> validate_git_installation() + # Success - no exception raised + + Git not installed: + >>> validate_git_installation() + ConfigurationError: Git is not installed or not available in PATH... + """ + logger.debug("Validating Git installation...") + + try: + result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=10) + + if result.returncode != 0: + raise ConfigurationError( + f"Git command failed with exit code {result.returncode}.\n" + f"Error output: {result.stderr}\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Verify installation:\n" + " git --version\n\n" + "3. Check PATH configuration:\n" + " which git # On Unix-like systems\n" + " where git # On Windows\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + git_version = result.stdout.strip() + logger.debug(f"Git is available: {git_version}") + + except FileNotFoundError: + raise ConfigurationError( + "Git is not installed or not available in PATH.\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Add Git to PATH (if installed but not in PATH):\n" + " • Find Git installation directory\n" + " • Add to PATH environment variable\n" + " • Restart terminal/command prompt\n\n" + "3. Verify installation:\n" + " git --version\n\n" + "TROUBLESHOOTING:\n" + "• Check if Git is installed: ls /usr/bin/git\n" + "• Check PATH: echo $PATH\n" + "• For Windows: Check 'Program Files\\Git\\bin' is in PATH\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + except subprocess.TimeoutExpired: + raise ConfigurationError( + "Git command timed out. This may indicate system issues.\n\n" + "SOLUTION:\n" + "1. Check system resources:\n" + " • Ensure sufficient memory and CPU available\n" + " • Check for system overload\n\n" + "2. Verify Git installation:\n" + " git --version\n\n" + "3. Try running Git commands manually:\n" + " git status\n\n" + "If problem persists, consider reinstalling Git." + ) + + except Exception as e: + raise ConfigurationError( + f"Unexpected error checking Git installation: {e}\n\n" + "SOLUTION:\n" + "1. Verify Git is properly installed:\n" + " git --version\n\n" + "2. Check system permissions:\n" + " • Ensure user can execute Git commands\n" + " • Check file permissions on Git executable\n\n" + "3. Reinstall Git if necessary:\n" + " • Download from https://git-scm.com/downloads\n" + " • Follow installation instructions for your OS\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + +def validate_git_repository(working_directory: str = ".") -> None: + """ + Validate Git repository access when Git operations are needed. + + Uses GitPython to verify the specified directory is within a valid Git + repository and that the repository is accessible (can read config and remotes). + + Args: + working_directory: Directory to check for Git repository. Defaults to + current directory. Parent directories are searched for .git folder. + + Raises: + ConfigurationError: If directory is not in a Git repository, repository + is corrupted/inaccessible, or permission errors occur, with + troubleshooting guidance and repair suggestions. + + Examples: + Valid repository: + >>> validate_git_repository("/path/to/repo") + # Success - no exception raised + + Not a Git repository: + >>> validate_git_repository("/tmp") + ConfigurationError: Not a Git repository... + + Corrupted repository: + >>> validate_git_repository("/path/to/corrupted/repo") + ConfigurationError: Git repository found but not fully accessible... + """ + logger.debug(f"Validating Git repository access in: {working_directory}") + + try: + import git + + # Find Git repository (searches parent directories) + repo = git.Repo(working_directory, search_parent_directories=True) + + logger.debug(f"Git repository found at: {repo.working_dir}") + + # Test basic repository operations + try: + # Try to read repository configuration + repo.config_reader() # Just verify it's accessible + logger.debug("Git repository configuration accessible") + + # Try to read remotes + remotes = list(repo.remotes) + logger.debug(f"Git remotes accessible: {len(remotes)} remote(s)") + + except Exception as e: + raise ConfigurationError( + f"Git repository found but not fully accessible: {e}\n\n" + "SOLUTION:\n" + "1. Check repository integrity:\n" + " git fsck\n\n" + "2. Check file permissions:\n" + f" ls -la {repo.working_dir}/.git\n" + " • Ensure .git directory is readable\n" + " • Check ownership and permissions\n\n" + "3. Try repository repair:\n" + " git gc --prune=now\n" + " git repack -ad\n\n" + "4. If corrupted, consider re-cloning:\n" + " • Backup any uncommitted changes\n" + " • Clone fresh copy from remote\n\n" + "TROUBLESHOOTING:\n" + "• Check disk space: df -h\n" + "• Check file system errors: dmesg | grep -i error\n" + "• Verify Git version: git --version\n\n" + "For more help: https://git-scm.com/docs/git-fsck" + ) + + except git.InvalidGitRepositoryError: + raise ConfigurationError( + f"Directory '{working_directory}' is not inside a Git repository.\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize repository if needed:\n" + " git init\n" + " git remote add origin \n\n" + "3. Use manual project specification if Git auto-detection isn't needed:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except PermissionError as e: + raise ConfigurationError( + f"Permission denied accessing Git repository in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions:\n" + f" ls -la {working_directory}\n" + " • Ensure directory is readable and accessible\n\n" + "2. Check .git directory permissions:\n" + f" ls -la {working_directory}/.git\n\n" + "3. Fix permissions if needed:\n" + f" chmod -R u+rw {working_directory}/.git\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except git.GitCommandError as e: + raise ConfigurationError( + f"Git command error in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify Git is installed and working:\n" + " git --version\n\n" + "2. Check repository status:\n" + " git status\n\n" + "3. Check for repository corruption:\n" + " git fsck\n\n" + "For more help: https://git-scm.com/docs/git-fsck" + ) + + except OSError as e: + raise ConfigurationError( + f"OS error accessing Git repository in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify directory exists and is accessible:\n" + f" ls -la {working_directory}\n\n" + "2. Check disk space:\n" + " df -h\n\n" + "3. Check file system health:\n" + " dmesg | grep -i error\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except Exception as e: + raise ConfigurationError( + f"Unexpected error validating Git repository access: {e}\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize repository if needed:\n" + " git init\n" + " git remote add origin \n\n" + "3. Check directory permissions:\n" + f" ls -la {working_directory}\n" + " • Ensure directory is readable and accessible\n\n" + "4. Use manual project specification if Git auto-detection isn't needed:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + +def validate_project_specification( + project_spec: str, + spec_type: str = "auto", + gitlab_url: str = DEFAULT_GITLAB_URL, +) -> tuple[str, str]: + """ + Validate and normalize a project specification (URL or path). + + Handles both GitLab project URLs and project paths, validating format + and extracting components. + + Args: + project_spec: Project specification - either a full GitLab URL + (e.g., "https://gitlab.com/namespace/project") or a project path + (e.g., "namespace/project") + spec_type: Type of specification - "url", "path", or "auto" (default). + When "auto", attempts to detect the type based on the spec format. + gitlab_url: Base URL of the GitLab instance to use for path specs. + Defaults to DEFAULT_GITLAB_URL ("https://gitlab.com"). + Ignored when spec_type is "url" or when auto-detected as URL. + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + For path specs, returns the provided gitlab_url parameter + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ProjectResolutionError: If project specification is invalid, with + format examples and suggestions. + + Examples: + URL specification: + >>> validate_project_specification("https://gitlab.com/mygroup/myproject") + ('https://gitlab.com', 'mygroup/myproject') + + Path specification: + >>> validate_project_specification("mygroup/myproject", spec_type="path") + ('https://gitlab.com', 'mygroup/myproject') + + Path with custom GitLab URL: + >>> validate_project_specification("mygroup/myproject", gitlab_url="https://gitlab.example.com") + ('https://gitlab.example.com', 'mygroup/myproject') + + Auto-detect URL: + >>> validate_project_specification("https://gitlab.example.com/ns/proj") + ('https://gitlab.example.com', 'ns/proj') + + Invalid path: + >>> validate_project_specification("invalid") + ProjectResolutionError: Invalid project path format... + """ + if not project_spec or not isinstance(project_spec, str): + raise ProjectResolutionError( + "Project specification is required but not provided.\n\n" + "SOLUTION:\n" + "Provide a project URL or path:\n" + " • URL format: --project-url https://gitlab.com/namespace/project\n" + " • Path format: --project-path namespace/project\n\n" + "Examples:\n" + " • https://gitlab.com/mycompany/my-project\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name" + ) + + project_spec = project_spec.strip() + + # Auto-detect spec type if needed + if spec_type == "auto": + if project_spec.startswith(("http://", "https://")): + spec_type = "url" + else: + spec_type = "path" + + if spec_type == "url": + # Use existing normalize_gitlab_url function + try: + return normalize_gitlab_url(project_spec) + except ConfigurationError as e: + raise ProjectResolutionError(str(e)) + + elif spec_type == "path": + # Validate and normalize project path + # Strip leading/trailing slashes and whitespace + path = project_spec.strip().strip("/") + + if not path: + raise ProjectResolutionError( + "Project path cannot be empty.\n\n" + "SOLUTION:\n" + "Provide a valid project path in namespace/project format:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name\n" + " • username/personal-project" + ) + + # Split path into components + path_components = path.split("/") + + # Validate path has at least namespace/project components + if len(path_components) < 2: + raise ProjectResolutionError( + f"Invalid project path format: '{project_spec}'.\n" + "Path must contain at least namespace/project.\n\n" + "SOLUTION:\n" + "Provide a valid project path:\n" + " • namespace/project (minimum required)\n" + " • group/subgroup/project (nested groups)\n\n" + "Examples:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name\n" + " • username/personal-project" + ) + + # Validate all path components are non-empty + for i, component in enumerate(path_components): + if not component: + raise ProjectResolutionError( + f"Invalid project path: '{project_spec}'.\n" + "Path contains empty component (consecutive slashes).\n\n" + "SOLUTION:\n" + "Remove consecutive slashes from the path:\n" + f" • Invalid: {project_spec}\n" + f" • Valid: {'/'.join(c for c in path_components if c)}" + ) + + # Return the provided GitLab URL for path specs + return gitlab_url, path + + else: + raise ProjectResolutionError( + f"Unknown specification type: '{spec_type}'.\nExpected 'url', 'path', or 'auto'." + ) + + +def validate_configuration( + token: Optional[str] = None, + gitlab_url: str = DEFAULT_GITLAB_URL, + require_git: bool = False, + working_directory: str = ".", +) -> None: + """ + Comprehensive configuration validation for GitLab package uploads. + + Orchestrates validation of all configuration components in sequence: + 1. Dependencies (Python version, required modules) + 2. GitLab token (format and availability) + 3. Git installation (always checked, fails only if require_git=True) + 4. Git repository access (only if require_git=True) + + Args: + token: GitLab authentication token. If None, attempts to retrieve + from environment variable via get_gitlab_token(). + gitlab_url: GitLab instance URL for token validation help messages. + Defaults to "https://gitlab.com". + require_git: Whether Git operations are required. If False, Git + validation failures are logged as warnings but don't raise errors. + working_directory: Working directory for Git repository validation. + Defaults to current directory. + + Raises: + ConfigurationError: If any required validation fails. Includes + detailed error messages with resolution steps. + + Examples: + Basic validation (no Git required): + >>> validate_configuration(token="glpat-xxxx") + # Success - dependencies and token validated + + With Git requirement: + >>> validate_configuration(token="glpat-xxxx", require_git=True) + # Success - all validations passed including Git + + Missing token: + >>> validate_configuration() + ConfigurationError: No GitLab token provided... + + Git required but not installed: + >>> validate_configuration(require_git=True) + ConfigurationError: Git is not installed... + """ + logger.info("Starting configuration validation...") + + # 1. Validate dependencies + try: + validate_dependencies() + logger.info("Dependencies validation passed") + except ConfigurationError: + logger.error("Dependencies validation failed") + raise + + # 2. Validate GitLab token + try: + if token is None: + token = get_gitlab_token(None) + validate_gitlab_token(token, gitlab_url) + logger.info("GitLab token validation passed") + except ConfigurationError: + logger.error("GitLab token validation failed") + raise + + # 3. Validate Git installation (always check since it might be needed) + try: + validate_git_installation() + logger.info("Git installation validation passed") + except ConfigurationError as e: + if require_git: + logger.error("Git installation validation failed") + raise + else: + logger.warning("Git installation validation failed (not required for this operation)") + logger.debug(f"Git validation error: {e}") + + # 4. Validate Git repository access (only if Git operations are required) + if require_git: + try: + validate_git_repository(working_directory) + logger.info("Git repository access validation passed") + except ConfigurationError: + logger.error("Git repository access validation failed") + raise + + logger.info("Configuration validation completed successfully") diff --git a/tests/README.md b/tests/README.md index 45663e5..fb0b234 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,85 +1,96 @@ # GitLab Package Upload Test Suite -This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite has been refactored from a monolithic test file into focused, maintainable modules that can be run individually or as a complete suite. +This directory contains a comprehensive pytest-based test suite for the GitLab +package upload functionality. The test suite validates the `glpkg` command +through both unit tests and end-to-end integration testing. ## Overview -The test suite validates the [`gitlab-pkg-upload.py`](../gitlab-pkg-upload.py) script through end-to-end testing, executing the actual script and verifying results against the GitLab Package Registry. Tests use real GitLab API interactions for authentic validation without mocking. +The test suite is organized into two categories: + +- **Unit tests** (`tests/unit/`): Fast tests that validate individual + components without external dependencies +- **Integration tests** (`tests/integration/`): End-to-end tests that execute + the actual upload script and verify results against the GitLab Package + Registry ## Quick Start -### Basic Usage +All test dependencies are automatically managed by uv. However, the `glpkg` +package must be installed in development mode before running tests. ```bash -# Run all available tests (unit tests if no GITLAB_TOKEN) -./run_tests.py +# Install the package in development mode (required before running tests) +uv pip install -e . + +# Run all tests +uv run pytest tests/ # Run only unit tests (fast, no external dependencies) -./run_tests.py --unit +uv run pytest tests/unit/ -# Run integration tests (requires GITLAB_TOKEN, takes 10-15 minutes) +# Run integration tests (requires RUN_INTEGRATION_TESTS=1 and GITLAB_TOKEN) +export RUN_INTEGRATION_TESTS=1 export GITLAB_TOKEN="your-token" -./run_tests.py --integration - -# Run all test categories sequentially -./run_tests.py --all -``` - -### Advanced Usage - -```bash -# Run specific test file -./run_tests.py tests/test_basic_uploads.py - -# Run specific test function -./run_tests.py tests/test_basic_uploads.py::test_single_file_upload +uv run pytest tests/integration/ -m integration # Run with parallel execution -./run_tests.py -n auto tests/ - -# Run with verbose output and stop on first failure -./run_tests.py -v -x tests/ +uv run pytest tests/ -n auto -# Filter tests by marker -./run_tests.py -m "unit and not slow" - -# Show 10 slowest tests -./run_tests.py --durations=10 tests/ +# Run with verbose output +uv run pytest tests/ -v ``` -### Getting Help +### Convenience Wrapper + +The `run_tests.py` script provides convenience commands that delegate to +`uv run pytest`: ```bash -# Show wrapper help -./run_tests.py --help +# Run unit tests +./run_tests.py --unit -# Show pytest help (pass-through mode) -./run_tests.py --help +# Run integration tests +./run_tests.py --integration + +# Run all test categories +./run_tests.py --all + +# Pass-through to pytest +./run_tests.py -v -k "test_import" tests/ ``` ## Test Structure -``` +```text tests/ ├── conftest.py # Shared fixtures and configuration -├── conftest_performance.py # Performance tracking plugin -├── test_basic_uploads.py # Single file, multiple files, directory uploads -├── test_duplicate_handling.py # Skip, replace, error policies -├── test_project_resolution.py # Auto-detection and manual specification -├── test_error_scenarios.py # Network failures, auth errors, validation -├── test_integration.py # End-to-end comprehensive scenarios -├── test_fixtures.py # Fixture validation tests +├── unit/ # Unit tests (no external dependencies) +│ ├── __init__.py +│ ├── test_cli.py # CLI argument parsing and validation +│ ├── test_models.py # Data models and structures +│ ├── test_uploader.py # Upload logic and file handling +│ └── test_validators.py # Input validation functions +├── integration/ # Integration tests (requires GITLAB_TOKEN) +│ ├── __init__.py +│ ├── conftest.py # Integration-specific fixtures +│ ├── test_single_file_upload.py # Single file upload tests +│ ├── test_multiple_files_upload.py # Multiple files upload tests +│ ├── test_duplicate_handling.py # Skip, replace, error policies +│ ├── test_project_resolution.py # Auto-detection and manual spec +│ ├── test_error_scenarios.py # Network failures, auth errors +│ └── test_end_to_end.py # Comprehensive end-to-end scenarios ├── utils/ │ ├── __init__.py │ ├── test_helpers.py # Common test utilities -│ ├── artifact_factory.py # Test file creation utilities +│ ├── artifact_factory.py # Test file creation utilities │ ├── gitlab_helpers.py # GitLab API interaction utilities │ ├── rate_limiter.py # API rate limiting utilities │ └── performance.py # Performance monitoring utilities └── README.md # This file ../ -├── run_tests.py # Test runner with uv dependency management +├── run_tests.py # Convenience wrapper for uv run pytest └── pyproject.toml # Project configuration and pytest settings ``` @@ -87,404 +98,335 @@ tests/ ### Dependency Management -All test dependencies are managed automatically by the uv package manager through the [`run_tests.py`](../run_tests.py) script. No manual installation is required. +All test dependencies are automatically installed by uv when running +`uv run pytest`. The dependencies are defined in `pyproject.toml` under +`[project.optional-dependencies]`: -### GitLab Configuration +- pytest +- pytest-xdist (parallel execution) +- pytest-timeout (timeout management) +- pytest-sugar (progress visualization) +- pytest-instafail (instant failure reporting) +- pytest-cov (code coverage reporting) + +**Important**: The `glpkg` package itself must be installed in development +mode before running tests: -Set the following environment variables: ```bash -export GITLAB_TOKEN="your-gitlab-token" -export GITLAB_URL="https://gitlab.example.com" # Optional, defaults to GitLab.com -export GITLAB_PROJECT_PATH="group/project" # Optional, can auto-detect from git +uv pip install -e . ``` -### Git Repository +Alternatively, you can use `pip install -e .` if not using uv. -Tests can auto-detect GitLab project from the current git repository, or you can specify manually via environment variables. +### GitLab Configuration (Integration Tests Only) + +Integration tests require explicit opt-in and a GitLab API token: + +```bash +# Required: Opt-in to run integration tests +export RUN_INTEGRATION_TESTS=1 + +# Required: GitLab API token +export GITLAB_TOKEN="your-gitlab-token" + +# Optional: Custom GitLab URL (defaults to GitLab.com) +export GITLAB_URL="https://gitlab.example.com" + +# Optional: Project path (can auto-detect from git) +export GITLAB_PROJECT_PATH="group/project" +``` ### Required Permissions Your GitLab token needs the following permissions: + - `api` scope for full API access - Write access to the target project's Package Registry - Ability to create and delete packages in the registry -## Pytest Plugins - -The test suite uses the following pytest plugins: +## Running Tests -| Plugin | Purpose | Usage | -|--------|---------|-------| -| **pytest-xdist** | Parallel test execution | Enables running tests across multiple CPU cores with `-n auto` flag for faster execution | -| **pytest-timeout** | Test timeout management | Automatically fails tests that exceed time limits; configured with markers like `@pytest.mark.timeout(60)` | -| **pytest-sugar** | Progress visualization | Provides real-time progress bars and improved test output formatting | -| **pytest-instafail** | Instant failure reporting | Shows test failures immediately as they occur, enabled by default with `--instafail` flag | +### Primary Method: uv run pytest -**Note**: These plugins are automatically installed when running tests via [`run_tests.py`](../run_tests.py) using the uv package manager. Plugin configuration is defined in [`pyproject.toml`](../pyproject.toml) under `[tool.pytest.ini_options]`. +```bash +# Run all tests +uv run pytest tests/ -## Running Tests +# Run only unit tests +uv run pytest tests/unit/ -### Using the Test Runner (Primary Method) +# Run only integration tests +uv run pytest tests/integration/ -m integration -The [`run_tests.py`](../run_tests.py) script is the primary and recommended method for running tests. It automatically manages dependencies using uv and provides convenient test execution options. +# Run specific test file +uv run pytest tests/unit/test_cli.py -```bash -# Run all available tests (auto-detects GITLAB_TOKEN) -./run_tests.py +# Run specific test function +uv run pytest tests/unit/test_cli.py::test_parse_args -# Run only unit tests (fast, no external dependencies) -./run_tests.py --unit +# Run with verbose output +uv run pytest tests/ -v -# Run only integration tests (requires GITLAB_TOKEN, takes 10-15 minutes) -./run_tests.py --integration +# Run with parallel execution +uv run pytest tests/ -n auto -# Run configuration validation tests -./run_tests.py --config +# Stop on first failure +uv run pytest tests/ -x -# Run all test categories sequentially -./run_tests.py --all +# Show 10 slowest tests +uv run pytest tests/ --durations=10 ``` -### Pass-Through Mode for Advanced Usage - -Any arguments not matching convenience flags are passed directly to pytest, enabling advanced usage: +### Run Tests by Markers ```bash -# Run specific test -./run_tests.py tests/test_unit_basic.py::test_import_gitlab_common +# Run only unit tests +uv run pytest tests/ -m unit -# Filter tests by name pattern -./run_tests.py -v -k "test_import" tests/ +# Run only integration tests +uv run pytest tests/ -m integration -# Run with parallel execution -./run_tests.py -n auto tests/ +# Run only fast tests +uv run pytest tests/ -m fast -# Filter by marker -./run_tests.py -m "unit and not slow" +# Skip slow tests +uv run pytest tests/ -m "not slow" -# Combine multiple pytest options -./run_tests.py -v -x -n auto tests/test_basic_uploads.py +# Run API tests +uv run pytest tests/ -m api ``` -### Duration Reporting - -The wrapper automatically adds `--durations` flags to show test timing information: +### Wrapper Script Usage ```bash -# Show 5 slowest tests -./run_tests.py --durations=5 tests/ +# Run unit tests +./run_tests.py --unit -# Show tests taking at least 2 seconds -./run_tests.py --durations-min=2.0 tests/ +# Run integration tests (requires GITLAB_TOKEN) +./run_tests.py --integration -# Disable duration reporting -./run_tests.py --durations=0 tests/ +# Run all test categories +./run_tests.py --all + +# Pass-through to pytest with custom arguments +./run_tests.py -v -k "upload" tests/ +./run_tests.py -n auto tests/ +./run_tests.py --durations=5 tests/ ``` -### Direct pytest Execution (Not Recommended) +## Code Coverage -Direct pytest execution requires manual dependency installation with uv and is only for advanced users who need full pytest control: +The test suite includes code coverage reporting with dual thresholds: -```bash -# Install dependencies manually -uv pip install -r requirements-test.txt +- **Warning threshold**: 95% (CI adds a warning annotation when below) +- **Failure threshold**: 90% (tests fail if coverage drops below) -# Run pytest directly -pytest +### Running Tests with Coverage -# Run with verbose output -pytest -v +```bash +# Run unit tests with coverage (default configuration) +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=term -# Run with parallel execution -pytest -n auto -``` +# Generate HTML coverage report +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=html -### Run Specific Test Modules +# View HTML report +open htmlcov/index.html # macOS +xdg-open htmlcov/index.html # Linux -```bash -# Test basic upload functionality -./run_tests.py tests/test_basic_uploads.py +# Generate XML coverage report (for CI) +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=xml +``` -# Test duplicate handling policies -./run_tests.py tests/test_duplicate_handling.py +### Coverage Configuration -# Test project resolution -./run_tests.py tests/test_project_resolution.py +Coverage is configured in `pyproject.toml`: -# Test error scenarios -./run_tests.py tests/test_error_scenarios.py +```toml +[tool.coverage.run] +source = ["src/glpkg"] +omit = ["tests/*", "*/tests/*"] -# Test integration scenarios -./run_tests.py tests/test_integration.py +[tool.coverage.report] +precision = 2 +show_missing = true ``` -### Run Tests by Markers +### Interpreting Coverage Reports -```bash -# Run only fast tests -./run_tests.py -m fast +- **Term-missing output**: Shows which lines are not covered in terminal +- **HTML report**: Provides an interactive view in `htmlcov/index.html` +- **XML report**: Machine-readable format for CI integration in `coverage.xml` -# Run only integration tests -./run_tests.py -m integration +## Integration Test Requirements -# Skip slow tests -./run_tests.py -m "not slow" +Integration tests require explicit opt-in and automatically validate their +environment before running. -# Run only API tests -./run_tests.py -m api +### Automatic Environment Validation -# Run sequential tests only -./run_tests.py -m sequential -``` +When you run integration tests, the test suite checks: -## Test Execution Time +1. **RUN_INTEGRATION_TESTS environment variable** - Must be set to `1` +2. **GITLAB_TOKEN environment variable** - Must be set with a valid token +3. **Git repository** - Must run from within a Git repository +4. **GitLab remotes** - Repository must have at least one GitLab remote -### Expected Duration +### Verifying Your Setup -- **Unit tests**: 10-30 seconds (no external dependencies) -- **Integration tests**: 10-15 minutes (full suite with GitLab API operations) -- **Individual integration tests**: 30-120 seconds each +```bash +# Check if integration tests are enabled +echo $RUN_INTEGRATION_TESTS -### Reasons for Slow Execution +# Check if in Git repository +git remote -v -Integration tests interact with real GitLab infrastructure, which introduces several time factors: +# Verify GitLab remote exists +git remote -v | grep gitlab -- **Real GitLab API Operations**: Tests perform actual uploads, deletions, and verifications against the GitLab Package Registry -- **Rate Limiting**: Thread-safe rate limiter prevents API overload and respects GitLab rate limits (implemented in [`utils/rate_limiter.py`](utils/rate_limiter.py)) -- **Sequential Package Operations**: Each test creates unique packages, uploads files, verifies checksums, and cleans up -- **Network Latency**: Communication with GitLab servers introduces inherent delays -- **Checksum Verification**: Post-upload validation downloads files and verifies SHA256 checksums -- **Parallel Execution Overhead**: When using `-n auto`, pytest-xdist coordination adds slight overhead +# Check token is set +echo $GITLAB_TOKEN -### Progress Monitoring +# Verify full setup +[ "$RUN_INTEGRATION_TESTS" = "1" ] && \ + echo "Integration tests enabled" || \ + echo "Integration tests NOT enabled" +[ -n "$GITLAB_TOKEN" ] && \ + echo "Token is set" || \ + echo "Token is NOT set" +``` -The test suite provides several mechanisms for monitoring progress: +### When Validation Fails -- **pytest-sugar**: Provides real-time progress bars during execution -- **pytest-instafail**: Shows failures immediately as they occur -- **Duration reporting**: Shows timing for each test at the end -- **Wrapper script**: Shows elapsed time after completion +If integration tests are skipped, the error message explains what's missing: -## Debugging Test Failures +- **Integration tests disabled**: Set `export RUN_INTEGRATION_TESTS=1` +- **Missing GITLAB_TOKEN**: Set with `export GITLAB_TOKEN='your-token'` +- **No Git repository**: Navigate to a Git repository or initialize one +- **No GitLab remotes**: Add a GitLab remote with `git remote add origin` -### Using the Test Runner for Debugging +## Debugging Test Failures -The [`run_tests.py`](../run_tests.py) wrapper supports all pytest debugging options: +### Common Debugging Commands ```bash # Verbose output -./run_tests.py -v tests/test_basic_uploads.py +uv run pytest tests/ -v # Extra verbose output -./run_tests.py -vv tests/test_basic_uploads.py +uv run pytest tests/ -vv # Stop on first failure -./run_tests.py -x tests/ +uv run pytest tests/ -x # Full traceback -./run_tests.py --tb=long tests/ +uv run pytest tests/ --tb=long -# Short traceback (default) -./run_tests.py --tb=short tests/ +# Short traceback +uv run pytest tests/ --tb=short # Maximum verbosity for specific test -./run_tests.py -vvv --tb=long tests/test_basic_uploads.py::test_single_file_upload -``` +uv run pytest tests/unit/test_cli.py::test_parse_args -vvv --tb=long -### Common Debugging Scenarios +# Enable debug logging +uv run pytest tests/ -v --log-cli-level=DEBUG +``` -#### Authentication Issues +### Common Issues -If you encounter authentication errors: +#### Authentication Errors -- Check `GITLAB_TOKEN` is set and valid -- Verify token hasn't expired -- Ensure token has `api` scope and write access to Package Registry -- Test with: `./run_tests.py --unit` (doesn't require token) +- Verify `GITLAB_TOKEN` is set and valid +- Check token has required permissions (`api` scope) +- Test with unit tests: `uv run pytest tests/unit/` (doesn't require token) #### Timeout Errors -Integration tests have extended timeouts to accommodate GitLab API operations: - -- Integration tests: 600s timeout (configured in [`pyproject.toml`](../pyproject.toml)) -- Slow integration tests: 900s timeout -- If tests timeout, check network connectivity to GitLab -- Consider running sequentially: `./run_tests.py -m integration` (without `-n auto`) -- For very slow networks: `./run_tests.py --timeout=1200 tests/` +- Check network connectivity to GitLab +- Run sequentially: `uv run pytest tests/integration/` (without `-n auto`) +- Increase timeout: `uv run pytest tests/ --timeout=1200` #### Parallel Execution Issues -If tests fail in parallel but pass sequentially: - -- May indicate a race condition or resource conflict -- Run sequentially for debugging: `./run_tests.py tests/` (without `-n auto`) -- Check rate limiter is working properly in [`utils/rate_limiter.py`](utils/rate_limiter.py) -- Verify unique package naming includes worker ID - -#### Package Cleanup Failures - -Tests automatically clean up packages in fixture teardown: - -- If cleanup fails, packages may remain in GitLab registry -- Manual cleanup: access GitLab project → Packages & Registries → Delete test packages -- Test packages are prefixed with `test-` and include timestamps -- Run cleanup tests: `./run_tests.py -m cleanup` - -### Viewing Test Logs - -Control log output verbosity: - -```bash -# pytest-sugar provides clean output by default -./run_tests.py tests/ - -# Enable debug logging -./run_tests.py --log-cli-level=DEBUG tests/ - -# Show INFO level logs -./run_tests.py --log-cli-level=INFO tests/ - -# Combine with verbose output -./run_tests.py -v --log-cli-level=DEBUG tests/test_basic_uploads.py -``` - -**Note**: GitLab API calls are rate-limited and logged in verbose mode. +- Run sequentially for debugging: `uv run pytest tests/` (without `-n auto`) +- Check rate limiter is working properly ## Parallel Execution -### Using Parallel Execution - -Parallel execution uses the pytest-xdist plugin to run tests across multiple CPU cores: +Tests are designed for safe parallel execution using pytest-xdist: ```bash # Auto-detect CPU cores -./run_tests.py -n auto tests/ +uv run pytest tests/ -n auto -# Specify worker count manually -./run_tests.py -n 4 tests/ +# Specify worker count +uv run pytest tests/ -n 4 # Use worksteal distribution for better load balancing -./run_tests.py --dist=worksteal -n auto tests/ +uv run pytest tests/ --dist=worksteal -n auto ``` ### Parallel Execution Safety -Tests are designed for safe parallel execution with: - - **Unique package names per test**: Timestamp + worker ID + random suffix - **Thread-safe rate limiting**: For GitLab API calls across all workers - **Isolated temporary directories**: Per worker isolation - **Automatic cleanup**: In fixture teardown -**Note**: Tests marked with `@pytest.mark.sequential` run sequentially even with `-n auto`. +## Test Categories -### Performance Considerations +### Unit Tests (`tests/unit/`) -- **Parallel execution reduces total time** but increases API load -- **Rate limiter prevents API overload** across all workers -- **Optimal worker count**: `-n auto` (matches CPU cores) -- **For debugging**: Run sequentially without `-n` flag +Fast tests that validate individual components: -## Test Categories +- **test_cli.py**: CLI argument parsing and validation +- **test_models.py**: Data models and structures +- **test_uploader.py**: Upload logic and file handling +- **test_validators.py**: Input validation functions -### Basic Upload Tests ([`test_basic_uploads.py`](test_basic_uploads.py)) -- **Single file upload**: Tests uploading individual files -- **Multiple file upload**: Tests uploading multiple files in one command -- **Directory upload**: Tests uploading entire directories -- **File mapping**: Tests custom file-to-package mapping - -### Duplicate Handling Tests ([`test_duplicate_handling.py`](test_duplicate_handling.py)) -- **Skip policy**: Tests skipping existing packages -- **Replace policy**: Tests replacing existing packages -- **Error policy**: Tests error handling for duplicates - -### Project Resolution Tests ([`test_project_resolution.py`](test_project_resolution.py)) -- **Git auto-detection**: Tests automatic project detection from git remote -- **Manual URL specification**: Tests explicit project URL specification -- **Manual path specification**: Tests explicit project path specification - -### Error Scenario Tests ([`test_error_scenarios.py`](test_error_scenarios.py)) -- **Network failures**: Tests handling of network connectivity issues -- **Authentication errors**: Tests handling of invalid tokens or permissions -- **Invalid inputs**: Tests handling of invalid file paths or project specifications -- **Error message validation**: Tests that error messages are informative - -### Integration Tests ([`test_integration.py`](test_integration.py)) -- **End-to-end scenarios**: Tests complete upload workflows -- **Multi-scenario validation**: Tests complex scenarios with multiple operations -- **Performance validation**: Tests upload performance and reliability +### Integration Tests (`tests/integration/`) -## Adding New Tests +End-to-end tests requiring GitLab API access: -### Creating a New Test Function +- **test_single_file_upload.py**: Single file upload tests +- **test_multiple_files_upload.py**: Multiple files upload tests +- **test_duplicate_handling.py**: Skip, replace, error policies +- **test_project_resolution.py**: Auto-detection and manual specification +- **test_error_scenarios.py**: Network failures, auth errors +- **test_end_to_end.py**: Comprehensive end-to-end scenarios -1. **Choose the appropriate test module** based on functionality -2. **Follow pytest naming conventions** (`test_*` functions) -3. **Use existing fixtures** for common setup -4. **Add appropriate markers** for categorization +## Test Execution Time -Example: -```python -import pytest -from utils.test_helpers import execute_upload_script -from utils.gitlab_helpers import verify_package_exists - -@pytest.mark.api -@pytest.mark.fast -def test_new_upload_scenario(gitlab_client, artifact_manager, temp_dir): - """Test a new upload scenario.""" - # Create test file - test_file = artifact_manager.create_test_file("test.txt", size=100) - - # Execute upload script - result = execute_upload_script( - files=[test_file.path], - package_name="test-package", - version="1.0.0", - working_dir=temp_dir - ) - - # Verify success - assert result.exit_code == 0 - assert "Upload successful" in result.stdout - - # Verify in GitLab - assert verify_package_exists( - gitlab_client, - "test-package", - "1.0.0", - "test.txt" - ) -``` +| Test Category | Duration | Requirements | +| --------------------- | -------------- | -------------- | +| Unit tests | 10-30 seconds | None | +| Integration tests | 10-15 minutes | GITLAB_TOKEN | +| All tests (parallel) | 5-10 minutes | GITLAB_TOKEN | -### Creating a New Test Module +## Adding New Tests -1. **Create new file** following `test_*.py` naming convention -2. **Import required fixtures** from [`conftest.py`](conftest.py) -3. **Add module docstring** describing the test category -4. **Use appropriate markers** for the entire module +### Creating a New Test + +1. Choose the appropriate directory (`tests/unit/` or `tests/integration/`) +2. Follow pytest naming conventions (`test_*` functions) +3. Use existing fixtures from `conftest.py` +4. Add appropriate markers for categorization Example: -```python -"""Tests for new functionality category.""" +```python import pytest -from utils.test_helpers import execute_upload_script - -# Mark all tests in this module -pytestmark = [pytest.mark.api, pytest.mark.integration] -def test_new_functionality(gitlab_client, artifact_manager): +@pytest.mark.unit +def test_new_functionality(): """Test new functionality.""" # Test implementation - pass + assert True ``` ### Available Fixtures -The test suite provides several fixtures for common operations: - -- **`gitlab_client`**: Authenticated GitLab client +- **`gitlab_client`**: Authenticated GitLab client (integration tests) - **`artifact_manager`**: Test file creation and cleanup - **`temp_dir`**: Isolated temporary directory - **`project_resolver`**: Project identification utilities @@ -492,132 +434,34 @@ The test suite provides several fixtures for common operations: ### Test Markers -Use these markers to categorize your tests: - -- **`@pytest.mark.fast`**: Quick tests that can run in parallel -- **`@pytest.mark.slow`**: Tests that take longer to execute -- **`@pytest.mark.integration`**: End-to-end integration tests -- **`@pytest.mark.api`**: Tests requiring GitLab API access +- **`@pytest.mark.unit`**: Unit tests (no external dependencies) +- **`@pytest.mark.integration`**: Integration tests (requires GITLAB_TOKEN) +- **`@pytest.mark.fast`**: Quick tests +- **`@pytest.mark.slow`**: Slow tests +- **`@pytest.mark.api`**: Tests requiring API access - **`@pytest.mark.sequential`**: Tests that must run sequentially -- **`@pytest.mark.cleanup`**: Tests that perform cleanup operations - -## Troubleshooting - -### Common Issues - -#### Authentication Errors -- Verify `GITLAB_TOKEN` is set and valid -- Check token has required permissions (`api` scope) -- Ensure token hasn't expired -- Test with unit tests: `./run_tests.py --unit` (doesn't require token) - -#### Project Not Found -- Verify `GITLAB_PROJECT_PATH` is correct -- Check git remote URL is accessible -- Ensure project exists and is accessible -- Try manual specification: `export GITLAB_PROJECT_PATH="group/project"` - -#### Network Timeouts -- Check network connectivity to GitLab instance -- Verify GitLab URL is correct -- Consider running tests sequentially if parallel execution fails -- Increase timeout: `./run_tests.py --timeout=1200 tests/` - -#### Integration Test Timeouts -- Integration tests have extended timeouts (600-900s) -- Timeout errors indicate network issues or GitLab API slowness -- Try running sequentially: `./run_tests.py -m integration` -- For very slow networks: `./run_tests.py --timeout=1200 tests/` - -#### Test Failures -- Check test output for specific error messages -- Verify all environment variables are set -- Ensure no leftover test artifacts from previous runs -- See [Debugging Test Failures](#debugging-test-failures) section -### Debug Mode - -Run tests with additional debugging information using the [`run_tests.py`](../run_tests.py) wrapper: +## Getting Help ```bash -# Enable debug logging -./run_tests.py -v --log-cli-level=DEBUG tests/ - -# Show local variables on failure -./run_tests.py --tb=long tests/ - -# Stop on first failure -./run_tests.py -x tests/ +# Show pytest help +uv run pytest --help -# Run specific test with maximum verbosity -./run_tests.py -vvv --tb=long tests/test_basic_uploads.py::test_single_file_upload +# Show available markers +uv run pytest --markers -# Combine multiple debugging options -./run_tests.py -vvv -x --tb=long --log-cli-level=DEBUG tests/ -``` - -### Cleanup - -If tests are interrupted and leave artifacts: - -```bash -# Run cleanup tests specifically -./run_tests.py -m cleanup - -# Manual cleanup (if needed) -python -c " -from tests.conftest import cleanup_test_packages -cleanup_test_packages() -" +# Show available fixtures +uv run pytest --fixtures ``` -## Performance Considerations - -### Parallel Execution - -Tests are designed to run in parallel safely. See the [Parallel Execution](#parallel-execution) section for detailed information. - -- Use `./run_tests.py -n auto tests/` for optimal parallel execution -- Sequential tests are marked with `@pytest.mark.sequential` -- Rate limiting prevents API overload across all workers -- Unique package names include worker ID to prevent conflicts - -### Test Isolation - -- Each test uses unique package names and versions -- Package names include worker ID to prevent conflicts -- Temporary directories provide file system isolation -- Automatic cleanup prevents resource leaks -- Fixtures ensure proper setup and teardown - -### Resource Management - -- API rate limiting prevents GitLab API overload (see [`utils/rate_limiter.py`](utils/rate_limiter.py)) -- Temporary files are cleaned up automatically -- Test packages are removed from GitLab registry -- Memory usage is minimized through efficient fixtures -- Performance tracking is enabled via [`conftest_performance.py`](conftest_performance.py) plugin -- Duration reporting shows timing for each test automatically - -## Test Execution Methods Summary - -| Method | Use Case | Duration | Requirements | -|--------|----------|----------|--------------| -| `./run_tests.py` | Default, all available tests | 10s-15m | None (auto-detects token) | -| `./run_tests.py --unit` | Fast validation, no external deps | 10-30s | None | -| `./run_tests.py --integration` | Full GitLab API testing | 10-15m | GITLAB_TOKEN | -| `./run_tests.py --all` | Complete test suite | 15-20m | GITLAB_TOKEN (optional) | -| `./run_tests.py -n auto` | Parallel execution | 5-10m | GITLAB_TOKEN | -| `./run_tests.py -v -x` | Debugging failures | Varies | None | - ## Contributing When contributing new tests: -1. **Follow existing patterns** for consistency -2. **Add appropriate documentation** and docstrings -3. **Use existing fixtures** to avoid duplication -4. **Add proper markers** for categorization -5. **Ensure cleanup** of any created resources -6. **Test both success and failure scenarios** -7. **Run tests locally** before submitting: `./run_tests.py --all` +1. Follow existing patterns for consistency +2. Add appropriate documentation and docstrings +3. Use existing fixtures to avoid duplication +4. Add proper markers for categorization +5. Ensure cleanup of any created resources +6. Test both success and failure scenarios +7. Run tests locally before submitting: `uv run pytest tests/` diff --git a/tests/conftest.py b/tests/conftest.py index 6ff275f..afdac8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,17 +18,21 @@ import pytest import requests -# Import the classes we need to extract from the monolithic test file +# Import from the new modular structure try: - from gitlab_common import ProjectResolver - from gitlab import Gitlab + from glpkg.cli.upload import GitAutoDetector, ProjectResolver + from glpkg.models import GitRemoteInfo, ProjectInfo + GITLAB_AVAILABLE = True except ImportError: - # Handle case where python-gitlab is not available + # Handle case where python-gitlab or glpkg is not available Gitlab = None ProjectResolver = None + GitAutoDetector = None + GitRemoteInfo = None + ProjectInfo = None GITLAB_AVAILABLE = False # Import our thread-safe rate limiter and performance utilities @@ -236,7 +240,7 @@ def verify_upload( "GitLab Generic Package Registry may not support subdirectories properly." ) # For subdirectory files, we'll assume success if the file was uploaded - # This matches the behavior in gitlab-pkg-upload.py validate_upload() + # This matches the behavior in gitlab_pkg_upload.uploader.validate_upload() return True return False @@ -793,13 +797,13 @@ def artifact_manager(): @pytest.fixture def project_resolver(gitlab_client): """ - Provide project resolver fixture using existing ProjectResolver from gitlab_common. + Provide project resolver fixture using ProjectResolver from gitlab_pkg_upload.cli. This fixture creates a ProjectResolver instance using the authenticated GitLab client for project identification and validation. """ if not GITLAB_AVAILABLE or not ProjectResolver: - pytest.skip("python-gitlab or gitlab_common not available") + pytest.skip("python-gitlab or gitlab_pkg_upload not available") return ProjectResolver(gitlab_client.gl) @@ -850,7 +854,8 @@ def project_path(): """ # Try to auto-detect project path from Git repository try: - from gitlab_common import GitAutoDetector + if not GitAutoDetector: + raise ImportError("GitAutoDetector not available") detector = GitAutoDetector() repo = detector.find_git_repository() @@ -863,10 +868,10 @@ def project_path(): pass # Fallback to environment variable or skip - project_path = os.environ.get("GITLAB_PROJECT_PATH") - if not project_path: + project_path_env = os.environ.get("GITLAB_PROJECT_PATH") + if not project_path_env: pytest.skip( "Could not auto-detect project path and GITLAB_PROJECT_PATH not set" ) - return project_path + return project_path_env diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..88b6b79 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,33 @@ +""" +Integration tests package for GitLab package upload CLI. + +This package contains integration tests that use direct module invocation +instead of subprocess execution. Tests call the CLI main() function directly +to improve test isolation and reduce overhead. + +Test Modules: + - test_helpers_module: ModuleExecutor and helper utilities + - test_single_file_upload: Single file upload tests + - test_multiple_files_upload: Multiple files and directory upload tests + - test_duplicate_handling: Duplicate handling policy tests + - test_project_resolution: Project resolution tests + - test_error_scenarios: Error handling tests + - test_end_to_end: Comprehensive end-to-end tests + +Usage: + Run all integration tests: + pytest tests/integration/ -v + + Run specific test file: + pytest tests/integration/test_single_file_upload.py -v + + Run with parallel execution: + pytest tests/integration/ -n auto + +Test Markers: + - integration: All integration tests + - api: Tests requiring GitLab API access + - slow: Tests that take longer to run + - fast: Tests that run quickly + - cleanup: Tests that verify cleanup functionality +""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..81482b0 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,267 @@ +""" +Pytest configuration for integration tests using direct module invocation. + +This module provides fixtures and configuration specific to the integration +tests that call the CLI main() function directly. + +Environment Requirements: + Integration tests require the following environment setup: + + 1. GITLAB_TOKEN environment variable must be set + - Can be set via: export GITLAB_TOKEN="your-token" + - Token needs 'api' scope and write access to Package Registry + + 2. Must run from within a Git repository with GitLab remotes + - The repository must have at least one remote pointing to a GitLab instance + - Remotes are auto-detected using the GitAutoDetector class + - Alternatively, use GITLAB_PROJECT_PATH environment variable + + If requirements aren't met, all integration tests will be skipped with + clear, actionable error messages explaining what's missing and how to fix it. + +Fixtures from parent conftest.py are automatically inherited: + - gitlab_client: GitLab test client for API verification + - artifact_manager: Test artifact management + - project_path: GitLab project path + +Usage: + Fixtures are automatically available to all tests in this package. + Import additional utilities from test_helpers_module as needed. + +Example commands to verify your environment: + # Check if in Git repository + git remote -v + + # Verify GitLab remote exists + git remote -v | grep gitlab + + # Check token is set + echo $GITLAB_TOKEN +""" + +import logging +import os +from typing import Tuple + +import pytest + +from glpkg.cli.upload import GitAutoDetector, ProjectResolutionError + + +def _validate_gitlab_repository() -> Tuple[bool, str, str]: + """ + Validate that the current environment has a GitLab repository. + + Uses GitAutoDetector to find Git repository and check for GitLab remotes. + + Returns: + Tuple of (is_valid, error_message, success_info): + - is_valid: True if GitLab repository is properly configured + - error_message: Detailed error message if validation fails, empty string otherwise + - success_info: Information about the detected repository if valid + """ + detector = GitAutoDetector() + + # Step 1: Find Git repository + try: + repo = detector.find_git_repository() + except ProjectResolutionError as e: + return ( + False, + f"Git repository access error: {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions\n" + "2. Use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + if repo is None: + return ( + False, + "No Git repository found in the current directory or parent directories.\n\n" + "Integration tests require running from within a Git repository.\n\n" + "SOLUTION:\n" + "1. Navigate to a Git repository before running tests:\n" + " cd /path/to/your/git/repo\n\n" + "2. Or initialize a Git repository:\n" + " git init\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Step 2: Check for remotes + remotes = list(repo.remotes) + if not remotes: + return ( + False, + f"Git repository found at '{repo.working_dir}' but no remotes are configured.\n\n" + "Integration tests require at least one GitLab remote.\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Step 3: Check for GitLab remotes + try: + gitlab_remotes = detector.get_gitlab_remotes(repo) + except ProjectResolutionError: + # No GitLab remotes found + remote_urls = [f" - {r.name}: {r.url}" for r in remotes] + return ( + False, + f"Git repository found at '{repo.working_dir}' but no GitLab remotes detected.\n\n" + "Current remotes:\n" + + "\n".join(remote_urls) + + "\n\n" + "Integration tests require at least one remote pointing to a GitLab instance.\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add gitlab https://gitlab.com/namespace/project.git\n\n" + "2. Or update an existing remote to point to GitLab:\n" + " git remote set-url origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Success - build info string + remote_info = ", ".join(f"{r.name}={r.project_path}" for r in gitlab_remotes) + success_info = ( + f"Git repository: {repo.working_dir}\n" + f"GitLab remotes detected: {remote_info}" + ) + + return (True, "", success_info) + + +@pytest.fixture(scope="session", autouse=True) +def validate_integration_environment(): + """ + Validate that the integration test environment is properly configured. + + This session-scoped fixture runs once before any integration tests execute. + It validates: + 0. RUN_INTEGRATION_TESTS environment variable is set to "1" + 1. GITLAB_TOKEN environment variable is set + 2. Current directory is within a Git repository + 3. Git repository has at least one GitLab remote + + If any requirement is not met, all integration tests are skipped with + clear, actionable error messages. + + This fixture is marked autouse=True so it runs automatically for all + integration tests without needing to be explicitly requested. + """ + # Check 0: RUN_INTEGRATION_TESTS environment variable (opt-in mechanism) + run_integration = os.environ.get("RUN_INTEGRATION_TESTS") + if run_integration != "1": + pytest.skip( + "Integration tests disabled. Set RUN_INTEGRATION_TESTS=1 to enable.\n\n" + "SOLUTION:\n" + " export RUN_INTEGRATION_TESTS=1\n\n" + "Or run with:\n" + " RUN_INTEGRATION_TESTS=1 pytest tests/integration/ -m integration", + allow_module_level=True, + ) + + # Check 1: GITLAB_TOKEN environment variable + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip( + "GITLAB_TOKEN environment variable not set.\n\n" + "Integration tests require a valid GitLab API token.\n\n" + "SOLUTION:\n" + "1. Create a GitLab personal access token with 'api' scope:\n" + " GitLab → Settings → Access Tokens → Create token\n\n" + "2. Set the environment variable:\n" + " export GITLAB_TOKEN='your-token-here'\n\n" + "3. Or add it to your shell profile for persistence:\n" + " echo 'export GITLAB_TOKEN=\"your-token\"' >> ~/.bashrc", + allow_module_level=True, + ) + + # Check 2 & 3: Git repository with GitLab remotes + # Skip this check if GITLAB_PROJECT_PATH is manually specified + if not os.environ.get("GITLAB_PROJECT_PATH"): + is_valid, error_message, success_info = _validate_gitlab_repository() + + if not is_valid: + pytest.skip(error_message, allow_module_level=True) + + # Log successful validation + print(f"\nIntegration test environment validated:") + print(f" - GITLAB_TOKEN: [set]") + print(f" - {success_info}") + else: + # Manual project path specified + project_path = os.environ.get("GITLAB_PROJECT_PATH") + print(f"\nIntegration test environment validated:") + print(f" - GITLAB_TOKEN: [set]") + print(f" - GITLAB_PROJECT_PATH: {project_path} (manually specified)") + + yield + + +def pytest_configure(config): + """Configure pytest for integration tests.""" + # Register custom markers + config.addinivalue_line( + "markers", + "module_integration: Integration tests using direct module invocation", + ) + config.addinivalue_line( + "markers", + "api: Tests requiring GitLab API access", + ) + config.addinivalue_line( + "markers", + "cleanup: Tests that verify cleanup functionality", + ) + config.addinivalue_line( + "markers", + "fast: Tests that run quickly", + ) + + +@pytest.fixture(autouse=True) +def setup_integration_logging(caplog): + """ + Configure logging for integration tests. + + This fixture sets up appropriate logging levels for integration tests + to capture relevant debug information while avoiding excessive output. + """ + # Set logging level to capture warnings and errors + caplog.set_level(logging.WARNING) + + # Set specific loggers to appropriate levels + logging.getLogger("gitlab_pkg_upload").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + + yield + + # Cleanup logging after test + logging.getLogger("gitlab_pkg_upload").setLevel(logging.WARNING) + + +@pytest.fixture +def module_executor(): + """ + Provide a ModuleExecutor instance for tests. + + This fixture creates a fresh ModuleExecutor for each test that requests it. + Most tests will create their own executor, but this fixture is available + for tests that need a shared or pre-configured executor. + + Returns: + ModuleExecutor instance + """ + from .test_helpers_module import ModuleExecutor + + return ModuleExecutor() diff --git a/tests/test_duplicate_handling.py b/tests/integration/test_duplicate_handling.py similarity index 52% rename from tests/test_duplicate_handling.py rename to tests/integration/test_duplicate_handling.py index 7b9b085..a157d90 100644 --- a/tests/test_duplicate_handling.py +++ b/tests/integration/test_duplicate_handling.py @@ -1,9 +1,8 @@ """ -Duplicate handling policy tests for GitLab package upload script. +Duplicate handling policy integration tests using direct module invocation. -This module contains tests for duplicate handling policies extracted from the -monolithic test file. It validates skip, replace, and error duplicate policies -using pytest framework with real GitLab API interactions. +This module tests skip, replace, and error duplicate policies by calling +the CLI main() function directly. """ import os @@ -11,13 +10,18 @@ import pytest -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, +from .test_helpers_module import ( + ModuleExecutor, validate_json_result, ) +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.slow, # These tests take longer due to multiple uploads and API calls +] + def _validate_upload_consistency( gitlab_client, @@ -51,7 +55,7 @@ def _validate_upload_consistency( if not download_url: return False - # Step 3: Verify downloaded content matches expected checksum (same as upload script validation) + # Step 3: Verify downloaded content matches expected checksum if not gitlab_client.download_and_verify( package_name, version, filename, expected_checksum ): @@ -63,130 +67,15 @@ def _validate_upload_consistency( return False -def _validate_duplicate_behavior( - upload_result, expected_behavior: str, json_data=None -) -> bool: - """ - Validate that the upload result indicates the expected duplicate handling behavior. - - Args: - upload_result: Result from script execution - expected_behavior: Expected behavior ("skip", "replace", "error") - json_data: Optional JSON data for structured validation - - Returns: - True if behavior is indicated in output or JSON, False otherwise - """ - # If JSON data is provided, use structured validation - if json_data is not None: - if expected_behavior == "skip": - # Check for skip indicators in JSON - stats = json_data.get("statistics", {}) - skipped_list = json_data.get("skipped_duplicates", []) - successful = json_data.get("successful_uploads", []) - - # Check if any files were skipped - if stats.get("skipped_duplicates", 0) > 0: - return True - if len(skipped_list) > 0: - return True - # Check if any successful upload was marked as skipped duplicate - for upload in successful: - if ( - upload.get("was_duplicate") - and upload.get("duplicate_action") == "skipped" - ): - return True - return False - - elif expected_behavior == "replace": - # Check for replace indicators in JSON - stats = json_data.get("statistics", {}) - successful = json_data.get("successful_uploads", []) - - # Check if any files were replaced - if stats.get("replaced_duplicates", 0) > 0: - return True - # Check if any successful upload was marked as replaced duplicate - for upload in successful: - if ( - upload.get("was_duplicate") - and upload.get("duplicate_action") == "replaced" - ): - return True - return False - - elif expected_behavior == "error": - # Check for error indicators in JSON - failed = json_data.get("failed_uploads", []) - success = json_data.get("success", True) - - # Check if upload failed - if not success: - return True - if len(failed) > 0: - # Check if error message mentions duplicates - for failure in failed: - error_msg = failure.get("error_message", "").lower() - if "duplicate" in error_msg or "already exists" in error_msg: - return True - return False - - # Fallback to regex matching if JSON data not provided - output_text = (upload_result.stdout + upload_result.stderr).lower() - - if expected_behavior == "skip": - # Look for skip indicators - skip_patterns = ["skip", "already exists", "duplicate", "existing"] - return any(pattern in output_text for pattern in skip_patterns) - - elif expected_behavior == "replace": - # Look for replace indicators - replace_patterns = ["replac", "overwrit", "updat"] - return any(pattern in output_text for pattern in replace_patterns) - - elif expected_behavior == "error": - # Look for error indicators - error_patterns = [ - "duplicate", - "error", - "already exists", - "file exists", - "conflict", - ] - return any(pattern in output_text for pattern in error_patterns) - - return False - - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests take longer due to multiple uploads and API calls -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - class TestDuplicateHandling: """ - Test class for duplicate handling policies. - - Extracted and adapted from TestOrchestrator._test_skip_duplicate_policy, - _test_replace_duplicate_policy, and _test_error_duplicate_policy methods. + Test class for duplicate handling policies using direct module invocation. """ @pytest.mark.timeout(180) def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_path): """ - Test skip duplicate policy using subprocess execution. + Test skip duplicate policy - should skip uploading existing files. Args: gitlab_client: GitLab test client fixture @@ -198,37 +87,29 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa # Create test file test_file = artifact_manager.create_test_file( - filename="duplicate-skip-test.txt", size_bytes=2048, content_pattern="text" + filename="duplicate-skip-module.txt", size_bytes=2048, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("skip-duplicate", "1.0.0") - - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) + package_name = gitlab_client.create_test_package("skip-duplicate-module", "1.0.0") + + executor = ModuleExecutor() - # Add GitLab token to environment - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} + # First upload - should succeed as new file + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) - first_upload_result = executor.execute_upload(first_upload_execution) + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate first upload succeeded assert first_upload_result.json_data is not None @@ -243,9 +124,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa assert first_upload_result.success, ( f"First upload failed: {first_upload_result.error_message}" ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) + assert first_upload_result.exit_code == 0 first_validation = _validate_upload_consistency( gitlab_client, @@ -254,44 +133,23 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa test_file.path.name, test_file.checksum, ) - assert first_validation, ( - "First upload validation failed using upload script consistency logic" - ) + assert first_validation, "First upload validation failed" # Wait to ensure first upload is processed time.sleep(2) - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, + # Second upload - should skip duplicate + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) - # Validate second upload succeeded (skip behavior) assert second_upload_result.success, ( f"Second upload failed: {second_upload_result.error_message}" ) - assert second_upload_result.exit_code == 0, ( - f"Unexpected exit code: {second_upload_result.exit_code}" - ) + assert second_upload_result.exit_code == 0 registry_verification = _validate_upload_consistency( gitlab_client, @@ -300,9 +158,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa test_file.path.name, test_file.checksum, ) - assert registry_verification, ( - "Registry verification failed after skip duplicate test using upload script consistency logic" - ) + assert registry_verification, "Registry verification failed after skip duplicate test" assert second_upload_result.json_data is not None assert ( @@ -333,39 +189,31 @@ def test_replace_duplicate_policy( # Create first test file first_test_file = artifact_manager.create_test_file( - filename="duplicate-replace-test.txt", + filename="duplicate-replace-module.txt", size_bytes=1024, content_pattern="text", ) # Create unique package name - package_name = gitlab_client.create_test_package("replace-duplicate", "1.0.0") + package_name = gitlab_client.create_test_package("replace-duplicate-module", "1.0.0") + + executor = ModuleExecutor() # First upload - should succeed - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(first_test_file.path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(first_test_file.path)], + project_path=project_path, + duplicate_policy="replace", + json_output=True, ) - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - first_upload_result = executor.execute_upload(first_upload_execution) + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate first upload succeeded assert first_upload_result.json_data is not None @@ -376,48 +224,33 @@ def test_replace_duplicate_policy( ) assert first_upload_result.json_data["success"] is True assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) + assert first_upload_result.success # Wait a moment to ensure the first upload is processed time.sleep(2) # Create second test file with same name but different content second_test_file = artifact_manager.create_test_file( - filename="duplicate-replace-test.txt", + filename="duplicate-replace-module.txt", size_bytes=2048, # Different size content_pattern="json", # Different content pattern ) # Second upload with same filename but different content - should replace - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(second_test_file.path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, + second_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(second_test_file.path)], + project_path=project_path, + duplicate_policy="replace", + json_output=True, ) - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) + second_upload_result = executor.execute_upload( + argv=second_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Verify that both uploads succeeded assert first_upload_result.success, ( @@ -427,7 +260,7 @@ def test_replace_duplicate_policy( f"Second upload failed: {second_upload_result.error_message}" ) - # GitLab API verification - file should exist with the second file's checksum (indicating replacement) + # GitLab API verification - file should exist with the second file's checksum api_verification = _validate_upload_consistency( gitlab_client, package_name, @@ -435,9 +268,7 @@ def test_replace_duplicate_policy( second_test_file.path.name, second_test_file.checksum, ) - assert api_verification, ( - "GitLab API verification failed - file was not replaced using upload script consistency logic" - ) + assert api_verification, "GitLab API verification failed - file was not replaced" # Additional check: download and verify content matches second file download_verification = gitlab_client.download_and_verify( @@ -463,9 +294,7 @@ def test_replace_duplicate_policy( ), None, ) - assert replaced_upload is not None, ( - "Expected replaced upload in successful_uploads" - ) + assert replaced_upload is not None, "Expected replaced upload in successful_uploads" assert replaced_upload["target_filename"] == second_test_file.path.name @pytest.mark.timeout(180) @@ -485,37 +314,29 @@ def test_error_duplicate_policy( # Create test file test_file = artifact_manager.create_test_file( - filename="duplicate-error-test.txt", size_bytes=1536, content_pattern="text" + filename="duplicate-error-module.txt", size_bytes=1536, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("error-duplicate", "1.0.0") + package_name = gitlab_client.create_test_package("error-duplicate-module", "1.0.0") + + executor = ModuleExecutor() # First upload - should succeed - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "error", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="error", + json_output=True, ) - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - first_upload_result = executor.execute_upload(first_upload_execution) + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate first upload succeeded assert first_upload_result.json_data is not None @@ -526,42 +347,20 @@ def test_error_duplicate_policy( ) assert first_upload_result.json_data["success"] is True assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) + assert first_upload_result.success + assert first_upload_result.exit_code == 0 # Wait a moment to ensure the first upload is processed time.sleep(2) # Second upload with same file - should fail due to error policy - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "error", - "--json-output", - ] - + get_project_args(project_path), + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=120, use_json_output=True, ) - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) - # For error policy, we expect the second upload to fail assert not second_upload_result.success, ( "Second upload should have failed with error policy but succeeded" @@ -591,12 +390,7 @@ def test_error_duplicate_policy( test_file.path.name, test_file.checksum, ) - assert api_verification, ( - "GitLab API verification failed - original file should still exist using upload script consistency logic" - ) - - -# Additional test functions for edge cases and specific duplicate scenarios + assert api_verification, "Original file should still exist" @pytest.mark.slow @@ -615,38 +409,32 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ # Create test files test_files = [ - artifact_manager.create_test_file("multi-skip-1.txt", 1024, "text"), - artifact_manager.create_test_file("multi-skip-2.json", 2048, "json"), - artifact_manager.create_test_file("multi-skip-3.bin", 512, "binary"), + artifact_manager.create_test_file("multi-skip-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("multi-skip-module-2.json", 2048, "json"), + artifact_manager.create_test_file("multi-skip-module-3.bin", 512, "binary"), ] # Create unique package name - package_name = gitlab_client.create_test_package("multi-skip-duplicate", "1.0.0") + package_name = gitlab_client.create_test_package("multi-skip-duplicate-module", "1.0.0") - # First upload - all files should succeed - executor = ScriptExecutor() + executor = ModuleExecutor() file_paths = [str(f.path) for f in test_files] - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, + + # First upload - all files should succeed + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, ) - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - first_upload_result = executor.execute_upload(first_upload_execution) + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate first upload succeeded assert first_upload_result.json_data is not None @@ -658,42 +446,24 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ assert first_upload_result.json_data["success"] is True assert first_upload_result.json_data["statistics"]["new_uploads"] == 3 assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) + assert first_upload_result.success # Wait for processing time.sleep(3) # Second upload with same files - should skip all duplicates - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - second_upload_result = executor.execute_upload(second_upload_execution) - # Validate second upload succeeded (skip behavior) assert second_upload_result.success, ( f"Second upload failed: {second_upload_result.error_message}" ) - # Verify all files still exist in registry using comprehensive validation + # Verify all files still exist in registry registry_failures = [] for test_file in test_files: registry_verification = _validate_upload_consistency( @@ -739,37 +509,31 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ # Create initial test files initial_files = [ - artifact_manager.create_test_file("mixed-1.txt", 1024, "text"), - artifact_manager.create_test_file("mixed-2.json", 2048, "json"), + artifact_manager.create_test_file("mixed-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("mixed-module-2.json", 2048, "json"), ] # Create unique package name - package_name = gitlab_client.create_test_package("mixed-duplicate", "1.0.0") + package_name = gitlab_client.create_test_package("mixed-duplicate-module", "1.0.0") - # First upload - initial files - executor = ScriptExecutor() + executor = ModuleExecutor() initial_file_paths = [str(f.path) for f in initial_files] - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + initial_file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, + + # First upload - initial files + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=initial_file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, ) - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - first_upload_result = executor.execute_upload(first_upload_execution) + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate first upload succeeded assert first_upload_result.json_data is not None @@ -781,51 +545,42 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ assert first_upload_result.json_data["success"] is True assert first_upload_result.json_data["statistics"]["new_uploads"] == 2 assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) + assert first_upload_result.success # Wait for processing time.sleep(2) # Create additional new files new_files = [ - artifact_manager.create_test_file("mixed-3.bin", 512, "binary"), - artifact_manager.create_test_file("mixed-4.xml", 1536, "xml"), + artifact_manager.create_test_file("mixed-module-3.bin", 512, "binary"), + artifact_manager.create_test_file("mixed-module-4.xml", 1536, "text"), ] # Second upload with mix of duplicate and new files all_files = initial_files + new_files all_file_paths = [str(f.path) for f in all_files] - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + all_file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, + second_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=all_file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, ) - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - second_upload_result = executor.execute_upload(second_upload_execution) + second_upload_result = executor.execute_upload( + argv=second_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate second upload succeeded assert second_upload_result.success, ( f"Second upload failed: {second_upload_result.error_message}" ) - # Verify all files exist in registry (duplicates skipped, new files uploaded) using comprehensive validation + # Verify all files exist in registry registry_failures = [] for test_file in all_files: registry_verification = _validate_upload_consistency( diff --git a/tests/test_integration.py b/tests/integration/test_end_to_end.py similarity index 67% rename from tests/test_integration.py rename to tests/integration/test_end_to_end.py index c0feb9c..fc275a4 100644 --- a/tests/test_integration.py +++ b/tests/integration/test_end_to_end.py @@ -1,16 +1,21 @@ """ -Integration tests for GitLab upload script. +End-to-end integration tests using direct module invocation. -This module contains comprehensive integration tests extracted from the monolithic -test file. These tests validate end-to-end scenarios, error handling, and overall -test coverage to ensure the upload script works correctly in real-world conditions. +This module contains comprehensive integration tests that validate complete +workflows, error handling, coverage verification, and parallel execution +safety by calling the CLI main() function directly. """ +import os +import secrets from pathlib import Path import pytest -from .utils.test_helpers import ScriptExecutor, UploadExecution, get_project_args +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) # Test markers for categorization pytestmark = [ @@ -25,60 +30,53 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec """ Test comprehensive upload validation covering all major scenarios. - Extracted from TestOrchestrator._test_comprehensive_upload_validation + This test validates single file, multiple files, and directory uploads + in a single comprehensive test. """ - executor = ScriptExecutor() + executor = ModuleExecutor() # Create a variety of test files and scenarios single_file = artifact_manager.create_test_file( - "comprehensive-single.txt", 1024, "text" + "comprehensive-single-module.txt", 1024, "text" ) multiple_files = [ - artifact_manager.create_test_file("comp-multi-1.json", 2048, "json"), - artifact_manager.create_test_file("comp-multi-2.bin", 4096, "binary"), - artifact_manager.create_test_file("comp-multi-3.csv", 1536, "text"), + artifact_manager.create_test_file("comp-multi-module-1.json", 2048, "json"), + artifact_manager.create_test_file("comp-multi-module-2.bin", 4096, "binary"), + artifact_manager.create_test_file("comp-multi-module-3.csv", 1536, "text"), ] - directory_files = artifact_manager.create_test_directory("comp-directory", 3) - directory_path = artifact_manager.base_dir / "comp-directory" + directory_files = artifact_manager.create_test_directory("comp-directory-module", 3) + directory_path = artifact_manager.base_dir / "comp-directory-module" # Set up GitLab client with project gitlab_client.set_project(project_path) # Create unique package names for each scenario - single_package = gitlab_client.create_test_package("comp-single", "1.0.0") - multi_package = gitlab_client.create_test_package("comp-multi", "1.0.0") - dir_package = gitlab_client.create_test_package("comp-dir", "1.0.0") + single_package = gitlab_client.create_test_package("comp-single-module", "1.0.0") + multi_package = gitlab_client.create_test_package("comp-multi-module", "1.0.0") + dir_package = gitlab_client.create_test_package("comp-dir-module", "1.0.0") # Test 1: Single file upload - single_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - single_package, - "--version", - "1.0.0", - "--files", - str(single_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + single_argv = executor.build_argv( + package_name=single_package, + version="1.0.0", + files=[str(single_file.path)], + project_path=project_path, + json_output=True, + ) + + single_result = executor.execute_upload( + argv=single_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - single_result = executor.execute_upload(single_upload_execution) assert single_result.success, ( f"Single file upload failed: {single_result.error_message}" ) # Validate JSON output - from .utils.test_helpers import validate_json_result - assert single_result.json_data is not None, "JSON output not available" assert validate_json_result( single_result.json_data, @@ -106,26 +104,20 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec # Test 2: Multiple files upload multi_file_paths = [str(f.path) for f in multiple_files] - multi_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - multi_package, - "--version", - "1.0.0", - "--files", - ] - + multi_file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + multi_argv = executor.build_argv( + package_name=multi_package, + version="1.0.0", + files=multi_file_paths, + project_path=project_path, + json_output=True, + ) + + multi_result = executor.execute_upload( + argv=multi_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - multi_result = executor.execute_upload(multi_upload_execution) assert multi_result.success, ( f"Multiple files upload failed: {multi_result.error_message}" ) @@ -157,26 +149,20 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec ), f"Multiple files validation failed for {test_file.path.name}" # Test 3: Directory upload - dir_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - dir_package, - "--version", - "1.0.0", - "--directory", - str(directory_path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + dir_argv = executor.build_argv( + package_name=dir_package, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + json_output=True, + ) + + dir_result = executor.execute_upload( + argv=dir_argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - dir_result = executor.execute_upload(dir_upload_execution) assert dir_result.success, f"Directory upload failed: {dir_result.error_message}" # Validate JSON output @@ -234,39 +220,28 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path Test comprehensive error scenario validation. This test validates that various error scenarios are handled correctly - and produce appropriate error messages and exit codes. Tests include: - 1. Invalid file paths - 2. Permission errors - 3. Network connectivity issues - 4. Authentication failures - 5. Invalid project specifications - - Extracted from TestOrchestrator._test_error_scenario_validation + and produce appropriate error messages and exit codes. """ - executor = ScriptExecutor() + executor = ModuleExecutor() gitlab_client.set_project(project_path) test_results = [] # Error scenario 1: Invalid file path - command = executor.build_command( - package_name="error-test", + argv = executor.build_argv( + package_name="error-test-module", version="1.0.0", files=["/nonexistent/invalid/file.txt"], project_path=project_path, duplicate_policy="skip", - use_json_output=True, + json_output=True, ) result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, ) test_results.append(("invalid_file_path", result)) @@ -279,33 +254,24 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path assert result.json_data["success"] is False assert result.json_data["exit_code"] == 1 assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid file path" - ) # Error scenario 2: Invalid project path - test_artifact = artifact_manager.create_test_file("valid.txt", 512, "text") + test_artifact = artifact_manager.create_test_file("valid-module.txt", 512, "text") - command = executor.build_command( - package_name="error-test", + argv = executor.build_argv( + package_name="error-test-module", version="1.0.0", files=[str(test_artifact.path)], project_path="nonexistent/invalid-project-12345", duplicate_policy="skip", - use_json_output=True, + json_output=True, ) result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, ) test_results.append(("invalid_project_path", result)) @@ -318,34 +284,25 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path assert result.json_data["success"] is False assert result.json_data["exit_code"] == 1 assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid project path" - ) # Error scenario 3: Invalid GitLab URL - test_artifact2 = artifact_manager.create_test_file("valid2.txt", 512, "text") + test_artifact2 = artifact_manager.create_test_file("valid2-module.txt", 512, "text") - command = executor.build_command( - package_name="error-test", + argv = executor.build_argv( + package_name="error-test-module", version="1.0.0", files=[str(test_artifact2.path)], project_path=project_path, gitlab_url="https://invalid-gitlab-instance-12345.com", duplicate_policy="skip", - use_json_output=True, + json_output=True, ) result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, ) test_results.append(("invalid_gitlab_url", result)) @@ -358,16 +315,10 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path assert result.json_data["success"] is False assert result.json_data["exit_code"] == 1 assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid GitLab URL" - ) # Error scenario 4: Missing required arguments - # Try to build command without required package name with pytest.raises((ValueError, TypeError)): - executor.build_command( + executor.build_argv( package_name="", # Empty package name should cause error version="1.0.0", files=["dummy.txt"], @@ -376,16 +327,10 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path # Validate that all error scenarios produced appropriate responses for scenario_name, result in test_results: - # Check that exit code indicates failure assert result.exit_code != 0, ( f"Scenario {scenario_name} exit code should be non-zero" ) - # Check that some error information is provided - assert result.stderr or result.error_message or result.stdout, ( - f"Scenario {scenario_name} provided no error information" - ) - @pytest.mark.integration @pytest.mark.timeout(60) @@ -394,11 +339,7 @@ def test_coverage_verification(): Test coverage verification to ensure all required functionality is tested. This test verifies that the test suite covers all required functionality - by checking that all major features have been tested and that the - test results provide comprehensive coverage of the upload script's - capabilities. - - Extracted from TestOrchestrator._test_coverage_verification + by checking that all major test modules exist. """ # Define required test coverage areas required_coverage = { @@ -419,16 +360,14 @@ def test_coverage_verification(): "error_scenario_validation": False, } - # In a real implementation, this would check the results of previously run tests - # For now, we'll simulate checking test module existence and basic functionality - # Check that test modules exist test_modules = [ - "test_basic_uploads.py", + "test_single_file_upload.py", + "test_multiple_files_upload.py", "test_duplicate_handling.py", "test_project_resolution.py", "test_error_scenarios.py", - "test_integration.py", + "test_end_to_end.py", ] tests_dir = Path(__file__).parent @@ -440,8 +379,9 @@ def test_coverage_verification(): existing_modules.append(module) # Mark coverage areas as covered based on module existence - if module == "test_basic_uploads.py": + if module == "test_single_file_upload.py": required_coverage["single_file_upload"] = True + elif module == "test_multiple_files_upload.py": required_coverage["multiple_file_upload"] = True required_coverage["directory_upload"] = True required_coverage["file_mapping"] = True @@ -457,7 +397,7 @@ def test_coverage_verification(): required_coverage["error_handling"] = True required_coverage["network_failure"] = True required_coverage["authentication_error"] = True - elif module == "test_integration.py": + elif module == "test_end_to_end.py": required_coverage["comprehensive_validation"] = True required_coverage["error_scenario_validation"] = True @@ -472,7 +412,6 @@ def test_coverage_verification(): ] # Determine success criteria - # Require at least 80% coverage for success, with all critical areas covered critical_areas = [ "single_file_upload", "multiple_file_upload", @@ -486,17 +425,6 @@ def test_coverage_verification(): ) sufficient_coverage = coverage_percentage >= 80.0 - # Generate detailed coverage report (for potential future use) - # coverage_report = { - # "total_areas": total_areas, - # "covered_areas": covered_areas, - # "coverage_percentage": coverage_percentage, - # "missing_coverage": missing_coverage, - # "critical_areas_covered": critical_covered, - # "detailed_coverage": required_coverage, - # "existing_modules": existing_modules, - # } - print( f"Test coverage: {covered_areas}/{total_areas} areas ({coverage_percentage:.1f}%)" ) @@ -513,8 +441,6 @@ def test_coverage_verification(): f"Insufficient coverage: {coverage_percentage:.1f}% (need 80%)" ) - print("✓ Test coverage verification passed") - @pytest.mark.integration @pytest.mark.slow @@ -524,47 +450,37 @@ def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project Test end-to-end workflow validation with comprehensive cleanup verification. This test validates the complete workflow from file creation through upload - to cleanup, ensuring that all components work together correctly and that - cleanup operations function properly in the pytest context. - + to cleanup, ensuring that all components work together correctly. """ - executor = ScriptExecutor() + executor = ModuleExecutor() gitlab_client.set_project(project_path) # Create test artifacts test_files = [ - artifact_manager.create_test_file("workflow-test-1.txt", 1024, "text"), - artifact_manager.create_test_file("workflow-test-2.json", 2048, "json"), - artifact_manager.create_test_file("workflow-test-3.bin", 512, "binary"), + artifact_manager.create_test_file("workflow-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("workflow-module-2.json", 2048, "json"), + artifact_manager.create_test_file("workflow-module-3.bin", 512, "binary"), ] # Create unique package for this workflow test - package_name = gitlab_client.create_test_package("workflow-validation", "1.0.0") + package_name = gitlab_client.create_test_package("workflow-validation-module", "1.0.0") # Execute upload - from .utils.test_helpers import validate_json_result - file_paths = [str(f.path) for f in test_files] - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result = executor.execute_upload(upload_execution) assert result.success, f"End-to-end upload failed: {result.error_message}" # Validate JSON output @@ -591,14 +507,13 @@ def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project ), f"End-to-end verification failed for {test_file.path.name}" # Test cleanup verification - this will be handled by fixtures - # but we can verify that the artifacts exist before cleanup for test_file in test_files: assert test_file.path.exists(), ( f"Test artifact {test_file.path} should exist before cleanup" ) print( - f"✓ End-to-end workflow validation completed successfully for package {package_name}" + f"End-to-end workflow validation completed successfully for package {package_name}" ) @@ -609,51 +524,38 @@ def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path Test that integration tests can run safely in parallel without conflicts. This test validates that the test infrastructure properly isolates tests - when running in parallel using pytest-xdist, ensuring no race conditions - or shared state issues occur. - + when running in parallel using pytest-xdist. """ - executor = ScriptExecutor() + executor = ModuleExecutor() gitlab_client.set_project(project_path) # Create unique test artifacts with process-specific naming - import os - import secrets - process_id = os.getpid() random_suffix = secrets.token_hex(4) - unique_prefix = f"parallel-{process_id}-{random_suffix}" + unique_prefix = f"parallel-module-{process_id}-{random_suffix}" test_file = artifact_manager.create_test_file( f"{unique_prefix}-test.txt", 1024, "text" ) package_name = gitlab_client.create_test_package( - f"parallel-test-{random_suffix}", "1.0.0" + f"parallel-test-module-{random_suffix}", "1.0.0" ) # Execute upload with unique identifiers - from .utils.test_helpers import validate_json_result - - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result = executor.execute_upload(upload_execution) assert result.success, f"Parallel execution test failed: {result.error_message}" # Validate JSON output @@ -676,7 +578,7 @@ def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path package_name, "1.0.0", test_file.path.name, test_file.checksum ), "Parallel execution upload verification failed" - print(f"✓ Parallel execution safety test completed for process {process_id}") + print(f"Parallel execution safety test completed for process {process_id}") @pytest.mark.integration @@ -689,11 +591,9 @@ def test_comprehensive_cleanup_verification( Test comprehensive cleanup verification to ensure all test artifacts are properly cleaned up. This test validates that the pytest fixture cleanup mechanisms work correctly - and that no test artifacts are left behind after test execution, preserving - the cleanup verification functionality from the original monolithic test. - + and that no test artifacts are left behind after test execution. """ - executor = ScriptExecutor() + executor = ModuleExecutor() gitlab_client.set_project(project_path) # Track initial state @@ -704,14 +604,14 @@ def test_comprehensive_cleanup_verification( test_files = [] for i in range(3): test_file = artifact_manager.create_test_file( - f"cleanup-test-{i}.txt", 1024, "text" + f"cleanup-module-{i}.txt", 1024, "text" ) test_files.append(test_file) # Create test packages that should be cleaned up package_names = [] for i in range(2): - package_name = gitlab_client.create_test_package(f"cleanup-test-{i}", "1.0.0") + package_name = gitlab_client.create_test_package(f"cleanup-module-{i}", "1.0.0") package_names.append(package_name) # Verify artifacts were created @@ -727,29 +627,21 @@ def test_comprehensive_cleanup_verification( assert test_file.path.exists(), f"Test file {test_file.path} should exist" # Perform some uploads to create actual GitLab packages - from .utils.test_helpers import validate_json_result - for i, package_name in enumerate(package_names): - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_files[i].path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=240, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_files[i].path)], + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result = executor.execute_upload(upload_execution) assert result.success, ( f"Upload failed for cleanup test package {package_name}: {result.error_message}" ) @@ -765,19 +657,12 @@ def test_comprehensive_cleanup_verification( assert result.json_data["statistics"]["new_uploads"] == 1 assert len(result.json_data["successful_uploads"]) == 1 - # Verify uploaded filename appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in result.json_data["successful_uploads"] - ] - assert test_files[i].path.name in uploaded_filenames - # Verify upload was successful via GitLab API assert gitlab_client.verify_upload( package_name, "1.0.0", test_files[i].path.name, test_files[i].checksum ), f"Upload verification failed for cleanup test package {package_name}" - # Test manual cleanup to verify it works (fixtures will also clean up automatically) + # Test manual cleanup to verify it works artifact_successful, artifact_failed = artifact_manager.cleanup_artifacts( force=True ) @@ -805,7 +690,7 @@ def test_comprehensive_cleanup_verification( "GitLab client should have no tracked packages after cleanup" ) - print("✓ Comprehensive cleanup verification completed successfully") + print("Comprehensive cleanup verification completed successfully") @pytest.mark.integration @@ -818,44 +703,33 @@ def test_multi_scenario_workflow_validation( Test multi-scenario workflow validation combining different upload types and policies. This test validates complex workflows that combine multiple upload scenarios, - duplicate policies, and error conditions to ensure the system handles - real-world usage patterns correctly. - + duplicate policies, and error conditions. """ - executor = ScriptExecutor() + executor = ModuleExecutor() gitlab_client.set_project(project_path) # Scenario 1: Upload with skip duplicate policy - from .utils.test_helpers import validate_json_result - test_file_1 = artifact_manager.create_test_file( - "multi-scenario-1.txt", 1024, "text" + "multi-scenario-module-1.txt", 1024, "text" ) - package_name_1 = gitlab_client.create_test_package("multi-scenario-skip", "1.0.0") + package_name_1 = gitlab_client.create_test_package("multi-scenario-skip-module", "1.0.0") # First upload - upload_execution_1 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_1, - "--version", - "1.0.0", - "--files", - str(test_file_1.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + argv_1 = executor.build_argv( + package_name=package_name_1, + version="1.0.0", + files=[str(test_file_1.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + result_1 = executor.execute_upload( + argv=argv_1, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result_1 = executor.execute_upload(upload_execution_1) assert result_1.success, f"First upload failed: {result_1.error_message}" # Validate JSON output for first upload @@ -869,7 +743,12 @@ def test_multi_scenario_workflow_validation( assert result_1.json_data["statistics"]["new_uploads"] == 1 # Second upload (should skip duplicate) - result_1_dup = executor.execute_upload(upload_execution_1) + result_1_dup = executor.execute_upload( + argv=argv_1, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + assert result_1_dup.success, ( f"Duplicate upload with skip policy failed: {result_1_dup.error_message}" ) @@ -880,34 +759,27 @@ def test_multi_scenario_workflow_validation( assert result_1_dup.json_data["statistics"]["skipped_duplicates"] == 1 # Scenario 2: Directory upload with replace policy - directory_files = artifact_manager.create_test_directory("multi-scenario-dir", 2) - directory_path = artifact_manager.base_dir / "multi-scenario-dir" + directory_files = artifact_manager.create_test_directory("multi-scenario-dir-module", 2) + directory_path = artifact_manager.base_dir / "multi-scenario-dir-module" package_name_2 = gitlab_client.create_test_package( - "multi-scenario-replace", "1.0.0" - ) - - upload_execution_2 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_2, - "--version", - "1.0.0", - "--directory", - str(directory_path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + "multi-scenario-replace-module", "1.0.0" + ) + + argv_2 = executor.build_argv( + package_name=package_name_2, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + duplicate_policy="replace", + json_output=True, + ) + + result_2 = executor.execute_upload( + argv=argv_2, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result_2 = executor.execute_upload(upload_execution_2) assert result_2.success, f"Directory upload failed: {result_2.error_message}" # Validate JSON output for directory upload @@ -929,32 +801,27 @@ def test_multi_scenario_workflow_validation( # Scenario 3: Multiple files with error handling multiple_files = [ - artifact_manager.create_test_file("multi-scenario-3a.json", 2048, "json"), - artifact_manager.create_test_file("multi-scenario-3b.bin", 1024, "binary"), + artifact_manager.create_test_file("multi-scenario-module-3a.json", 2048, "json"), + artifact_manager.create_test_file("multi-scenario-module-3b.bin", 1024, "binary"), ] - package_name_3 = gitlab_client.create_test_package("multi-scenario-multi", "1.0.0") + package_name_3 = gitlab_client.create_test_package("multi-scenario-multi-module", "1.0.0") file_paths = [str(f.path) for f in multiple_files] - upload_execution_3 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_3, - "--version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "error", "--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, + argv_3 = executor.build_argv( + package_name=package_name_3, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="error", + json_output=True, + ) + + result_3 = executor.execute_upload( + argv=argv_3, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - result_3 = executor.execute_upload(upload_execution_3) assert result_3.success, f"Multiple files upload failed: {result_3.error_message}" # Validate JSON output for multiple files upload @@ -989,5 +856,5 @@ def test_multi_scenario_workflow_validation( total_files = 1 + len(directory_files) + len(multiple_files) print( - f"✓ Multi-scenario workflow validation completed successfully for {total_files} files across 3 scenarios" + f"Multi-scenario workflow validation completed successfully for {total_files} files across 3 scenarios" ) diff --git a/tests/integration/test_environment_validation.py b/tests/integration/test_environment_validation.py new file mode 100644 index 0000000..0463fc2 --- /dev/null +++ b/tests/integration/test_environment_validation.py @@ -0,0 +1,122 @@ +""" +Environment validation tests for the integration test suite. + +This module contains tests that verify the integration test environment +validation mechanism works correctly. These tests serve as both documentation +and verification that the validation fixture properly detects: + - GITLAB_TOKEN environment variable + - Git repository presence + - GitLab remote configuration + +These tests run quickly and help users understand what environment setup +is required for the full integration test suite. +""" + +import os + +import pytest + +from glpkg.cli.upload import GitAutoDetector + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, + pytest.mark.fast, # These tests are quick validation checks +] + + +class TestEnvironmentValidation: + """ + Test class for environment validation functionality. + + These tests verify that the integration test environment is properly + configured and that the validation fixtures work correctly. + """ + + @pytest.mark.timeout(30) + def test_gitlab_token_is_set(self): + """ + Test that GITLAB_TOKEN environment variable is set. + + This test verifies that the GitLab API token is available. + The session-scoped validation fixture ensures this test only runs + if the token is present, so this test documents the requirement. + """ + token = os.environ.get("GITLAB_TOKEN") + assert token is not None, "GITLAB_TOKEN should be set" + assert len(token) > 0, "GITLAB_TOKEN should not be empty" + + @pytest.mark.timeout(30) + def test_git_repository_detected(self): + """ + Test that a Git repository is detected in the current environment. + + This test verifies that GitAutoDetector can find a Git repository. + """ + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is not None, "Git repository should be detected" + assert repo.working_dir is not None, "Repository should have a working directory" + + @pytest.mark.timeout(30) + def test_gitlab_remotes_detected(self): + """ + Test that GitLab remotes are detected in the Git repository. + + This test verifies that at least one GitLab remote is configured. + """ + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is not None, "Git repository should be detected" + + # This will raise ProjectResolutionError if no GitLab remotes found + gitlab_remotes = detector.get_gitlab_remotes(repo) + + assert len(gitlab_remotes) > 0, "At least one GitLab remote should be detected" + + # Verify remote structure + for remote in gitlab_remotes: + assert remote.name is not None, "Remote should have a name" + assert remote.project_path is not None, "Remote should have a project path" + assert remote.gitlab_url is not None, "Remote should have a GitLab URL" + + @pytest.mark.timeout(30) + def test_environment_validation_fixture_ran(self, validate_integration_environment): + """ + Test that the environment validation fixture executed successfully. + + This test explicitly requests the validation fixture to verify + it completes without skipping tests. + """ + # If we reach this point, the fixture ran successfully + # The fixture yields after validation, so this test body executes + # only if all validation checks passed + assert True, "Environment validation fixture completed successfully" + + +@pytest.mark.timeout(30) +def test_project_path_fixture_available(project_path): + """ + Test that the project_path fixture provides a valid project path. + + This test verifies that the project path can be resolved either + from Git auto-detection or from manual specification. + """ + assert project_path is not None, "project_path fixture should provide a value" + assert len(project_path) > 0, "project_path should not be empty" + assert "/" in project_path, "project_path should be in 'namespace/project' format" + + +@pytest.mark.timeout(30) +def test_gitlab_client_fixture_available(gitlab_client): + """ + Test that the gitlab_client fixture provides a usable client. + + This test verifies that the GitLab client fixture is properly + configured and can communicate with the GitLab API. + """ + assert gitlab_client is not None, "gitlab_client fixture should provide a client" + # Basic check that client has expected attributes + assert hasattr(gitlab_client, "gitlab_url"), "Client should have gitlab_url attribute" diff --git a/tests/integration/test_error_scenarios.py b/tests/integration/test_error_scenarios.py new file mode 100644 index 0000000..d0b727a --- /dev/null +++ b/tests/integration/test_error_scenarios.py @@ -0,0 +1,638 @@ +""" +Error scenario integration tests using direct module invocation. + +This module tests network failures, authentication errors, error message +validation, failure continuation behavior, and non-ASCII filename rejection +by calling the CLI main() function directly. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.slow, # These tests simulate failures and take longer +] + + +class TestErrorScenarios: + """ + Test class for error scenario handling using direct module invocation. + """ + + @pytest.mark.timeout(90) + def test_network_failure_simulation( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test network failure simulation with invalid GitLab URL. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + "network-test-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("network-failure-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv with invalid GitLab URL to simulate network failure + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--gitlab-url", "https://invalid-gitlab-url.example.com", + "--project-path", project_path, + "--token", os.environ.get("GITLAB_TOKEN"), + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (should fail due to network issues) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": ""}, # Clear GITLAB_TOKEN to force use of --token argument + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for network-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + network_keywords = [ + "network", + "connection", + "timeout", + "failed to connect", + "resolve", + "dns", + ] + network_error_found = any( + keyword in error_msg for keyword in network_keywords + ) + assert network_error_found, ( + f"Expected network error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + network_error_patterns = [ + "network", + "connection", + "timeout", + "failed to connect", + ] + network_error_found = any( + pattern in error_output.lower() for pattern in network_error_patterns + ) + assert network_error_found, ( + f"Expected network error patterns in output: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_authentication_error(self, gitlab_client, artifact_manager, project_path): + """ + Test authentication error handling with invalid token. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="auth-error-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("auth-error-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv with invalid token + invalid_token = "invalid-token-that-should-fail-authentication" + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--token", invalid_token, + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (should fail due to authentication issues) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": ""}, # Clear GITLAB_TOKEN to force use of --token argument + expected_exit_code=1, + use_json_output=True, + ) + + # Validate that the upload failed as expected + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") != 0 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for authentication-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + auth_keywords = [ + "authentication", + "token", + "unauthorized", + "401", + "403", + "access denied", + ] + auth_error_found = any(keyword in error_msg for keyword in auth_keywords) + assert auth_error_found, ( + f"Expected authentication error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + auth_error_indicators = [ + "authentication", + "token", + "unauthorized", + "401", + "403", + "access denied", + ] + auth_error_present = any( + indicator in error_output.lower() for indicator in auth_error_indicators + ) + assert auth_error_present, ( + f"Expected authentication error patterns in output: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_error_message_validation( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test error message validation for various error scenarios. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + executor = ModuleExecutor() + + # Test scenario 1: Non-existent file + nonexistent_file = str(artifact_manager.base_dir / "nonexistent-module-file.txt") + package_name = gitlab_client.create_test_package("error-msg-module", "1.0.0") + + # Build argv with non-existent file + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[nonexistent_file], + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to missing file) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate error message quality + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + # Check for file-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + file_keywords = ["file", "not found", "does not exist", "missing"] + file_error_found = any(keyword in error_msg for keyword in file_keywords) + assert file_error_found, ( + f"Expected file error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + file_error_patterns = [ + "file", + "not found", + "does not exist", + "missing", + ] + file_error_found = any( + pattern in error_output.lower() for pattern in file_error_patterns + ) + assert file_error_found, ( + f"Expected file error patterns in output: {error_output}" + ) + + # Test scenario 2: Invalid project path + test_file = artifact_manager.create_test_file( + filename="error-msg-test2-module.txt", size_bytes=512, content_pattern="text" + ) + + invalid_project_path = "invalid/nonexistent-project" + + # Build argv with invalid project path + argv2 = executor.build_argv( + package_name=package_name, + version="1.0.1", + files=[str(test_file.path)], + project_path=invalid_project_path, + json_output=True, + ) + + # Execute upload (should fail due to invalid project) + upload_result2 = executor.execute_upload( + argv=argv2, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate second error scenario + assert upload_result2.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result2.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result2.json_data is not None: + assert upload_result2.json_data.get("success") is False + assert "error" in upload_result2.json_data + + # Check for project-related keywords in error message + error_msg2 = upload_result2.json_data["error"].lower() + project_keywords = ["project", "404", "not found", "access", "invalid"] + project_error_found = any( + keyword in error_msg2 for keyword in project_keywords + ) + assert project_error_found, ( + f"Expected project error keywords in JSON error: {upload_result2.json_data['error']}" + ) + + @pytest.mark.timeout(90) + def test_failure_continuation_behavior( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test that the system continues processing after individual failures. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a mix of valid and invalid files for testing continuation behavior + valid_file = artifact_manager.create_test_file( + filename="valid-continuation-module.txt", + size_bytes=1024, + content_pattern="text", + ) + + nonexistent_file = str( + artifact_manager.base_dir / "nonexistent-continuation-module.txt" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package( + "failure-continuation-module", "1.0.0" + ) + + executor = ModuleExecutor() + + # Test multiple file upload with one invalid file + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--files", str(valid_file.path), nonexistent_file, + "--json-output", + ] + + # Execute upload (should fail due to invalid file) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate failure continuation behavior + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "failed_uploads" in upload_result.json_data + + # Check that the problematic file is mentioned in failed_uploads + failed_uploads = upload_result.json_data.get("failed_uploads", []) + file_mentioned = any( + "nonexistent-continuation-module.txt" in str(item).lower() + or "nonexistent" in str(item).lower() + for item in failed_uploads + ) + assert file_mentioned, ( + f"Expected problematic file in failed_uploads: {failed_uploads}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + error_mentions_file = ( + "nonexistent-continuation-module.txt" in error_output + or "nonexistent" in error_output.lower() + ) + assert error_mentions_file, ( + f"Expected error to mention the problematic file: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_non_ascii_filename_rejection( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test that non-ASCII filenames are properly rejected with detailed error messages. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file with non-ASCII filename + test_file = artifact_manager.create_test_file( + filename="unicode-名前-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("non-ascii-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to non-ASCII filename) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + + # Check for ASCII-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + ascii_keywords = ["ascii", "non-ascii", "character"] + ascii_error_found = any(keyword in error_msg for keyword in ascii_keywords) + assert ascii_error_found, ( + f"Expected ASCII-related error keywords in JSON error: {upload_result.json_data['error']}" + ) + + # Check that error message mentions the problematic filename + filename_mentioned = "名前" in upload_result.json_data["error"] + assert filename_mentioned, ( + f"Expected error to mention the problematic filename: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + ascii_error_patterns = ["ascii", "non-ascii", "character"] + ascii_error_found = any( + pattern in error_output.lower() for pattern in ascii_error_patterns + ) + assert ascii_error_found, ( + f"Expected ASCII-related error patterns in output: {error_output}" + ) + + +@pytest.mark.slow +@pytest.mark.timeout(90) +def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, project_path): + """ + Test that non-ASCII filenames in directories are properly rejected. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a temporary directory + test_dir = artifact_manager.base_dir / "non-ascii-dir-module" + test_dir.mkdir(parents=True, exist_ok=True) + + # Create a file with non-ASCII filename directly + non_ascii_filename = "unicode-测试文件-module.txt" + non_ascii_file_path = test_dir / non_ascii_filename + non_ascii_file_path.write_text("Test content with non-ASCII filename") + + # Create unique package name + package_name = gitlab_client.create_test_package("non-ascii-dir-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv to upload directory with non-ASCII filename + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + directory=str(test_dir), + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to non-ASCII filename) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + # Check that error message mentions the specific non-ASCII filename + error_msg = upload_result.json_data["error"] + filename_mentioned = ( + non_ascii_filename in error_msg + or "测试文件" in error_msg + or "unicode-" in error_msg.lower() + ) + assert filename_mentioned, ( + f"Expected error to mention the specific non-ASCII filename: {error_msg}" + ) + + # Check for ASCII-related keywords + ascii_keywords = ["ascii", "non-ascii", "character"] + ascii_error_found = any( + keyword in error_msg.lower() for keyword in ascii_keywords + ) + assert ascii_error_found, ( + f"Expected ASCII-related error keywords in JSON error: {error_msg}" + ) + + +@pytest.mark.timeout(120) +def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, project_path): + """ + Test that mixed ASCII and non-ASCII filenames are handled correctly. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create multiple test files: some with ASCII names, some with non-ASCII names + ascii_file1 = artifact_manager.create_test_file( + filename="ascii-module-1.txt", size_bytes=512, content_pattern="text" + ) + ascii_file2 = artifact_manager.create_test_file( + filename="ascii-module-2.txt", size_bytes=512, content_pattern="text" + ) + + # Create files with non-ASCII names + test_dir = artifact_manager.base_dir / "mixed-module-test" + test_dir.mkdir(parents=True, exist_ok=True) + + non_ascii_file1 = test_dir / "unicode-名前-module.txt" + non_ascii_file1.write_text("Non-ASCII content 1") + + non_ascii_file2 = test_dir / "unicode-测试-module.txt" + non_ascii_file2.write_text("Non-ASCII content 2") + + # Create unique package name + package_name = gitlab_client.create_test_package("mixed-ascii-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv to upload all files together + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--files", + str(ascii_file1.path), + str(ascii_file2.path), + str(non_ascii_file1), + str(non_ascii_file2), + "--json-output", + ] + + # Execute upload (should fail due to non-ASCII filenames) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + error_msg = upload_result.json_data["error"] + + # Check that error identifies non-ASCII filenames + non_ascii_mentioned = ( + "名前" in error_msg + or "测试" in error_msg + or "unicode-" in error_msg.lower() + ) + assert non_ascii_mentioned, ( + f"Expected error to identify non-ASCII filenames: {error_msg}" + ) + else: + # Fallback to stderr/stdout checking + error_output = upload_result.stdout + upload_result.stderr + non_ascii_mentioned = "名前" in error_output or "测试" in error_output + assert non_ascii_mentioned, ( + f"Expected error to identify non-ASCII filenames: {error_output}" + ) diff --git a/tests/integration/test_helpers_module.py b/tests/integration/test_helpers_module.py new file mode 100644 index 0000000..a2567e5 --- /dev/null +++ b/tests/integration/test_helpers_module.py @@ -0,0 +1,601 @@ +""" +Test helper utilities for module-based test execution. + +This module provides utilities for calling the CLI main() function directly +instead of using subprocess execution. It captures stdout/stderr, handles +SystemExit exceptions for exit codes, and parses JSON output. +""" + +from __future__ import annotations + +import io +import json +import os +import sys +import threading +import time +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: + pass + +# Import the main function from CLI module +from glpkg.cli.main import main + + +@dataclass +class UploadResult: + """ + Represents the result of an upload execution via module invocation. + + Args: + success: Whether the execution was successful + exit_code: Exit code from sys.exit() call + stdout: Captured standard output + stderr: Captured standard error + duration: Execution duration in seconds + uploaded_files: List of uploaded file names + upload_urls: List of upload URLs + error_message: Optional error message + json_data: Parsed JSON output when --json-output is used + Contains structured data with fields: + - success: bool + - exit_code: int + - package_name: str + - version: str + - successful_uploads: list of dicts with source_path, target_filename, download_url, was_duplicate, duplicate_action + - skipped_duplicates: list of dicts (same structure as successful_uploads) + - failed_uploads: list of dicts with source_path, target_filename, error_message + - statistics: dict with total_processed, new_uploads, replaced_duplicates, skipped_duplicates, failed_uploads + - error: str (on failure) + - error_type: str (on failure) + """ + + success: bool + exit_code: int + stdout: str + stderr: str + duration: float + uploaded_files: List[str] + upload_urls: List[str] + error_message: Optional[str] = None + json_data: Optional[Dict] = None + + def __post_init__(self): + if self.uploaded_files is None: + self.uploaded_files = [] + if self.upload_urls is None: + self.upload_urls = [] + + +class ModuleExecutor: + """ + Handles execution of the glpkg CLI via direct module invocation. + + This class calls the main() function from the CLI module directly instead + of spawning a subprocess. It captures stdout/stderr via context managers + and handles SystemExit exceptions to capture exit codes. + """ + + # Thread lock for stdout/stderr capture to ensure thread safety + _capture_lock = threading.Lock() + + def __init__(self): + """Initialize module executor.""" + pass + + def execute_upload( + self, + argv: List[str], + env_vars: Optional[Dict[str, str]] = None, + expected_exit_code: int = 0, + use_json_output: bool = False, + timeout: int = 120, + ) -> UploadResult: + """ + Execute upload by calling the main() function directly. + + Args: + argv: Command-line arguments to pass to main() (without script path) + env_vars: Optional environment variables to set during execution + expected_exit_code: Expected exit code for success determination + use_json_output: Whether JSON output mode is enabled + timeout: Execution timeout in seconds (not enforced for direct calls) + + Returns: + UploadResult with execution details + + Example: + executor = ModuleExecutor() + result = executor.execute_upload( + argv=["--package-name", "test", "--package-version", "1.0.0", + "--files", "file.txt", "--json-output"], + env_vars={"GITLAB_TOKEN": "token"}, + use_json_output=True + ) + if result.json_data: + print(result.json_data["success"]) + """ + start_time = time.time() + + # Prepare environment variables + original_env = {} + if env_vars: + for key, value in env_vars.items(): + original_env[key] = os.environ.get(key) + if value is not None: + os.environ[key] = value + elif key in os.environ: + del os.environ[key] + + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + exit_code = 0 + error_message = None + + try: + # Use lock to ensure thread-safe capture + with self._capture_lock: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + try: + main(argv) + exit_code = 0 # If main() returns normally, exit code is 0 + except SystemExit as e: + # Capture exit code from sys.exit() + exit_code = e.code if isinstance(e.code, int) else 1 + + except Exception as e: + duration = time.time() - start_time + error_message = f"Execution failed with exception: {e}" + return UploadResult( + success=False, + exit_code=-1, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + duration=duration, + error_message=error_message, + uploaded_files=[], + upload_urls=[], + json_data=None, + ) + + finally: + # Restore original environment + for key, original_value in original_env.items(): + if original_value is not None: + os.environ[key] = original_value + elif key in os.environ: + del os.environ[key] + + duration = time.time() - start_time + stdout_content = stdout_capture.getvalue() + stderr_content = stderr_capture.getvalue() + + # Parse JSON output if enabled + json_data = None + if use_json_output: + json_data = self._parse_json_output(stdout_content) + + # Extract uploaded files and URLs + if json_data is not None: + uploaded_files, upload_urls = self._extract_data_from_json(json_data) + else: + uploaded_files = self._extract_uploaded_files(stdout_content) + upload_urls = self._extract_upload_urls(stdout_content) + + # Determine success + if json_data is not None: + # Use JSON data for success determination + success = ( + json_data.get("success", False) + and exit_code == expected_exit_code + and json_data.get("exit_code", -1) == expected_exit_code + ) + else: + # Use exit code for success determination + success = exit_code == expected_exit_code + + if not success: + if json_data is not None and "error" in json_data: + error_message = ( + f"{json_data.get('error_type', 'Error')}: " + f"{json_data.get('error', 'Unknown error')}" + ) + elif exit_code != expected_exit_code: + error_message = ( + f"Unexpected exit code: {exit_code} " + f"(expected {expected_exit_code})" + ) + + if stderr_content: + if error_message: + error_message += f". Stderr: {stderr_content}" + else: + error_message = f"Stderr: {stderr_content}" + + return UploadResult( + success=success, + exit_code=exit_code, + stdout=stdout_content, + stderr=stderr_content, + duration=duration, + error_message=error_message, + uploaded_files=uploaded_files, + upload_urls=upload_urls, + json_data=json_data, + ) + + def build_argv( + self, + package_name: str, + version: str, + files: Optional[List[str]] = None, + directory: Optional[str] = None, + project_path: Optional[str] = None, + project_url: Optional[str] = None, + gitlab_url: Optional[str] = None, + duplicate_policy: str = "skip", + file_mapping: Optional[List[str]] = None, + json_output: bool = False, + dry_run: bool = False, + fail_fast: bool = False, + verbose: bool = False, + quiet: bool = False, + debug: bool = False, + retry: int = 0, + ) -> List[str]: + """ + Build command line arguments for main() function. + + Args: + package_name: Name of the package + version: Package version + files: List of file paths to upload + directory: Directory containing files to upload + project_path: GitLab project path (namespace/project) + project_url: Full GitLab project URL + gitlab_url: GitLab instance URL + duplicate_policy: Policy for handling duplicates (skip, replace, error) + file_mapping: List of file mappings in source:target format + json_output: Enable JSON output mode + dry_run: Enable dry run mode + fail_fast: Enable fail fast mode + verbose: Enable verbose output + quiet: Enable quiet output + debug: Enable debug output + retry: Number of retry attempts + + Returns: + List of command line arguments for main() + + Raises: + ValueError: If required parameters are missing or invalid + """ + # Validate required parameters + if not package_name or not package_name.strip(): + raise ValueError("package_name is required and cannot be empty") + + if not version or not version.strip(): + raise ValueError("version is required and cannot be empty") + + if not files and not directory: + raise ValueError("Either files or directory must be provided") + + # Start with the upload subcommand + argv = ["upload"] + + # Required arguments + argv.extend(["--package-name", package_name]) + argv.extend(["--package-version", version]) + + # File input + if files: + argv.append("--files") + if isinstance(files, list): + argv.extend(files) + else: + argv.append(files) + elif directory: + argv.extend(["--directory", directory]) + + # Project specification + if project_url: + argv.extend(["--project-url", project_url]) + elif project_path: + argv.extend(["--project-path", project_path]) + if gitlab_url: + argv.extend(["--gitlab-url", gitlab_url]) + + # Duplicate policy + if duplicate_policy: + argv.extend(["--duplicate-policy", duplicate_policy]) + + # File mappings + if file_mapping: + for mapping in file_mapping: + argv.extend(["--file-mapping", mapping]) + + # Output flags + if json_output: + argv.append("--json-output") + + # Operational flags + if dry_run: + argv.append("--dry-run") + if fail_fast: + argv.append("--fail-fast") + if retry > 0: + argv.extend(["--retry", str(retry)]) + + # Verbosity flags + if debug: + argv.append("--debug") + elif verbose: + argv.append("--verbose") + elif quiet: + argv.append("--quiet") + + return argv + + def _parse_json_output(self, stdout: str) -> Optional[Dict]: + """ + Parse JSON output from captured stdout. + + Args: + stdout: Captured standard output + + Returns: + Parsed JSON dictionary, or None if parsing fails + """ + if not stdout or not stdout.strip(): + return None + + try: + # Try to parse the entire stdout as JSON + parsed = json.loads(stdout) + return parsed + except json.JSONDecodeError: + # Try to find JSON in the output (in case there's other text) + try: + # Look for JSON object starting with { and ending with } + start_idx = stdout.find("{") + end_idx = stdout.rfind("}") + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + json_str = stdout[start_idx : end_idx + 1] + parsed = json.loads(json_str) + return parsed + except (json.JSONDecodeError, ValueError): + pass + + return None + + def _extract_data_from_json(self, json_data: Dict) -> tuple[List[str], List[str]]: + """ + Extract uploaded files and URLs from JSON data. + + Args: + json_data: Parsed JSON output from script + + Returns: + Tuple of (uploaded_files, upload_urls) + """ + uploaded_files = [] + upload_urls = [] + + # Handle success case + if json_data.get("success", False): + successful_uploads = json_data.get("successful_uploads", []) + for upload in successful_uploads: + if isinstance(upload, dict): + # Extract target filename + target_filename = upload.get("target_filename", "") + if target_filename: + uploaded_files.append(target_filename) + + # Extract download URL + download_url = upload.get("download_url", "") + if download_url: + upload_urls.append(download_url) + + return uploaded_files, upload_urls + + def _extract_uploaded_files(self, stdout: str) -> List[str]: + """ + Extract uploaded file names from output. + + Args: + stdout: Captured standard output + + Returns: + List of uploaded file names + """ + import re + + uploaded_files = [] + + # Pattern for successful file uploads + upload_patterns = [ + r"✓ Uploaded: (.+)", + r"Successfully uploaded: (.+)", + r"File uploaded: (.+)", + ] + + for pattern in upload_patterns: + matches = re.findall(pattern, stdout) + uploaded_files.extend(matches) + + return uploaded_files + + def _extract_upload_urls(self, stdout: str) -> List[str]: + """ + Extract upload URLs from output. + + Args: + stdout: Captured standard output + + Returns: + List of upload URLs + """ + import re + + url_pattern = r"https?://[^\s]+" + matches = re.findall(url_pattern, stdout) + + return matches + + +def get_project_args( + project_path: Optional[str] = None, + gitlab_url: Optional[str] = None, +) -> List[str]: + """ + Get project arguments for CLI invocation. + + Args: + project_path: GitLab project path + gitlab_url: GitLab instance URL (optional, only included if provided) + + Returns: + List of command-line arguments for project specification + """ + if not project_path: + return [] + + if gitlab_url is None: + return ["--project-path", project_path] + + return ["--project-path", project_path, "--gitlab-url", gitlab_url] + + +def validate_json_result( + json_data: Dict, + expected_success: bool, + expected_files: Optional[List[str]] = None, +) -> bool: + """ + Validate JSON output from upload execution. + + Args: + json_data: Parsed JSON output from execution + expected_success: Expected success status + expected_files: Optional list of expected uploaded files + + Returns: + True if validation passes, False otherwise + + Example: + result = executor.execute_upload(argv, use_json_output=True) + if result.json_data: + is_valid = validate_json_result( + result.json_data, + expected_success=True, + expected_files=["file1.txt", "file2.txt"] + ) + """ + # Validate success status + if json_data.get("success", False) != expected_success: + return False + + # Validate exit code matches success status + expected_exit_code = 0 if expected_success else 1 + if json_data.get("exit_code", -1) != expected_exit_code: + return False + + # If expecting success, validate structure + if expected_success: + # Check required fields are present + required_fields = [ + "package_name", + "version", + "successful_uploads", + "statistics", + ] + for field in required_fields: + if field not in json_data: + return False + + # Validate statistics consistency + stats = json_data.get("statistics", {}) + successful_uploads = json_data.get("successful_uploads", []) + # Calculate expected successful count from new_uploads + replaced_duplicates + expected_successful = stats.get("new_uploads", 0) + stats.get( + "replaced_duplicates", 0 + ) + if expected_successful != len(successful_uploads): + return False + + # Validate expected files if provided + if expected_files: + uploaded_filenames = [ + upload.get("target_filename", "") + for upload in successful_uploads + if isinstance(upload, dict) + ] + for expected_file in expected_files: + file_name = Path(expected_file).name + if file_name not in uploaded_filenames: + return False + else: + # If expecting failure, check error fields + if "error" not in json_data or "error_type" not in json_data: + return False + + return True + + +def execute_with_retry( + executor: ModuleExecutor, + argv: List[str], + env_vars: Optional[Dict[str, str]] = None, + max_retries: int = 3, + retry_delay: float = 1.0, + use_json_output: bool = False, +) -> UploadResult: + """ + Execute upload with retry logic for handling transient failures. + + Args: + executor: Module executor instance + argv: Command-line arguments + env_vars: Optional environment variables + max_retries: Maximum number of retry attempts + retry_delay: Delay between retries in seconds + use_json_output: Whether JSON output mode is enabled + + Returns: + UploadResult from the final attempt + """ + last_result = None + + for attempt in range(max_retries + 1): + result = executor.execute_upload( + argv=argv, + env_vars=env_vars, + use_json_output=use_json_output, + ) + + if result.success: + return result + + last_result = result + + # Don't retry on certain types of failures + if result.exit_code in [2, 3]: # Argument or configuration errors + break + + if attempt < max_retries: + time.sleep(retry_delay * (2**attempt)) # Exponential backoff + + return last_result or UploadResult( + success=False, + exit_code=-1, + stdout="", + stderr="", + duration=0.0, + error_message="All retry attempts failed", + uploaded_files=[], + upload_urls=[], + ) diff --git a/tests/integration/test_multiple_files_upload.py b/tests/integration/test_multiple_files_upload.py new file mode 100644 index 0000000..9ed2f2d --- /dev/null +++ b/tests/integration/test_multiple_files_upload.py @@ -0,0 +1,411 @@ +""" +Multiple files upload integration tests using direct module invocation. + +This module tests multiple file uploads, directory uploads, file mapping, +and large file uploads by calling the CLI main() function directly. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access +] + + +@pytest.mark.timeout(180) +def test_multiple_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test multiple file upload functionality using direct module invocation. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create multiple test files with different characteristics + test_files = [ + artifact_manager.create_test_file("multi-module-1.txt", 512, "text"), + artifact_manager.create_test_file("multi-module-2.json", 1024, "json"), + artifact_manager.create_test_file("multi-module-3.bin", 2048, "binary"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("multi-file-module", "1.0.0") + + file_paths = [str(f.path) for f in test_files] + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=file_paths, + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 3 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 3 + + # Verify each test file appears in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + for test_file in test_files: + assert test_file.path.name in uploaded_filenames, ( + f"File {test_file.path.name} not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_failures = [] + for test_file in test_files: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.timeout(180) +def test_directory_upload(gitlab_client, artifact_manager, project_path): + """ + Test directory upload functionality using direct module invocation. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test directory with files + test_files = artifact_manager.create_test_directory("upload-dir-module", 4) + directory_path = artifact_manager.base_dir / "upload-dir-module" + + # Create unique package name + package_name = gitlab_client.create_test_package("directory-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(f.path) for f in test_files], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 4 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 4 + + # Verify all directory files appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + for test_file in test_files: + assert test_file.path.name in uploaded_filenames, ( + f"File {test_file.path.name} not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_failures = [] + for test_file in test_files: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.timeout(180) +def test_file_mapping_upload(gitlab_client, artifact_manager, project_path): + """ + Test file mapping upload functionality with custom target names. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test files + test_files = [ + artifact_manager.create_test_file("source1-module.txt", 1024, "text"), + artifact_manager.create_test_file("source2-module.json", 2048, "json"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("file-mapping-module", "1.0.0") + + # Build argv with file mappings + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(f.path) for f in test_files], + project_path=project_path, + duplicate_policy="skip", + file_mapping=[ + f"{test_files[0].path.name}:target1-module.txt", + f"{test_files[1].path.name}:config/target2-module.json", + ], + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 2 + assert len(upload_result.json_data["successful_uploads"]) == 2 + + # Verify mapped filenames appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + assert "target1-module.txt" in uploaded_filenames, ( + "Mapped file target1-module.txt not found in successful uploads" + ) + assert "config/target2-module.json" in uploaded_filenames, ( + "Mapped file config/target2-module.json not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry with mapped names + target_mappings = [ + ("target1-module.txt", test_files[0].checksum), + ("config/target2-module.json", test_files[1].checksum), + ] + + registry_failures = [] + for target_filename, expected_checksum in target_mappings: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", target_filename, expected_checksum + ) + if not registry_verification: + registry_failures.append(target_filename) + + assert not registry_failures, ( + f"Registry verification failed for mapped files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.slow +@pytest.mark.timeout(180) +def test_large_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test upload of a larger file to ensure the module handles various file sizes. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a larger test file (10KB) + test_file = artifact_manager.create_test_file( + filename="large-test-module.bin", size_bytes=10240, content_pattern="binary" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("large-file-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 1 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + assert registry_verification, "Large file verification failed in GitLab registry" + + +@pytest.mark.timeout(180) +def test_multiple_files_with_different_sizes( + gitlab_client, artifact_manager, project_path +): + """ + Test uploading multiple files with varying sizes. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test files with different sizes + test_files = [ + artifact_manager.create_test_file("size-small.txt", 256, "text"), + artifact_manager.create_test_file("size-medium.bin", 4096, "binary"), + artifact_manager.create_test_file("size-large.json", 8192, "json"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("mixed-sizes-module", "1.0.0") + + file_paths = [str(f.path) for f in test_files] + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 3 + assert upload_result.exit_code == 0 + + # Verify all files in registry + for test_file in test_files: + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), f"Verification failed for {test_file.path.name}" diff --git a/tests/test_project_resolution.py b/tests/integration/test_project_resolution.py similarity index 52% rename from tests/test_project_resolution.py rename to tests/integration/test_project_resolution.py index 497f50c..bb7f039 100644 --- a/tests/test_project_resolution.py +++ b/tests/integration/test_project_resolution.py @@ -1,16 +1,18 @@ """ -Project resolution functionality tests for GitLab package upload script. +Project resolution integration tests using direct module invocation. -This module contains tests for project resolution scenarios extracted from the -monolithic test file. It validates Git auto-detection, manual project URL -specification, and manual project path specification using pytest framework. +This module tests Git auto-detection, manual project URL specification, +and manual project path specification by calling the CLI main() function directly. """ import os import pytest -from .utils.test_helpers import ScriptExecutor, UploadExecution, validate_json_result +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) # Test markers for categorization pytestmark = [ @@ -20,21 +22,9 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - class TestProjectResolution: """ - Test class for project resolution functionality. - - Extracted and adapted from TestOrchestrator._test_git_auto_detection, - _test_manual_project_url_specification, and - _test_manual_project_path_specification methods. + Test class for project resolution functionality using direct module invocation. """ @pytest.mark.timeout(120) @@ -52,45 +42,30 @@ def test_git_auto_detection(self, gitlab_client, artifact_manager, project_path) # Create test file test_file = artifact_manager.create_test_file( - filename="git-auto-test.txt", size_bytes=1024, content_pattern="text" + filename="git-auto-module.txt", size_bytes=1024, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("git-auto", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Build command WITHOUT specifying project_path or project_url - # This should trigger Git auto-detection in the upload script - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", + package_name = gitlab_client.create_test_package("git-auto-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv WITHOUT specifying project_path or project_url + # This should trigger Git auto-detection in the CLI + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--files", str(test_file.path), "--json-output", - "--files", - str(test_file.path), ] - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - # Execute upload - upload_result = executor.execute_upload(execution) - # Validate basic execution success assert upload_result.success, f"Upload failed: {upload_result.error_message}" assert upload_result.exit_code == 0, ( @@ -137,24 +112,21 @@ def test_manual_project_url_specification( # Create test file test_file = artifact_manager.create_test_file( - filename="manual-url-test.txt", size_bytes=1024, content_pattern="text" + filename="manual-url-module.txt", size_bytes=1024, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("manual-url", "1.0.0") + package_name = gitlab_client.create_test_package("manual-url-module", "1.0.0") - # Create script executor - executor = ScriptExecutor() + executor = ModuleExecutor() # NOTE: The current upload script has a limitation in URL parsing where it only - # takes the first two path components. For "LinaroLtd/iotil/meta-onelab", it - # extracts "LinaroLtd/iotil" which doesn't exist. This is a known limitation. - # For this test, we'll handle projects with more than 2 path components differently. + # takes the first two path components. For projects with >2 path components, + # this test handles them differently. path_components = project_path.split("/") if len(path_components) > 2: # For projects with more than 2 path components, the URL parsing will fail - # This is a limitation of the current upload script implementation print( f"Project path has {len(path_components)} components, URL parsing will fail" ) @@ -162,38 +134,24 @@ def test_manual_project_url_specification( gitlab_url = gitlab_client.gitlab_url project_url = f"{gitlab_url}/{project_path}" - # Build command with explicit project URL (expecting failure) - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--project-url", - project_url, + # Build argv with explicit project URL (expecting failure) + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", project_url, + "--files", str(test_file.path), "--json-output", - "--files", - str(test_file.path), ] - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to URL parsing limitation - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, + # Execute upload (expecting it to fail) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, use_json_output=True, ) - # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) - - # For this test, success means the error execution succeeded (i.e., the upload failed as expected) + # Success means the error execution succeeded (i.e., upload failed as expected) assert upload_result.success, ( f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" ) @@ -203,15 +161,9 @@ def test_manual_project_url_specification( # Validate JSON error fields if available if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data # Check for project-related keywords in error message error_msg = upload_result.json_data["error"].lower() @@ -222,30 +174,6 @@ def test_manual_project_url_specification( assert project_error_found, ( f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" ) - else: - # Fallback to stderr/stdout checking for early script errors - error_indicated = any( - pattern in upload_result.stderr.lower() - for pattern in ["project", "not found", "404", "resolution failed"] - ) - - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) print( f"URL parsing limitation correctly detected for project: {project_path}" @@ -253,46 +181,28 @@ def test_manual_project_url_specification( return # If project path has 2 or fewer components, proceed with normal test - # (This branch would be used for simpler project structures) gitlab_url = gitlab_client.gitlab_url project_url = f"{gitlab_url}/{project_path}" - # Build command with explicit project URL - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--project-url", - project_url, + # Build argv with explicit project URL + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", project_url, + "--files", str(test_file.path), "--json-output", - "--files", - str(test_file.path), ] - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) - # Execute upload - upload_result = executor.execute_upload(execution) - # Validate basic execution success assert upload_result.success, f"Upload failed: {upload_result.error_message}" - assert upload_result.exit_code == 0, ( - f"Expected exit code 0, got {upload_result.exit_code}" - ) + assert upload_result.exit_code == 0 # Validate JSON output assert upload_result.json_data is not None, "JSON output not found" @@ -329,51 +239,33 @@ def test_manual_project_path_specification( # Create test file test_file = artifact_manager.create_test_file( - filename="manual-path-test.txt", size_bytes=1024, content_pattern="text" + filename="manual-path-module.txt", size_bytes=1024, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("manual-path", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Build command with explicit project path - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--project-path", - project_path, - "--json-output", - "--files", - str(test_file.path), - ] + package_name = gitlab_client.create_test_package("manual-path-module", "1.0.0") - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} + executor = ModuleExecutor() - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, + # Build argv with explicit project path + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, ) # Execute upload - upload_result = executor.execute_upload(execution) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) # Validate basic execution success assert upload_result.success, f"Upload failed: {upload_result.error_message}" - assert upload_result.exit_code == 0, ( - f"Expected exit code 0, got {upload_result.exit_code}" - ) + assert upload_result.exit_code == 0 # Validate JSON output assert upload_result.json_data is not None, "JSON output not found" @@ -412,48 +304,33 @@ def test_invalid_project_path_error_handling( # Create test file test_file = artifact_manager.create_test_file( - filename="invalid-project-test.txt", size_bytes=1024, content_pattern="text" + filename="invalid-project-module.txt", size_bytes=1024, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("invalid-project", "1.0.0") + package_name = gitlab_client.create_test_package("invalid-project-module", "1.0.0") - # Create script executor - executor = ScriptExecutor() + executor = ModuleExecutor() # Use an invalid project path that should not exist invalid_project_path = "nonexistent/invalid-project-12345" - # Build command with invalid project path - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--project-path", - invalid_project_path, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid project - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, + # Build argv with invalid project path + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=invalid_project_path, + json_output=True, ) # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, + use_json_output=True, + ) # Validate that the script failed as expected assert upload_result.success, ( @@ -465,18 +342,10 @@ def test_invalid_project_path_error_handling( # Validate JSON error fields if available if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data # Check for project-related keywords in error message error_msg = upload_result.json_data["error"].lower() @@ -494,9 +363,10 @@ def test_invalid_project_path_error_handling( f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" ) else: - # Fallback to stderr/stdout checking for early script errors + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr error_indicated = any( - pattern in upload_result.stderr.lower() + pattern in error_output.lower() for pattern in [ "project", "not found", @@ -505,25 +375,8 @@ def test_invalid_project_path_error_handling( "invalid", ] ) - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) + print("Note: Expected error message not found, but upload failed as expected") @pytest.mark.timeout(120) def test_invalid_project_url_error_handling( @@ -542,51 +395,36 @@ def test_invalid_project_url_error_handling( # Create test file test_file = artifact_manager.create_test_file( - filename="invalid-url-test.txt", size_bytes=1024, content_pattern="text" + filename="invalid-url-module.txt", size_bytes=1024, content_pattern="text" ) # Create unique package name - package_name = gitlab_client.create_test_package("invalid-url", "1.0.0") + package_name = gitlab_client.create_test_package("invalid-url-module", "1.0.0") - # Create script executor - executor = ScriptExecutor() + executor = ModuleExecutor() # Use an invalid project URL that should not exist invalid_project_url = ( f"{gitlab_client.gitlab_url}/nonexistent/invalid-project-12345" ) - # Build command with invalid project URL - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--project-url", - invalid_project_url, + # Build argv with invalid project URL + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", invalid_project_url, + "--files", str(test_file.path), "--json-output", - "--files", - str(test_file.path), ] - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid project - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, + # Execute upload (expecting it to fail) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + expected_exit_code=1, use_json_output=True, ) - # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) - # Validate that the script failed as expected assert upload_result.success, ( f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" @@ -597,18 +435,10 @@ def test_invalid_project_url_error_handling( # Validate JSON error fields if available if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data # Check for project-related keywords in error message error_msg = upload_result.json_data["error"].lower() @@ -626,9 +456,10 @@ def test_invalid_project_url_error_handling( f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" ) else: - # Fallback to stderr/stdout checking for early script errors + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr error_indicated = any( - pattern in upload_result.stderr.lower() + pattern in error_output.lower() for pattern in [ "project", "not found", @@ -637,22 +468,5 @@ def test_invalid_project_url_error_handling( "invalid", ] ) - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) + print("Note: Expected error message not found, but upload failed as expected") diff --git a/tests/integration/test_single_file_upload.py b/tests/integration/test_single_file_upload.py new file mode 100644 index 0000000..86427e6 --- /dev/null +++ b/tests/integration/test_single_file_upload.py @@ -0,0 +1,262 @@ +""" +Single file upload integration test using direct module invocation. + +This module tests single file upload functionality by calling the CLI main() +function directly instead of using subprocess execution. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + get_project_args, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access +] + + +@pytest.mark.timeout(180) +def test_single_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test single file upload functionality using direct module invocation. + + This test validates that a single file can be uploaded successfully + by calling the CLI main() function directly. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-test-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-file-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["package_name"] == package_name + assert upload_result.json_data["version"] == "1.0.0" + assert upload_result.json_data["statistics"]["new_uploads"] == 1 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 1 + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + assert registry_verification, ( + "Upload verification failed - file not found in GitLab registry" + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_with_verbose(gitlab_client, artifact_manager, project_path): + """ + Test single file upload with verbose output enabled. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-verbose-test.txt", size_bytes=512, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-verbose", "1.0.0") + + # Build argv with verbose flag + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + verbose=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.exit_code == 0 + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_with_quiet(gitlab_client, artifact_manager, project_path): + """ + Test single file upload with quiet output enabled. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-quiet-test.txt", size_bytes=512, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-quiet", "1.0.0") + + # Build argv with quiet flag + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + quiet=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.exit_code == 0 + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_different_content_types( + gitlab_client, artifact_manager, project_path +): + """ + Test single file uploads with different content types. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + executor = ModuleExecutor() + + # Test different file types + file_types = [ + ("content-type-text.txt", 1024, "text"), + ("content-type-json.json", 2048, "json"), + ("content-type-binary.bin", 512, "binary"), + ] + + for filename, size, pattern in file_types: + # Create test file + test_file = artifact_manager.create_test_file( + filename=filename, size_bytes=size, content_pattern=pattern + ) + + # Create unique package name + package_name = gitlab_client.create_test_package( + f"content-type-{pattern}", "1.0.0" + ) + + # Build argv + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None, f"JSON output not found for {filename}" + assert upload_result.json_data["success"] is True, f"Upload failed for {filename}" + assert upload_result.exit_code == 0, f"Non-zero exit code for {filename}" + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), f"Registry verification failed for {filename}" diff --git a/tests/test_basic_uploads.py b/tests/test_basic_uploads.py deleted file mode 100644 index ebbd176..0000000 --- a/tests/test_basic_uploads.py +++ /dev/null @@ -1,471 +0,0 @@ -""" -Basic upload functionality tests for GitLab package upload script. - -This module contains tests for basic upload scenarios extracted from the -monolithic test file. It validates single file uploads, multiple file uploads, -directory uploads, and file mapping functionality using pytest framework. -""" - -import os - -import pytest - -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, - validate_json_result, -) - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests take longer to run due to real API calls -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestBasicUploads: - """ - Test class for basic upload functionality. - - Extracted and adapted from TestOrchestrator._test_single_file_upload, - _test_multiple_file_upload, _test_directory_upload, and - _test_file_mapping_upload methods. - """ - - @pytest.mark.timeout(180) - def test_single_file_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test single file upload functionality using subprocess execution of upload script. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="single-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("single-file", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["package_name"] == package_name - assert upload_result.json_data["version"] == "1.0.0" - assert upload_result.json_data["statistics"]["new_uploads"] == 1 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 1 - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - - assert registry_verification, ( - "Upload verification failed - file not found in GitLab registry" - ) - - @pytest.mark.timeout(180) - def test_multiple_file_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test multiple file upload functionality using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create multiple test files with different characteristics - test_files = [ - artifact_manager.create_test_file("multi-1.txt", 512, "text"), - artifact_manager.create_test_file("multi-2.json", 1024, "json"), - artifact_manager.create_test_file("multi-3.bin", 2048, "binary"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("multi-file", "1.0.0") - - file_paths = [str(f.path) for f in test_files] - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - ] - + file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=file_paths, - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 3 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 3 - - # Verify each test file appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - for test_file in test_files: - assert test_file.path.name in uploaded_filenames, ( - f"File {test_file.path.name} not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_failures = [] - for test_file in test_files: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - @pytest.mark.timeout(180) - def test_directory_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test directory upload functionality using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test directory with files - test_files = artifact_manager.create_test_directory("upload-dir", 4) - directory_path = artifact_manager.base_dir / "upload-dir" - - # Create unique package name - package_name = gitlab_client.create_test_package("directory", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--directory", - str(directory_path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(f.path) for f in test_files], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 4 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 4 - - # Verify all directory files appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - for test_file in test_files: - assert test_file.path.name in uploaded_filenames, ( - f"File {test_file.path.name} not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_failures = [] - for test_file in test_files: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - @pytest.mark.timeout(180) - def test_file_mapping_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test file mapping upload functionality with custom target names using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test files - test_files = [ - artifact_manager.create_test_file("source1.txt", 1024, "text"), - artifact_manager.create_test_file("source2.json", 2048, "json"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("file-mapping", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_files[0].path), - str(test_files[1].path), - "--file-mapping", - f"{test_files[0].path.name}:target1.txt", - "--file-mapping", - f"{test_files[1].path.name}:config/target2.json", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 2 - assert len(upload_result.json_data["successful_uploads"]) == 2 - - # Verify mapped filenames appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - assert "target1.txt" in uploaded_filenames, ( - "Mapped file target1.txt not found in successful uploads" - ) - assert "config/target2.json" in uploaded_filenames, ( - "Mapped file config/target2.json not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - target_mappings = [ - ("target1.txt", test_files[0].checksum), - ("config/target2.json", test_files[1].checksum), - ] - - registry_failures = [] - for target_filename, expected_checksum in target_mappings: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", target_filename, expected_checksum - ) - if not registry_verification: - registry_failures.append(target_filename) - - assert not registry_failures, ( - f"Registry verification failed for mapped files: {', '.join(registry_failures)}" - ) - - -# Additional test functions for edge cases and specific scenarios - - -@pytest.mark.slow -@pytest.mark.timeout(180) -def test_large_file_upload(gitlab_client, artifact_manager, project_path): - """ - Test upload of a larger file to ensure the script handles various file sizes. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a larger test file (10KB) - test_file = artifact_manager.create_test_file( - filename="large-test.bin", size_bytes=10240, content_pattern="binary" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("large-file", "1.0.0") - - # Execute upload script - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, # Longer timeout for larger file - use_json_output=True, - ) - - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 1 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - - # Validate results - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - # Verify in GitLab registry - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - assert registry_verification, "Large file verification failed in GitLab registry" diff --git a/tests/test_error_scenarios.py b/tests/test_error_scenarios.py deleted file mode 100644 index a33e600..0000000 --- a/tests/test_error_scenarios.py +++ /dev/null @@ -1,983 +0,0 @@ -""" -Error scenario tests for GitLab package upload script. - -This module contains tests for error handling scenarios extracted from the -monolithic test file. It validates network failures, authentication errors, -error message validation, failure continuation behavior, and non-ASCII filename -rejection using pytest framework. -""" - -import os -from pathlib import Path - -import pytest - -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, -) - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests simulate failures and take longer -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestErrorScenarios: - """ - Test class for error scenario handling. - - Extracted and adapted from TestOrchestrator._test_network_failure_simulation, - _test_authentication_error, _test_error_message_validation, and - _test_failure_continuation_behavior methods. - """ - - @pytest.mark.timeout(90) - def test_network_failure_simulation( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test network failure simulation and recovery. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - "network-test.txt", size_bytes=1024, content_pattern="network-test" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("network-failure", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command with invalid GitLab URL to simulate network failure - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--gitlab-url", - "https://invalid-gitlab-url.example.com", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file.path), - ] + project_args - - # Create execution configuration expecting network failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to network issues) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for network-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - network_keywords = [ - "network", - "connection", - "timeout", - "failed to connect", - "resolve", - "dns", - ] - network_error_found = any( - keyword in error_msg for keyword in network_keywords - ) - assert network_error_found, ( - f"Expected network error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - network_error_patterns = [ - "network", - "connection", - "timeout", - "failed to connect", - ] - network_error_found = any( - pattern in error_output.lower() for pattern in network_error_patterns - ) - assert network_error_found, ( - f"Expected network error patterns in output: {error_output}" - ) - - @pytest.mark.timeout(90) - def test_authentication_error(self, gitlab_client, artifact_manager, project_path): - """ - Test authentication error handling with invalid token. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="auth-error-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("auth-error", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command with invalid token - invalid_token = "invalid-token-that-should-fail-authentication" - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - invalid_token, - "--json-output", - "--files", - str(test_file.path), - ] + project_args - - # Create execution configuration expecting authentication failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to authentication issues) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate that the upload failed as expected - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") != 0, ( - "Expected non-zero exit_code" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for authentication-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - auth_keywords = [ - "authentication", - "token", - "unauthorized", - "401", - "403", - "access denied", - ] - auth_error_found = any(keyword in error_msg for keyword in auth_keywords) - assert auth_error_found, ( - f"Expected authentication error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - auth_error_indicators = [ - "authentication", - "token", - "unauthorized", - "401", - "403", - "access denied", - ] - auth_error_present = any( - indicator in error_output.lower() for indicator in auth_error_indicators - ) - assert auth_error_present, ( - f"Expected authentication error patterns in output: {error_output}" - ) - - @pytest.mark.timeout(90) - def test_error_message_validation( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test error message validation for various error scenarios. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Test scenario 1: Non-existent file - nonexistent_file = str(artifact_manager.base_dir / "nonexistent-file.txt") - package_name = gitlab_client.create_test_package("error-msg", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path) - - # Build command with non-existent file - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - nonexistent_file, - ] + project_args - - # Create execution configuration expecting file not found error - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to missing file) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate error message quality - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - - # Check for file-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - file_keywords = ["file", "not found", "does not exist", "missing"] - file_error_found = any(keyword in error_msg for keyword in file_keywords) - assert file_error_found, ( - f"Expected file error keywords in JSON error: {upload_result.json_data['error']}" - ) - - # Check that error message is informative - informative_error = ( - nonexistent_file.lower() in error_msg - or "nonexistent-file.txt" in error_msg - or any(word in error_msg for word in file_keywords) - ) - assert informative_error, ( - f"Expected informative error message mentioning the file: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - file_error_patterns = [ - "file", - "not found", - "does not exist", - "missing", - "cannot find", - ] - file_error_found = any( - pattern in error_output.lower() for pattern in file_error_patterns - ) - assert file_error_found, ( - f"Expected file error patterns in output: {error_output}" - ) - - # Check that error message is informative - informative_error = ( - nonexistent_file in error_output - or "nonexistent-file.txt" in error_output - or any( - word in error_output.lower() - for word in ["file", "path", "not found", "missing"] - ) - ) - assert informative_error, ( - f"Expected informative error message mentioning the file: {error_output}" - ) - - # Test scenario 2: Invalid project path - test_file = artifact_manager.create_test_file( - filename="error-msg-test2.txt", size_bytes=512, content_pattern="text" - ) - - invalid_project_path = "invalid/nonexistent-project" - - # Build command with invalid project path - command2 = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.1", - "--project-path", - invalid_project_path, - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file.path), - ] - - # Create execution configuration expecting project error - execution_config2 = UploadExecution( - command=command2, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to invalid project) - upload_result2 = executor.execute_upload(execution_config2) - - # Validate second error scenario - assert upload_result2.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result2.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result2.json_data is not None: - assert upload_result2.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result2.json_data, ( - "Expected error field in JSON output" - ) - - # Check for project-related keywords in error message - error_msg2 = upload_result2.json_data["error"].lower() - project_keywords = ["project", "404", "not found", "access", "invalid"] - project_error_found = any( - keyword in error_msg2 for keyword in project_keywords - ) - assert project_error_found, ( - f"Expected project error keywords in JSON error: {upload_result2.json_data['error']}" - ) - - # Check that error message mentions the project path - informative_error2 = invalid_project_path.lower() in error_msg2 or any( - word in error_msg2 for word in project_keywords - ) - assert informative_error2, ( - f"Expected informative error message mentioning the project: {upload_result2.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output2 = upload_result2.stdout + upload_result2.stderr - project_error_patterns = [ - "project", - "404", - "not found", - "access", - "invalid", - ] - project_error_found = any( - pattern in error_output2.lower() for pattern in project_error_patterns - ) - assert project_error_found, ( - f"Expected project error patterns in output: {error_output2}" - ) - - # Check that error message mentions the project path - informative_error2 = invalid_project_path in error_output2 or any( - word in error_output2.lower() - for word in ["project", "404", "not found", "access"] - ) - assert informative_error2, ( - f"Expected informative error message mentioning the project: {error_output2}" - ) - - @pytest.mark.timeout(90) - def test_failure_continuation_behavior( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test that the system continues processing after individual failures. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a mix of valid and invalid files for testing continuation behavior - valid_file = artifact_manager.create_test_file( - filename="valid-continuation-test.txt", - size_bytes=1024, - content_pattern="text", - ) - - nonexistent_file = str( - artifact_manager.base_dir / "nonexistent-continuation-test.txt" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package( - "failure-continuation", "1.0.0" - ) - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Test multiple file upload with one invalid file - # The upload script should handle the error gracefully and continue or report appropriately - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(valid_file.path), - nonexistent_file, # Mix of valid and invalid - ] + project_args - - # Create execution configuration expecting failure but graceful handling - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid file - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail but handle error gracefully) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate failure continuation behavior - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "failed_uploads" in upload_result.json_data, ( - "Expected failed_uploads field in JSON output" - ) - - # Check that the problematic file is mentioned in failed_uploads - failed_uploads = upload_result.json_data.get("failed_uploads", []) - file_mentioned = any( - "nonexistent-continuation-test.txt" in str(item).lower() - or "nonexistent" in str(item).lower() - for item in failed_uploads - ) - assert file_mentioned, ( - f"Expected problematic file in failed_uploads: {failed_uploads}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - error_mentions_file = ( - "nonexistent-continuation-test.txt" in error_output - or "nonexistent" in error_output.lower() - ) - assert error_mentions_file, ( - f"Expected error to mention the problematic file: {error_output}" - ) - - # Check that the error is descriptive and doesn't just crash - descriptive_error = any( - word in error_output.lower() - for word in ["file", "not found", "error", "failed", "missing"] - ) - assert descriptive_error, ( - f"Expected descriptive error message: {error_output}" - ) - - # Test a second scenario: Invalid duplicate policy - test_file2 = artifact_manager.create_test_file( - filename="continuation-test2.txt", size_bytes=512, content_pattern="text" - ) - - # Build command with invalid duplicate policy - command2 = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.1", - "--duplicate-policy", - "invalid-policy", # Invalid policy - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file2.path), - ] + project_args - - # Create execution configuration expecting policy error - execution_config2 = UploadExecution( - command=command2, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to invalid policy) - upload_result2 = executor.execute_upload(execution_config2) - - # Validate second error scenario - assert upload_result2.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result2.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result2.json_data is not None: - assert upload_result2.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result2.json_data, ( - "Expected error field in JSON output" - ) - - # Check for policy-related keywords in error message - error_msg2 = upload_result2.json_data["error"].lower() - policy_keywords = ["policy", "invalid", "choice", "option"] - policy_error_found = any( - keyword in error_msg2 for keyword in policy_keywords - ) - assert policy_error_found, ( - f"Expected policy error keywords in JSON error: {upload_result2.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output2 = upload_result2.stdout + upload_result2.stderr - policy_error_mentioned = "invalid-policy" in error_output2 or any( - word in error_output2.lower() - for word in ["policy", "invalid", "choice", "option"] - ) - assert policy_error_mentioned, ( - f"Expected policy error to be mentioned: {error_output2}" - ) - - @pytest.mark.timeout(90) - def test_non_ascii_filename_rejection( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test that non-ASCII filenames are properly rejected with detailed error messages. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file with ASCII filename - test_file = artifact_manager.create_test_file( - filename="unicode-名前.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("non-ascii-test", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - f"{test_file.path}", - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filename) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - - # Check for ASCII-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - ascii_keywords = ["ascii", "non-ascii", "character"] - ascii_error_found = any(keyword in error_msg for keyword in ascii_keywords) - assert ascii_error_found, ( - f"Expected ASCII-related error keywords in JSON error: {upload_result.json_data['error']}" - ) - - # Check that error message mentions the problematic filename - filename_mentioned = "名前" in upload_result.json_data["error"] - assert filename_mentioned, ( - f"Expected error to mention the problematic filename: {upload_result.json_data['error']}" - ) - - # Check that error message suggests ASCII-only characters - suggestion_keywords = [ - "letter", - "digit", - "dot", - "hyphen", - "underscore", - "slash", - ] - suggestion_found = any( - keyword in error_msg for keyword in suggestion_keywords - ) - assert suggestion_found, ( - f"Expected error to suggest ASCII-only characters: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - ascii_error_patterns = ["ascii", "non-ascii", "character"] - ascii_error_found = any( - pattern in error_output.lower() for pattern in ascii_error_patterns - ) - assert ascii_error_found, ( - f"Expected ASCII-related error patterns in output: {error_output}" - ) - - # Check that error message mentions the problematic filename - filename_mentioned = "名前" in error_output - assert filename_mentioned, ( - f"Expected error to mention the problematic filename: {error_output}" - ) - - -@pytest.mark.slow -@pytest.mark.timeout(90) -def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, project_path): - """ - Test that non-ASCII filenames in directories are properly rejected. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a temporary directory - test_dir = artifact_manager.base_dir / "non-ascii-dir-test" - test_dir.mkdir(parents=True, exist_ok=True) - - # Create a file with non-ASCII filename directly - non_ascii_filename = "unicode-测试文件.txt" - non_ascii_file_path = test_dir / non_ascii_filename - non_ascii_file_path.write_text("Test content with non-ASCII filename") - - # Create unique package name - package_name = gitlab_client.create_test_package("non-ascii-dir", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command to upload directory with non-ASCII filename - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--directory", - str(test_dir), - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filename) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, "Expected error field in JSON output" - - # Check that error message mentions the specific non-ASCII filename - error_msg = upload_result.json_data["error"] - filename_mentioned = ( - non_ascii_filename in error_msg - or "测试文件" in error_msg - or "unicode-" in error_msg.lower() - ) - assert filename_mentioned, ( - f"Expected error to mention the specific non-ASCII filename: {error_msg}" - ) - - # Check for ASCII-related keywords - ascii_keywords = ["ascii", "non-ascii", "character"] - ascii_error_found = any( - keyword in error_msg.lower() for keyword in ascii_keywords - ) - assert ascii_error_found, ( - f"Expected ASCII-related error keywords in JSON error: {error_msg}" - ) - else: - # Fallback to stderr/stdout checking - error_output = upload_result.stdout + upload_result.stderr - filename_mentioned = ( - non_ascii_filename in error_output or "测试文件" in error_output - ) - assert filename_mentioned, ( - f"Expected error to mention the specific non-ASCII filename: {error_output}" - ) - - -@pytest.mark.timeout(120) -def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, project_path): - """ - Test that mixed ASCII and non-ASCII filenames are handled correctly. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create multiple test files: some with ASCII names, some with non-ASCII names - ascii_file1 = artifact_manager.create_test_file( - filename="ascii-file1.txt", size_bytes=512, content_pattern="text" - ) - ascii_file2 = artifact_manager.create_test_file( - filename="ascii-file2.txt", size_bytes=512, content_pattern="text" - ) - - # Create files with non-ASCII names - test_dir = artifact_manager.base_dir / "mixed-test" - test_dir.mkdir(parents=True, exist_ok=True) - - non_ascii_file1 = test_dir / "unicode-名前.txt" - non_ascii_file1.write_text("Non-ASCII content 1") - - non_ascii_file2 = test_dir / "unicode-测试.txt" - non_ascii_file2.write_text("Non-ASCII content 2") - - # Create unique package name - package_name = gitlab_client.create_test_package("mixed-ascii", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command to upload all files together - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(ascii_file1.path), - str(ascii_file2.path), - str(non_ascii_file1), - str(non_ascii_file2), - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=90, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filenames) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, "Expected error field in JSON output" - - error_msg = upload_result.json_data["error"] - - # Check that error identifies non-ASCII filenames - non_ascii_mentioned = ( - "名前" in error_msg - or "测试" in error_msg - or "unicode-" in error_msg.lower() - ) - assert non_ascii_mentioned, ( - f"Expected error to identify non-ASCII filenames: {error_msg}" - ) - - # Check that ASCII files are not mentioned in the error - # (or if mentioned, it's clear they're not the problem) - ascii_file_names = ["ascii-file1.txt", "ascii-file2.txt"] - if any(name in error_msg for name in ascii_file_names): - # If ASCII files are mentioned, ensure the error is clear about which files are problematic - clear_about_problem = any( - keyword in error_msg.lower() - for keyword in ["non-ascii", "invalid", "problematic"] - ) - assert clear_about_problem, ( - f"Expected error to be clear about which files are problematic: {error_msg}" - ) - else: - # Fallback to stderr/stdout checking - error_output = upload_result.stdout + upload_result.stderr - non_ascii_mentioned = "名前" in error_output or "测试" in error_output - assert non_ascii_mentioned, ( - f"Expected error to identify non-ASCII filenames: {error_output}" - ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py deleted file mode 100644 index 65333fc..0000000 --- a/tests/test_fixtures.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Test the basic fixture functionality to ensure the extracted code works correctly. -""" - -from pathlib import Path - -import pytest - - -def test_artifact_manager_fixture(artifact_manager): - """Test that the artifact manager fixture works correctly.""" - # Create a test file - artifact = artifact_manager.create_test_file("test.txt", 100, "text") - - # Verify the artifact was created - assert artifact.path.exists() - assert artifact.size == 100 - assert artifact.artifact_type == "file" - assert len(artifact.checksum) == 64 # SHA256 hex length - - # Verify the file has the expected content - content = artifact.path.read_bytes() - assert len(content) == 100 - assert b"test content" in content - - -def test_temp_dir_fixture(temp_dir): - """Test that the temporary directory fixture works correctly.""" - # Verify temp_dir is a Path object and exists - assert isinstance(temp_dir, Path) - assert temp_dir.exists() - assert temp_dir.is_dir() - - # Create a file in the temp directory - test_file = temp_dir / "test.txt" - test_file.write_text("test content") - - assert test_file.exists() - assert test_file.read_text() == "test content" - - -def test_gitlab_token_fixture(gitlab_token): - """Test that the GitLab token fixture works correctly.""" - # This test will be skipped if GITLAB_TOKEN is not set - assert isinstance(gitlab_token, str) - assert len(gitlab_token) > 0 - - -@pytest.mark.skipif( - not pytest.importorskip("gitlab", minversion=None), - reason="python-gitlab not available", -) -def test_gitlab_client_fixture(gitlab_client): - """Test that the GitLab client fixture works correctly.""" - # This test will be skipped if python-gitlab is not available - assert gitlab_client is not None - assert hasattr(gitlab_client, "gl") - assert hasattr(gitlab_client, "token") - assert gitlab_client._authenticated - - -def test_project_resolver_fixture_skip_if_no_gitlab(): - """Test that project resolver fixture is properly skipped when GitLab is not available.""" - try: - import importlib.util - - if importlib.util.find_spec("gitlab") is not None: - pytest.skip("GitLab is available, this test is for when it's not available") - except ImportError: - # This is expected when GitLab is not available - pass diff --git a/tests/test_unit_basic.py b/tests/test_unit_basic.py deleted file mode 100644 index d64d5f9..0000000 --- a/tests/test_unit_basic.py +++ /dev/null @@ -1,434 +0,0 @@ -""" -Basic unit tests that don't require GitLab API access. - -These tests validate the core functionality of the upload script components -without requiring external dependencies like GitLab tokens or network access. -""" - -import os -import tempfile -from pathlib import Path - -import pytest - -# Mark these as fast unit tests -pytestmark = [pytest.mark.fast, pytest.mark.unit] - - -class TestBasicFunctionality: - """Basic unit tests for core functionality.""" - - @pytest.mark.timeout(60) - def test_import_gitlab_common(self): - """Test that gitlab_common module can be imported.""" - try: - import gitlab_common - - assert hasattr(gitlab_common, "ProjectResolver") - assert hasattr(gitlab_common, "GitAutoDetector") - except ImportError as e: - pytest.fail(f"Failed to import gitlab_common: {e}") - - @pytest.mark.timeout(60) - def test_import_main_script(self): - """Test that the main upload script can be imported.""" - import sys - - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Check if the script file exists - script_path = script_dir / "gitlab-pkg-upload.py" - if not script_path.exists(): - pytest.skip("Main script file not found") - - # Try to read the script to check basic syntax - script_content = script_path.read_text() - - # Check for key components without importing - assert "def main(" in script_content - assert "argparse" in script_content - assert "upload" in script_content.lower() - - except Exception as e: - pytest.skip(f"Cannot test main script import: {e}") - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_file_operations(self): - """Test basic file operations used by the script.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - # Create a test file - test_file = temp_path / "test.txt" - test_content = b"Hello, World!" - test_file.write_bytes(test_content) - - # Verify file exists and has correct content - assert test_file.exists() - assert test_file.read_bytes() == test_content - assert test_file.stat().st_size == len(test_content) - - @pytest.mark.timeout(60) - def test_checksum_calculation(self): - """Test checksum calculation functionality.""" - import hashlib - - test_data = b"Test data for checksum" - expected_sha256 = hashlib.sha256(test_data).hexdigest() - - # Calculate checksum - calculated_sha256 = hashlib.sha256(test_data).hexdigest() - - assert calculated_sha256 == expected_sha256 - assert len(calculated_sha256) == 64 # SHA256 is 64 hex characters - - @pytest.mark.timeout(60) - def test_path_handling(self): - """Test path handling functionality.""" - # Test various path operations - test_path = Path("/some/test/path/file.txt") - - assert test_path.name == "file.txt" - assert test_path.suffix == ".txt" - assert test_path.stem == "file" - assert test_path.parent.name == "path" - - @pytest.mark.timeout(60) - def test_environment_variable_handling(self): - """Test environment variable handling.""" - # Test setting and getting environment variables - test_var = "TEST_GITLAB_VAR" - test_value = "test_value_123" - - # Set environment variable - os.environ[test_var] = test_value - - # Verify it can be retrieved - assert os.environ.get(test_var) == test_value - - # Clean up - del os.environ[test_var] - - # Verify it's gone - assert os.environ.get(test_var) is None - - -class TestUtilityFunctions: - """Test utility functions and helpers.""" - - @pytest.mark.timeout(60) - def test_rate_limiter_import(self): - """Test that rate limiter utilities can be imported.""" - try: - from .utils.rate_limiter import get_rate_limiter - - limiter = get_rate_limiter() - assert limiter is not None - assert hasattr(limiter, "acquire") - assert hasattr(limiter, "_lock") - - except ImportError as e: - pytest.fail(f"Failed to import rate limiter: {e}") - - @pytest.mark.timeout(60) - def test_performance_utilities_import(self): - """Test that performance utilities can be imported.""" - try: - from .utils.performance import get_data_generator, get_performance_tracker - - tracker = get_performance_tracker() - generator = get_data_generator() - - assert tracker is not None - assert generator is not None - assert hasattr(generator, "generate_content") - - except ImportError as e: - pytest.fail(f"Failed to import performance utilities: {e}") - - @pytest.mark.timeout(60) - def test_data_generation(self): - """Test data generation functionality.""" - try: - from .utils.performance import get_data_generator - - generator = get_data_generator() - - # Test different content types - text_content = generator.generate_content(100, "text") - assert len(text_content) == 100 - assert isinstance(text_content, bytes) - - binary_content = generator.generate_content(50, "binary") - assert len(binary_content) == 50 - assert isinstance(binary_content, bytes) - - except ImportError as e: - pytest.skip(f"Performance utilities not available: {e}") - - -class TestConfigurationValidation: - """Test configuration and setup validation.""" - - @pytest.mark.timeout(60) - def test_pytest_markers_available(self): - """Test that pytest markers are properly configured.""" - # This test verifies that the markers we use are available - import pytest - - # These should not raise warnings when used - pytest.mark.fast - pytest.mark.slow - pytest.mark.integration - pytest.mark.api - pytest.mark.unit - pytest.mark.sequential - - # If we get here without exceptions, markers are working - assert True - - @pytest.mark.timeout(60) - def test_test_directory_structure(self): - """Test that test directory structure is correct.""" - test_dir = Path(__file__).parent - - # Check for required files - assert (test_dir / "conftest.py").exists() - assert (test_dir / "utils").exists() - assert (test_dir / "utils" / "rate_limiter.py").exists() - assert (test_dir / "utils" / "performance.py").exists() - - # Check for test files - test_files = list(test_dir.glob("test_*.py")) - assert len(test_files) > 0 - - # This file should be in the list - assert Path(__file__) in test_files - - -class TestFilenameValidation: - """Test filename validation functionality.""" - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_valid_filenames(self): - """Test that valid ASCII filenames pass validation.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test valid ASCII filenames - valid_filenames = [ - "package.tar.gz", - "my-file_v1.0.bin", - "subdir/file.txt", - "test123.txt", - "file-name_with.dots.tar.gz", - "a/b/c/deep/path/file.bin", - "UPPERCASE.TXT", - "MixedCase_File-123.tar.gz", - ] - - for filename in valid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert is_valid, ( - f"Expected '{filename}' to be valid, but got error: {error_message}" - ) - assert error_message == "", ( - f"Expected empty error message for valid filename '{filename}', got: {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_invalid_non_ascii(self): - """Test that non-ASCII filenames are rejected.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test invalid non-ASCII filenames - invalid_filenames = [ - ("café.tar.gz", "café"), - ("文件.bin", "文件"), - ("file™.txt", "™"), - ("tëst.txt", "ë"), - ("файл.tar.gz", "файл"), - ("αρχείο.bin", "αρχείο"), - ] - - for filename, non_ascii_part in invalid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert not is_valid, f"Expected '{filename}' to be invalid" - assert error_message != "", ( - f"Expected error message for invalid filename '{filename}'" - ) - - # Check that error message contains key information - assert ( - "non-ascii" in error_message.lower() - or "ascii" in error_message.lower() - ), ( - f"Expected error message to mention ASCII for '{filename}': {error_message}" - ) - assert filename in error_message, ( - f"Expected error message to mention the problematic filename '{filename}': {error_message}" - ) - assert "allowed characters" in error_message.lower(), ( - f"Expected error message to mention allowed characters for '{filename}': {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_invalid_special_chars(self): - """Test that filenames with unsupported special characters are rejected.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test filenames with unsupported special characters (but still ASCII) - invalid_filenames = [ - "file@name.txt", - "file#name.txt", - "file$name.txt", - "file%name.txt", - "file&name.txt", - "file*name.txt", - "file(name).txt", - "file[name].txt", - "file{name}.txt", - "file name.txt", # space - "file+name.txt", - "file=name.txt", - "file!name.txt", - "file?name.txt", - ] - - for filename in invalid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert not is_valid, f"Expected '{filename}' to be invalid" - assert error_message != "", ( - f"Expected error message for invalid filename '{filename}'" - ) - - # Check that error message contains key information - assert ( - "special characters" in error_message.lower() - or "allowed characters" in error_message.lower() - ), ( - f"Expected error message to mention special characters for '{filename}': {error_message}" - ) - assert filename in error_message, ( - f"Expected error message to mention the problematic filename '{filename}': {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_error_message_quality(self): - """Test that error messages are detailed and helpful.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test with a non-ASCII filename - is_valid, error_message = validate_filename_ascii("café.tar.gz") - - assert not is_valid - assert error_message != "" - - # Check that error message contains all required elements - required_elements = [ - "café.tar.gz", # The problematic filename - "allowed characters", # Explanation of what's allowed - "rename", # Suggestion to fix the issue - ] - - for element in required_elements: - assert element.lower() in error_message.lower(), ( - f"Expected error message to contain '{element}': {error_message}" - ) - - # Check that error message mentions specific allowed characters - allowed_chars = ["letter", "digit", "dot", "hyphen", "underscore", "slash"] - chars_mentioned = sum( - 1 for char in allowed_chars if char in error_message.lower() - ) - assert chars_mentioned >= 4, ( - f"Expected error message to mention at least 4 allowed character types: {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..d4d411d --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests for glpkg diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..06480d2 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,1861 @@ +""" +Comprehensive unit tests for the CLI module. + +Tests cover argument parsing, flag validation, project resolution, +Git auto-detection, context building, and main orchestration. +All tests are isolated with mocked dependencies. + +Test Structure: + - TestDetermineVerbosity: Tests for verbosity flag priority + - TestSetupLogging: Tests for logging configuration + - TestCreateArgumentParser: Tests for argument parser creation + - TestValidateFlags: Tests for flag validation and conflict detection + - TestGitAutoDetector: Tests for Git repository auto-detection + - TestProjectResolver: Tests for GitLab project resolution + - TestUploadContextBuilder: Tests for context building + - TestHelperFunctions: Tests for utility functions + - TestParseArguments: Tests for argument parsing with shell completion + - TestMainFunction: Tests for main orchestration flow + - TestExceptionExitCodeMapping: Tests for exit code mapping + - TestEdgeCases: Tests for edge cases and error scenarios + +Running Tests: + # Run all CLI tests + pytest tests/unit/test_cli.py -v + + # Run specific test class + pytest tests/unit/test_cli.py::TestDetermineVerbosity -v + + # Run tests with coverage + pytest tests/unit/test_cli.py --cov=gitlab_pkg_upload.cli --cov-report=term-missing +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch, call + +import pytest +import git +from gitlab import Gitlab +from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError + +from glpkg.cli.upload import ( + # Constants + EXCEPTION_EXIT_CODE_MAP, + # Functions + auto_detect_project, + execute_upload, + resolve_project_manually, + validate_upload_flags, + # Classes + GitAutoDetector, + ProjectResolver, + UploadContextBuilder, +) +from glpkg.cli.main import ( + main, + create_argument_parser, + determine_verbosity, + get_version, + setup_logging, +) +from glpkg.models import ( + DuplicatePolicy, + GitRemoteInfo, + ProjectInfo, + UploadConfig, + UploadContext, + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, +) + +# Mark all tests as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_args(): + """Create mock argument namespace with default values.""" + args = argparse.Namespace() + args.package_name = "test-package" + args.package_version = "1.0.0" + args.files = ["file1.txt"] + args.directory = None + args.file_mapping = None + args.project_url = None + args.project_path = None + args.gitlab_url = "https://gitlab.com" + args.token = None + args.duplicate_policy = "skip" + args.retry = 0 + args.verbose = False + args.quiet = False + args.debug = False + args.dry_run = False + args.fail_fast = False + args.json_output = False + args.plain = False + return args + + +@pytest.fixture +def mock_gitlab_client(): + """Create mock GitLab client.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + mock_gl.auth = MagicMock() + mock_gl.user = MagicMock(username="testuser", name="Test User") + mock_gl.projects = MagicMock() + return mock_gl + + +@pytest.fixture +def mock_git_repo(): + """Create mock Git repository.""" + mock_repo = MagicMock(spec=git.Repo) + mock_repo.working_dir = "/path/to/repo" + return mock_repo + + +@pytest.fixture +def mock_git_remote(): + """Create mock Git remote.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@gitlab.com:mygroup/myproject.git"]) + return mock_remote + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestDetermineVerbosity: + """Tests for determine_verbosity function.""" + + @pytest.mark.timeout(60) + def test_debug_flag_highest_priority(self, mock_args): + """Test debug flag takes highest priority.""" + mock_args.debug = True + mock_args.verbose = True + mock_args.quiet = True + assert determine_verbosity(mock_args) == "debug" + + @pytest.mark.timeout(60) + def test_verbose_flag_second_priority(self, mock_args): + """Test verbose flag takes second priority.""" + mock_args.debug = False + mock_args.verbose = True + mock_args.quiet = True + assert determine_verbosity(mock_args) == "verbose" + + @pytest.mark.timeout(60) + def test_quiet_flag_third_priority(self, mock_args): + """Test quiet flag takes third priority.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = True + assert determine_verbosity(mock_args) == "quiet" + + @pytest.mark.timeout(60) + def test_normal_default(self, mock_args): + """Test normal is default when no flags set.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = False + assert determine_verbosity(mock_args) == "normal" + + +class TestSetupLogging: + """Tests for setup_logging function.""" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') + def test_logging_setup_normal(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with normal verbosity.""" + setup_logging(mock_args) + mock_basic_config.assert_called_once() + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.INFO + + @pytest.mark.timeout(60) + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') + def test_logging_setup_debug(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with debug verbosity.""" + mock_args.debug = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.DEBUG + + @pytest.mark.timeout(60) + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') + def test_logging_setup_quiet(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with quiet verbosity.""" + mock_args.quiet = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.WARNING + + @pytest.mark.timeout(60) + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') + def test_logging_setup_verbose(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with verbose verbosity.""" + mock_args.verbose = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.INFO + + @pytest.mark.timeout(60) + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') + def test_logging_uses_stderr_for_json_output(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging uses stderr when json_output is enabled.""" + mock_args.json_output = True + setup_logging(mock_args) + mock_console.assert_called_once() + call_kwargs = mock_console.call_args[1] + assert call_kwargs['file'] == sys.stderr + + +class TestCreateArgumentParser: + """Tests for create_argument_parser function.""" + + @pytest.mark.timeout(60) + def test_parser_creation(self): + """Test argument parser is created successfully.""" + parser = create_argument_parser() + assert isinstance(parser, argparse.ArgumentParser) + assert parser.prog == "glpkg" + + @pytest.mark.timeout(60) + def test_parser_has_global_options(self): + """Test parser has expected global options.""" + parser = create_argument_parser() + # Parse with no args - shows help and has command=None + args = parser.parse_args([]) + # Verify that global argument attributes exist + assert hasattr(args, 'verbose') + assert hasattr(args, 'quiet') + assert hasattr(args, 'debug') + assert hasattr(args, 'json_output') + assert hasattr(args, 'command') + assert args.command is None # No subcommand provided + + @pytest.mark.timeout(60) + def test_parser_accepts_upload_subcommand(self): + """Test parser accepts upload subcommand with valid arguments.""" + parser = create_argument_parser() + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.command == 'upload' + assert args.package_name == 'test' + assert args.package_version == '1.0.0' + assert args.files == ['file.txt'] + + @pytest.mark.timeout(60) + def test_parser_duplicate_policy_choices(self): + """Test duplicate policy accepts valid choices.""" + parser = create_argument_parser() + for policy in ['skip', 'replace', 'error']: + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', policy + ]) + assert args.duplicate_policy == policy + + @pytest.mark.timeout(60) + def test_parser_invalid_duplicate_policy(self): + """Test invalid duplicate policy is rejected.""" + parser = create_argument_parser() + with pytest.raises(SystemExit): + parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', 'invalid' + ]) + + @pytest.mark.timeout(60) + def test_parser_multiple_files(self): + """Test parser accepts multiple files.""" + parser = create_argument_parser() + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file1.txt', 'file2.txt', 'file3.txt' + ]) + assert args.files == ['file1.txt', 'file2.txt', 'file3.txt'] + + @pytest.mark.timeout(60) + def test_parser_default_values(self): + """Test parser has correct default values.""" + parser = create_argument_parser() + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.duplicate_policy == 'skip' + assert args.retry == 0 + assert args.verbose is False + assert args.quiet is False + assert args.debug is False + assert args.dry_run is False + assert args.fail_fast is False + assert args.json_output is False + assert args.plain is False + + @pytest.mark.timeout(60) + def test_parser_directory_option(self): + """Test parser accepts directory option.""" + parser = create_argument_parser() + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--directory', '/path/to/dir' + ]) + assert args.directory == '/path/to/dir' + assert args.files is None + + @pytest.mark.timeout(60) + def test_parser_file_mapping_option(self): + """Test parser accepts file mapping options.""" + parser = create_argument_parser() + args = parser.parse_args([ + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--file-mapping', 'file.txt:renamed.txt', + '--file-mapping', 'other.bin:new.bin' + ]) + assert args.file_mapping == ['file.txt:renamed.txt', 'other.bin:new.bin'] + + @pytest.mark.timeout(60) + def test_parser_global_flags_before_subcommand(self): + """Test global flags can be placed before the subcommand.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--verbose', + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.verbose is True + assert args.command == 'upload' + + +class TestValidateUploadFlags: + """Tests for validate_upload_flags function.""" + + @pytest.mark.timeout(60) + def test_missing_package_name_raises_error(self, mock_args): + """Test missing package name raises SystemExit.""" + mock_args.package_name = None + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_missing_package_version_raises_error(self, mock_args): + """Test missing package version raises SystemExit.""" + mock_args.package_version = None + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_both_files_and_directory_raises_error(self, mock_args): + """Test specifying both --files and --directory raises error.""" + mock_args.files = ["file.txt"] + mock_args.directory = "/path/to/dir" + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_both_project_url_and_path_raises_error(self, mock_args): + """Test specifying both project URL and path raises error.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = "group/project" + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_file_mapping_with_directory_raises_error(self, mock_args): + """Test file mapping with directory raises error.""" + mock_args.files = None + mock_args.directory = "/path/to/dir" + mock_args.file_mapping = ["source:target"] + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_negative_retry_raises_error(self, mock_args): + """Test negative retry count raises error.""" + mock_args.retry = -1 + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_valid_flags_pass_validation(self, mock_args): + """Test valid flag combination passes validation.""" + # Should not raise + validate_upload_flags(mock_args) + + @pytest.mark.timeout(60) + def test_no_file_input_raises_error(self, mock_args): + """Test no file input raises error.""" + mock_args.files = None + mock_args.directory = None + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_zero_retry_is_valid(self, mock_args): + """Test zero retry count is valid.""" + mock_args.retry = 0 + # Should not raise + validate_upload_flags(mock_args) + + @pytest.mark.timeout(60) + def test_positive_retry_is_valid(self, mock_args): + """Test positive retry count is valid.""" + mock_args.retry = 5 + # Should not raise + validate_upload_flags(mock_args) + + +class TestGitAutoDetector: + """Tests for GitAutoDetector class.""" + + @pytest.mark.timeout(60) + def test_initialization(self): + """Test GitAutoDetector initialization.""" + detector = GitAutoDetector() + assert detector.working_directory == "." + + @pytest.mark.timeout(60) + def test_initialization_with_custom_directory(self): + """Test GitAutoDetector with custom directory.""" + detector = GitAutoDetector("/custom/path") + assert detector.working_directory == "/custom/path" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.git.Repo') + def test_find_git_repository_success(self, mock_repo_class): + """Test finding Git repository successfully.""" + mock_repo = MagicMock() + mock_repo.working_dir = "/path/to/repo" + mock_repo_class.return_value = mock_repo + + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is mock_repo + mock_repo_class.assert_called_once_with(".", search_parent_directories=True) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.git.Repo') + def test_find_git_repository_not_found(self, mock_repo_class): + """Test Git repository not found returns None.""" + mock_repo_class.side_effect = git.InvalidGitRepositoryError() + + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is None + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.git.Repo') + def test_find_git_repository_permission_error(self, mock_repo_class): + """Test Git repository permission error raises ProjectResolutionError.""" + mock_repo_class.side_effect = PermissionError("Access denied") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.git.Repo') + def test_find_git_repository_git_command_error(self, mock_repo_class): + """Test Git command error raises ProjectResolutionError.""" + mock_repo_class.side_effect = git.GitCommandError("git status", 128, stderr="fatal: error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "Git command error" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.git.Repo') + def test_find_git_repository_os_error(self, mock_repo_class): + """Test OS error raises ProjectResolutionError.""" + mock_repo_class.side_effect = OSError("Disk error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "OS error" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_looks_like_gitlab_url(self): + """Test GitLab URL detection.""" + detector = GitAutoDetector() + assert detector._looks_like_gitlab_url("https://gitlab.com/project") + assert detector._looks_like_gitlab_url("https://my.gitlab.io/project") + assert detector._looks_like_gitlab_url("https://gitlab.example.com/project") + assert detector._looks_like_gitlab_url("https://git.lab.company.com/project") + assert not detector._looks_like_gitlab_url("https://github.com/project") + assert not detector._looks_like_gitlab_url("https://example.com/project") + + @pytest.mark.timeout(60) + def test_is_known_non_gitlab_host(self): + """Test known non-GitLab host detection.""" + detector = GitAutoDetector() + assert detector._is_known_non_gitlab_host("github.com") + assert detector._is_known_non_gitlab_host("bitbucket.org") + assert detector._is_known_non_gitlab_host("codeberg.org") + assert detector._is_known_non_gitlab_host("dev.azure.com") + assert not detector._is_known_non_gitlab_host("gitlab.com") + assert not detector._is_known_non_gitlab_host("gitlab.example.com") + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.parse_git_url') + def test_parse_git_url_success(self, mock_parse): + """Test parsing Git URL successfully.""" + mock_parse.return_value = ("https://gitlab.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@gitlab.com:group/project.git") + + assert result == ("https://gitlab.com", "group/project") + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.parse_git_url') + def test_parse_git_url_non_gitlab(self, mock_parse): + """Test parsing non-GitLab URL returns None.""" + mock_parse.return_value = ("https://github.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@github.com:group/project.git") + + assert result is None + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.parse_git_url') + def test_parse_git_url_unknown_host(self, mock_parse): + """Test parsing URL from unknown host still returns it.""" + mock_parse.return_value = ("https://git.example.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@git.example.com:group/project.git") + + # Unknown hosts are returned (could be self-hosted GitLab) + assert result == ("https://git.example.com", "group/project") + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.parse_git_url') + def test_parse_git_url_gitlab_like_error(self, mock_parse): + """Test parsing GitLab-like URL that fails raises error.""" + mock_parse.side_effect = Exception("Parse error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.parse_git_url("https://gitlab.com/invalid") + assert "format is unrecognized" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.parse_git_url') + def test_parse_git_url_non_gitlab_error_returns_none(self, mock_parse): + """Test parsing non-GitLab URL that fails returns None.""" + mock_parse.side_effect = Exception("Parse error") + + detector = GitAutoDetector() + result = detector.parse_git_url("https://example.com/something") + + assert result is None + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_success(self, mock_git_repo): + """Test extracting GitLab remotes successfully.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@gitlab.com:group/project.git"]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=("https://gitlab.com", "group/project")): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + assert len(remotes) == 1 + assert remotes[0].name == "origin" + assert remotes[0].gitlab_url == "https://gitlab.com" + assert remotes[0].project_path == "group/project" + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_no_remotes(self, mock_git_repo): + """Test no remotes raises ProjectResolutionError.""" + mock_git_repo.remotes = [] + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No Git remotes configured" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_prioritizes_origin(self, mock_git_repo): + """Test origin remote is prioritized.""" + mock_remote1 = MagicMock() + mock_remote1.name = "upstream" + mock_remote1.urls = iter(["git@gitlab.com:group/project1.git"]) + + mock_remote2 = MagicMock() + mock_remote2.name = "origin" + mock_remote2.urls = iter(["git@gitlab.com:group/project2.git"]) + + mock_git_repo.remotes = [mock_remote1, mock_remote2] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', side_effect=[ + ("https://gitlab.com", "group/project1"), + ("https://gitlab.com", "group/project2") + ]): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + assert remotes[0].name == "origin" + assert remotes[0].project_path == "group/project2" + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_no_gitlab_remotes(self, mock_git_repo): + """Test no GitLab remotes raises ProjectResolutionError.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@github.com:group/project.git"]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=None): + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No GitLab remotes found" in str(exc_info.value) + + +class TestProjectResolver: + """Tests for ProjectResolver class.""" + + @pytest.mark.timeout(60) + def test_initialization(self, mock_gitlab_client): + """Test ProjectResolver initialization.""" + resolver = ProjectResolver(mock_gitlab_client) + assert resolver.gl is mock_gitlab_client + assert isinstance(resolver.project_cache, dict) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_parse_project_url_success(self, mock_normalize, mock_gitlab_client): + """Test parsing project URL successfully.""" + mock_normalize.return_value = ("https://gitlab.com", "group/project") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url("https://gitlab.com/group/project") + + assert isinstance(result, ProjectInfo) + assert result.gitlab_url == "https://gitlab.com" + assert result.project_path == "group/project" + assert result.namespace == "group" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_parse_project_url_nested_namespace(self, mock_normalize, mock_gitlab_client): + """Test parsing project URL with nested namespace.""" + mock_normalize.return_value = ("https://gitlab.com", "group/subgroup/project") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url("https://gitlab.com/group/subgroup/project") + + assert result.namespace == "group/subgroup" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_parse_project_url_invalid(self, mock_normalize, mock_gitlab_client): + """Test parsing invalid project URL raises error.""" + mock_normalize.side_effect = Exception("Invalid URL") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError) as exc_info: + resolver.parse_project_url("invalid-url") + assert "Invalid GitLab project URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_id_success(self, mock_gitlab_client): + """Test resolving project ID successfully.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + project_id = resolver.resolve_project_id("https://gitlab.com", "group/project") + + assert project_id == 12345 + mock_gitlab_client.projects.get.assert_called_once_with("group/project") + + @pytest.mark.timeout(60) + def test_resolve_project_id_cached(self, mock_gitlab_client): + """Test project ID resolution uses cache.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + # First call + project_id1 = resolver.resolve_project_id("https://gitlab.com", "group/project") + # Second call should use cache + project_id2 = resolver.resolve_project_id("https://gitlab.com", "group/project") + + assert project_id1 == project_id2 + # Should only call API once + assert mock_gitlab_client.projects.get.call_count == 1 + + @pytest.mark.timeout(60) + def test_resolve_project_id_not_found(self, mock_gitlab_client): + """Test project not found raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = GitlabGetError("404 Not Found") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError): + resolver.resolve_project_id("https://gitlab.com", "group/nonexistent") + + @pytest.mark.timeout(60) + def test_resolve_project_id_auth_error(self, mock_gitlab_client): + """Test authentication error raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = GitlabAuthenticationError("401 Unauthorized") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError): + resolver.resolve_project_id("https://gitlab.com", "group/project") + + @pytest.mark.timeout(60) + def test_validate_project_access_success(self, mock_gitlab_client): + """Test validating project access successfully.""" + mock_project = MagicMock() + mock_project.name = "Test Project" + mock_project.path_with_namespace = "group/project" + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.validate_project_access(12345) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_project_access_failure(self, mock_gitlab_client): + """Test validating project access failure.""" + mock_gitlab_client.projects.get.side_effect = Exception("Access denied") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.validate_project_access(12345) + + assert result is False + + +class TestUploadContextBuilder: + """Tests for UploadContextBuilder class.""" + + @pytest.mark.timeout(60) + def test_initialization(self): + """Test UploadContextBuilder initialization.""" + builder = UploadContextBuilder() + assert builder is not None + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_success(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test building upload context successfully.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert isinstance(context, UploadContext) + assert context.gl is mock_gitlab_client + assert context.project_id == 12345 + assert context.project_path == "group/project" + assert isinstance(context.config, UploadConfig) + assert context.config.package_name == "test-package" + assert context.config.version == "1.0.0" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_verbosity(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building respects verbosity settings.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.verbose = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.verbosity == "verbose" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_dry_run(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with dry run enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.dry_run = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.dry_run is True + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_debug(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with debug verbosity.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.debug = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.verbosity == "debug" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_replace_policy(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with replace duplicate policy.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.duplicate_policy = DuplicatePolicy.REPLACE + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.duplicate_policy == DuplicatePolicy.REPLACE + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_error_handling(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building raises ConfigurationError on failure.""" + mock_detector_class.side_effect = Exception("Detector init failed") + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + with pytest.raises(ConfigurationError) as exc_info: + builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + assert "Failed to build upload context" in str(exc_info.value) + + +class TestHelperFunctions: + """Tests for helper functions.""" + + @pytest.mark.timeout(60) + def test_get_version_returns_string(self): + """Test get_version returns a string.""" + version = get_version() + assert isinstance(version, str) + # Version should not be empty + assert len(version) > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.GitAutoDetector') + def test_auto_detect_project_success(self, mock_detector_class): + """Test auto-detecting project successfully.""" + mock_detector = MagicMock() + mock_repo = MagicMock() + mock_remote = GitRemoteInfo( + name="origin", + url="git@gitlab.com:group/project.git", + gitlab_url="https://gitlab.com", + project_path="group/project" + ) + mock_detector.find_git_repository.return_value = mock_repo + mock_detector.get_gitlab_remotes.return_value = [mock_remote] + mock_detector_class.return_value = mock_detector + + gitlab_url, project_path = auto_detect_project() + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.GitAutoDetector') + def test_auto_detect_project_no_repo(self, mock_detector_class): + """Test auto-detect fails when no Git repository found.""" + mock_detector = MagicMock() + mock_detector.find_git_repository.return_value = None + mock_detector_class.return_value = mock_detector + + with pytest.raises(ProjectResolutionError) as exc_info: + auto_detect_project() + assert "No Git repository found" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_resolve_project_manually_with_url(self, mock_normalize): + """Test manual project resolution with URL.""" + mock_normalize.return_value = ("https://gitlab.com", "group/project") + + gitlab_url, project_path = resolve_project_manually( + project_url="https://gitlab.com/group/project", + project_path=None, + gitlab_url="https://gitlab.com" + ) + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_with_path(self): + """Test manual project resolution with path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="group/project", + gitlab_url="https://gitlab.com" + ) + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_with_nested_path(self): + """Test manual project resolution with nested path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="group/subgroup/project", + gitlab_url="https://gitlab.example.com" + ) + + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_invalid_path(self): + """Test manual resolution with invalid path format.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="invalid", # Missing namespace + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path format" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_manually_no_specification(self): + """Test manual resolution with no specification raises error.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path=None, + gitlab_url="https://gitlab.com" + ) + assert "No project specification provided" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_resolve_project_manually_url_parse_error(self, mock_normalize): + """Test manual resolution with URL parse error.""" + mock_normalize.side_effect = Exception("Invalid URL format") + + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url="not-a-valid-url", + project_path=None, + gitlab_url="https://gitlab.com" + ) + assert "Invalid project URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_manually_strips_slashes(self): + """Test manual resolution strips leading/trailing slashes from path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="/group/project/", + gitlab_url="https://gitlab.com" + ) + + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_empty_path_parts(self): + """Test manual resolution with empty path parts raises error.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="/project", # Empty namespace + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path" in str(exc_info.value) + + +class TestMainFunction: + """Tests for main function orchestration.""" + + @pytest.mark.timeout(60) + def test_main_no_subcommand_shows_help_and_exits_zero(self): + """Test main function with no subcommand exits with code 0.""" + with pytest.raises(SystemExit) as exc_info: + main([]) + + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + def test_main_help_flag(self): + """Test main function with --help exits with code 0.""" + with pytest.raises(SystemExit) as exc_info: + main(['--help']) + + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + def test_main_version_flag(self): + """Test main function with --version exits with code 0.""" + with pytest.raises(SystemExit) as exc_info: + main(['--version']) + + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + def test_main_upload_help_flag(self): + """Test main function with upload --help exits with code 0.""" + with pytest.raises(SystemExit) as exc_info: + main(['upload', '--help']) + + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + def test_main_conflicting_verbosity_flags(self): + """Test main function detects conflicting verbosity flags.""" + with pytest.raises(SystemExit) as exc_info: + main(['--verbose', '--quiet', 'upload', '--package-name', 'test', + '--package-version', '1.0.0', '--files', 'file.txt']) + + assert exc_info.value.code == 3 + + +class TestExceptionExitCodeMapping: + """Tests for exception exit code mapping.""" + + @pytest.mark.timeout(60) + def test_exception_exit_code_map_structure(self): + """Test EXCEPTION_EXIT_CODE_MAP has expected structure.""" + assert isinstance(EXCEPTION_EXIT_CODE_MAP, dict) + assert FileNotFoundError in EXCEPTION_EXIT_CODE_MAP + assert PermissionError in EXCEPTION_EXIT_CODE_MAP + assert ValueError in EXCEPTION_EXIT_CODE_MAP + assert ConnectionError in EXCEPTION_EXIT_CODE_MAP + assert TimeoutError in EXCEPTION_EXIT_CODE_MAP + + @pytest.mark.timeout(60) + def test_exception_exit_codes_are_integers(self): + """Test all exit codes are integers.""" + for exc_type, exit_code in EXCEPTION_EXIT_CODE_MAP.items(): + assert isinstance(exit_code, int) + assert exit_code > 0 + + @pytest.mark.timeout(60) + def test_file_not_found_exit_code(self): + """Test FileNotFoundError maps to exit code 5.""" + assert EXCEPTION_EXIT_CODE_MAP[FileNotFoundError] == 5 + + @pytest.mark.timeout(60) + def test_permission_error_exit_code(self): + """Test PermissionError maps to exit code 5.""" + assert EXCEPTION_EXIT_CODE_MAP[PermissionError] == 5 + + @pytest.mark.timeout(60) + def test_value_error_exit_code(self): + """Test ValueError maps to exit code 3.""" + assert EXCEPTION_EXIT_CODE_MAP[ValueError] == 3 + + @pytest.mark.timeout(60) + def test_connection_error_exit_code(self): + """Test ConnectionError maps to exit code 6.""" + assert EXCEPTION_EXIT_CODE_MAP[ConnectionError] == 6 + + @pytest.mark.timeout(60) + def test_timeout_error_exit_code(self): + """Test TimeoutError maps to exit code 6.""" + assert EXCEPTION_EXIT_CODE_MAP[TimeoutError] == 6 + + +class TestProjectResolverExceptionHandling: + """Tests for ProjectResolver exception handling.""" + + @pytest.mark.timeout(60) + def test_resolve_project_id_generic_exception(self, mock_gitlab_client): + """Test generic Exception in resolve_project_id raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = RuntimeError("Unexpected error") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError) as exc_info: + resolver.resolve_project_id("https://gitlab.com", "group/project") + + # The error should be wrapped in ProjectResolutionError + assert "Unexpected error" in str(exc_info.value) or exc_info.value is not None + + +class TestResolveProjectManuallyEdgeCases: + """Tests for edge cases in resolve_project_manually.""" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_path_empty_components(self): + """Test manual resolution with path that has empty components after split.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="//project", # Empty namespace component + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path" in str(exc_info.value) + + +class TestGetVersionFallbacks: + """Tests for get_version function fallback behavior.""" + + @pytest.mark.timeout(60) + @patch('builtins.open', side_effect=FileNotFoundError("pyproject.toml not found")) + def test_get_version_file_not_found_fallback(self, mock_open): + """Test get_version falls back when pyproject.toml not found.""" + with patch('importlib.metadata.version', side_effect=Exception("Not installed")): + version = get_version() + # Should return "unknown" when all methods fail + assert version == "unknown" or isinstance(version, str) + + @pytest.mark.timeout(60) + @patch('builtins.open', side_effect=Exception("Read error")) + def test_get_version_read_error_fallback(self, mock_open): + """Test get_version handles exceptions gracefully.""" + version = get_version() + # Should return "unknown" or actual version + assert isinstance(version, str) + + +class TestMainFunctionEdgeCases: + """Tests for main function edge cases.""" + + @pytest.mark.timeout(60) + def test_main_with_only_debug_flag_no_subcommand(self): + """Test main with only debug flag and no subcommand.""" + with pytest.raises(SystemExit) as exc_info: + main(['--debug']) + # Should exit with 0 (show help) + assert exc_info.value.code == 0 + + +class TestEdgeCases: + """Tests for edge cases and error scenarios.""" + + @pytest.mark.timeout(60) + def test_git_auto_detector_with_empty_remotes(self, mock_git_repo): + """Test GitAutoDetector with repository that has no remotes.""" + mock_git_repo.remotes = [] + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No Git remotes configured" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_project_resolver_with_nested_subgroups(self, mock_gitlab_client): + """Test ProjectResolver with deeply nested subgroups.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + project_id = resolver.resolve_project_id( + "https://gitlab.com", + "group/subgroup1/subgroup2/project" + ) + + assert project_id == 12345 + + @pytest.mark.timeout(60) + def test_git_auto_detector_multiple_urls_per_remote(self, mock_git_repo): + """Test GitAutoDetector handles multiple URLs per remote.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + # Multiple URLs - should use first valid one + mock_remote.urls = iter([ + "git@gitlab.com:group/project.git", + "https://gitlab.com/group/project.git" + ]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=("https://gitlab.com", "group/project")): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + # Should only have one remote info (uses first valid URL) + assert len(remotes) == 1 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.normalize_gitlab_url') + def test_project_resolver_deeply_nested_namespace(self, mock_normalize, mock_gitlab_client): + """Test parsing URL with deeply nested namespace.""" + mock_normalize.return_value = ( + "https://gitlab.com", + "org/team/sub1/sub2/project" + ) + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url( + "https://gitlab.com/org/team/sub1/sub2/project" + ) + + assert result.namespace == "org/team/sub1/sub2" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + def test_validate_upload_flags_multiple_errors_all_reported(self, mock_args, capsys): + """Test that multiple validation errors are all reported.""" + mock_args.package_name = None + mock_args.package_version = None + mock_args.files = None + mock_args.directory = None + + with pytest.raises(SystemExit) as exc_info: + validate_upload_flags(mock_args) + + assert exc_info.value.code == 3 + captured = capsys.readouterr() + # Should report all errors + assert "--package-name" in captured.err + assert "--package-version" in captured.err + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_debug(self, mock_args): + """Test verbosity with only debug flag set.""" + mock_args.debug = True + mock_args.verbose = False + mock_args.quiet = False + assert determine_verbosity(mock_args) == "debug" + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_verbose(self, mock_args): + """Test verbosity with only verbose flag set.""" + mock_args.debug = False + mock_args.verbose = True + mock_args.quiet = False + assert determine_verbosity(mock_args) == "verbose" + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_quiet(self, mock_args): + """Test verbosity with only quiet flag set.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = True + assert determine_verbosity(mock_args) == "quiet" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_json_output(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with JSON output enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.json_output = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.json_output is True + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_plain_output(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with plain output enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.plain = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.plain_output is True + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_fail_fast(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with fail_fast enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.fail_fast = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.fail_fast is True + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.DuplicateDetector') + def test_build_context_with_retry_count(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with custom retry count.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.retry = 5 + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.retry_count == 5 + + +class TestExecuteUpload: + """Tests for execute_upload function.""" + + @pytest.fixture + def upload_args(self, mock_args, tmp_path): + """Create args for execute_upload testing.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.files = [str(test_file)] + mock_args.project_url = "https://gitlab.com/mygroup/myproject" + mock_args.project_path = None + return mock_args + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.upload_files') + @patch('glpkg.cli.upload.OutputFormatter') + @patch('glpkg.cli.upload.collect_files') + def test_execute_upload_success( + self, + mock_collect, + mock_formatter_class, + mock_upload_files, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + upload_args, + tmp_path + ): + """Test successful execute_upload flow.""" + test_file = tmp_path / "test.bin" + + # Setup mocks + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([(test_file, "test.bin")], []) + + mock_result = MagicMock() + mock_result.success = True + mock_upload_files.return_value = [mock_result] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + execute_upload(upload_args) + + # Should exit with 0 for success + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.auto_detect_project') + def test_execute_upload_auto_detect_project_error(self, mock_auto_detect, mock_args): + """Test execute_upload handles ProjectResolutionError during auto-detect.""" + mock_args.project_url = None + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_auto_detect.side_effect = ProjectResolutionError("No Git repository found") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with the error's exit code + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_manual_resolution_error(self, mock_resolve, mock_args): + """Test execute_upload handles errors during manual project resolution.""" + mock_args.project_url = "https://gitlab.com/invalid" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.side_effect = ProjectResolutionError("Invalid project URL") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_authentication_error( + self, mock_resolve, mock_get_token, mock_args + ): + """Test execute_upload handles AuthenticationError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.side_effect = AuthenticationError("No token found") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_connection_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles ConnectionError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = ConnectionError("Network error") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 6 # Connection error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_no_valid_files( + self, + mock_resolve, + mock_collect, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + ): + """Test execute_upload exits when no valid files to upload.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["nonexistent.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # No valid files, only errors + mock_collect.return_value = ([], [{"source_path": "nonexistent.txt", "error_message": "Not found"}]) + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 5 # File validation error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.upload_files') + @patch('glpkg.cli.upload.OutputFormatter') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_with_failed_uploads( + self, + mock_resolve, + mock_collect, + mock_formatter_class, + mock_upload_files, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + tmp_path, + ): + """Test execute_upload exits with 1 when some uploads fail.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = [str(test_file)] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([(test_file, "test.bin")], []) + + # One failed upload + mock_result = MagicMock() + mock_result.success = False + mock_upload_files.return_value = [mock_result] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with 1 for failed uploads + assert exc_info.value.code == 1 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_project_access_denied( + self, + mock_resolve, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + ): + """Test execute_upload handles project access validation failure.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = False # Access denied + mock_resolver_class.return_value = mock_resolver + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_timeout_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles TimeoutError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = TimeoutError("Connection timed out") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 6 # Timeout error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_value_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles ValueError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = ValueError("Invalid value") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 3 # Value error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_unexpected_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles unexpected errors.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = RuntimeError("Unexpected error") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 1 # Generic error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_file_errors_fail_fast( + self, + mock_resolve, + mock_collect, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + tmp_path, + ): + """Test execute_upload with file errors and fail_fast enabled.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = [str(test_file)] + mock_args.directory = None + mock_args.file_mapping = None + mock_args.fail_fast = True + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # Some valid files, some errors + mock_collect.return_value = ( + [(test_file, "test.bin")], + [{"source_path": "bad.txt", "error_message": "Not found"}] + ) + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with 5 (file validation error) due to fail_fast + assert exc_info.value.code == 5 diff --git a/tests/unit/test_completion.py b/tests/unit/test_completion.py new file mode 100644 index 0000000..38712c1 --- /dev/null +++ b/tests/unit/test_completion.py @@ -0,0 +1,403 @@ +""" +Unit tests for the shell completion module. + +Tests cover completion script generation, path resolution, +installation functionality, and integration with the main CLI. + +Test Structure: + - TestGenerateCompletionScript: Tests for script generation + - TestGetCompletionPath: Tests for completion directory paths + - TestInstallCompletion: Tests for installation functionality + - TestMainIntegration: Tests for CLI integration + +Running Tests: + # Run all completion tests + pytest tests/unit/test_completion.py -v + + # Run specific test class + pytest tests/unit/test_completion.py::TestGenerateCompletionScript -v +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +from glpkg.cli.completion import ( + SUPPORTED_SHELLS, + COMPLETION_PATHS, + COMPLETION_FILENAMES, + generate_completion_script, + get_completion_path, + install_completion, +) +from glpkg.cli.main import create_argument_parser, main + +# Mark all tests as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestGenerateCompletionScript: + """Tests for generate_completion_script function.""" + + @pytest.mark.timeout(60) + def test_bash_script_generation(self): + """Test bash completion script generation returns non-empty string.""" + script = generate_completion_script("bash") + assert isinstance(script, str) + assert len(script) > 0 + assert "glpkg" in script + + @pytest.mark.timeout(60) + def test_zsh_script_generation(self): + """Test zsh completion script generation returns non-empty string.""" + script = generate_completion_script("zsh") + assert isinstance(script, str) + assert len(script) > 0 + assert "glpkg" in script + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + generate_completion_script("fish") + assert "Unsupported shell" in str(exc_info.value) + assert "fish" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_empty_shell_raises_error(self): + """Test empty shell string raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + generate_completion_script("") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.argcomplete.shellcode') + def test_shellcode_called_with_correct_args_bash(self, mock_shellcode): + """Test argcomplete.shellcode is called with correct arguments for bash.""" + mock_shellcode.return_value = "# bash completion script" + + generate_completion_script("bash") + + mock_shellcode.assert_called_once_with(["glpkg"], shell="bash") + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.argcomplete.shellcode') + def test_shellcode_called_with_correct_args_zsh(self, mock_shellcode): + """Test argcomplete.shellcode is called with correct arguments for zsh.""" + mock_shellcode.return_value = "# zsh completion script" + + generate_completion_script("zsh") + + mock_shellcode.assert_called_once_with(["glpkg"], shell="zsh") + + +class TestGetCompletionPath: + """Tests for get_completion_path function.""" + + @pytest.mark.timeout(60) + def test_bash_returns_correct_path(self): + """Test bash returns ~/.bash_completion.d/ expanded to absolute path.""" + path = get_completion_path("bash") + assert isinstance(path, Path) + assert path.is_absolute() + assert ".bash_completion.d" in str(path) + + @pytest.mark.timeout(60) + def test_zsh_returns_correct_path(self): + """Test zsh returns ~/.zsh/completion/ expanded to absolute path.""" + path = get_completion_path("zsh") + assert isinstance(path, Path) + assert path.is_absolute() + assert ".zsh" in str(path) + assert "completion" in str(path) + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + get_completion_path("fish") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_paths_are_path_objects(self): + """Test returned paths are Path objects.""" + for shell in SUPPORTED_SHELLS: + path = get_completion_path(shell) + assert isinstance(path, Path) + + @pytest.mark.timeout(60) + def test_paths_match_defined_constants(self): + """Test paths match the defined COMPLETION_PATHS constants.""" + for shell in SUPPORTED_SHELLS: + path = get_completion_path(shell) + expected = Path(COMPLETION_PATHS[shell]).expanduser() + assert path == expected + + +class TestInstallCompletion: + """Tests for install_completion function.""" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_successful_installation_bash( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test successful installation for bash.""" + mock_generate.return_value = "# bash completion script" + + install_completion("bash") + + mock_generate.assert_called_once_with("bash") + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_write.assert_called_once_with("# bash completion script") + mock_chmod.assert_called_once_with(0o644) + + captured = capsys.readouterr() + assert "bash" in captured.out + assert "installed" in captured.out.lower() + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_successful_installation_zsh( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test successful installation for zsh.""" + mock_generate.return_value = "# zsh completion script" + + install_completion("zsh") + + mock_generate.assert_called_once_with("zsh") + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_write.assert_called_once_with("# zsh completion script") + mock_chmod.assert_called_once_with(0o644) + + captured = capsys.readouterr() + assert "zsh" in captured.out + assert "installed" in captured.out.lower() + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_directory_creation_permission_error(self, mock_generate, mock_mkdir): + """Test permission error during directory creation.""" + mock_generate.return_value = "# script" + mock_mkdir.side_effect = PermissionError("Access denied") + + with pytest.raises(PermissionError) as exc_info: + install_completion("bash") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_directory_creation_os_error(self, mock_generate, mock_mkdir): + """Test OS error during directory creation.""" + mock_generate.return_value = "# script" + mock_mkdir.side_effect = OSError("Disk error") + + with pytest.raises(OSError) as exc_info: + install_completion("bash") + assert "Disk error" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_file_write_permission_error(self, mock_generate, mock_mkdir, mock_write): + """Test permission error during file write.""" + mock_generate.return_value = "# script" + mock_write.side_effect = PermissionError("Cannot write") + + with pytest.raises(PermissionError) as exc_info: + install_completion("bash") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_file_write_os_error(self, mock_generate, mock_mkdir, mock_write): + """Test OS error during file write.""" + mock_generate.return_value = "# script" + mock_write.side_effect = OSError("No space left") + + with pytest.raises(OSError) as exc_info: + install_completion("bash") + assert "No space left" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + install_completion("fish") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_activation_instructions_bash( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test activation instructions are printed for bash.""" + mock_generate.return_value = "# bash script" + + install_completion("bash") + + captured = capsys.readouterr() + assert "bashrc" in captured.out.lower() + assert "source" in captured.out + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_activation_instructions_zsh( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test activation instructions are printed for zsh.""" + mock_generate.return_value = "# zsh script" + + install_completion("zsh") + + captured = capsys.readouterr() + assert "fpath" in captured.out + assert "compinit" in captured.out + + +class TestMainIntegration: + """Tests for integration with main CLI parser.""" + + @pytest.mark.timeout(60) + def test_parser_has_install_completion_argument(self): + """Test argument parser has --install-completion option.""" + parser = create_argument_parser() + args = parser.parse_args([]) + assert hasattr(args, 'install_completion') + + @pytest.mark.timeout(60) + def test_install_completion_bash_parsing(self): + """Test --install-completion bash argument parsing.""" + parser = create_argument_parser() + args = parser.parse_args(['--install-completion', 'bash']) + assert args.install_completion == 'bash' + + @pytest.mark.timeout(60) + def test_install_completion_zsh_parsing(self): + """Test --install-completion zsh argument parsing.""" + parser = create_argument_parser() + args = parser.parse_args(['--install-completion', 'zsh']) + assert args.install_completion == 'zsh' + + @pytest.mark.timeout(60) + def test_invalid_shell_shows_error(self): + """Test invalid shell value shows error.""" + parser = create_argument_parser() + with pytest.raises(SystemExit): + parser.parse_args(['--install-completion', 'fish']) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_calls_install_completion(self, mock_install): + """Test main function calls install_completion for --install-completion.""" + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + mock_install.assert_called_once_with('bash') + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_value_error(self, mock_install): + """Test main function handles ValueError with exit code 3.""" + mock_install.side_effect = ValueError("Unsupported shell") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_permission_error(self, mock_install): + """Test main function handles PermissionError with exit code 5.""" + mock_install.side_effect = PermissionError("Access denied") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_os_error(self, mock_install): + """Test main function handles OSError with exit code 5.""" + mock_install.side_effect = OSError("Disk error") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + def test_install_completion_before_subcommand(self): + """Test --install-completion is processed before subcommand requirement.""" + parser = create_argument_parser() + # Should not require a subcommand when --install-completion is used + args = parser.parse_args(['--install-completion', 'bash']) + assert args.install_completion == 'bash' + assert args.command is None + + +class TestConstants: + """Tests for module constants.""" + + @pytest.mark.timeout(60) + def test_supported_shells_contains_bash(self): + """Test SUPPORTED_SHELLS contains bash.""" + assert "bash" in SUPPORTED_SHELLS + + @pytest.mark.timeout(60) + def test_supported_shells_contains_zsh(self): + """Test SUPPORTED_SHELLS contains zsh.""" + assert "zsh" in SUPPORTED_SHELLS + + @pytest.mark.timeout(60) + def test_completion_paths_defined_for_all_shells(self): + """Test COMPLETION_PATHS has entries for all supported shells.""" + for shell in SUPPORTED_SHELLS: + assert shell in COMPLETION_PATHS + + @pytest.mark.timeout(60) + def test_completion_filenames_defined_for_all_shells(self): + """Test COMPLETION_FILENAMES has entries for all supported shells.""" + for shell in SUPPORTED_SHELLS: + assert shell in COMPLETION_FILENAMES + + @pytest.mark.timeout(60) + def test_bash_filename_is_glpkg(self): + """Test bash completion filename is 'glpkg'.""" + assert COMPLETION_FILENAMES["bash"] == "glpkg" + + @pytest.mark.timeout(60) + def test_zsh_filename_is_underscored(self): + """Test zsh completion filename follows convention with underscore.""" + assert COMPLETION_FILENAMES["zsh"] == "_glpkg" diff --git a/tests/unit/test_duplicate_detector.py b/tests/unit/test_duplicate_detector.py new file mode 100644 index 0000000..9150a68 --- /dev/null +++ b/tests/unit/test_duplicate_detector.py @@ -0,0 +1,934 @@ +""" +Comprehensive unit tests for the duplicate_detector module. + +These tests validate the DuplicateDetector class and related helper functions +for session-level and remote duplicate detection. All external dependencies +(GitLab API, filesystem, network) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import hashlib +import time +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from glpkg.duplicate_detector import ( + DuplicateDetector, + calculate_sha256, + handle_network_error_with_retry, +) +from glpkg.models import FileFingerprint, RemoteFile + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock GitLab client for testing.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + return mock_gl + + +@pytest.fixture +def mock_project() -> MagicMock: + """Create a mock GitLab project for testing.""" + mock_proj = MagicMock() + mock_proj.id = 12345 + mock_proj.packages = MagicMock() + mock_proj.generic_packages = MagicMock() + return mock_proj + + +@pytest.fixture +def sample_file_path(tmp_path) -> Path: + """Create a sample file for testing.""" + file_path = tmp_path / "test_file.txt" + file_path.write_text("test content for hashing") + return file_path + + +@pytest.fixture +def sample_checksum() -> str: + """Return a valid SHA256 hex string (64 characters).""" + return "a" * 64 + + +@pytest.fixture +def mock_package_file() -> MagicMock: + """Create a mock package file with checksum.""" + mock_file = MagicMock() + mock_file.id = 1001 + mock_file.file_name = "test.bin" + mock_file.file_sha256 = "a" * 64 + mock_file.size = 1024 + return mock_file + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestCalculateSHA256: + """Test the calculate_sha256 helper function.""" + + @pytest.mark.timeout(60) + def test_calculate_sha256_text_file(self, tmp_path): + """Test SHA256 calculation for a text file.""" + test_file = tmp_path / "test.txt" + content = b"Hello, World!" + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert len(result) == 64 + + @pytest.mark.timeout(60) + def test_calculate_sha256_binary_file(self, tmp_path): + """Test SHA256 calculation for a binary file.""" + test_file = tmp_path / "binary.bin" + content = bytes(range(256)) + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_empty_file(self, tmp_path): + """Test SHA256 calculation for an empty file returns expected empty file SHA256.""" + test_file = tmp_path / "empty.txt" + test_file.write_bytes(b"") + + expected = hashlib.sha256(b"").hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert result == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + @pytest.mark.timeout(60) + def test_calculate_sha256_large_file(self, tmp_path): + """Test SHA256 calculation reads file in chunks (8192 bytes).""" + test_file = tmp_path / "large.bin" + # Create a file larger than 8192 bytes to test chunked reading + content = b"x" * 50000 + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_file_not_found(self): + """Test FileNotFoundError is raised for missing file.""" + nonexistent = Path("/nonexistent/path/to/file.txt") + + with pytest.raises(FileNotFoundError): + calculate_sha256(nonexistent) + + @pytest.mark.timeout(60) + def test_calculate_sha256_permission_error(self, tmp_path): + """Test PermissionError is raised for unreadable file.""" + test_file = tmp_path / "unreadable.txt" + test_file.write_bytes(b"content") + + with patch("builtins.open", side_effect=PermissionError("Permission denied")): + with pytest.raises(PermissionError): + calculate_sha256(test_file) + + +class TestHandleNetworkErrorWithRetry: + """Test the retry logic helper function.""" + + @pytest.mark.timeout(60) + def test_retry_success_first_attempt(self): + """Test operation succeeds immediately, no retries needed.""" + mock_operation = MagicMock(return_value="success") + + with patch("time.sleep"): + result = handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + ) + + assert result == "success" + mock_operation.assert_called_once() + + @pytest.mark.timeout(60) + def test_retry_success_after_failures(self): + """Test operation fails twice then succeeds, verify retry count.""" + call_count = 0 + + def operation_with_failures(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("Network error") + return "success" + + with patch("time.sleep"): + result = handle_network_error_with_retry( + operation_name="test op", + operation_func=operation_with_failures, + max_retries=3, + ) + + assert result == "success" + assert call_count == 3 + + @pytest.mark.timeout(60) + def test_retry_exhausted_raises_exception(self): + """Test all retries fail, verify last exception is raised.""" + mock_operation = MagicMock(side_effect=ConnectionError("Persistent failure")) + + with patch("time.sleep"): + with pytest.raises(ConnectionError) as exc_info: + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=2, + ) + + assert "Persistent failure" in str(exc_info.value) + assert mock_operation.call_count == 3 # Initial + 2 retries + + @pytest.mark.timeout(60) + def test_retry_with_custom_delays(self): + """Test custom retry delay list is respected.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + custom_delays = [5, 10, 15] + + with patch("time.sleep") as mock_sleep: + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=3, + retry_delays=custom_delays, + ) + + # Verify sleep was called with custom delays + sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] + assert sleep_calls == [5, 10, 15] + + @pytest.mark.timeout(60) + def test_retry_logs_attempts(self, caplog): + """Test logging calls for each retry attempt.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + + with patch("time.sleep"): + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test operation", + operation_func=mock_operation, + max_retries=2, + ) + + # Check that retry attempts were logged + assert "test operation failed" in caplog.text + assert "attempt" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_retry_default_delays(self): + """Test default delays [1, 2, 4] are used when not specified.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + + with patch("time.sleep") as mock_sleep: + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=3, + ) + + sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] + assert sleep_calls == [1, 2, 4] + + +class TestDuplicateDetectorInit: + """Test DuplicateDetector initialization.""" + + @pytest.mark.timeout(60) + def test_init_with_valid_params(self, mock_gitlab_client): + """Test detector creation with mock client and project ID.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.gl is mock_gitlab_client + assert detector.project_id == 12345 + assert isinstance(detector.session_registry, dict) + + @pytest.mark.timeout(60) + def test_init_session_registry_empty(self, mock_gitlab_client): + """Test session_registry starts as empty dict.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.session_registry == {} + assert len(detector.session_registry) == 0 + + @pytest.mark.timeout(60) + def test_init_stores_gitlab_client(self, mock_gitlab_client): + """Test GitLab client is stored correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.gl is mock_gitlab_client + + @pytest.mark.timeout(60) + def test_init_stores_project_id(self, mock_gitlab_client): + """Test project ID is stored correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=99999) + + assert detector.project_id == 99999 + + +class TestCheckSessionDuplicate: + """Test session-level duplicate detection.""" + + @pytest.mark.timeout(60) + def test_no_session_duplicate_when_empty(self, mock_gitlab_client, sample_file_path): + """Test empty registry returns None.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_session_duplicate(sample_file_path, "target.txt") + + assert result is None + + @pytest.mark.timeout(60) + def test_session_duplicate_same_checksum(self, mock_gitlab_client, tmp_path): + """Test file with same target name and checksum returns FileFingerprint.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with identical content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"identical content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum) + + # Check for duplicate with second file + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert isinstance(result, FileFingerprint) + assert result.target_filename == "target.txt" + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_session_duplicate_different_checksum(self, mock_gitlab_client, tmp_path, caplog): + """Test same target name but different checksum returns None, logs warning.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with different content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_bytes(b"content version 1") + file2.write_bytes(b"content version 2") + + checksum1 = hashlib.sha256(b"content version 1").hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum1) + + # Check for duplicate with second file (different content) + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is None + assert "different content" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_session_duplicate_different_source_path(self, mock_gitlab_client, tmp_path): + """Test same target name and checksum but different source path still detected as duplicate.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with identical content in different locations + subdir = tmp_path / "subdir" + subdir.mkdir() + + file1 = tmp_path / "file1.txt" + file2 = subdir / "file2.txt" + content = b"same content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum) + + # Check for duplicate with second file from different location + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_session_duplicate_checksum_calculation(self, mock_gitlab_client, tmp_path): + """Test calculate_sha256 is called with correct file path.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + detector.register_file(file1, "target.txt", checksum) + + with patch( + "glpkg.duplicate_detector.calculate_sha256", + return_value=checksum, + ) as mock_calc: + detector.check_session_duplicate(file2, "target.txt") + mock_calc.assert_called_once_with(file2) + + @pytest.mark.timeout(60) + def test_session_duplicate_logging(self, mock_gitlab_client, tmp_path, caplog): + """Test appropriate log messages for duplicate detection.""" + import logging + + caplog.set_level(logging.DEBUG) + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + detector.register_file(file1, "target.txt", checksum) + + detector.check_session_duplicate(file2, "target.txt") + + assert "Session duplicate detected" in caplog.text + + +class TestCheckRemoteDuplicate: + """Test remote duplicate detection with GitLab API.""" + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_package_not_found( + self, mock_gitlab_client, mock_project + ): + """Test packages.list returns empty, verify None returned.""" + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_version_not_found( + self, mock_gitlab_client, mock_project + ): + """Test package exists but version doesn't match, verify None.""" + mock_package = MagicMock() + mock_package.version = "2.0.0" # Different version + mock_package.id = 1 + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_filename_not_found( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test package and version exist but filename doesn't match, verify None.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_remote_duplicate_checksum_match( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test package file with matching checksum, verify RemoteFile returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert isinstance(result, RemoteFile) + assert result.filename == "test.bin" + assert result.sha256_checksum == "a" * 64 + + @pytest.mark.timeout(60) + def test_remote_duplicate_checksum_mismatch( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test filename matches but checksum differs, verify None returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "b" * 64 # Different checksum + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_remote_duplicate_no_checksum_available( + self, mock_gitlab_client, mock_project, caplog + ): + """Test remote file has no checksum attribute, verify None returned with warning.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "test.bin" + # No file_sha256 attribute - use spec to control what attrs exist + del mock_file.file_sha256 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert "checksum not available" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_remote_duplicate_case_insensitive_checksum( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test checksum comparison is case-insensitive.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "AABBCC" + "d" * 58 # Upper case + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="aabbcc" + "d" * 58, # Lower case + ) + + assert result is not None + assert isinstance(result, RemoteFile) + + @pytest.mark.timeout(60) + def test_remote_duplicate_constructs_download_url( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test RemoteFile has correctly formatted download URL.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert "test-pkg" in result.download_url + assert "1.0.0" in result.download_url + assert "test.bin" in result.download_url + assert "12345" in result.download_url + + @pytest.mark.timeout(60) + def test_remote_duplicate_retry_on_network_error(self, mock_gitlab_client): + """Test network error triggers retry logic.""" + call_count = 0 + + def mock_get(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("Network error") + mock_proj = MagicMock() + mock_proj.packages.list.return_value = [] + return mock_proj + + mock_gitlab_client.projects.get.side_effect = mock_get + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + with patch("time.sleep"): + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert call_count == 3 + + @pytest.mark.timeout(60) + def test_remote_duplicate_returns_none_on_persistent_error( + self, mock_gitlab_client, caplog + ): + """Test all retries fail, verify None returned (not exception).""" + mock_gitlab_client.projects.get.side_effect = ConnectionError("Persistent error") + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + with patch("time.sleep"): + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert "Proceeding without duplicate detection" in caplog.text + + @pytest.mark.timeout(60) + def test_remote_duplicate_multiple_files_same_name( + self, mock_gitlab_client, mock_project + ): + """Test multiple files with same name, verify correct one matched by checksum.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + # Create multiple files with same name but different checksums + mock_file1 = MagicMock() + mock_file1.file_name = "test.bin" + mock_file1.file_sha256 = "b" * 64 + mock_file1.id = 1001 + mock_file1.size = 1024 + + mock_file2 = MagicMock() + mock_file2.file_name = "test.bin" + mock_file2.file_sha256 = "a" * 64 # This one matches + mock_file2.id = 1002 + mock_file2.size = 2048 + + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [mock_file1, mock_file2] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert result.sha256_checksum == "a" * 64 + assert result.file_id == 1002 + + +class TestRegisterFile: + """Test file registration in session.""" + + @pytest.mark.timeout(60) + def test_register_file_creates_fingerprint(self, mock_gitlab_client, tmp_path): + """Test register file creates FileFingerprint with correct attributes.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + checksum = "a" * 64 + + detector.register_file(test_file, "target.txt", checksum) + + fingerprint = detector.session_registry.get("target.txt") + assert fingerprint is not None + assert isinstance(fingerprint, FileFingerprint) + assert fingerprint.target_filename == "target.txt" + assert fingerprint.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_register_file_adds_to_registry(self, mock_gitlab_client, tmp_path): + """Test file added to session_registry with target_filename as key.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + + detector.register_file(test_file, "target.txt", "a" * 64) + + assert "target.txt" in detector.session_registry + assert len(detector.session_registry) == 1 + + @pytest.mark.timeout(60) + def test_register_file_overwrites_existing(self, mock_gitlab_client, tmp_path): + """Test register same target_filename twice, verify second overwrites first.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + detector.register_file(file1, "target.txt", "a" * 64) + detector.register_file(file2, "target.txt", "b" * 64) + + assert len(detector.session_registry) == 1 + assert detector.session_registry["target.txt"].sha256_checksum == "b" * 64 + + @pytest.mark.timeout(60) + def test_register_file_uses_file_stats(self, mock_gitlab_client, tmp_path): + """Test Path.stat() is used to extract file size correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + content = b"test content for size verification" + test_file.write_bytes(content) + + detector.register_file(test_file, "target.txt", "a" * 64) + + fingerprint = detector.session_registry["target.txt"] + assert fingerprint.file_size == len(content) + + @pytest.mark.timeout(60) + def test_register_file_uses_current_timestamp(self, mock_gitlab_client, tmp_path): + """Test time.time() is used to record timestamp.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + + mock_time = 1704067200.0 # Fixed timestamp + + with patch("glpkg.duplicate_detector.time.time", return_value=mock_time): + detector.register_file(test_file, "target.txt", "a" * 64) + + fingerprint = detector.session_registry["target.txt"] + assert fingerprint.timestamp == mock_time + + @pytest.mark.timeout(60) + def test_register_file_logging(self, mock_gitlab_client, tmp_path, caplog): + """Test registration is logged with checksum.""" + import logging + + caplog.set_level(logging.DEBUG) + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + checksum = "abcd1234" + "e" * 56 + + detector.register_file(test_file, "target.txt", checksum) + + assert "Registered file in session" in caplog.text + assert "target.txt" in caplog.text + assert "abcd1234" in caplog.text + + +class TestDuplicateDetectorIntegration: + """Integration tests for complete workflows.""" + + @pytest.mark.timeout(60) + def test_workflow_register_then_check_session(self, mock_gitlab_client, tmp_path): + """Test register file, then check for session duplicate, verify found.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with same content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"identical content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register first file + detector.register_file(file1, "target.txt", checksum) + + # Check second file for session duplicate + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_workflow_check_remote_then_register( + self, mock_gitlab_client, mock_project, tmp_path + ): + """Test check remote (not found), register, check session (found).""" + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Check remote - not found + remote_result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="target.txt", + checksum=checksum, + ) + assert remote_result is None + + # Register file + detector.register_file(file1, "target.txt", checksum) + + # Check session - found + session_result = detector.check_session_duplicate(file2, "target.txt") + assert session_result is not None + + @pytest.mark.timeout(60) + def test_workflow_multiple_files_different_names(self, mock_gitlab_client, tmp_path): + """Test register multiple files with different names, verify all tracked.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + files = [] + for i in range(5): + f = tmp_path / f"file{i}.txt" + f.write_bytes(f"content {i}".encode()) + files.append(f) + detector.register_file(f, f"target{i}.txt", f"{'a' * 63}{i}") + + assert len(detector.session_registry) == 5 + for i in range(5): + assert f"target{i}.txt" in detector.session_registry + + @pytest.mark.timeout(60) + def test_workflow_session_registry_size(self, mock_gitlab_client, tmp_path): + """Test register N files, verify session_registry has N entries.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + n = 10 + for i in range(n): + f = tmp_path / f"file{i}.txt" + f.write_bytes(f"content {i}".encode()) + detector.register_file(f, f"target{i}.txt", f"{'a' * 63}{i}") + + assert len(detector.session_registry) == n diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py new file mode 100644 index 0000000..6067ece --- /dev/null +++ b/tests/unit/test_formatters.py @@ -0,0 +1,1703 @@ +""" +Comprehensive unit tests for the formatters module. + +These tests validate the output formatting functionality including terminal +detection, rich console output, JSON output, plain text output, error +formatting, and progress display. + +All Rich console/spinner dependencies are mocked to prevent real terminal +rendering and ensure all output is captured via StringIO/mocks. +""" + +from __future__ import annotations + +import io +import json +import os +import re +import sys +from contextlib import contextmanager +from typing import Any, Dict, Generator, List, Optional +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from glpkg.formatters import ( + OutputFormatter, + detect_color_support, + detect_tty, + detect_unicode_support, + display_progress, + format_error, + get_formatter, +) +from glpkg.models import ( + DuplicatePolicy, + GitLabUploadError, + UploadConfig, + UploadResult, +) +from tests.utils.test_helpers import validate_json_result + +# Mark these as fast unit tests +pytestmark = [pytest.mark.fast, pytest.mark.unit] + + +# ============================================================================= +# Mock Console and Status Classes +# ============================================================================= + + +class MockConsole: + """Mock Console that captures output to a StringIO buffer without terminal rendering.""" + + def __init__(self, *args, **kwargs): + self._buffer = io.StringIO() + self._force_terminal = kwargs.get("force_terminal", False) + self._file = kwargs.get("file", self._buffer) + + def print(self, *args, **kwargs): + """Capture print output to buffer.""" + text = " ".join(str(arg) for arg in args) + self._file.write(text + "\n") + + def rule(self, title="", **kwargs): + """Capture rule output.""" + self._file.write(f"--- {title} ---\n") + + def status(self, message, **kwargs): + """Return a mock status context manager.""" + return MockStatus(message, console=self) + + def getvalue(self): + """Get captured output.""" + if hasattr(self._file, "getvalue"): + return self._file.getvalue() + return self._buffer.getvalue() + + +class MockStatus: + """Mock Status that doesn't perform real terminal rendering.""" + + def __init__(self, message="", console=None, **kwargs): + self._message = message + self._console = console + self._started = False + + def __enter__(self): + self._started = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._started = False + return False + + def start(self): + self._started = True + + def stop(self): + self._started = False + + def update(self, message): + self._message = message + + +@pytest.fixture +def mock_rich_console(): + """Fixture that patches rich.console.Console with MockConsole.""" + with patch("rich.console.Console", MockConsole): + with patch("glpkg.formatters.Console", MockConsole): + yield MockConsole + + +@pytest.fixture +def mock_rich_status(): + """Fixture that patches rich.status.Status with MockStatus.""" + with patch("rich.status.Status", MockStatus): + with patch("glpkg.formatters.Status", MockStatus): + yield MockStatus + + +# ============================================================================= +# Helper Functions and Fixtures +# ============================================================================= + + +def create_upload_config( + package_name: str = "test-package", + version: str = "1.0.0", + duplicate_policy: DuplicatePolicy = DuplicatePolicy.SKIP, + retry_count: int = 3, + verbosity: str = "normal", + dry_run: bool = False, + fail_fast: bool = False, + json_output: bool = False, + plain_output: bool = False, + gitlab_url: str = "https://gitlab.com", + token: Optional[str] = "test-token", +) -> UploadConfig: + """Factory function to create UploadConfig with customizable parameters.""" + return UploadConfig( + package_name=package_name, + version=version, + duplicate_policy=duplicate_policy, + retry_count=retry_count, + verbosity=verbosity, + dry_run=dry_run, + fail_fast=fail_fast, + json_output=json_output, + plain_output=plain_output, + gitlab_url=gitlab_url, + token=token, + ) + + +def create_upload_result( + source_path: str = "/path/to/file.txt", + target_filename: str = "file.txt", + success: bool = True, + result: str = "https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file.txt", + was_duplicate: bool = False, + duplicate_action: Optional[str] = None, + existing_url: Optional[str] = None, +) -> UploadResult: + """Factory function to create UploadResult with customizable parameters.""" + return UploadResult( + source_path=source_path, + target_filename=target_filename, + success=success, + result=result, + was_duplicate=was_duplicate, + duplicate_action=duplicate_action, + existing_url=existing_url, + ) + + +def assert_no_ansi_codes(text: str) -> None: + """Helper to verify string contains no ANSI escape sequences.""" + # ANSI escape code pattern + ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + matches = ansi_pattern.findall(text) + assert not matches, f"Found ANSI escape codes in text: {matches}" + + +def assert_valid_json(text: str) -> Dict[str, Any]: + """Helper to parse and validate JSON structure.""" + try: + return json.loads(text) + except json.JSONDecodeError as e: + pytest.fail(f"Invalid JSON: {e}\nText: {text}") + + +@contextmanager +def capture_stdout() -> Generator[io.StringIO, None, None]: + """Context manager to capture stdout.""" + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + yield captured + finally: + sys.stdout = old_stdout + + +@contextmanager +def capture_stderr() -> Generator[io.StringIO, None, None]: + """Context manager to capture stderr.""" + captured = io.StringIO() + old_stderr = sys.stderr + sys.stderr = captured + try: + yield captured + finally: + sys.stderr = old_stderr + + +@pytest.fixture +def mock_upload_config() -> UploadConfig: + """Fixture that returns a basic UploadConfig instance with default values.""" + return create_upload_config() + + +@pytest.fixture +def mock_upload_results() -> List[UploadResult]: + """Fixture that returns a list of mock UploadResult objects with various scenarios.""" + return [ + # Successful new upload + create_upload_result( + source_path="/path/to/file1.txt", + target_filename="file1.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file1.txt", + ), + # Successful replaced duplicate + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file2.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file2.txt", + ), + # Skipped duplicate + create_upload_result( + source_path="/path/to/file3.txt", + target_filename="file3.txt", + success=True, + result="Skipped: duplicate detected", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file3.txt", + ), + # Failed upload + create_upload_result( + source_path="/path/to/file4.txt", + target_filename="file4.txt", + success=False, + result="Upload failed: network error", + ), + ] + + +@pytest.fixture +def clean_env(): + """Fixture that provides a clean environment for terminal detection tests.""" + # Save original environment + original_env = os.environ.copy() + + # Remove terminal-related variables + vars_to_remove = [ + "NO_COLOR", "FORCE_COLOR", "COLORTERM", "TERM", + "WT_SESSION", "ANSICON", "ConEmuANSI", + "LANG", "LC_ALL", + ] + for var in vars_to_remove: + os.environ.pop(var, None) + + yield + + # Restore original environment + os.environ.clear() + os.environ.update(original_env) + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestTerminalDetection: + """Tests for terminal detection functions.""" + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_tty(self): + """Test detect_tty returns True when stdout.isatty() returns True.""" + mock_stdout = Mock() + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is True + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_not_tty(self): + """Test detect_tty returns False when stdout.isatty() returns False.""" + mock_stdout = Mock() + mock_stdout.isatty.return_value = False + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_none(self): + """Test detect_tty returns False when stdout is None.""" + with patch.object(sys, "stdout", None): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_isatty_missing(self): + """Test detect_tty returns False when stdout lacks isatty attribute.""" + mock_stdout = object() # Object without isatty + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_exception_raised(self): + """Test detect_tty returns False when isatty() raises exception.""" + mock_stdout = Mock() + mock_stdout.isatty.side_effect = OSError("Permission denied") + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_with_no_color_env(self, clean_env): + """Test detect_color_support returns False when NO_COLOR is set.""" + os.environ["NO_COLOR"] = "1" + + with patch("glpkg.formatters.detect_tty", return_value=True): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_with_force_color_env(self, clean_env): + """Test detect_color_support returns True when FORCE_COLOR is set.""" + os.environ["FORCE_COLOR"] = "1" + + with patch("glpkg.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_with_colorterm_env(self, clean_env): + """Test detect_color_support returns True when COLORTERM is set.""" + os.environ["COLORTERM"] = "truecolor" + + with patch("glpkg.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_with_term_color(self, clean_env): + """Test detect_color_support returns True when TERM contains color.""" + os.environ["TERM"] = "xterm-256color" + + with patch("glpkg.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_without_tty(self, clean_env): + """Test detect_color_support returns False when not in a TTY.""" + with patch("glpkg.formatters.detect_tty", return_value=False): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_wt_session(self, clean_env): + """Test detect_color_support returns True on Windows with WT_SESSION.""" + os.environ["WT_SESSION"] = "some-session-id" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_ansicon(self, clean_env): + """Test detect_color_support returns True on Windows with ANSICON.""" + os.environ["ANSICON"] = "1" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_conemu_on(self, clean_env): + """Test detect_color_support returns True on Windows with ConEmuANSI=ON.""" + os.environ["ConEmuANSI"] = "ON" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_conemu_off(self, clean_env): + """Test detect_color_support returns False on Windows with ConEmuANSI not ON.""" + os.environ["ConEmuANSI"] = "OFF" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + # Should return False as only "ON" enables color support + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_precedence(self, clean_env): + """Test that NO_COLOR takes precedence over FORCE_COLOR.""" + os.environ["NO_COLOR"] = "1" + os.environ["FORCE_COLOR"] = "1" + + with patch("glpkg.formatters.detect_tty", return_value=True): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_utf8_encoding(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 encoding.""" + mock_stdout = Mock() + mock_stdout.encoding = "utf-8" + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_lang_utf8(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 LANG.""" + os.environ["LANG"] = "en_US.UTF-8" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" # Non-UTF8 + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_lc_all_utf8(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 LC_ALL.""" + os.environ["LC_ALL"] = "en_US.UTF-8" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" # Non-UTF8 + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_without_tty(self, clean_env): + """Test detect_unicode_support returns False when not in a TTY.""" + with patch("glpkg.formatters.detect_tty", return_value=False): + assert detect_unicode_support() is False + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_ascii_encoding(self, clean_env): + """Test detect_unicode_support returns False with ASCII encoding.""" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is False + + @pytest.mark.timeout(60) + def test_detect_unicode_support_encoding_raises_exception(self, clean_env): + """Test detect_unicode_support handles exception when accessing encoding.""" + mock_stdout = Mock() + # Make encoding access raise an exception + type(mock_stdout).encoding = property(lambda self: (_ for _ in ()).throw(AttributeError("No encoding"))) + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + with patch("glpkg.formatters.detect_tty", return_value=True): + # Should return False as encoding check failed + result = detect_unicode_support() + assert result is False + + +class TestOutputFormatterInit: + """Tests for OutputFormatter initialization.""" + + @pytest.mark.timeout(60) + def test_init_with_plain_output_flag(self, mock_rich_console): + """Test OutputFormatter with plain_output=True disables all capabilities.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + assert formatter.is_tty is False + assert formatter.supports_color is False + assert formatter.supports_unicode is False + + @pytest.mark.timeout(60) + def test_init_without_plain_output_flag(self, mock_rich_console): + """Test OutputFormatter detects terminal capabilities when plain_output=False.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_unicode_support", return_value=True): + formatter = OutputFormatter(config) + + assert formatter.is_tty is True + assert formatter.supports_color is True + assert formatter.supports_unicode is True + + @pytest.mark.timeout(60) + def test_init_console_configuration(self, mock_rich_console): + """Test Console is initialized with correct parameters.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + # Check that console was created (using MockConsole) + assert formatter.console is not None + assert isinstance(formatter.console, MockConsole) + + @pytest.mark.timeout(60) + def test_init_with_json_output_flag(self, mock_rich_console): + """Test OutputFormatter initializes correctly with json_output=True.""" + config = create_upload_config(json_output=True) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + assert formatter.config.json_output is True + + @pytest.mark.timeout(60) + def test_init_stores_config(self, mock_rich_console): + """Test that OutputFormatter stores the config reference.""" + config = create_upload_config() + formatter = OutputFormatter(config) + + assert formatter.config is config + + +class TestRichOutputFormatting: + """Tests for rich console output formatting.""" + + @pytest.mark.timeout(60) + def test_format_rich_output_successful_uploads(self, mock_rich_console): + """Test rich output displays successful uploads correctly.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_unicode_support", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + ) + ] + + # Capture the console output using MockConsole + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Successful Uploads" in output + assert "file.txt" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_skipped_duplicates(self, mock_rich_console): + """Test rich output displays skipped duplicates correctly.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Skipped Duplicates" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_failed_uploads(self, mock_rich_console): + """Test rich output displays failed uploads correctly.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Failed Uploads" in output + assert "network error" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_replaced_duplicates(self, mock_rich_console): + """Test rich output displays replaced duplicates correctly.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Replaced existing duplicate" in output + assert "Previous URL" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_statistics(self, mock_upload_results, mock_rich_console): + """Test rich output displays statistics correctly.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Duplicate Detection Statistics" in output + assert "New uploads:" in output + assert "Replaced duplicates:" in output + assert "Skipped duplicates:" in output + assert "Failed uploads:" in output + assert "Total processed:" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_failed_with_duplicate_info(self, mock_rich_console): + """Test rich output displays failed uploads with duplicate metadata.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Duplicate file detected", + was_duplicate=True, + duplicate_action="error", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Failed Uploads" in output + assert "Duplicate Action:" in output or "error" in output + assert "Existing URL:" in output or "existing" in output.lower() + + @pytest.mark.timeout(60) + def test_format_rich_output_empty_results(self, mock_rich_console): + """Test rich output handles empty results list.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output([], "test-package", "1.0.0") + + output = captured.getvalue() + assert "Upload Summary" in output + # Should show 0 in statistics + assert "Total processed:" in output + # MockConsole doesn't produce ANSI codes, so no stripping needed + assert "Total processed: 0" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_all_successful(self, mock_rich_console): + """Test rich output shows success message when all uploads succeed.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=True, + ), + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "All files processed successfully" in output + + +class TestJsonOutputFormatting: + """Tests for JSON output formatting.""" + + @pytest.mark.timeout(60) + def test_format_json_output_successful_uploads(self, mock_rich_console): + """Test JSON output structure for successful uploads.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["success"] is True + assert len(json_data["successful_uploads"]) == 1 + assert json_data["successful_uploads"][0]["target_filename"] == "file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_with_skipped_duplicates(self, mock_rich_console): + """Test JSON output includes skipped duplicates array.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert len(json_data["skipped_duplicates"]) == 1 + assert json_data["skipped_duplicates"][0]["duplicate_action"] == "skipped" + assert json_data["skipped_duplicates"][0]["existing_url"] == "https://gitlab.com/existing/file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_with_failed_uploads(self, mock_rich_console): + """Test JSON output includes failed_uploads array and error fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["success"] is False + assert len(json_data["failed_uploads"]) == 1 + assert "error" in json_data + assert "error_type" in json_data + + @pytest.mark.timeout(60) + def test_format_json_output_statistics_accuracy(self, mock_upload_results, mock_rich_console): + """Test JSON output statistics match actual counts.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter._format_json_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + stats = json_data["statistics"] + # mock_upload_results has: 1 new upload, 1 replaced, 1 skipped, 1 failed + assert stats["total_processed"] == 4 + assert stats["new_uploads"] == 1 + assert stats["replaced_duplicates"] == 1 + assert stats["skipped_duplicates"] == 1 + assert stats["failed_uploads"] == 1 + + @pytest.mark.timeout(60) + def test_format_json_output_duplicate_metadata(self, mock_rich_console): + """Test JSON output includes duplicate detection metadata.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + upload = json_data["successful_uploads"][0] + assert upload["was_duplicate"] is True + assert upload["duplicate_action"] == "replaced" + assert upload["existing_url"] == "https://gitlab.com/old/file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_exit_code_success(self, mock_rich_console): + """Test JSON output has exit_code=0 when no failures.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=True)] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["exit_code"] == 0 + + @pytest.mark.timeout(60) + def test_format_json_output_exit_code_failure(self, mock_rich_console): + """Test JSON output has exit_code=1 when failures exist.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=False, result="Error")] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["exit_code"] == 1 + + @pytest.mark.timeout(60) + def test_format_json_output_required_fields(self, mock_rich_console): + """Test JSON output contains all required fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + required_fields = [ + "success", "exit_code", "package_name", "version", + "successful_uploads", "statistics" + ] + for field in required_fields: + assert field in json_data, f"Missing required field: {field}" + + @pytest.mark.timeout(60) + def test_format_json_output_goes_to_stdout(self, mock_rich_console): + """Test JSON output is printed to stdout, not stderr.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as stdout_captured: + with capture_stderr() as stderr_captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + stdout_output = stdout_captured.getvalue() + stderr_output = stderr_captured.getvalue() + + # JSON should be in stdout + assert stdout_output.strip().startswith("{") + # stderr should be empty or contain only logs + assert "success" not in stderr_output + + @pytest.mark.timeout(60) + def test_format_json_output_validation(self, mock_rich_console): + """Test JSON output can be validated with validate_json_result helper.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = json.loads(output) + + # Use the helper from test_helpers + is_valid = validate_json_result( + json_data, + expected_success=True, + expected_files=["file.txt"] + ) + assert is_valid + + @pytest.mark.timeout(60) + def test_format_json_output_multiple_failures_error_message(self, mock_rich_console): + """Test JSON output error message for multiple failures.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file1.txt", + target_filename="file1.txt", + success=False, + result="Error 1", + ), + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=False, + result="Error 2", + ), + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert "2 file(s) failed" in json_data["error"] + + +class TestPlainTextOutputFormatting: + """Tests for plain text output formatting.""" + + @pytest.mark.timeout(60) + def test_format_plain_output_successful_uploads(self, mock_rich_console): + """Test plain output displays successful uploads with [OK] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[OK] Successful Uploads" in output + assert "file.txt" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_skipped_duplicates(self, mock_rich_console): + """Test plain output displays skipped duplicates with [SKIP] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=True, + result="Skipped", + was_duplicate=True, + duplicate_action="skipped", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[SKIP] Skipped Duplicates" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_failed_uploads(self, mock_rich_console): + """Test plain output displays failed uploads with [FAIL] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=False, + result="Network error", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[FAIL] Failed Uploads" in output + assert "Network error" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_no_color_codes(self, mock_rich_console): + """Test plain output contains no ANSI escape sequences.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result(success=False, result="Error"), + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert_no_ansi_codes(output) + + @pytest.mark.timeout(60) + def test_format_plain_output_no_unicode_characters(self, mock_rich_console): + """Test plain output uses only ASCII characters.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result(success=False, result="Error"), + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + # Check that all characters are ASCII + try: + output.encode("ascii") + except UnicodeEncodeError as e: + pytest.fail(f"Plain output contains non-ASCII characters: {e}") + + @pytest.mark.timeout(60) + def test_format_plain_output_statistics(self, mock_rich_console): + """Test plain output displays statistics with asterisk bullets.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=True)] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Duplicate Detection Statistics:" in output + assert "* New uploads:" in output + assert "* Replaced duplicates:" in output + assert "* Skipped duplicates:" in output + assert "* Failed uploads:" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_replaced_duplicates(self, mock_rich_console): + """Test plain output displays replacement action in plain text.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=True, + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Action: Replaced existing duplicate" in output + assert "Previous URL:" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_failed_with_duplicate_info(self, mock_rich_console): + """Test plain output displays failed uploads with duplicate metadata.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Duplicate file detected", + was_duplicate=True, + duplicate_action="error", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[FAIL] Failed Uploads" in output + assert "Duplicate Action:" in output + assert "error" in output + assert "Existing URL:" in output + assert "existing" in output + + +class TestErrorFormatting: + """Tests for error formatting functions.""" + + @pytest.mark.timeout(60) + def test_format_error_basic(self): + """Test basic error formatting with error type and message.""" + error = ValueError("Invalid input") + result = format_error(error) + + assert "ERROR: ValueError" in result + assert "Invalid input" in result + + @pytest.mark.timeout(60) + def test_format_error_with_context(self): + """Test error formatting with context dictionary.""" + error = ValueError("Not found") + context = { + "operation": "upload", + "project_path": "group/project", + "gitlab_url": "https://gitlab.com", + } + result = format_error(error, context) + + assert "ERROR: ValueError" in result + # Context should be included via enhance_error_message + + @pytest.mark.timeout(60) + def test_format_error_gitlab_upload_error(self): + """Test error formatting includes exit code for GitLabUploadError.""" + error = GitLabUploadError("Upload failed") + result = format_error(error) + + assert "ERROR: GitLabUploadError" in result + assert "Exit code:" in result + + @pytest.mark.timeout(60) + def test_format_error_without_context(self): + """Test error formatting without context uses basic formatting.""" + error = RuntimeError("Something went wrong") + result = format_error(error) + + assert "ERROR: RuntimeError" in result + assert "Something went wrong" in result + + @pytest.mark.timeout(60) + def test_format_error_uses_enhance_error_message(self): + """Test that enhance_error_message is called when context is provided.""" + error = ValueError("404 not found") + context = { + "operation": "fetch", + "project_path": "group/project", + "gitlab_url": "https://gitlab.com", + } + + with patch("glpkg.formatters.enhance_error_message") as mock_enhance: + mock_enhance.return_value = "Enhanced error message" + result = format_error(error, context) + + mock_enhance.assert_called_once_with(error, context) + assert "Enhanced error message" in result + + @pytest.mark.timeout(60) + def test_format_error_handles_exceptions_gracefully(self): + """Test format_error doesn't crash on unusual exceptions.""" + # Create an exception with unusual attributes + class CustomError(Exception): + pass + + error = CustomError("Custom error message") + result = format_error(error) + + assert "ERROR: CustomError" in result + assert "Custom error message" in result + + +class TestProgressDisplay: + """Tests for progress display functionality.""" + + @pytest.mark.timeout(60) + def test_create_progress_spinner_returns_status(self, mock_rich_console, mock_rich_status): + """Test create_progress_spinner returns a Status-like object.""" + config = create_upload_config() + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + # MockStatus should be returned + assert spinner is not None + assert hasattr(spinner, "__enter__") # Should be a context manager + assert hasattr(spinner, "__exit__") + + @pytest.mark.timeout(60) + def test_create_progress_spinner_with_message(self, mock_rich_console, mock_rich_status): + """Test create_progress_spinner accepts custom message.""" + config = create_upload_config() + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + message = "Uploading files..." + spinner = formatter.create_progress_spinner(message) + + # The Status object should have been created with our message + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_plain_output_mode(self, mock_rich_console, mock_rich_status): + """Test spinner is created but doesn't display in plain output mode.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + # Should still return a Status-like object, just won't display + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_non_tty(self, mock_rich_console, mock_rich_status): + """Test spinner creation when is_tty is False.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=False): + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_as_context_manager(self, mock_rich_console, mock_rich_status): + """Test spinner works as a context manager.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + # Use MockConsole to avoid actual output + formatter.console = MockConsole(file=io.StringIO(), force_terminal=False) + + # Should not raise an exception + with formatter.create_progress_spinner("Loading..."): + pass + + @pytest.mark.timeout(60) + def test_display_progress_function(self, mock_rich_console, mock_rich_status): + """Test standalone display_progress function delegates to formatter.""" + config = create_upload_config() + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + spinner = display_progress(formatter, "Processing...") + # Should return a Status-like object + assert spinner is not None + assert hasattr(spinner, "__enter__") + + +class TestOutputFormatSelection: + """Tests for output format selection logic.""" + + @pytest.mark.timeout(60) + def test_format_output_selects_json_when_json_output_true(self, mock_rich_console): + """Test format_output calls JSON formatter when json_output=True.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_called_once() + mock_plain.assert_not_called() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_plain_when_plain_output_true(self, mock_rich_console): + """Test format_output calls plain formatter when plain_output=True.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_called_once() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_plain_when_not_tty(self, mock_rich_console): + """Test format_output calls plain formatter when not in TTY.""" + config = create_upload_config(plain_output=False, json_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=False): + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_called_once() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_rich_when_tty(self, mock_rich_console): + """Test format_output calls rich formatter when in TTY.""" + config = create_upload_config(plain_output=False, json_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_not_called() + mock_rich.assert_called_once() + + @pytest.mark.timeout(60) + def test_format_output_json_takes_precedence(self, mock_rich_console): + """Test JSON output takes precedence over plain output.""" + config = create_upload_config(json_output=True, plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_called_once() + mock_plain.assert_not_called() + + +class TestOutputFormatterIntegration: + """Integration tests for OutputFormatter end-to-end workflows.""" + + @pytest.mark.timeout(60) + def test_full_workflow_rich_output(self, mock_upload_results, mock_rich_console): + """Test complete rich output workflow.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + + # Verify all sections are present + assert "Upload Summary" in output + assert "Successful Uploads" in output + assert "Skipped Duplicates" in output + assert "Failed Uploads" in output + assert "Duplicate Detection Statistics" in output + + @pytest.mark.timeout(60) + def test_full_workflow_json_output(self, mock_upload_results, mock_rich_console): + """Test complete JSON output workflow.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + # Verify JSON structure + assert "success" in json_data + assert "exit_code" in json_data + assert "package_name" in json_data + assert json_data["package_name"] == "test-package" + assert "version" in json_data + assert json_data["version"] == "1.0.0" + + @pytest.mark.timeout(60) + def test_full_workflow_plain_output(self, mock_upload_results, mock_rich_console): + """Test complete plain text output workflow.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + + # Verify plain text format + assert_no_ansi_codes(output) + assert "Upload Summary" in output + assert "[OK]" in output or "[SKIP]" in output or "[FAIL]" in output + + @pytest.mark.timeout(60) + def test_formatter_factory_function(self, mock_rich_console): + """Test get_formatter factory function returns correct instance.""" + config = create_upload_config() + formatter = get_formatter(config) + + assert isinstance(formatter, OutputFormatter) + assert formatter.config is config + + @pytest.mark.timeout(60) + def test_multiple_format_calls(self, mock_rich_console): + """Test multiple format_output calls work correctly.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results1 = [create_upload_result(target_filename="file1.txt")] + results2 = [create_upload_result(target_filename="file2.txt")] + + with capture_stdout() as captured1: + formatter.format_output(results1, "package1", "1.0.0") + + json1 = assert_valid_json(captured1.getvalue()) + assert json1["package_name"] == "package1" + + with capture_stdout() as captured2: + formatter.format_output(results2, "package2", "2.0.0") + + json2 = assert_valid_json(captured2.getvalue()) + assert json2["package_name"] == "package2" + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.timeout(60) + def test_empty_results_list(self, mock_rich_console): + """Test handling of empty results list in all format methods.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output([], "test-package", "1.0.0") + + json_data = assert_valid_json(captured.getvalue()) + assert json_data["success"] is True + assert json_data["statistics"]["total_processed"] == 0 + + @pytest.mark.timeout(60) + def test_very_long_filenames(self, mock_rich_console): + """Test handling of extremely long filenames.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + long_name = "a" * 500 + ".txt" + results = [create_upload_result(target_filename=long_name)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + # Should not crash and should contain the filename (possibly truncated) + assert "a" in output + + @pytest.mark.timeout(60) + def test_special_characters_in_paths(self, mock_rich_console): + """Test handling of special characters in file paths.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + special_path = '/path/with spaces/and "quotes"/file.txt' + results = [create_upload_result(source_path=special_path)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + # JSON should properly escape special characters + assert json_data["successful_uploads"][0]["source_path"] == special_path + + @pytest.mark.timeout(60) + def test_unicode_in_error_messages(self, mock_rich_console): + """Test handling of Unicode characters in error messages.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + unicode_error = "Error: файл не найден (file not found) 文件未找到" + results = [create_upload_result(success=False, result=unicode_error)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + # Unicode should be preserved in JSON + assert unicode_error in json_data["failed_uploads"][0]["error_message"] + + @pytest.mark.timeout(60) + def test_large_number_of_results(self, mock_rich_console): + """Test handling of many upload results.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + # Create 100 results + results = [ + create_upload_result( + source_path=f"/path/to/file{i}.txt", + target_filename=f"file{i}.txt", + ) + for i in range(100) + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["statistics"]["total_processed"] == 100 + assert len(json_data["successful_uploads"]) == 100 + + @pytest.mark.timeout(60) + def test_result_with_none_values(self, mock_rich_console): + """Test handling of results with None optional fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + was_duplicate=False, + duplicate_action=None, + existing_url=None, + ) + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + upload = json_data["successful_uploads"][0] + assert upload["was_duplicate"] is False + assert upload["duplicate_action"] is None + assert upload["existing_url"] is None + + @pytest.mark.timeout(60) + def test_mixed_success_and_failure_results(self, mock_rich_console): + """Test proper categorization of mixed results.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True, target_filename="success1.txt"), + create_upload_result(success=False, target_filename="fail1.txt", result="Error 1"), + create_upload_result(success=True, target_filename="success2.txt"), + create_upload_result(success=False, target_filename="fail2.txt", result="Error 2"), + create_upload_result( + success=True, + target_filename="skipped.txt", + was_duplicate=True, + duplicate_action="skipped", + ), + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert len(json_data["successful_uploads"]) == 2 + assert len(json_data["failed_uploads"]) == 2 + assert len(json_data["skipped_duplicates"]) == 1 + assert json_data["statistics"]["total_processed"] == 5 + + @pytest.mark.timeout(60) + def test_empty_package_name_and_version(self, mock_rich_console): + """Test handling of empty package name and version.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as captured: + formatter.format_output(results, "", "") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["package_name"] == "" + assert json_data["version"] == "" + + @pytest.mark.timeout(60) + def test_console_file_output_isolation(self, mock_rich_console): + """Test that console output doesn't interfere with stdout capture.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + # Redirect console to a separate buffer using MockConsole + console_buffer = io.StringIO() + formatter.console = MockConsole(file=console_buffer, force_terminal=True) + + results = [create_upload_result()] + + with capture_stdout() as stdout_buffer: + formatter._format_rich_output(results, "test-package", "1.0.0") + + # Rich output should go to console_buffer, not stdout_buffer + assert console_buffer.getvalue() # Console got output + assert not stdout_buffer.getvalue() # stdout is empty diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..0ae355d --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,933 @@ +""" +Comprehensive unit tests for the models module. + +These tests validate the data models, enums, exceptions, and error enhancement +functions used throughout the gitlab-pkg-upload package. + +All tests are isolated and do not require external dependencies like GitLab API +or filesystem access. +""" + +from __future__ import annotations + +from typing import Optional +from unittest.mock import MagicMock, Mock + +import pytest + +from glpkg.models import ( + # Dataclasses + FileFingerprint, + GitRemoteInfo, + ProjectInfo, + ProjectResolutionResult, + RemoteFile, + UploadConfig, + UploadContext, + UploadResult, + # Enums + DuplicatePolicy, + # Exceptions + AuthenticationError, + ChecksumValidationError, + ConfigurationError, + FileValidationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + # Error enhancement functions + enhance_error_message, + handle_authentication_error, + handle_network_connectivity_error, + handle_permission_error, + handle_project_not_found_error, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def sample_file_fingerprint() -> FileFingerprint: + """Create a sample FileFingerprint for testing.""" + return FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, # 2024-01-01 00:00:00 UTC + ) + + +@pytest.fixture +def sample_remote_file() -> RemoteFile: + """Create a sample RemoteFile for testing.""" + return RemoteFile( + file_id=12345, + filename="package.tar.gz", + sha256_checksum="b" * 64, + file_size=2048, + download_url="https://gitlab.com/api/v4/projects/1/packages/generic/pkg/1.0.0/package.tar.gz", + package_name="my-package", + version="1.0.0", + ) + + +@pytest.fixture +def sample_upload_result() -> UploadResult: + """Create a sample UploadResult for testing.""" + return UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/pkg/1.0.0/file.txt", + ) + + +@pytest.fixture +def sample_project_info() -> ProjectInfo: + """Create a sample ProjectInfo for testing.""" + return ProjectInfo( + gitlab_url="https://gitlab.com", + namespace="mygroup", + project_name="myproject", + project_path="mygroup/myproject", + original_url="https://gitlab.com/mygroup/myproject", + ) + + +@pytest.fixture +def sample_upload_config() -> UploadConfig: + """Create a sample UploadConfig for testing.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock Gitlab client for testing UploadContext.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + return mock_gl + + +@pytest.fixture +def mock_duplicate_detector() -> MagicMock: + """Create a mock DuplicateDetector for testing UploadContext.""" + mock_detector = MagicMock() + mock_detector.policy = DuplicatePolicy.SKIP + return mock_detector + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestDuplicatePolicy: + """Tests for DuplicatePolicy enum.""" + + @pytest.mark.timeout(60) + def test_duplicate_policy_skip_value(self): + """Test DuplicatePolicy.SKIP has correct value.""" + assert DuplicatePolicy.SKIP.value == "skip" + + @pytest.mark.timeout(60) + def test_duplicate_policy_replace_value(self): + """Test DuplicatePolicy.REPLACE has correct value.""" + assert DuplicatePolicy.REPLACE.value == "replace" + + @pytest.mark.timeout(60) + def test_duplicate_policy_error_value(self): + """Test DuplicatePolicy.ERROR has correct value.""" + assert DuplicatePolicy.ERROR.value == "error" + + @pytest.mark.timeout(60) + def test_duplicate_policy_enum_members(self): + """Test all expected enum members exist.""" + members = list(DuplicatePolicy) + assert len(members) == 3 + assert DuplicatePolicy.SKIP in members + assert DuplicatePolicy.REPLACE in members + assert DuplicatePolicy.ERROR in members + + @pytest.mark.timeout(60) + def test_duplicate_policy_from_string(self): + """Test creating DuplicatePolicy from string value.""" + assert DuplicatePolicy("skip") == DuplicatePolicy.SKIP + assert DuplicatePolicy("replace") == DuplicatePolicy.REPLACE + assert DuplicatePolicy("error") == DuplicatePolicy.ERROR + + @pytest.mark.timeout(60) + def test_duplicate_policy_invalid_value(self): + """Test that invalid value raises ValueError.""" + with pytest.raises(ValueError): + DuplicatePolicy("invalid") + + @pytest.mark.timeout(60) + def test_duplicate_policy_string_representation(self): + """Test string representation of enum.""" + assert str(DuplicatePolicy.SKIP) == "DuplicatePolicy.SKIP" + assert DuplicatePolicy.SKIP.name == "SKIP" + + +class TestFileFingerprint: + """Tests for FileFingerprint dataclass.""" + + @pytest.mark.timeout(60) + def test_file_fingerprint_creation(self, sample_file_fingerprint: FileFingerprint): + """Test FileFingerprint can be created with all required fields.""" + assert sample_file_fingerprint.source_path == "/path/to/file.txt" + assert sample_file_fingerprint.target_filename == "file.txt" + assert sample_file_fingerprint.sha256_checksum == "a" * 64 + assert sample_file_fingerprint.file_size == 1024 + assert sample_file_fingerprint.timestamp == 1704067200.0 + + @pytest.mark.timeout(60) + def test_file_fingerprint_equality(self): + """Test FileFingerprint equality comparison.""" + fp1 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + fp2 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + assert fp1 == fp2 + + @pytest.mark.timeout(60) + def test_file_fingerprint_inequality(self): + """Test FileFingerprint inequality when fields differ.""" + fp1 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + fp2 = FileFingerprint( + source_path="/path/to/other.txt", + target_filename="other.txt", + sha256_checksum="b" * 64, + file_size=2048, + timestamp=1704067200.0, + ) + assert fp1 != fp2 + + +class TestRemoteFile: + """Tests for RemoteFile dataclass.""" + + @pytest.mark.timeout(60) + def test_remote_file_creation(self, sample_remote_file: RemoteFile): + """Test RemoteFile can be created with all fields.""" + assert sample_remote_file.file_id == 12345 + assert sample_remote_file.filename == "package.tar.gz" + assert sample_remote_file.sha256_checksum == "b" * 64 + assert sample_remote_file.file_size == 2048 + assert "package.tar.gz" in sample_remote_file.download_url + assert sample_remote_file.package_name == "my-package" + assert sample_remote_file.version == "1.0.0" + + @pytest.mark.timeout(60) + def test_remote_file_optional_checksum(self): + """Test RemoteFile with None checksum (optional field).""" + remote_file = RemoteFile( + file_id=12345, + filename="package.tar.gz", + sha256_checksum=None, + file_size=2048, + download_url="https://gitlab.com/download/file", + package_name="my-package", + version="1.0.0", + ) + assert remote_file.sha256_checksum is None + + @pytest.mark.timeout(60) + def test_remote_file_equality(self): + """Test RemoteFile equality comparison.""" + rf1 = RemoteFile( + file_id=1, + filename="file.txt", + sha256_checksum="abc", + file_size=100, + download_url="https://example.com/file", + package_name="pkg", + version="1.0", + ) + rf2 = RemoteFile( + file_id=1, + filename="file.txt", + sha256_checksum="abc", + file_size=100, + download_url="https://example.com/file", + package_name="pkg", + version="1.0", + ) + assert rf1 == rf2 + + +class TestUploadResult: + """Tests for UploadResult dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_result_creation(self, sample_upload_result: UploadResult): + """Test UploadResult can be created with required fields.""" + assert sample_upload_result.source_path == "/path/to/file.txt" + assert sample_upload_result.target_filename == "file.txt" + assert sample_upload_result.success is True + assert "file.txt" in sample_upload_result.result + + @pytest.mark.timeout(60) + def test_upload_result_default_values(self): + """Test UploadResult has correct default values for optional fields.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://example.com/file.txt", + ) + assert result.was_duplicate is False + assert result.duplicate_action is None + assert result.existing_url is None + + @pytest.mark.timeout(60) + def test_upload_result_with_duplicate_info(self): + """Test UploadResult with duplicate detection information.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate detected", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://example.com/existing/file.txt", + ) + assert result.was_duplicate is True + assert result.duplicate_action == "skipped" + assert result.existing_url == "https://example.com/existing/file.txt" + + @pytest.mark.timeout(60) + def test_upload_result_failed(self): + """Test UploadResult for failed upload.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + assert result.success is False + assert "network error" in result.result + + @pytest.mark.timeout(60) + def test_upload_result_replaced_duplicate(self): + """Test UploadResult for replaced duplicate.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://example.com/new/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://example.com/old/file.txt", + ) + assert result.duplicate_action == "replaced" + + +class TestProjectInfo: + """Tests for ProjectInfo dataclass.""" + + @pytest.mark.timeout(60) + def test_project_info_creation(self, sample_project_info: ProjectInfo): + """Test ProjectInfo can be created with all fields.""" + assert sample_project_info.gitlab_url == "https://gitlab.com" + assert sample_project_info.namespace == "mygroup" + assert sample_project_info.project_name == "myproject" + assert sample_project_info.project_path == "mygroup/myproject" + assert sample_project_info.original_url == "https://gitlab.com/mygroup/myproject" + + @pytest.mark.timeout(60) + def test_project_info_nested_namespace(self): + """Test ProjectInfo with nested namespace (subgroups).""" + project_info = ProjectInfo( + gitlab_url="https://gitlab.com", + namespace="group/subgroup", + project_name="myproject", + project_path="group/subgroup/myproject", + original_url="https://gitlab.com/group/subgroup/myproject", + ) + assert project_info.namespace == "group/subgroup" + assert "subgroup" in project_info.project_path + + +class TestProjectResolutionResult: + """Tests for ProjectResolutionResult dataclass.""" + + @pytest.mark.timeout(60) + def test_project_resolution_result_success(self, sample_project_info: ProjectInfo): + """Test ProjectResolutionResult for successful resolution.""" + result = ProjectResolutionResult( + success=True, + project_id=12345, + error_message=None, + project_info=sample_project_info, + gitlab_url="https://gitlab.com", + ) + assert result.success is True + assert result.project_id == 12345 + assert result.error_message is None + assert result.project_info is not None + + @pytest.mark.timeout(60) + def test_project_resolution_result_failure(self): + """Test ProjectResolutionResult for failed resolution.""" + result = ProjectResolutionResult( + success=False, + project_id=None, + error_message="Project not found: mygroup/nonexistent", + project_info=None, + gitlab_url="https://gitlab.com", + ) + assert result.success is False + assert result.project_id is None + assert "not found" in result.error_message + assert result.project_info is None + + +class TestGitRemoteInfo: + """Tests for GitRemoteInfo dataclass.""" + + @pytest.mark.timeout(60) + def test_git_remote_info_creation(self): + """Test GitRemoteInfo can be created with all fields.""" + remote_info = GitRemoteInfo( + name="origin", + url="git@gitlab.com:mygroup/myproject.git", + gitlab_url="https://gitlab.com", + project_path="mygroup/myproject", + ) + assert remote_info.name == "origin" + assert remote_info.url == "git@gitlab.com:mygroup/myproject.git" + assert remote_info.gitlab_url == "https://gitlab.com" + assert remote_info.project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_git_remote_info_https_url(self): + """Test GitRemoteInfo with HTTPS URL.""" + remote_info = GitRemoteInfo( + name="upstream", + url="https://gitlab.com/mygroup/myproject.git", + gitlab_url="https://gitlab.com", + project_path="mygroup/myproject", + ) + assert remote_info.name == "upstream" + assert "https://" in remote_info.url + + +class TestUploadConfig: + """Tests for UploadConfig dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_config_creation(self, sample_upload_config: UploadConfig): + """Test UploadConfig can be created with all fields.""" + assert sample_upload_config.package_name == "test-package" + assert sample_upload_config.version == "1.0.0" + assert sample_upload_config.duplicate_policy == DuplicatePolicy.SKIP + assert sample_upload_config.retry_count == 3 + assert sample_upload_config.verbosity == "normal" + assert sample_upload_config.dry_run is False + assert sample_upload_config.fail_fast is False + assert sample_upload_config.json_output is False + assert sample_upload_config.plain_output is False + assert sample_upload_config.gitlab_url == "https://gitlab.com" + assert sample_upload_config.token is not None + + @pytest.mark.timeout(60) + def test_upload_config_with_none_token(self): + """Test UploadConfig with None token (optional field).""" + config = UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.token is None + + @pytest.mark.timeout(60) + def test_upload_config_verbosity_options(self): + """Test UploadConfig with different verbosity options.""" + for verbosity in ["quiet", "normal", "verbose", "debug"]: + config = UploadConfig( + package_name="test", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity=verbosity, + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.verbosity == verbosity + + @pytest.mark.timeout(60) + def test_upload_config_dry_run_enabled(self): + """Test UploadConfig with dry_run enabled.""" + config = UploadConfig( + package_name="test", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=True, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.dry_run is True + + +class TestUploadContext: + """Tests for UploadContext dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_context_creation( + self, + mock_gitlab_client: MagicMock, + mock_duplicate_detector: MagicMock, + sample_upload_config: UploadConfig, + ): + """Test UploadContext can be created with all fields.""" + context = UploadContext( + gl=mock_gitlab_client, + config=sample_upload_config, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + assert context.gl is mock_gitlab_client + assert context.config is sample_upload_config + assert context.detector is mock_duplicate_detector + assert context.project_id == 12345 + assert context.project_path == "mygroup/myproject" + + +class TestExceptionHierarchy: + """Tests for exception hierarchy and exit codes.""" + + @pytest.mark.timeout(60) + def test_gitlab_upload_error_base(self): + """Test GitLabUploadError base exception.""" + error = GitLabUploadError("Base error message") + assert str(error) == "Base error message" + assert error.exit_code == 1 + + @pytest.mark.timeout(60) + def test_authentication_error(self): + """Test AuthenticationError exception.""" + error = AuthenticationError("Authentication failed") + assert str(error) == "Authentication failed" + assert error.exit_code == 2 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_configuration_error(self): + """Test ConfigurationError exception.""" + error = ConfigurationError("Invalid configuration") + assert str(error) == "Invalid configuration" + assert error.exit_code == 3 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_project_resolution_error(self): + """Test ProjectResolutionError exception.""" + error = ProjectResolutionError("Project not found") + assert str(error) == "Project not found" + assert error.exit_code == 4 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_file_validation_error(self): + """Test FileValidationError exception.""" + error = FileValidationError("File not readable") + assert str(error) == "File not readable" + assert error.exit_code == 5 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_network_error(self): + """Test NetworkError exception.""" + error = NetworkError("Connection refused") + assert str(error) == "Connection refused" + assert error.exit_code == 6 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_checksum_validation_error(self): + """Test ChecksumValidationError exception.""" + error = ChecksumValidationError("Checksum mismatch") + assert str(error) == "Checksum mismatch" + assert error.exit_code == 7 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_exception_inheritance(self): + """Test all custom exceptions inherit from GitLabUploadError.""" + exceptions = [ + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, + ChecksumValidationError, + ] + for exc_class in exceptions: + assert issubclass(exc_class, GitLabUploadError) + assert issubclass(exc_class, Exception) + + @pytest.mark.timeout(60) + def test_exception_can_be_caught_as_base(self): + """Test custom exceptions can be caught as GitLabUploadError.""" + try: + raise AuthenticationError("Test error") + except GitLabUploadError as e: + assert e.exit_code == 2 + assert str(e) == "Test error" + + @pytest.mark.timeout(60) + def test_exit_codes_are_unique(self): + """Test all exception classes have unique exit codes.""" + exceptions = [ + GitLabUploadError, + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, + ChecksumValidationError, + ] + exit_codes = [exc.exit_code for exc in exceptions] + assert len(exit_codes) == len(set(exit_codes)) + + +class TestHandleProjectNotFoundError: + """Tests for handle_project_not_found_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic error message generation.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="404 Project Not Found", + ) + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + assert "404 Project Not Found" in result + + @pytest.mark.timeout(60) + def test_includes_suggestions(self): + """Test error message includes helpful suggestions.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "Please check the following" in result + assert "Project path format is correct" in result + assert "namespace/project-name" in result + + @pytest.mark.timeout(60) + def test_includes_examples(self): + """Test error message includes example project paths.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "Examples of valid project paths" in result + assert "mycompany/my-project" in result + assert "group/subgroup/project-name" in result + + @pytest.mark.timeout(60) + def test_includes_verification_url(self): + """Test error message includes URL to verify project.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "You can verify the project exists by visiting" in result + assert "https://gitlab.com/mygroup/myproject" in result + + +class TestHandleAuthenticationError: + """Tests for handle_authentication_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic authentication error message.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="401 Unauthorized", + ) + assert "Authentication failed" in result + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + + @pytest.mark.timeout(60) + def test_includes_token_guidance(self): + """Test error message includes token configuration guidance.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Unauthorized", + ) + assert "GitLab token" in result + assert "GITLAB_TOKEN" in result + assert "--token" in result + + @pytest.mark.timeout(60) + def test_includes_token_creation_steps(self): + """Test error message includes steps to create new token.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Unauthorized", + ) + assert "To create a new token" in result + assert "personal_access_tokens" in result + assert "api" in result or "read_api" in result + + +class TestHandlePermissionError: + """Tests for handle_permission_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic permission error message.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="upload", + original_error="403 Forbidden", + ) + assert "Permission denied" in result + assert "upload" in result + assert "mygroup/myproject" in result + + @pytest.mark.timeout(60) + def test_includes_required_permissions(self): + """Test error message includes required permission levels.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="upload", + original_error="Forbidden", + ) + assert "Required permissions" in result + assert "Developer" in result or "Reporter" in result + + @pytest.mark.timeout(60) + def test_includes_project_members_link(self): + """Test error message includes link to project members page.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="read packages", + original_error="Forbidden", + ) + assert "project_members" in result + + +class TestHandleNetworkConnectivityError: + """Tests for handle_network_connectivity_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic network connectivity error message.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.com", + original_error="Connection refused", + ) + assert "Network connectivity issue" in result + assert "https://gitlab.com" in result + assert "Connection refused" in result + + @pytest.mark.timeout(60) + def test_includes_troubleshooting_steps(self): + """Test error message includes troubleshooting steps.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.com", + original_error="Timeout", + ) + assert "Troubleshooting steps" in result + assert "curl" in result + assert "nslookup" in result + + @pytest.mark.timeout(60) + def test_includes_corporate_network_hints(self): + """Test error message includes hints for corporate networks.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.example.com", + original_error="Connection timeout", + ) + assert "corporate network" in result or "proxy" in result + + +class TestEnhanceErrorMessage: + """Tests for enhance_error_message function.""" + + @pytest.mark.timeout(60) + def test_404_error_detection(self): + """Test 404 error is detected and enhanced.""" + error = Exception("404 Not Found") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "fetch", + } + result = enhance_error_message(error, context) + assert "not found" in result.lower() or "Project" in result + + @pytest.mark.timeout(60) + def test_401_error_detection(self): + """Test 401 error is detected and enhanced.""" + error = Exception("401 Unauthorized") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Authentication" in result or "token" in result.lower() + + @pytest.mark.timeout(60) + def test_403_permission_error_detection(self): + """Test 403 permission error is detected and enhanced.""" + error = Exception("403 Forbidden: Permission denied") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Permission" in result or "permission" in result + + @pytest.mark.timeout(60) + def test_connection_error_detection(self): + """Test connection error is detected and enhanced.""" + error = Exception("Connection refused by server") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "connect", + } + result = enhance_error_message(error, context) + assert "Network" in result or "connection" in result.lower() + + @pytest.mark.timeout(60) + def test_timeout_error_detection(self): + """Test timeout error is detected and enhanced.""" + error = Exception("Request timeout after 30 seconds") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Network" in result or "timeout" in result.lower() + + @pytest.mark.timeout(60) + def test_rate_limit_error_detection(self): + """Test rate limit error is detected and enhanced.""" + error = Exception("429 Too Many Requests - Rate limit exceeded") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "rate limit" in result.lower() or "Rate" in result + + @pytest.mark.timeout(60) + def test_generic_error_enhancement(self): + """Test generic error gets basic enhancement.""" + error = Exception("Something unexpected happened") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "process", + } + result = enhance_error_message(error, context) + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + assert "Something unexpected happened" in result + + @pytest.mark.timeout(60) + def test_missing_context_keys_use_defaults(self): + """Test missing context keys use default values.""" + error = Exception("Error") + context = {} + result = enhance_error_message(error, context) + assert "unknown" in result + + @pytest.mark.timeout(60) + def test_dns_error_detection(self): + """Test DNS resolution error is detected.""" + error = Exception("Failed to resolve hostname: DNS error") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.example.com", + "operation": "connect", + } + result = enhance_error_message(error, context) + assert "Network" in result or "DNS" in result or "resolve" in result.lower() + + @pytest.mark.timeout(60) + def test_case_insensitive_error_detection(self): + """Test error detection is case-insensitive.""" + error = Exception("NOT FOUND") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "fetch", + } + result = enhance_error_message(error, context) + # Should detect as not found error + assert len(result) > len("NOT FOUND") diff --git a/tests/unit/test_uploader.py b/tests/unit/test_uploader.py new file mode 100644 index 0000000..8b123f8 --- /dev/null +++ b/tests/unit/test_uploader.py @@ -0,0 +1,1442 @@ +""" +Comprehensive unit tests for the uploader module. + +These tests validate upload orchestration including retry logic, duplicate handling, +checksum validation, and file deletion. All external dependencies (GitLab API, +filesystem, network) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import MagicMock, Mock, call, patch + +import pytest +from gitlab.exceptions import GitlabError + +from glpkg.models import ( + ChecksumValidationError, + DuplicatePolicy, + RemoteFile, + UploadConfig, + UploadContext, + UploadResult, +) +from glpkg.uploader import ( + delete_file_from_registry, + handle_duplicate, + is_transient_error, + upload_files, + upload_single_file, + validate_upload, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_upload_config() -> UploadConfig: + """Create a sample UploadConfig for testing.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_upload_config_dry_run() -> UploadConfig: + """Create a sample UploadConfig with dry_run enabled.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=True, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock GitLab client for testing.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + return mock_gl + + +@pytest.fixture +def mock_project() -> MagicMock: + """Create a mock GitLab project for testing.""" + mock_proj = MagicMock() + mock_proj.id = 12345 + mock_proj.packages = MagicMock() + mock_proj.generic_packages = MagicMock() + return mock_proj + + +@pytest.fixture +def mock_duplicate_detector() -> MagicMock: + """Create a mock DuplicateDetector for testing.""" + mock_detector = MagicMock() + mock_detector.check_session_duplicate.return_value = None + mock_detector.check_remote_duplicate.return_value = None + mock_detector.register_file.return_value = None + return mock_detector + + +@pytest.fixture +def mock_upload_context( + mock_gitlab_client, mock_upload_config, mock_duplicate_detector +) -> UploadContext: + """Create a sample UploadContext for testing.""" + return UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + +@pytest.fixture +def mock_file_path(tmp_path) -> Path: + """Create a mock file for testing.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content for upload") + return test_file + + +@pytest.fixture +def sample_remote_file() -> RemoteFile: + """Create a sample RemoteFile for testing duplicate handling.""" + return RemoteFile( + file_id=12345, + filename="test.bin", + sha256_checksum="a" * 64, + file_size=1024, + download_url="https://gitlab.com/api/v4/projects/12345/packages/generic/test-package/1.0.0/test.bin", + package_name="test-package", + version="1.0.0", + ) + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestIsTransientError: + """Test error classification for retry logic.""" + + @pytest.mark.timeout(60) + def test_connection_error_is_transient(self): + """Test ConnectionError returns True.""" + error = ConnectionError("Connection refused") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_502(self): + """Test GitlabError with response_code=502 returns True.""" + error = GitlabError("Bad Gateway") + error.response_code = 502 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_503(self): + """Test GitlabError with response_code=503 returns True.""" + error = GitlabError("Service Unavailable") + error.response_code = 503 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_408(self): + """Test GitlabError with response_code=408 (Request Timeout) returns True.""" + error = GitlabError("Request Timeout") + error.response_code = 408 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_429(self): + """Test GitlabError with response_code=429 (Rate Limited) returns True.""" + error = GitlabError("Too Many Requests") + error.response_code = 429 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_403(self): + """Test GitlabError with response_code=403 returns False.""" + error = GitlabError("Forbidden") + error.response_code = 403 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_404(self): + """Test GitlabError with response_code=404 returns False.""" + error = GitlabError("Not Found") + error.response_code = 404 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_400(self): + """Test GitlabError with response_code=400 returns False.""" + error = GitlabError("Bad Request") + error.response_code = 400 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_422(self): + """Test GitlabError with response_code=422 (Unprocessable Entity) returns False.""" + error = GitlabError("Unprocessable Entity") + error.response_code = 422 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_timeout_error_is_transient(self): + """Test TimeoutError returns True.""" + error = TimeoutError("Connection timed out") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_500_error_is_transient(self): + """Test exception with '500' in message returns True.""" + error = Exception("500 Internal Server Error") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_502_bad_gateway_is_transient(self): + """Test exception with '502' or 'bad gateway' returns True.""" + assert is_transient_error(Exception("502 Bad Gateway")) is True + assert is_transient_error(Exception("bad gateway error")) is True + + @pytest.mark.timeout(60) + def test_503_service_unavailable_is_transient(self): + """Test exception with '503' or 'service unavailable' returns True.""" + assert is_transient_error(Exception("503 Service Unavailable")) is True + assert is_transient_error(Exception("service unavailable")) is True + + @pytest.mark.timeout(60) + def test_429_rate_limit_is_transient(self): + """Test exception with '429' or 'rate limit' returns True.""" + assert is_transient_error(Exception("429 Too Many Requests")) is True + assert is_transient_error(Exception("rate limit exceeded")) is True + + @pytest.mark.timeout(60) + def test_401_unauthorized_is_permanent(self): + """Test exception with '401' returns False.""" + error = Exception("401 Unauthorized") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_403_forbidden_is_permanent(self): + """Test exception with '403' returns False.""" + error = Exception("403 Forbidden") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_404_not_found_is_permanent(self): + """Test exception with '404' returns False.""" + error = Exception("404 Not Found") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_400_bad_request_is_permanent(self): + """Test exception with '400' returns False.""" + error = Exception("400 Bad Request") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_500(self): + """Test GitlabError with response_code=500 returns True.""" + error = GitlabError("Server error") + error.response_code = 500 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_401(self): + """Test GitlabError with response_code=401 returns False.""" + error = GitlabError("Unauthorized") + error.response_code = 401 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_unknown_error_is_permanent(self): + """Test generic Exception returns False (default behavior).""" + error = Exception("Something unknown happened") + assert is_transient_error(error) is False + + +class TestUploadSingleFile: + """Test single file upload with retry decorator.""" + + @pytest.mark.timeout(60) + def test_upload_success(self, mock_upload_context, mock_file_path, mock_project): + """Test successful upload returns download URL.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + result = upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + assert "target.bin" in result + assert "test-package" in result + assert "1.0.0" in result + + @pytest.mark.timeout(60) + def test_upload_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector, mock_file_path + ): + """Test dry_run=True returns mock URL without actual upload.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = upload_single_file(context, mock_file_path, "target.bin") + + assert "target.bin" in result + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_upload_calls_generic_packages_upload( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test project.generic_packages.upload called with correct params.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + mock_project.generic_packages.upload.assert_called_once() + call_kwargs = mock_project.generic_packages.upload.call_args[1] + assert call_kwargs["package_name"] == "test-package" + assert call_kwargs["package_version"] == "1.0.0" + assert call_kwargs["file_name"] == "target.bin" + + @pytest.mark.timeout(60) + def test_upload_logs_file_size_and_time( + self, mock_upload_context, mock_file_path, mock_project, caplog + ): + """Test logging includes file size in MB and elapsed time.""" + import logging + + caplog.set_level(logging.DEBUG) + + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + assert "MB" in caplog.text + assert "target.bin" in caplog.text + + @pytest.mark.timeout(60) + def test_upload_constructs_correct_download_url( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test returned URL matches expected format.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + result = upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + expected_base = "https://gitlab.com/api/v4/projects/12345/packages/generic" + assert result.startswith(expected_base) + assert "test-package" in result + assert "1.0.0" in result + assert "target.bin" in result + + @pytest.mark.timeout(60) + def test_upload_file_size_calculation( + self, mock_upload_context, tmp_path, mock_project, caplog + ): + """Test Path.stat() with specific size is logged correctly.""" + import logging + + caplog.set_level(logging.DEBUG) + + # Create a file with known size + test_file = tmp_path / "sized.bin" + content = b"x" * (1024 * 1024) # 1 MB + test_file.write_bytes(content) + + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, test_file, "sized.bin") + + assert "1.00 MB" in caplog.text or "1.0" in caplog.text + + +class TestValidateUpload: + """Test checksum validation after upload.""" + + @pytest.mark.timeout(60) + def test_validate_success_checksum_match(self, mock_upload_context, mock_project): + """Test package file with matching checksum returns True.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector + ): + """Test dry_run=True returns True without API calls.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = validate_upload(context, "target.bin", "a" * 64) + + assert result is True + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_validate_checksum_mismatch_raises_error( + self, mock_upload_context, mock_project + ): + """Test mismatched checksum raises ChecksumValidationError.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "b" * 64 # Different checksum + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with pytest.raises(ChecksumValidationError) as exc_info: + validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert "mismatch" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_validate_package_not_found(self, mock_upload_context, mock_project): + """Test packages.list returns empty, verify False returned.""" + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is False + + @pytest.mark.timeout(60) + def test_validate_file_not_found_in_package( + self, mock_upload_context, mock_project + ): + """Test package exists but file not in package_files, verify False.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is False + + @pytest.mark.timeout(60) + def test_validate_no_remote_checksum_available( + self, mock_upload_context, mock_project, caplog + ): + """Test remote file has no file_sha256 attribute, verify True (skip validation).""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock(spec=[]) # No file_sha256 attribute + mock_file.file_name = "target.bin" + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + assert "skipping validation" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_validate_empty_file_checksum(self, mock_upload_context, mock_project): + """Test special case for empty file SHA256.""" + empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = empty_sha256 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", empty_sha256) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_case_insensitive_checksum(self, mock_upload_context, mock_project): + """Test checksum comparison is case-insensitive.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "AABBCC" + "d" * 58 # Uppercase + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload( + mock_upload_context, "target.bin", "aabbcc" + "d" * 58 # Lowercase + ) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_filename_with_path_variations( + self, mock_upload_context, mock_project + ): + """Test filename matching handles path variations (exact match and endswith).""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "subdir/target.bin" + mock_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + + +class TestHandleDuplicate: + """Test duplicate handling based on policy.""" + + @pytest.mark.timeout(60) + def test_handle_duplicate_unknown_policy_raises_error( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test unknown duplicate policy raises ValueError.""" + # Set an invalid policy by bypassing the enum + mock_upload_context.config.duplicate_policy = "invalid_policy" + + with pytest.raises(ValueError) as exc_info: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Unknown duplicate policy" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_handle_duplicate_skip_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test Policy=SKIP returns ('skipped', download_url).""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert action == "skipped" + assert result == sample_remote_file.download_url + + @pytest.mark.timeout(60) + def test_handle_duplicate_replace_policy( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test Policy=REPLACE calls delete and returns ('replaced', 'proceed_with_upload').""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] # Simplified for this test + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert action == "replaced" + assert result == "proceed_with_upload" + + @pytest.mark.timeout(60) + def test_handle_duplicate_error_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test Policy=ERROR raises ValueError with helpful message.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + + with pytest.raises(ValueError) as exc_info: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Duplicate file detected" in str(exc_info.value) + assert "--duplicate-policy" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_handle_duplicate_skip_returns_existing_url( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test existing RemoteFile.download_url is returned for SKIP policy.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + sample_remote_file.download_url = "https://custom-url.com/file.bin" + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert result == "https://custom-url.com/file.bin" + + @pytest.mark.timeout(60) + def test_handle_duplicate_replace_calls_delete( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test delete_file_from_registry is called with correct filename.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + with patch( + "glpkg.uploader.delete_file_from_registry" + ) as mock_delete: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + mock_delete.assert_called_once_with( + mock_upload_context, sample_remote_file.filename + ) + + @pytest.mark.timeout(60) + def test_handle_duplicate_logging( + self, mock_upload_context, mock_file_path, sample_remote_file, caplog + ): + """Test appropriate log messages for each policy.""" + import logging + + caplog.set_level(logging.DEBUG) + + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Duplicate detected" in caplog.text + assert "SKIP" in caplog.text + + +class TestDeleteFileFromRegistry: + """Test file deletion from GitLab registry.""" + + @pytest.mark.timeout(60) + def test_delete_success(self, mock_upload_context, mock_project): + """Test package file delete() called and count returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.id = 1001 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 1 + mock_file.delete.assert_called_once() + + @pytest.mark.timeout(60) + def test_delete_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector + ): + """Test dry_run=True returns 0 without deletion.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = delete_file_from_registry(context, "target.bin") + + assert result == 0 + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_delete_package_not_found( + self, mock_upload_context, mock_project, caplog + ): + """Test packages.list returns empty, verify 0 returned with warning.""" + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 0 + assert "not found" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_delete_file_not_found(self, mock_upload_context, mock_project, caplog): + """Test package exists but filename not found, verify 0 returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 0 + assert "No files named" in caplog.text + + @pytest.mark.timeout(60) + def test_delete_multiple_files_same_name(self, mock_upload_context, mock_project): + """Test multiple files with same name, verify all deleted.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file1 = MagicMock() + mock_file1.file_name = "target.bin" + mock_file1.id = 1001 + mock_file2 = MagicMock() + mock_file2.file_name = "target.bin" + mock_file2.id = 1002 + mock_file3 = MagicMock() + mock_file3.file_name = "target.bin" + mock_file3.id = 1003 + mock_package_obj.package_files.list.return_value = [ + mock_file1, + mock_file2, + mock_file3, + ] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 3 + mock_file1.delete.assert_called_once() + mock_file2.delete.assert_called_once() + mock_file3.delete.assert_called_once() + + @pytest.mark.timeout(60) + def test_delete_handles_deletion_error( + self, mock_upload_context, mock_project, caplog + ): + """Test delete() raises exception, verify logged and continues.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file1 = MagicMock() + mock_file1.file_name = "target.bin" + mock_file1.id = 1001 + mock_file1.delete.side_effect = Exception("Delete failed") + mock_file2 = MagicMock() + mock_file2.file_name = "target.bin" + mock_file2.id = 1002 + mock_package_obj.package_files.list.return_value = [mock_file1, mock_file2] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 1 # Only second file deleted successfully + assert "Failed to delete" in caplog.text + + @pytest.mark.timeout(60) + def test_delete_returns_correct_count(self, mock_upload_context, mock_project): + """Test delete 3 files, verify returns 3.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + files = [] + for i in range(3): + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.id = 1000 + i + files.append(mock_file) + mock_package_obj.package_files.list.return_value = files + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 3 + + +class TestUploadFiles: + """Test main orchestration function.""" + + @pytest.mark.timeout(60) + def test_upload_files_single_file_success( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test upload one file, verify UploadResult with success=True.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].target_filename == "target.bin" + + @pytest.mark.timeout(60) + def test_upload_files_multiple_files_success( + self, mock_upload_context, tmp_path, mock_project + ): + """Test upload multiple files, verify all succeed.""" + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.validate_upload", return_value=True + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert all(r.success for r in results) + + @pytest.mark.timeout(60) + def test_upload_files_session_duplicate_skipped( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test session duplicate detected, verify skipped without upload.""" + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + mock_upload_context.gl.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_skip_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test remote duplicate with SKIP policy, verify skipped.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_replace_policy( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test remote duplicate with REPLACE policy, verify deleted then uploaded.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "replaced" + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_error_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test remote duplicate with ERROR policy, verify UploadResult with success=False.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is False + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "error" + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_error_policy_fail_fast( + self, mock_upload_context, mock_file_path, sample_remote_file, tmp_path + ): + """Test remote duplicate with ERROR policy and fail_fast enabled stops early.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + mock_upload_context.config.fail_fast = True + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + file2 = tmp_path / "file2.bin" + file2.write_bytes(b"content2") + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(mock_file_path, "target1.bin"), (file2, "target2.bin")] + ) + + # With fail_fast, should stop after first error + assert len(results) == 1 + assert results[0].success is False + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "error" + + @pytest.mark.timeout(60) + def test_upload_files_fail_fast_enabled( + self, mock_upload_context, tmp_path, mock_project + ): + """Test first file fails with fail_fast=True, verify second file not attempted.""" + mock_upload_context.config.fail_fast = True + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + mock_upload_context.gl.projects.get.side_effect = Exception("Upload failed") + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 1 + assert results[0].success is False + + @pytest.mark.timeout(60) + def test_upload_files_fail_fast_disabled( + self, mock_upload_context, tmp_path, mock_project + ): + """Test first file fails with fail_fast=False, verify second file attempted.""" + mock_upload_context.config.fail_fast = False + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + call_count = 0 + + def mock_get_project(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("First upload failed") + return mock_project + + mock_upload_context.gl.projects.get.side_effect = mock_get_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target2.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert results[0].success is False + assert results[1].success is True + + @pytest.mark.timeout(60) + def test_upload_files_checksum_calculation( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test calculate_sha256 called for each file.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ) as mock_sha: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_sha.assert_called_once_with(mock_file_path) + + @pytest.mark.timeout(60) + def test_upload_files_validation_called( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test validate_upload called after each successful upload.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.validate_upload", return_value=True + ) as mock_validate: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_validate.assert_called_once() + + @pytest.mark.timeout(60) + def test_upload_files_registration_called( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test detector.register_file called after validation.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.validate_upload", return_value=True + ): + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + mock_upload_context.detector.register_file.assert_called_once() + + @pytest.mark.timeout(60) + def test_upload_files_upload_exception_handled( + self, mock_upload_context, mock_file_path + ): + """Test upload raises exception, verify UploadResult with success=False.""" + mock_upload_context.gl.projects.get.side_effect = Exception("Network error") + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is False + # Error may be wrapped in RetryError from tenacity + assert results[0].result != "" + + @pytest.mark.timeout(60) + def test_upload_files_replace_policy_deletes_different_checksum( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test no remote duplicate but file exists with different checksum, verify deleted.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.detector.check_remote_duplicate.return_value = None + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.delete_file_from_registry", return_value=1 + ) as mock_delete: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_delete.assert_called() + + @pytest.mark.timeout(60) + def test_upload_files_result_includes_duplicate_metadata( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test UploadResult includes was_duplicate, duplicate_action, existing_url.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + assert results[0].existing_url == sample_remote_file.download_url + + @pytest.mark.timeout(60) + def test_upload_files_constructs_session_duplicate_url( + self, mock_upload_context, mock_file_path + ): + """Test session duplicate detected, verify URL constructed correctly.""" + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert results[0].success is True + assert "test-package" in results[0].existing_url + assert "1.0.0" in results[0].existing_url + assert "target.bin" in results[0].existing_url + + +class TestUploadFilesIntegration: + """Integration tests for complete upload workflows.""" + + @pytest.mark.timeout(60) + def test_workflow_no_duplicates_all_succeed( + self, mock_upload_context, tmp_path, mock_project + ): + """Test upload 3 files with no duplicates, verify all succeed.""" + files = [] + for i in range(3): + f = tmp_path / f"file{i}.bin" + f.write_bytes(f"content {i}".encode()) + files.append((f, f"target{i}.bin")) + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.validate_upload", return_value=True + ): + results = upload_files(mock_upload_context, files) + + assert len(results) == 3 + assert all(r.success for r in results) + assert all(not r.was_duplicate for r in results) + + @pytest.mark.timeout(60) + def test_workflow_mixed_success_and_failure( + self, mock_upload_context, tmp_path, mock_project + ): + """Test some files succeed, some fail, verify correct results.""" + mock_upload_context.config.fail_fast = False + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file3 = tmp_path / "file3.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + file3.write_bytes(b"content3") + + # Track calls to determine when to fail + # upload_single_file calls projects.get once per file + # validate_upload calls projects.get once per file + # So for 3 files: file1 upload, file1 validate, file2 upload (fail), ... + upload_call_count = 0 + + def mock_upload_side_effect(context, file, target): + nonlocal upload_call_count + upload_call_count += 1 + if upload_call_count == 2: # Second file fails during upload + raise Exception("Upload failed") + return f"https://gitlab.com/download/{target}" + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "glpkg.uploader.upload_single_file", + side_effect=mock_upload_side_effect, + ): + results = upload_files( + mock_upload_context, + [(file1, "t1.bin"), (file2, "t2.bin"), (file3, "t3.bin")], + ) + + assert len(results) == 3 + assert results[0].success is True + assert results[1].success is False + assert results[2].success is True + + @pytest.mark.timeout(60) + def test_workflow_all_duplicates_skip_policy( + self, mock_upload_context, tmp_path, sample_remote_file + ): + """Test all files are duplicates with SKIP policy, verify all skipped.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + files = [] + for i in range(3): + f = tmp_path / f"file{i}.bin" + f.write_bytes(f"content {i}".encode()) + files.append((f, f"target{i}.bin")) + + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files(mock_upload_context, files) + + assert len(results) == 3 + assert all(r.success for r in results) + assert all(r.was_duplicate for r in results) + assert all(r.duplicate_action == "skipped" for r in results) + + @pytest.mark.timeout(60) + def test_workflow_duplicate_then_new_file( + self, mock_upload_context, tmp_path, sample_remote_file, mock_project + ): + """Test first file is duplicate (skipped), second is new (uploaded).""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + # First file is duplicate, second is not + mock_upload_context.detector.check_remote_duplicate.side_effect = [ + sample_remote_file, + None, + ] + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target2.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + assert results[1].success is True + assert results[1].was_duplicate is False + + @pytest.mark.timeout(60) + def test_workflow_session_duplicate_prevents_remote_check( + self, mock_upload_context, tmp_path + ): + """Test session duplicate found, verify remote check not called.""" + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content") + file2.write_bytes(b"content") + + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(file1, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + mock_upload_context.detector.check_remote_duplicate.assert_not_called() diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py new file mode 100644 index 0000000..48438ff --- /dev/null +++ b/tests/unit/test_validators.py @@ -0,0 +1,1094 @@ +""" +Comprehensive unit tests for the validators module. + +These tests validate file validation, Git URL parsing, configuration validation, +token handling, and dependency checking functions. All external dependencies +(filesystem, subprocess, GitPython) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, Mock, mock_open, patch + +import pytest + +from glpkg.models import ( + ConfigurationError, + FileValidationError, + ProjectResolutionError, +) +from glpkg.validators import ( + DEFAULT_GITLAB_URL, + calculate_sha256, + collect_files, + get_gitlab_token, + normalize_gitlab_url, + parse_file_mapping, + parse_git_url, + validate_configuration, + validate_dependencies, + validate_file_exists, + validate_filename, + validate_git_installation, + validate_git_repository, + validate_gitlab_token, + validate_project_specification, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_path_exists(): + """Create a mock Path that exists.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.is_file.return_value = True + mock_path.is_dir.return_value = False + mock_path.name = "test_file.txt" + mock_path.__str__ = lambda self: "/path/to/test_file.txt" + return mock_path + + +@pytest.fixture +def mock_path_not_exists(): + """Create a mock Path that does not exist.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = False + mock_path.is_file.return_value = False + mock_path.__str__ = lambda self: "/path/to/nonexistent.txt" + return mock_path + + +@pytest.fixture +def mock_path_directory(): + """Create a mock Path that is a directory.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.is_file.return_value = False + mock_path.is_dir.return_value = True + mock_path.__str__ = lambda self: "/path/to/directory" + return mock_path + + +@pytest.fixture +def mock_git_repo(): + """Create a mock Git repository.""" + mock_repo = MagicMock() + mock_repo.working_dir = "/path/to/repo" + mock_repo.config_reader.return_value = MagicMock() + mock_repo.remotes = [MagicMock(name="origin")] + return mock_repo + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestFilenameValidation: + """Tests for validate_filename function.""" + + @pytest.mark.timeout(60) + def test_valid_ascii_filename(self): + """Test valid ASCII filename passes validation.""" + # Should not raise + validate_filename("package.tar.gz") + validate_filename("my-file_v1.0.bin") + validate_filename("subdir/file.txt") + validate_filename("a.b.c.d") + + @pytest.mark.timeout(60) + def test_valid_filename_with_numbers(self): + """Test filename with numbers passes validation.""" + validate_filename("file123.txt") + validate_filename("v1.2.3.tar.gz") + validate_filename("2024-01-01-backup.zip") + + @pytest.mark.timeout(60) + def test_valid_filename_with_hyphens_underscores(self): + """Test filename with hyphens and underscores passes validation.""" + validate_filename("my-file.txt") + validate_filename("my_file.txt") + validate_filename("my-file_v1.0.txt") + + @pytest.mark.timeout(60) + def test_valid_filename_with_slashes(self): + """Test filename with forward slashes (directory paths) passes validation.""" + validate_filename("subdir/file.txt") + validate_filename("a/b/c/file.txt") + validate_filename("deeply/nested/path/file.bin") + + @pytest.mark.timeout(60) + def test_non_ascii_filename_rejected(self): + """Test non-ASCII characters in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("café.tar.gz") + assert "non-ASCII" in str(exc_info.value) + assert "café.tar.gz" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_chinese_characters_rejected(self): + """Test Chinese characters in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("文件.bin") + assert "non-ASCII" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_emoji_rejected(self): + """Test emoji in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("file📦.txt") + assert "non-ASCII" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_special_characters_rejected(self): + """Test special characters are rejected.""" + special_chars = ["@", "#", "$", "%", " ", "!", "&", "(", ")", "+", "="] + for char in special_chars: + with pytest.raises(FileValidationError) as exc_info: + validate_filename(f"file{char}name.txt") + assert "special characters" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_space_in_filename_rejected(self): + """Test space in filename is rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("file name.txt") + assert "special characters" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_message_includes_allowed_characters(self): + """Test error message includes list of allowed characters.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("bad@file.txt") + error_msg = str(exc_info.value) + assert "letters" in error_msg or "a-z" in error_msg + assert "digits" in error_msg or "0-9" in error_msg + + +class TestFileExistsValidation: + """Tests for validate_file_exists function.""" + + @pytest.mark.timeout(60) + def test_existing_file_passes(self, tmp_path): + """Test existing readable file passes validation.""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + # Should not raise + validate_file_exists(test_file) + + @pytest.mark.timeout(60) + def test_nonexistent_file_raises(self): + """Test non-existent file raises FileValidationError.""" + nonexistent = Path("/path/to/definitely/not/existing/file.txt") + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(nonexistent) + assert "not found" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_directory_path_raises(self, tmp_path): + """Test directory path raises FileValidationError.""" + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(tmp_path) + assert "not a file" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_unreadable_file_raises(self, tmp_path): + """Test unreadable file raises FileValidationError.""" + test_file = tmp_path / "unreadable.txt" + test_file.write_text("content") + + # Mock the open function to raise PermissionError + with patch("builtins.open", side_effect=PermissionError("Permission denied")): + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(test_file) + assert "not readable" in str(exc_info.value).lower() + + +class TestSHA256Calculation: + """Tests for calculate_sha256 function.""" + + @pytest.mark.timeout(60) + def test_calculate_sha256_basic(self, tmp_path): + """Test SHA256 calculation for a basic file.""" + test_file = tmp_path / "test.txt" + content = b"Hello, World!" + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert len(result) == 64 # SHA256 hex digest is 64 characters + + @pytest.mark.timeout(60) + def test_calculate_sha256_empty_file(self, tmp_path): + """Test SHA256 calculation for empty file.""" + test_file = tmp_path / "empty.txt" + test_file.write_bytes(b"") + + expected = hashlib.sha256(b"").hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_binary_file(self, tmp_path): + """Test SHA256 calculation for binary file.""" + test_file = tmp_path / "binary.bin" + content = bytes(range(256)) + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_large_file(self, tmp_path): + """Test SHA256 calculation handles larger files correctly.""" + test_file = tmp_path / "large.bin" + # Create a file larger than the 8192 byte chunk size + content = b"x" * 50000 + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_read_error(self): + """Test SHA256 calculation raises on read error.""" + with patch("builtins.open", side_effect=IOError("Read error")): + with pytest.raises(FileValidationError) as exc_info: + calculate_sha256(Path("/some/file.txt")) + assert "Failed to read file" in str(exc_info.value) + + +class TestFileMappingParsing: + """Tests for parse_file_mapping function.""" + + @pytest.mark.timeout(60) + def test_valid_mapping(self): + """Test parsing valid file mapping.""" + mappings = ["local.bin:remote.bin"] + files = ["path/to/local.bin"] + + result = parse_file_mapping(mappings, files) + + assert result == {"local.bin": "remote.bin"} + + @pytest.mark.timeout(60) + def test_multiple_mappings(self): + """Test parsing multiple file mappings.""" + mappings = ["file1.txt:renamed1.txt", "file2.bin:renamed2.bin"] + files = ["path/to/file1.txt", "path/to/file2.bin"] + + result = parse_file_mapping(mappings, files) + + assert result == { + "file1.txt": "renamed1.txt", + "file2.bin": "renamed2.bin", + } + + @pytest.mark.timeout(60) + def test_empty_mappings(self): + """Test parsing empty mappings list returns empty dict.""" + result = parse_file_mapping([], ["file.txt"]) + assert result == {} + + @pytest.mark.timeout(60) + def test_invalid_mapping_no_colon(self): + """Test invalid mapping without colon raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["invalid_mapping"], ["file.txt"]) + assert "Invalid file mapping format" in str(exc_info.value) + assert "local.bin:remote.bin" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_mapping_multiple_colons(self): + """Test invalid mapping with multiple colons raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["a:b:c"], ["a"]) + assert "Invalid file mapping format" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_mapping_file_not_in_list(self): + """Test mapping referencing non-existent file raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["missing.bin:remote.bin"], ["other.bin"]) + assert "not in the files list" in str(exc_info.value) + + +class TestFileCollection: + """Tests for collect_files function.""" + + @pytest.mark.timeout(60) + def test_files_mode_basic(self, tmp_path): + """Test collecting files in files mode.""" + # Create test files + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_text("content1") + file2.write_text("content2") + + files_to_upload, errors = collect_files( + files=[str(file1), str(file2)] + ) + + assert len(files_to_upload) == 2 + assert len(errors) == 0 + + @pytest.mark.timeout(60) + def test_files_mode_with_mapping(self, tmp_path): + """Test collecting files with file mappings.""" + file1 = tmp_path / "local.txt" + file1.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(file1)], + file_mappings={"local.txt": "remote.txt"}, + ) + + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "remote.txt" + + @pytest.mark.timeout(60) + def test_directory_mode_basic(self, tmp_path): + """Test collecting files from directory.""" + # Create test files in directory + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + + files_to_upload, errors = collect_files(directory=str(tmp_path)) + + assert len(files_to_upload) == 2 + assert len(errors) == 0 + + @pytest.mark.timeout(60) + def test_directory_mode_ignores_subdirectories(self, tmp_path): + """Test directory mode only collects top-level files.""" + (tmp_path / "file.txt").write_text("content") + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "nested.txt").write_text("nested content") + + files_to_upload, errors = collect_files(directory=str(tmp_path)) + + # Should only find the top-level file, not the nested one + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "file.txt" + + @pytest.mark.timeout(60) + def test_mutually_exclusive_inputs_error(self, tmp_path): + """Test error when both files and directory are provided.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=["file.txt"], + directory=str(tmp_path), + ) + assert "mutually exclusive" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_inputs_error(self): + """Test error when neither files nor directory is provided.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files() + assert "must be provided" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nonexistent_file_collected_as_error(self, tmp_path): + """Test non-existent file is collected as error, not raised.""" + existing = tmp_path / "exists.txt" + existing.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(existing), "/nonexistent/file.txt"] + ) + + assert len(files_to_upload) == 1 + assert len(errors) == 1 + assert "FileValidationError" in errors[0]["error_type"] + + @pytest.mark.timeout(60) + def test_invalid_filename_collected_as_error(self, tmp_path): + """Test file with invalid filename is collected as error.""" + # Create file with valid local name, but map to invalid remote name + valid_file = tmp_path / "valid.txt" + valid_file.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(valid_file)], + file_mappings={"valid.txt": "invalid file.txt"}, # Space is invalid + ) + + assert len(files_to_upload) == 0 + assert len(errors) == 1 + + @pytest.mark.timeout(60) + def test_duplicate_target_filenames_error(self, tmp_path): + """Test duplicate target filenames raise error.""" + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_text("content1") + file2.write_text("content2") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=[str(file1), str(file2)], + file_mappings={ + "file1.txt": "same.txt", + "file2.txt": "same.txt", + }, + ) + assert "Duplicate target filenames" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nonexistent_directory_error(self): + """Test error for non-existent directory.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files(directory="/nonexistent/directory") + assert "Directory not found" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_file_path_as_directory_error(self, tmp_path): + """Test error when file path is provided as directory.""" + file_path = tmp_path / "file.txt" + file_path.write_text("content") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files(directory=str(file_path)) + assert "not a directory" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_file_mappings_as_list(self, tmp_path): + """Test file_mappings can be provided as list of strings.""" + file1 = tmp_path / "local.txt" + file1.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(file1)], + file_mappings=["local.txt:remote.txt"], + ) + + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "remote.txt" + + @pytest.mark.timeout(60) + def test_invalid_file_mappings_type_error(self, tmp_path): + """Test error for invalid file_mappings type.""" + file1 = tmp_path / "file.txt" + file1.write_text("content") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=[str(file1)], + file_mappings=123, # Invalid type + ) + assert "must be a dict or list" in str(exc_info.value) + + +class TestGitUrlParsing: + """Tests for parse_git_url function.""" + + @pytest.mark.timeout(60) + def test_https_url_basic(self): + """Test parsing basic HTTPS Git URL.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/namespace/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_https_url_without_git_suffix(self): + """Test parsing HTTPS URL without .git suffix.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_url_basic(self): + """Test parsing basic SSH Git URL.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.com:namespace/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_url_without_git_suffix(self): + """Test parsing SSH URL without .git suffix.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.com:namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_nested_namespace(self): + """Test parsing URL with nested namespace (subgroups).""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/group/subgroup/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_self_hosted_gitlab(self): + """Test parsing URL for self-hosted GitLab instance.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.example.com/namespace/project.git" + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_self_hosted(self): + """Test parsing SSH URL for self-hosted GitLab.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.example.com:namespace/project.git" + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_empty_url_error(self): + """Test error for empty URL.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("") + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_none_url_error(self): + """Test error for None URL.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url(None) + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_ssh_url_no_colon(self): + """Test error for SSH URL without colon.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("git@gitlab.com/namespace/project.git") + assert "Invalid SSH Git URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_https_scheme(self): + """Test error for non-HTTPS scheme.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("http://gitlab.com/namespace/project.git") + assert "Invalid Git URL scheme" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_project_path(self): + """Test error for URL missing project path.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("https://gitlab.com/namespace") + assert "Path must contain at least namespace/project" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_url_with_trailing_whitespace(self): + """Test URL with whitespace is trimmed.""" + gitlab_url, project_path = parse_git_url( + " https://gitlab.com/namespace/project.git " + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + +class TestNormalizeGitlabUrl: + """Tests for normalize_gitlab_url function.""" + + @pytest.mark.timeout(60) + def test_basic_url(self): + """Test normalizing basic GitLab URL.""" + gitlab_url, project_path = normalize_gitlab_url( + "https://gitlab.com/namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_url_with_trailing_slash(self): + """Test URL with trailing slash is normalized.""" + gitlab_url, project_path = normalize_gitlab_url( + "https://gitlab.com/namespace/project/" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_http_url(self): + """Test HTTP URL is accepted.""" + gitlab_url, project_path = normalize_gitlab_url( + "http://gitlab.example.com/namespace/project" + ) + assert gitlab_url == "http://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_empty_url_error(self): + """Test error for empty URL.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("") + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_scheme_error(self): + """Test error for invalid scheme.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("ftp://gitlab.com/namespace/project") + assert "Invalid GitLab URL scheme" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_path_error(self): + """Test error for URL missing path.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("https://gitlab.com") + assert "missing project path" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_incomplete_path_error(self): + """Test error for URL with incomplete path.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("https://gitlab.com/namespace") + assert "Path must contain at least namespace/project" in str(exc_info.value) + + +class TestTokenHandling: + """Tests for get_gitlab_token function.""" + + @pytest.mark.timeout(60) + def test_cli_token_takes_precedence(self, monkeypatch): + """Test CLI token takes precedence over environment variable.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token("cli-token") + + assert result == "cli-token" + + @pytest.mark.timeout(60) + def test_environment_token_used(self, monkeypatch): + """Test environment variable token is used when CLI token is None.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token(None) + + assert result == "env-token" + + @pytest.mark.timeout(60) + def test_missing_token_error(self, monkeypatch): + """Test error when no token is available.""" + monkeypatch.delenv("GITLAB_TOKEN", raising=False) + + with pytest.raises(ConfigurationError) as exc_info: + get_gitlab_token(None) + assert "No GitLab token provided" in str(exc_info.value) + assert "GITLAB_TOKEN" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_empty_cli_token_falls_through(self, monkeypatch): + """Test empty CLI token falls through to environment.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token("") + + assert result == "env-token" + + +class TestTokenValidation: + """Tests for validate_gitlab_token function.""" + + @pytest.mark.timeout(60) + def test_valid_token(self): + """Test valid token passes validation.""" + # Should not raise + validate_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + validate_gitlab_token("x" * 20) + + @pytest.mark.timeout(60) + def test_empty_token_error(self): + """Test empty token raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("") + assert "token is required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_none_token_error(self): + """Test None token raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token(None) + assert "token is required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_short_token_error(self): + """Test token too short raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("short") + assert "too short" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_incomplete_glpat_token_error(self): + """Test incomplete glpat- token raises error.""" + # glpat- tokens should be 26+ characters, use one that's 20-25 chars + # This is caught by the glpat- specific check, not the general short check + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("glpat-12345678901234") # 20 chars total + assert "incomplete" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_token_with_whitespace_trimmed(self): + """Test token with whitespace is trimmed before validation.""" + # Should not raise - whitespace is stripped + validate_gitlab_token(" " + "x" * 20 + " ") + + @pytest.mark.timeout(60) + def test_error_includes_help_url(self): + """Test error message includes help URL.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("") + assert "personal_access_tokens" in str(exc_info.value) + + +class TestDependencyValidation: + """Tests for validate_dependencies function.""" + + @pytest.mark.timeout(60) + def test_dependencies_available(self): + """Test validation passes when all dependencies are available.""" + with patch.object(sys, "version_info", (3, 11, 0)): + with patch("builtins.__import__") as mock_import: + mock_import.return_value = MagicMock() + # Should not raise + validate_dependencies() + + @pytest.mark.timeout(60) + def test_python_version_too_low(self): + """Test error when Python version is too low.""" + with patch.object(sys, "version_info", (3, 10, 0)): + with patch.object(sys, "version", "3.10.0"): + with pytest.raises(ConfigurationError) as exc_info: + validate_dependencies() + assert "Python 3.11" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_module_error(self): + """Test error when required module is missing.""" + def mock_import(name, *args, **kwargs): + if name == "gitlab": + raise ImportError("No module named 'gitlab'") + return MagicMock() + + with patch.object(sys, "version_info", (3, 11, 0)): + with patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ConfigurationError) as exc_info: + validate_dependencies() + assert "gitlab" in str(exc_info.value) + assert "python-gitlab" in str(exc_info.value) + + +class TestGitInstallationValidation: + """Tests for validate_git_installation function.""" + + @pytest.mark.timeout(60) + def test_git_installed(self): + """Test validation passes when Git is installed.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "git version 2.40.0" + + with patch("subprocess.run", return_value=mock_result): + # Should not raise + validate_git_installation() + + @pytest.mark.timeout(60) + def test_git_not_installed(self): + """Test error when Git is not installed.""" + with patch("subprocess.run", side_effect=FileNotFoundError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "Git is not installed" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_git_command_failed(self): + """Test error when Git command fails.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "git: command not found" + + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "Git command failed" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_git_command_timeout(self): + """Test error when Git command times out.""" + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("git", 10)): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "timed out" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_includes_installation_instructions(self): + """Test error includes platform-specific installation instructions.""" + with patch("subprocess.run", side_effect=FileNotFoundError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + error_msg = str(exc_info.value) + assert "apt" in error_msg or "brew" in error_msg or "Windows" in error_msg + + +class TestGitRepositoryValidation: + """Tests for validate_git_repository function.""" + + @pytest.mark.timeout(60) + def test_valid_repository(self, mock_git_repo): + """Test validation passes for valid Git repository.""" + with patch("git.Repo", return_value=mock_git_repo): + # Should not raise + validate_git_repository(".") + + @pytest.mark.timeout(60) + def test_not_a_git_repository(self): + """Test error when directory is not a Git repository.""" + import git + + with patch("git.Repo", side_effect=git.InvalidGitRepositoryError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/tmp") + assert "not inside a Git repository" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_permission_denied(self): + """Test error when permission is denied.""" + with patch("git.Repo", side_effect=PermissionError("Access denied")): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/protected/repo") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_repository_not_accessible(self, mock_git_repo): + """Test error when repository is not fully accessible.""" + mock_git_repo.config_reader.side_effect = Exception("Config not readable") + + with patch("git.Repo", return_value=mock_git_repo): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository(".") + assert "not fully accessible" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_includes_repair_guidance(self): + """Test error includes repository repair guidance.""" + import git + + with patch("git.Repo", side_effect=git.InvalidGitRepositoryError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/tmp") + error_msg = str(exc_info.value) + assert "git init" in error_msg or "git status" in error_msg + + +class TestProjectSpecValidation: + """Tests for validate_project_specification function.""" + + @pytest.mark.timeout(60) + def test_url_spec_auto_detected(self): + """Test URL specification is auto-detected.""" + gitlab_url, project_path = validate_project_specification( + "https://gitlab.com/mygroup/myproject" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_path_spec_auto_detected(self): + """Test path specification is auto-detected.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject" + ) + assert gitlab_url == DEFAULT_GITLAB_URL + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_url_spec_explicit(self): + """Test explicit URL specification type.""" + gitlab_url, project_path = validate_project_specification( + "https://gitlab.example.com/ns/proj", + spec_type="url", + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "ns/proj" + + @pytest.mark.timeout(60) + def test_path_spec_explicit(self): + """Test explicit path specification type.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject", + spec_type="path", + ) + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_path_spec_with_custom_gitlab_url(self): + """Test path specification with custom GitLab URL.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject", + spec_type="path", + gitlab_url="https://gitlab.example.com", + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_empty_spec_error(self): + """Test error for empty specification.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("") + assert "required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_none_spec_error(self): + """Test error for None specification.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification(None) + assert "required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_invalid_path_format(self): + """Test error for invalid path format (missing namespace).""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("myproject", spec_type="path") + assert "namespace/project" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_path_with_consecutive_slashes(self): + """Test error for path with consecutive slashes.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("group//project", spec_type="path") + assert "empty component" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nested_namespace_path(self): + """Test nested namespace path is accepted.""" + gitlab_url, project_path = validate_project_specification( + "group/subgroup/project", + spec_type="path", + ) + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_unknown_spec_type_error(self): + """Test error for unknown specification type.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification( + "mygroup/myproject", + spec_type="unknown", + ) + assert "Unknown specification type" in str(exc_info.value) + + +class TestConfigurationValidation: + """Tests for validate_configuration orchestration function.""" + + @pytest.mark.timeout(60) + def test_successful_validation(self, monkeypatch): + """Test successful configuration validation.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): + # Should not raise + validate_configuration(token="x" * 26, require_git=False) + + @pytest.mark.timeout(60) + def test_validation_with_require_git(self, monkeypatch): + """Test validation with Git requirement.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): + with patch("glpkg.validators.validate_git_repository"): + # Should not raise + validate_configuration( + token="x" * 26, + require_git=True, + ) + + @pytest.mark.timeout(60) + def test_dependencies_failure_propagates(self): + """Test dependencies validation failure propagates.""" + with patch( + "glpkg.validators.validate_dependencies", + side_effect=ConfigurationError("Missing dependency"), + ): + with pytest.raises(ConfigurationError) as exc_info: + validate_configuration(token="x" * 26) + assert "Missing dependency" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_token_validation_failure_propagates(self): + """Test token validation failure propagates.""" + with patch("glpkg.validators.validate_dependencies"): + with pytest.raises(ConfigurationError): + validate_configuration(token="short") + + @pytest.mark.timeout(60) + def test_git_failure_ignored_when_not_required(self, monkeypatch): + """Test Git validation failure is ignored when not required.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("glpkg.validators.validate_dependencies"): + with patch( + "glpkg.validators.validate_git_installation", + side_effect=ConfigurationError("Git not found"), + ): + # Should not raise - Git is not required + validate_configuration( + token="x" * 26, + require_git=False, + ) + + @pytest.mark.timeout(60) + def test_git_failure_propagates_when_required(self, monkeypatch): + """Test Git validation failure propagates when required.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("glpkg.validators.validate_dependencies"): + with patch( + "glpkg.validators.validate_git_installation", + side_effect=ConfigurationError("Git not found"), + ): + with pytest.raises(ConfigurationError) as exc_info: + validate_configuration( + token="x" * 26, + require_git=True, + ) + assert "Git not found" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_token_from_environment(self, monkeypatch): + """Test token is retrieved from environment when not provided.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): + # Should not raise - token from environment + validate_configuration(token=None, require_git=False) diff --git a/tests/utils/artifact_factory.py b/tests/utils/artifact_factory.py index 43e8f95..8ee3840 100644 --- a/tests/utils/artifact_factory.py +++ b/tests/utils/artifact_factory.py @@ -6,7 +6,6 @@ class in the monolithic test file. It provides utilities for generating test dat with various characteristics for upload testing. """ -import hashlib import secrets import tempfile from dataclasses import dataclass @@ -14,6 +13,8 @@ class in the monolithic test file. It provides utilities for generating test dat from pathlib import Path from typing import Dict, List, Optional +from glpkg.validators import calculate_sha256 + @dataclass class TestArtifact: @@ -69,8 +70,8 @@ def create_test_file( # Write file file_path.write_bytes(content) - # Calculate checksum - checksum = hashlib.sha256(content).hexdigest() + # Calculate checksum using validators module + checksum = calculate_sha256(file_path) # Determine content type content_type = ArtifactFactory._determine_content_type( @@ -404,13 +405,7 @@ def calculate_file_checksum(file_path: Path) -> str: Returns: SHA256 checksum as hexadecimal string """ - sha256_hash = hashlib.sha256() - - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - sha256_hash.update(chunk) - - return sha256_hash.hexdigest() + return calculate_sha256(file_path) def create_file_with_checksum( diff --git a/tests/utils/gitlab_helpers.py b/tests/utils/gitlab_helpers.py index 397ed47..9e60a9e 100644 --- a/tests/utils/gitlab_helpers.py +++ b/tests/utils/gitlab_helpers.py @@ -4,11 +4,14 @@ This module contains GitLab verification methods extracted from the GitLabTestClient class in the monolithic test file. It provides utilities for interacting with the GitLab API for upload verification and package management. + +Updated to use exception models from the new gitlab_pkg_upload module +for better error categorization and testing of exception handling. """ import hashlib import time -from typing import List, Optional, Dict, Any, Tuple +from typing import Any, Dict, List, Optional, Tuple import requests @@ -20,6 +23,24 @@ class in the monolithic test file. It provides utilities for interacting with th Gitlab = None GitlabError = Exception +# Import exception models from the new modular structure +try: + from glpkg.models import ( + AuthenticationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + ) + + EXCEPTION_MODELS_AVAILABLE = True +except ImportError: + # Fall back to basic exceptions when glpkg is not available + EXCEPTION_MODELS_AVAILABLE = False + GitLabUploadError = Exception + AuthenticationError = Exception + ProjectResolutionError = Exception + NetworkError = Exception + class GitLabVerifier: """ @@ -53,6 +74,11 @@ def verify_package_exists(self, package_name: str, version: str) -> bool: Returns: True if package exists, False otherwise + + Raises: + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: project = self.gl.projects.get(self.project_id) @@ -65,9 +91,24 @@ def verify_package_exists(self, package_name: str, version: str) -> bool: return False - except Exception as e: - print(f"Error checking package existence: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed while checking package existence: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found while checking package existence: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error while checking package existence: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error while checking package existence: {e}") + raise def verify_file_upload( self, @@ -162,9 +203,24 @@ def verify_file_upload( return True - except Exception as e: - print(f"Upload verification failed for {filename}: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during upload verification for {filename}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found during upload verification for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload verification for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload verification for {filename}: {e}") + raise def get_download_url( self, package_name: str, version: str, filename: str @@ -232,9 +288,24 @@ def get_download_url( return None - except Exception as e: - print(f"Failed to get download URL for {filename}: {e}") - return None + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed getting download URL for {filename}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found getting download URL for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error getting download URL for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error getting download URL for {filename}: {e}") + raise def download_and_verify_content( self, package_name: str, version: str, filename: str, expected_checksum: str @@ -282,16 +353,35 @@ def download_and_verify_content( print(f"Download and verification successful for {filename}") return True - except Exception as e: - print(f"Download and verification failed for {filename}: {e}") - # Special handling for subdirectory files - if "/" in filename: - print( - f"File '{filename}' contains subdirectory path. " - f"Assuming verification success due to GitLab limitations." - ) - return True - return False + except requests.exceptions.HTTPError as e: + status_code = e.response.status_code if e.response is not None else None + if status_code == 401 or status_code == 403: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during download for {filename}: {e}") + raise + elif status_code == 404: + # Special handling for subdirectory files + if "/" in filename: + print( + f"File '{filename}' contains subdirectory path. " + f"Assuming verification success due to GitLab limitations." + ) + return True + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"File not found during download for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"HTTP error during download for {filename}: {e}") + raise + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during download for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during download for {filename}: {e}") + raise def list_package_files( self, package_name: str, version: str @@ -337,9 +427,24 @@ def list_package_files( return file_list - except Exception as e: - print(f"Failed to list package files: {e}") - return [] + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed listing package files: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found listing package files: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error listing package files: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error listing package files: {e}") + raise def delete_package(self, package_name: str, version: str) -> bool: """ @@ -372,9 +477,24 @@ def delete_package(self, package_name: str, version: str) -> bool: print(f"Deleted package: {package_name} v{version}") return True - except Exception as e: - print(f"Failed to delete package {package_name} v{version}: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed deleting package {package_name} v{version}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + # Package not found - consider deletion successful + print(f"Package {package_name} v{version} not found (already deleted)") + return True + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error deleting package {package_name} v{version}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error deleting package {package_name} v{version}: {e}") + raise def cleanup_test_packages(self, package_prefix: str = "test-") -> Tuple[int, int]: """ @@ -403,15 +523,40 @@ def cleanup_test_packages(self, package_prefix: str = "test-") -> Tuple[int, int package.delete() print(f"Deleted test package: {package.name} v{package.version}") successful += 1 - except Exception as e: - print(f"Failed to delete test package {package.name}: {e}") - failed += 1 + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed deleting test package {package.name}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + # Package already deleted + print(f"Test package {package.name} already deleted") + successful += 1 + else: + print(f"Failed to delete test package {package.name}: {e}") + failed += 1 return successful, failed - except Exception as e: - print(f"Failed to cleanup test packages: {e}") - return 0, 0 + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during cleanup: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found during cleanup: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during cleanup: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during cleanup: {e}") + raise def validate_upload_consistency( @@ -463,9 +608,30 @@ def validate_upload_consistency( print(f"Upload consistency validation successful for {filename}") return True - except Exception as e: - print(f"Upload consistency validation failed with exception: {e}") - return False + except (AuthenticationError, ProjectResolutionError, NetworkError): + # Re-raise typed exceptions to propagate them + raise + except GitLabUploadError: + # Re-raise base upload error to preserve exit semantics + raise + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during upload consistency validation: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found during upload consistency validation: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload consistency validation: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload consistency validation: {e}") + raise def wait_for_package_availability( @@ -518,13 +684,31 @@ def create_gitlab_verifier( GitLabVerifier instance Raises: - ValueError: If project cannot be accessed + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: project = gitlab_client.projects.get(project_path) return GitLabVerifier(gitlab_client, project.id, token) except GitlabError as e: - raise ValueError(f"Failed to access project {project_path}: {e}") + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed accessing project {project_path}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found: {project_path}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error accessing project {project_path}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error accessing project {project_path}: {e}") + raise def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: @@ -537,6 +721,11 @@ def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: Returns: True if access is verified, False otherwise + + Raises: + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: # Test basic API access @@ -551,11 +740,27 @@ def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: try: _ = project.packages.list(per_page=1, get_all=False) print("Package registry access verified") - except Exception as e: + except GitlabError as e: + # Package registry access is optional - log but don't fail print(f"Package registry access may be limited: {e}") return True - except Exception as e: - print(f"GitLab API access verification failed: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed verifying API access: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found verifying API access: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error verifying API access: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error verifying API access: {e}") + raise diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index a02625a..7766135 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -3,16 +3,57 @@ This module contains script execution logic extracted from the TestOrchestrator class in the monolithic test file. It provides utilities for running the -upload script as a subprocess and validating results. +upload script via direct module invocation and validating results. + +Updated to use direct module invocation instead of subprocess execution +for better integration with the new modular structure in gitlab_pkg_upload. """ +import contextlib +import io import os -import subprocess +import sys import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional +# Add src directory to sys.path as a fallback for running tests without +# installing the package. This allows tests to run from the repository +# without requiring `uv pip install -e .` first. +_src_path = Path(__file__).parent.parent.parent / "src" +if _src_path.exists() and str(_src_path) not in sys.path: + sys.path.insert(0, str(_src_path)) + +# Import from the new modular structure +try: + from glpkg.cli.main import main as cli_main + from glpkg.models import ( + AuthenticationError, + ConfigurationError, + DuplicatePolicy, + FileValidationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + UploadConfig, + ) + + CLI_AVAILABLE = True +except ImportError: + cli_main = None + CLI_AVAILABLE = False + # Define placeholder exit codes when module not available + AuthenticationError = None + ConfigurationError = None + DuplicatePolicy = None + FileValidationError = None + GitLabUploadError = None + NetworkError = None + ProjectResolutionError = None + UploadConfig = None + @dataclass class UploadExecution: @@ -89,10 +130,14 @@ def __post_init__(self): class ScriptExecutor: """ - Handles execution of the gitlab-pkg-upload.py script. + Handles execution of the glpkg CLI. Extracted from the monolithic test file's UploadScriptInterface class. - This class manages subprocess execution of the upload script and result parsing. + This class manages execution of the upload CLI via direct module invocation + and result parsing. + + Updated to use direct module invocation instead of subprocess execution + for better integration with the new modular structure. """ def __init__(self, script_path: Optional[Path] = None): @@ -100,21 +145,25 @@ def __init__(self, script_path: Optional[Path] = None): Initialize script executor. Args: - script_path: Path to the upload script. If None, uses default location. + script_path: Deprecated parameter, kept for backward compatibility. + Direct module invocation is always used via glpkg.cli. """ - if script_path is None: - # Default to the upload script in the same directory as the test - script_path = Path(__file__).parent.parent.parent / "gitlab-pkg-upload.py" - self.script_path = script_path + self._use_direct_invocation = CLI_AVAILABLE - if not self.script_path.exists(): - raise FileNotFoundError(f"Upload script not found at: {self.script_path}") + if not self._use_direct_invocation: + raise ImportError( + "glpkg module is not available. " + "Install the package with: uv pip install -e ." + ) def execute_upload(self, execution: UploadExecution) -> UploadResult: """ Execute upload script with given configuration. + Uses direct module invocation when available, falls back to subprocess + when the gitlab_pkg_upload module is not importable. + Args: execution: Upload execution configuration @@ -133,6 +182,162 @@ def execute_upload(self, execution: UploadExecution) -> UploadResult: if result.json_data: print(result.json_data["success"]) """ + if self._use_direct_invocation: + return self._execute_direct(execution) + else: + return self._execute_subprocess(execution) + + def _execute_direct(self, execution: UploadExecution) -> UploadResult: + """ + Execute upload via direct module invocation with timeout handling. + + Args: + execution: Upload execution configuration + + Returns: + UploadResult with execution details + """ + start_time = time.time() + + # Extract argv from command (skip the script path) + argv = execution.command[1:] if len(execution.command) > 1 else [] + + # Capture stdout and stderr + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + # Save original environment and argv + original_env = os.environ.copy() + original_cwd = os.getcwd() + + exit_code = 0 + timed_out = False + + def run_cli(): + """Inner function to run CLI, to be executed with timeout.""" + nonlocal exit_code + try: + cli_main(argv) + exit_code = 0 + except SystemExit as e: + exit_code = e.code if isinstance(e.code, int) else 1 + except GitLabUploadError as e: + exit_code = e.exit_code + print(str(e), file=sys.stderr) + except Exception as e: + exit_code = 1 + print(f"Error: {e}", file=sys.stderr) + + try: + # Update environment if needed + if execution.env_vars: + os.environ.update(execution.env_vars) + + # Change working directory if specified + if execution.working_directory: + os.chdir(execution.working_directory) + + # Execute CLI with captured output and timeout + with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture): + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_cli) + try: + future.result(timeout=execution.timeout) + except FuturesTimeoutError: + timed_out = True + exit_code = -1 + + finally: + # Restore original environment + os.environ.clear() + os.environ.update(original_env) + # Restore working directory + os.chdir(original_cwd) + + duration = time.time() - start_time + + # Handle timeout case - return early with timeout error + if timed_out: + return UploadResult( + success=False, + exit_code=-1, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + duration=duration, + error_message=f"Script execution timed out after {execution.timeout} seconds", + uploaded_files=[], + upload_urls=[], + json_data=None, + ) + + stdout = stdout_capture.getvalue() + stderr = stderr_capture.getvalue() + + # Parse JSON output if enabled + json_data = None + if execution.use_json_output: + json_data = self._parse_json_output(stdout) + + # Extract uploaded files and URLs + if json_data is not None: + uploaded_files, upload_urls = self._extract_data_from_json(json_data) + else: + uploaded_files = self._extract_uploaded_files(stdout) + upload_urls = self._extract_upload_urls(stdout) + + # Determine success + if json_data is not None: + # Use JSON data for success determination + success = ( + json_data.get("success", False) + and exit_code == execution.expected_exit_code + and json_data.get("exit_code", -1) == execution.expected_exit_code + ) + else: + # Use traditional pattern matching + success = ( + exit_code == execution.expected_exit_code + and self._check_output_patterns( + stdout, execution.expected_output_patterns + ) + ) + + error_message = None + if not success: + if json_data is not None and "error" in json_data: + error_message = f"{json_data.get('error_type', 'Error')}: {json_data.get('error', 'Unknown error')}" + elif exit_code != execution.expected_exit_code: + error_message = f"Unexpected exit code: {exit_code} (expected {execution.expected_exit_code})" + else: + error_message = "Expected output patterns not found" + + if stderr: + error_message += f". Stderr: {stderr}" + + return UploadResult( + success=success, + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + duration=duration, + error_message=error_message, + uploaded_files=uploaded_files, + upload_urls=upload_urls, + json_data=json_data, + ) + + def _execute_subprocess(self, execution: UploadExecution) -> UploadResult: + """ + Execute upload via subprocess (fallback when module not available). + + Args: + execution: Upload execution configuration + + Returns: + UploadResult with execution details + """ + import subprocess + start_time = time.time() try: @@ -263,13 +468,17 @@ def build_command(self, use_json_output: bool = False, **kwargs) -> List[str]: ): raise ValueError("version is required and cannot be empty") - command = [str(self.script_path)] + # Use program name for direct invocation, script path for subprocess fallback + if self._use_direct_invocation: + command = ["glpkg", "upload"] + else: + command = [str(self.script_path)] # Add common parameters if "package_name" in kwargs: command.extend(["--package-name", kwargs["package_name"]]) if "version" in kwargs: - command.extend(["--version", kwargs["version"]]) + command.extend(["--package-version", kwargs["version"]]) if "files" in kwargs: if isinstance(kwargs["files"], list): command.extend(["--files"] + kwargs["files"]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d16c3fe --- /dev/null +++ b/uv.lock @@ -0,0 +1,1215 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "bump-my-version" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomlkit" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/d3/43acec2ec4a477d6c6191faebe5f2e79facd80936ab3e93b6f9d18d11593/bump_my_version-1.2.6.tar.gz", hash = "sha256:1f2f0daa5d699904e9739be8efb51c4c945461bad83cd4da4c89d324d9a18343", size = 1195328, upload-time = "2025-12-29T11:59:30.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/8e/39de3356f72327dd0bf569540a858723f3fc4f11f3c5bfae85b3dadac5c3/bump_my_version-1.2.6-py3-none-any.whl", hash = "sha256:a2f567c10574a374b81a9bd6d2bd3cb2ca74befe5c24c3021123773635431659", size = 59791, upload-time = "2025-12-29T11:59:27.873Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "glpkg-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "argcomplete" }, + { name = "gitpython" }, + { name = "python-gitlab" }, + { name = "rich" }, + { name = "tenacity" }, +] + +[package.optional-dependencies] +dev = [ + { name = "bump-my-version" }, + { name = "mypy" }, + { name = "pex" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "shiv" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-instafail" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, +] + +[package.metadata] +requires-dist = [ + { name = "argcomplete", specifier = ">=3.0.0" }, + { name = "bump-my-version", marker = "extra == 'dev'" }, + { name = "gitpython", specifier = ">=3.1.0" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "pex", marker = "extra == 'dev'", specifier = ">=2.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "pytest-instafail", marker = "extra == 'test'" }, + { name = "pytest-sugar", marker = "extra == 'test'" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, + { name = "python-gitlab", specifier = ">=4.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "shiv", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "tenacity", specifier = ">=8.0.0" }, +] +provides-extras = ["dev", "test"] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, + { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +] + +[[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.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[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 = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[package]] +name = "pex" +version = "2.77.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/48/02dc102a955fd46b5098fb3a7854a8ce2b1d3612d7c4354eddc50f75bb43/pex-2.77.3.tar.gz", hash = "sha256:f4e0d6f561570d27949bb0c9a268545129025a0544b6bba6335a45b9e272dc4d", size = 5224006, upload-time = "2026-01-09T14:32:13.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/dddaef21fabf62e109025f3122003ff7a3360d612bcbc623b40e4b1ac0de/pex-2.77.3-py2.py35.py36.py37.py38.py39.py310.py311-none-any.whl", hash = "sha256:de77352f03ef95b776e973926e4ce0c9f549c35a6a22bf2b6bf9158c0f9504a7", size = 3930903, upload-time = "2026-01-09T14:32:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7f/03a8e20c247647db23cd25c04207a3518fe485376043bc24ba549f0be32a/pex-2.77.3-py3.py312-none-any.whl", hash = "sha256:0e2654cf9d27e9489270c71dbc1963cd86cfa4bcee756283d7670171b7a64b41", size = 1731309, upload-time = "2026-01-09T14:32:11.166Z" }, +] + +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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 = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[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 = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-instafail" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/bd/e0ba6c3cd20b9aa445f0af229f3a9582cce589f083537978a23e6f14e310/pytest-instafail-0.5.0.tar.gz", hash = "sha256:33a606f7e0c8e646dc3bfee0d5e3a4b7b78ef7c36168cfa1f3d93af7ca706c9e", size = 5849, upload-time = "2023-03-31T17:17:32.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/c0/c32dc39fc172e684fdb3d30169843efb65c067be1e12689af4345731126e/pytest_instafail-0.5.0-py3-none-any.whl", hash = "sha256:6855414487e9e4bb76a118ce952c3c27d3866af15487506c4ded92eb72387819", size = 4176, upload-time = "2023-03-31T17:17:30.065Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-gitlab" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/98/0b5d0a0367b90aec818298390b60ae65e6a08989cf5140271d0ee0206882/python_gitlab-7.1.0.tar.gz", hash = "sha256:1c34da3de40ad21675d788136f73d20a60649513e692f52c5a9720434db97c46", size = 401058, upload-time = "2025-12-28T01:27:01.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/44/70fa1e395731b6a4b1f249d5f7326f3bb6281e2cf94d6535f679239f4b93/python_gitlab-7.1.0-py3-none-any.whl", hash = "sha256:8e42030cf27674e7ec9ea1f6d2fedcaaef0a6210f5fa22c80721abaa3a4fec90", size = 144441, upload-time = "2025-12-28T01:26:59.726Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[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 = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/d1/b60ca6a8745e76800b50c7ee246fd73f08a3be5d8e0b551fc93c19fa1203/rich_click-1.9.5.tar.gz", hash = "sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", size = 73927, upload-time = "2025-12-21T14:49:44.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/d865895e1e5d88a60baee0fc3703eb111c502ee10c8c107516bc7623abf8/rich_click-1.9.5-py3-none-any.whl", hash = "sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a", size = 70580, upload-time = "2025-12-21T14:49:42.905Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shiv" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pip" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/85/004e7123b4821c64be6d9bfed27f63147b4dde929a1cd848f137befeada4/shiv-1.0.8.tar.gz", hash = "sha256:2a68d69e98ce81cb5b8fdafbfc1e27efa93e6d89ca14bfae33482e4176f561d6", size = 32806, upload-time = "2024-11-01T19:47:46.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ec/afbb46f7c1ab071a50d92424daf149420ca1f0e02dc51239485747151d6c/shiv-1.0.8-py2.py3-none-any.whl", hash = "sha256:a60e4b05a2d2f8b820d567b1d89ee59af731759771c32c282d03c4ceae6aba24", size = 20516, upload-time = "2024-11-01T19:47:45.061Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +]