diff --git a/.github/scripts/combine-test-reports.sh b/.github/scripts/combine-test-reports.sh index e1ebb6f20d..2d6d9225d7 100755 --- a/.github/scripts/combine-test-reports.sh +++ b/.github/scripts/combine-test-reports.sh @@ -1,171 +1,27 @@ #!/bin/bash +# Thin wrapper around `python3 -m tests.rig report combine`. +# +# Kept for backward compatibility with any external script or local workflow +# that still invokes this path. Prefer calling the rig directly. set -euo pipefail -# Script to combine multiple test reports into a single markdown file -# Usage: ./combine-test-reports.sh - -OUTPUT_FILE="combined-test-report.md" -REPORTS=() - -# Find all test report files -for report in runner-report.md sdk1-report.md; do - if [ -f "$report" ]; then - REPORTS+=("$report") - fi -done - -# Exit if no reports found -if [ ${#REPORTS[@]} -eq 0 ]; then - echo "No test reports found. Skipping report generation." - exit 0 +REPORTS_DIR="${REPORTS_DIR:-reports}" + +# Stock Ubuntu CI runners only ship `python3`, not `python`. Pick whichever is +# on PATH; bail loudly if neither. +if command -v python3 >/dev/null 2>&1; then + PY=python3 +elif command -v python >/dev/null 2>&1; then + PY=python +else + echo "combine-test-reports.sh: no python interpreter on PATH" >&2 + exit 1 fi -# Function to strip LaTeX formatting from pytest-md-report output -# Converts $$\textcolor{...}{\tt{VALUE}}$$ to just VALUE -strip_latex() { - local text="$1" - # Extract content between \tt{ and }} - if [[ "$text" =~ \\tt\{([^}]+)\} ]]; then - echo "${BASH_REMATCH[1]}" - else - echo "$text" - fi -} - -# Function to extract test counts from pytest-md-report markdown table -extract_test_counts() { - local report_file=$1 - local passed=0 - local failed=0 - local total=0 - - # Find the header row to determine column positions - local header_line=$(grep -E '^\|.*filepath' "$report_file" | head -1) - - if [ -z "$header_line" ]; then - echo "0:0:0" - return - fi - - # Extract column names and find positions (strip LaTeX from headers) - IFS='|' read -ra headers <<< "$header_line" - local passed_col=-1 - local failed_col=-1 - local subtotal_col=-1 - - for i in "${!headers[@]}"; do - local col=$(strip_latex "${headers[$i]}" | tr -d ' ' | tr '[:upper:]' '[:lower:]') - case "$col" in - passed) passed_col=$i ;; - failed) failed_col=$i ;; - subtotal|sub) subtotal_col=$i ;; - esac - done - - # Find the TOTAL row (TOTAL appears in first column, not as SUBTOTAL in header) - local total_line=$(grep -E '^\|.*\\tt\{TOTAL\}' "$report_file" | head -1) - - if [ -z "$total_line" ]; then - echo "0:0:0" - return - fi - - # Parse the TOTAL row values - IFS='|' read -ra values <<< "$total_line" - - # Extract passed count (strip LaTeX and get number) - if [ "$passed_col" -ge 0 ] && [ "$passed_col" -lt "${#values[@]}" ]; then - local clean_value=$(strip_latex "${values[$passed_col]}") - passed=$(echo "$clean_value" | tr -d ' ' | grep -oE '[0-9]+' | head -1 || echo "0") - fi - - # Extract failed count (strip LaTeX and get number) - if [ "$failed_col" -ge 0 ] && [ "$failed_col" -lt "${#values[@]}" ]; then - local clean_value=$(strip_latex "${values[$failed_col]}") - failed=$(echo "$clean_value" | tr -d ' ' | grep -oE '[0-9]+' | head -1 || echo "0") - fi - - # Extract total from SUBTOTAL column (strip LaTeX and get number) - if [ "$subtotal_col" -ge 0 ] && [ "$subtotal_col" -lt "${#values[@]}" ]; then - local clean_value=$(strip_latex "${values[$subtotal_col]}") - total=$(echo "$clean_value" | tr -d ' ' | grep -oE '[0-9]+' | head -1 || echo "0") - fi - - # If total is still 0, calculate from passed + failed - if [ "$total" -eq 0 ]; then - total=$((passed + failed)) - fi - - echo "${total}:${passed}:${failed}" -} +"$PY" -m tests.rig report combine --reports-dir "$REPORTS_DIR" -# Initialize the combined report with collapsed summary -cat > "$OUTPUT_FILE" << 'EOF' -# Test Results - -
-Summary - -EOF - -# Extract and display summary for each report -for report in "${REPORTS[@]}"; do - report_name=$(basename "$report" .md) - - # Convert report name to title case - if [ "$report_name" = "runner-report" ]; then - title="Runner Tests" - elif [ "$report_name" = "sdk1-report" ]; then - title="SDK1 Tests" - else - title="${report_name}" - fi - - # Extract counts - counts=$(extract_test_counts "$report") - IFS=':' read -r total passed failed <<< "$counts" - - # Determine status icon - if [ "$failed" -gt 0 ]; then - status="❌" - elif [ "$passed" -gt 0 ]; then - status="✅" - else - status="⚠️" - fi - - echo "- ${status} **${title}**: ${passed} passed, ${failed} failed (${total} total)" >> "$OUTPUT_FILE" -done - -cat >> "$OUTPUT_FILE" << 'EOF' - -
- ---- - -EOF - -# Combine all reports with collapsible sections -for report in "${REPORTS[@]}"; do - report_name=$(basename "$report" .md) - - # Convert report name to title case - if [ "$report_name" = "runner-report" ]; then - title="Runner Tests" - elif [ "$report_name" = "sdk1-report" ]; then - title="SDK1 Tests" - else - title="${report_name}" - fi - - echo "
" >> "$OUTPUT_FILE" - echo "${title} - Full Report" >> "$OUTPUT_FILE" - echo "" >> "$OUTPUT_FILE" - cat "$report" >> "$OUTPUT_FILE" - echo "" >> "$OUTPUT_FILE" - echo "
" >> "$OUTPUT_FILE" - echo "" >> "$OUTPUT_FILE" -done - -echo "Combined test report created: $OUTPUT_FILE" -echo "Included reports: ${REPORTS[*]}" +# Backward-compatible alias for the existing sticky-comment step which uploads +# combined-test-report.md from the repo root. +if [ -f "$REPORTS_DIR/combined-test-report.md" ] && [ ! -f combined-test-report.md ]; then + cp "$REPORTS_DIR/combined-test-report.md" combined-test-report.md +fi diff --git a/.github/workflows/ci-test-e2e.yaml b/.github/workflows/ci-test-e2e.yaml new file mode 100644 index 0000000000..323a294beb --- /dev/null +++ b/.github/workflows/ci-test-e2e.yaml @@ -0,0 +1,85 @@ +name: Run e2e tests (rig + docker compose) + +on: + push: + branches: + - main + pull_request: + types: [labeled, synchronize] + branches: [main] + schedule: + # Nightly at 02:00 UTC. + - cron: "0 2 * * *" + workflow_dispatch: + +jobs: + e2e: + # Only run on PRs that opt in via the `run-e2e` label, plus main + nightly + manual. + if: > + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'run-e2e') + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: "0.6.14" + python-version: 3.12.9 + + - name: Install tox with UV + run: uv tool install tox --with tox-uv + + - name: Validate test manifests + run: tox -e rig -- validate + + - name: Restore main-branch test baseline + # See ci-test.yaml for the rationale on namespacing per-workflow. + uses: actions/cache@v5 + with: + path: reports/previous-summary.json + key: unstract-test-baseline-e2e-main-${{ github.run_id }} + restore-keys: | + unstract-test-baseline-e2e-main- + unstract-test-baseline-e2e- + + - name: Run e2e tier via docker compose + env: + UNSTRACT_E2E_RUNTIME: compose + run: | + # Use --tier e2e (not `all`) so this workflow runs only e2e groups. + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + tox -e e2e -- --fail-on-critical-gap --update-baseline + else + tox -e e2e + fi + + - name: Capture docker compose logs on failure + if: failure() + run: | + mkdir -p reports + docker compose -p unstract-test \ + -f docker/docker-compose.yaml \ + -f tests/compose/docker-compose.test.yaml \ + logs --no-color > reports/docker-compose-logs.txt || true + + - name: Output e2e report to job summary + if: always() && hashFiles('reports/summary.md') != '' + shell: bash + run: | + cat reports/summary.md >> $GITHUB_STEP_SUMMARY + + - name: Upload e2e reports artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-e2e + path: reports/ + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index c3a227ecfd..13de1475f4 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -1,4 +1,4 @@ -name: Run tox tests with UV +name: Run unit + integration tests (rig) on: push: @@ -24,11 +24,12 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 0 # rig --changed-only needs git history - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: - # Install a specific version of uv. version: "0.6.14" python-version: 3.12.9 @@ -40,29 +41,78 @@ jobs: restore-keys: | ${{ runner.os }}-tox-uv- + - name: Restore main-branch test baseline (for regression detection) + # actions/cache only saves on a cache miss. Include the run id in the + # key so each main build writes a fresh cache entry; the prefix in + # restore-keys pulls the most recent baseline. + # + # The unit/integration lane keeps a SEPARATE baseline from the e2e + # workflow because their `scope_groups` don't overlap — restoring an + # e2e-tier baseline here would flag every e2e-covered path as a + # regression in this lane (and vice versa). Each workflow is the + # source of truth for the paths covered by its own tiers. + uses: actions/cache@v5 + with: + path: reports/previous-summary.json + key: unstract-test-baseline-ut-main-${{ github.run_id }} + restore-keys: | + unstract-test-baseline-ut-main- + unstract-test-baseline-ut- + - name: Install tox with UV run: uv tool install tox --with tox-uv - - name: Run tox - id: tox + - name: Validate test manifests + # Cheap pre-flight: catches groups.yaml / critical_paths.yaml schema + # errors before we spend minutes on tier runs. Also catches malformed + # manifests on PRs that only touch paths-ignored files (because this + # step always runs). + run: tox -e rig -- validate + + - name: Run unit tier + # Each tier runs as a separate rig invocation so its results land in + # reports// before the next tier starts. --update-baseline (on + # main only) merges this tier's covered paths into the cached + # previous-summary.json; later tiers union on top. run: | - tox + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + tox -e unit -- --fail-on-critical-gap --update-baseline + else + tox -e unit + fi - - name: Combine test reports - if: always() && (hashFiles('runner-report.md') != '' || hashFiles('sdk1-report.md') != '') + - name: Run integration tier + if: always() run: | - bash .github/scripts/combine-test-reports.sh + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + tox -e integration -- --fail-on-critical-gap --update-baseline + else + tox -e integration + fi + + - name: Re-aggregate reports from both tiers + if: always() + run: tox -e rig -- report combine - name: Render combined test report to PR uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 - if: always() && hashFiles('combined-test-report.md') != '' && github.event.pull_request.head.repo.fork == false + if: always() && hashFiles('reports/combined-test-report.md') != '' && github.event.pull_request.head.repo.fork == false with: header: test-results recreate: true - path: combined-test-report.md + path: reports/combined-test-report.md - name: Output combined report to job summary - if: always() && hashFiles('combined-test-report.md') != '' + if: always() && hashFiles('reports/summary.md') != '' shell: bash run: | - cat combined-test-report.md >> $GITHUB_STEP_SUMMARY + cat reports/summary.md >> $GITHUB_STEP_SUMMARY + + - name: Upload reports artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-unit-integration + path: reports/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index c655f4acff..427afc934d 100644 --- a/.gitignore +++ b/.gitignore @@ -713,3 +713,7 @@ AGENTS.md # MCP servers .serena + +# Unstract test rig +reports/ +.test-selection diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7f17901d1..7e0004b295 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,6 +86,19 @@ repos: files: ^frontend/src/ types_or: [javascript, jsx, ts, tsx, json] + - id: unstract-tests + name: Run selected unstract tests (.test-selection) + # Only fires when the developer has opted in by creating .test-selection + # (newline-delimited group names, gitignored). Runs serially without + # coverage to stay fast in a commit hook. + # Use python3 (stock Ubuntu / many container images ship no `python` + # symlink), matching .github/scripts/combine-test-reports.sh. + entry: bash -c 'if [ -f .test-selection ]; then "$(command -v python3 || command -v python)" -m tests.rig run --from-file .test-selection --no-coverage --no-parallel; fi' + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] + - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.42.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index 543d942abc..04a4845fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,21 @@ workers = [ "unstract-workers", ] +test-rig = [ + # Test runner foundation. Installed by tox into the rig env. + "pytest>=8.0.1", + "pytest-xdist>=3.5.0", + "pytest-cov>=4.1.0", + "pytest-md-report>=0.6.2", + "pytest-mock>=3.12.0", + "pytest-timeout>=2.3.1", + "pytest-django>=4.8.0", + "coverage[toml]>=7.4.0", + "pyyaml>=6.0.1", + "testcontainers[postgres,redis,minio,rabbitmq]>=4.7.0", + "requests>=2.31.0", +] + hook-check-django-migrations = [ "celery>=5.3.4", "cron-descriptor==1.4.0", @@ -192,7 +207,44 @@ testpaths = ["tests"] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration (deselect with '-m \"not integration\"')", + "unit: marks tests as unit (no external services required)", + "e2e: marks tests as end-to-end (require full platform stack)", + "critical: marks tests covering a critical path (see tests/critical_paths.yaml)", +] + +[tool.coverage.run] +branch = false +relative_files = true +omit = [ + # Test modules shouldn't count themselves as covered code; that inflates + # the percentage and hides real gaps in the modules under test. + # Keep the patterns narrow: target test FILES, not "anything in a tests/ + # directory" (which would exclude tests/rig/*.py — those ARE the + # production modules for the unit-rig group). + "**/test_*.py", + "**/*_tests.py", + "**/tests.py", +] + +[tool.coverage.paths] +# Each group runs pytest from its own workdir, so per-group .coverage files +# store paths anchored at that workdir (e.g. "src/unstract/sdk1/foo.py" from +# unstract/sdk1/; "shared/foo.py" from workers/). `coverage combine` uses +# these mappings to fold parallel workdir paths back onto a single +# repo-relative path, otherwise xml/html show duplicate or orphaned files. +sdk1 = ["unstract/sdk1/src/unstract/sdk1", "*/unstract/sdk1/src/unstract/sdk1"] +runner = ["runner/src/unstract/runner", "*/runner/src/unstract/runner"] +prompt_service = [ + "prompt-service/src/unstract/prompt_service", + "*/prompt-service/src/unstract/prompt_service", ] +platform_service = ["platform-service/src", "*/platform-service/src"] +workers = ["workers/shared", "*/workers/shared"] +connectors = ["unstract/connectors/src", "*/unstract/connectors/src"] +core = ["unstract/core/src", "*/unstract/core/src"] +tool_registry = ["unstract/tool-registry/src", "*/unstract/tool-registry/src"] +backend = ["backend", "*/backend"] +rig = ["tests/rig", "*/tests/rig"] [tool.mypy] python_version = "3.12" diff --git a/runner/src/unstract/runner/clients/test_docker.py b/runner/src/unstract/runner/clients/test_docker.py index 1c1418b000..6841c4e2e8 100644 --- a/runner/src/unstract/runner/clients/test_docker.py +++ b/runner/src/unstract/runner/clients/test_docker.py @@ -34,7 +34,15 @@ def docker_client(mocker): @pytest.fixture -def docker_client_with_sidecar(): +def docker_client_with_sidecar(mocker): + # Mirror `docker_client`: mock DockerClient.from_env so constructing the + # Client doesn't reach for a real Docker daemon (CI runners have one, bare + # dev/sandbox environments don't — and the test is about sidecar wiring, + # not the daemon connection). + mock_client = MagicMock() + mocker.patch(f"{DOCKER_MODULE}.DockerClient.from_env", return_value=mock_client) + mocker.patch(f"{DOCKER_MODULE}.Client._Client__private_login") + image_name = "test-image" image_tag = "latest" logger = logging.getLogger("test-logger") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..e5677bffc5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,250 @@ +# Unstract test rig + +This directory hosts the test foundation for the Unstract platform: cross-service integration + end-to-end tests, plus the rig that orchestrates every test +suite in the repo (including the per-service unit tests that live alongside their source code). + +> Per-service **unit tests stay co-located** with the code they exercise (`backend//tests/`, `workers/tests/`, `unstract/sdk1/tests/`, etc.). +> Only **e2e** and **cross-service integration** tests live here. + +--- + +## Layout + +``` +tests/ +├── groups.yaml # SINGLE source of truth: groups, paths, deps +├── critical_paths.yaml # Critical user/system flows + their declared coverage +├── conftest.py # Shared pytest markers for the tests/ tree +├── rig/ # The rig itself (Python package) +│ ├── cli.py # `python -m tests.rig ` +│ ├── groups.py # YAML loader + dep-graph expansion +│ ├── selection.py # CLI / file / `all` / changed-only resolution +│ ├── runtime.py # docker-compose | testcontainers | local +│ ├── reporting.py # JUnit + markdown summary writer +│ ├── coverage.py # Per-group coverage files + combine +│ └── critical_paths.py # Gap + regression detection +├── e2e/ +│ ├── conftest.py # Session-scoped `platform` fixture +│ ├── smoke/ # Login → /health smoke +│ ├── workflows/ # (future) workflow execution e2e +│ ├── api_deployment/ # (future) API deployment e2e +│ ├── prompt_studio/ # (future) Prompt Studio e2e +│ └── hurl/ # (future) hurl-based HTTP suites +├── integration/ # Cross-service tests needing infra but not full platform +├── fixtures/ # Sample PDFs, JSON, adapter configs +└── compose/ + └── docker-compose.test.yaml # Test overlay on docker/docker-compose.yaml +``` + +--- + +## Quick start + +```bash +# List every defined group with its tier + dep edges. +tox -e rig -- list-groups + +# Show what would actually run for a selection, expanded over depends_on. +tox -e rig -- expand e2e-workflow + +# Run all unit groups in parallel, with coverage. +tox -e unit + +# Run a single group (positional arg). +tox -e groups -- unit-sdk1 + +# Run multiple groups; deps are pulled in automatically. +tox -e groups -- unit-backend e2e-smoke + +# Run everything (unit + integration + e2e). +tox -e groups -- all + +# Pre-commit / fast iteration: read a newline-delimited list of group names. +echo unit-backend > .test-selection +tox -e groups -- --from-file .test-selection --no-coverage --no-parallel + +# E2E lane (docker-compose by default; testcontainers for local dev). +tox -e e2e -- e2e-smoke +UNSTRACT_E2E_RUNTIME=testcontainers tox -e e2e -- e2e-smoke +``` + +The rig CLI is also callable directly without tox: + +```bash +python -m tests.rig run --tier unit +python -m tests.rig validate +python -m tests.rig platform up --runtime compose +python -m tests.rig report combine +``` + +--- + +## The two manifests + +### `groups.yaml` — the unit of selection + +Every test group is declared here. The rig refuses to start if `groups.yaml` has a cycle, an unknown `depends_on` target, or a missing path on a +non-`optional` group. + +Minimum a new group needs: + +```yaml +my-new-group: + tier: unit # unit | integration | e2e + workdir: backend # where pytest is invoked from + paths: [some_app/tests] # passed as pytest args +``` + +Optional knobs (see `groups.yaml` for examples): + +| Key | Purpose | +|---|---| +| `markers` | Forwarded to pytest `-m` (e.g. `"unit and not slow"`). | +| `pytest_extra` | Extra pytest CLI flags. | +| `env` | Env vars set for this group's pytest process. | +| `uv_sync_group` | Runs `uv sync --group ` in the workdir before pytest. | +| `install_editable` | Runs `uv pip install -e .` in the workdir. | +| `pip_install` | Explicit deps to install before pytest. | +| `requires_services` | Infra needed (`postgres`, `redis`, `minio`, ...). | +| `requires_platform` | Set true for e2e — rig brings the full platform up. | +| `depends_on` | Other groups that must run first. | +| `critical` | Marks the group as covering a critical path. | +| `timeout_seconds` | Override the default 600s. | +| `optional` | Two effects: (1) skip silently if paths/workdir are missing (placeholders, gitignored cloud-only dirs); (2) **non-blocking** — if the group runs and fails, its red result still shows in the summary but does not gate the overall exit code. Use for groups that need infra CI doesn't provision (e.g. live-DB connector tests) where a red run shouldn't block merge. | + +### `critical_paths.yaml` — what we promise not to break + +Each entry is a high-value user or system flow with an `id`, an `entry` (HTTP endpoint or internal hop), and a list of `covered_by` groups. The rig reports each path as: + +- ✅ **covered** — at least one group in `covered_by` ran green this build. +- ⚠️ **gap** — no covering group ran green (or `covered_by` is empty). +- ❌ **regression** — was ✅ on the cached main baseline but isn't now. + +The rig itself does not know about PRs or main — it just emits the markers and respects `--fail-on-critical-gap`. The CI workflow at `.github/workflows/ci-test.yaml` is what decides to pass that flag on main and not on PRs, so gaps surface as warnings during review and as errors only when merging. Regressions are **always** errors — the team target is zero. + +--- + +## How selection works + +Resolution order, then dep-expansion, then topo-sort: + +``` +positional GROUPS ∪ --from-file lines ∪ --tier filter ∪ --changed-only diff +``` + +The literal `all` expands to every group. An empty resolved set is treated as an error, not "run everything" — fail loudly rather than surprise. + +`--changed-only` runs `git diff ...HEAD` (default base: `origin/main`) and selects every group whose `workdir` or `paths` overlap a changed file. +Useful for fast feedback on feature branches. + +--- + +## E2E runtime + +Three modes behind one protocol, chosen by `--runtime` or `UNSTRACT_E2E_RUNTIME`. CI defaults to `compose`; everywhere else defaults to `testcontainers`. + +| Mode | Use when | How it works | +|---|---|---| +| `compose` | CI; testing the prod image. | `docker compose -f docker/docker-compose.yaml -f tests/compose/docker-compose.test.yaml up -d --wait`, then HTTP. Teardown wipes volumes. | +| `testcontainers` | Local iteration on infra-only groups. | Stands up Postgres/Redis/RabbitMQ/MinIO via testcontainers and exposes their handles on `PlatformEndpoints.infra`. **Stub today**: does NOT auto-launch backend/prompt-service/etc. as subprocesses — full-platform e2e on testcontainers will need that wiring added. Use `compose` for now if you need the whole stack. | +| `local` | After `./run-platform.sh`. | Assume a developer-managed stack; read URLs from env. | + +The rig brings the platform up **once** per `run` invocation (if any selected group has `requires_platform: true`) and exports its URLs via env vars (`UNSTRACT_BACKEND_URL`, `UNSTRACT_PROMPT_SERVICE_URL`, etc.). The rig uses `env.setdefault(...)` so a pre-set value (e.g. from `local` runtime or a developer override) wins over the runtime's default — useful when iterating against a custom stack, but means stale shell env can mask wiring bugs. The smoke test asserts the fixture's URL matches the env var to catch this. + +The `platform` pytest fixture in `tests/e2e/conftest.py` reads those env vars; e2e tests run elsewhere (without the rig) just skip with a clear message. + +--- + +## Reports + +After every `run`, the rig writes: + +``` +reports/ +├── summary.md # human-readable, used for PR sticky comments +├── summary.json # machine-readable +├── combined-test-report.md # alias kept for backward compatibility +├── coverage.xml # Cobertura (when --coverage) +├── htmlcov/ # browsable coverage (when --coverage) +└── / + ├── junit.xml # pytest --junitxml + ├── report.md # pytest-md-report output + └── exit.txt # group's pytest exit code +``` + +`reports/summary.md` has two sections: + +1. **Per-group results** table (passed/failed/errors/skipped/duration). +2. **Critical paths**, split into: + - ❌ Regressions — must be zero. + - ⚠️ Critical paths not yet covered — the gaps backlog. + - ✅ Covered critical paths (collapsed) — what's protected. + +CI uploads the whole `reports/` directory as an artifact and posts `combined-test-report.md` as a sticky PR comment. + +--- + +## Coverage + +Coverage is **on by default** and can be disabled per-run with `--no-coverage` (pre-commit and quick local runs typically disable it). + +Each group gets its own `COVERAGE_FILE` so parallel pytest invocations don't trample each other. After all groups complete, the rig runs +`coverage combine` + `coverage xml` + `coverage html`. + +We **do not** chase 100% coverage. The bar is critical-path coverage; the rig's job is to make gaps and regressions visible, not to enforce a number. + +--- + +## Branch policy + +The rig itself has **no branch awareness**. Branch behavior is enforced in GitHub Actions, not in the rig: + +- On `main`, each tier runs in its own step (`tox -e unit` then `tox -e integration`, then `tox -e e2e` in the slow lane). Each invocation passes `--fail-on-critical-gap --update-baseline`. The rig merges (rather than overwrites) covered paths into `previous-summary.json` so the second tier's run preserves the first tier's coverage. +- On PRs, the same tiered steps run without `--fail-on-critical-gap`, so gaps are visible but don't block. +- The e2e workflow only runs on main, on PRs labeled `run-e2e`, on nightly cron, or via manual dispatch. + +Developers can scope local runs however they like via positional args, `--from-file .test-selection`, `--tier`, or `--changed-only`. + +--- + +## Adding tests + +| Where it goes | What kind of test | +|---|---| +| `backend//tests/`, `workers/tests/`, `unstract//tests/`, ... | Unit tests for that service. | +| `tests/integration//` | Cross-service tests that need real infra but not the full platform. | +| `tests/e2e//` | HTTP-level tests against a running platform. | +| `tests/e2e/hurl/` | Hurl-based HTTP suites. | + +After adding tests, either: +1. Reuse an existing group whose `paths` already cover your file, **or** +2. Add a new group to `groups.yaml` (and, if relevant, a `critical_paths.yaml` entry that lists it in `covered_by`). + +Validate with `python -m tests.rig validate` before pushing. + +--- + +## Common commands cheat sheet + +```bash +# Discovery +python -m tests.rig list-groups +python -m tests.rig list-critical-paths +python -m tests.rig expand e2e-workflow +python -m tests.rig validate + +# Running +tox -e unit # all unit groups +tox -e e2e -- e2e-smoke # one e2e group +tox -e groups -- unit-backend unit-workers # multiple groups +tox -e groups -- --from-file .test-selection # opt-in file +tox -e groups -- --changed-only # diff vs origin/main +tox -e groups -- all --no-coverage # everything, fast + +# Platform control (manual) +python -m tests.rig platform up --runtime compose +python -m tests.rig platform down + +# Re-aggregate existing reports +python -m tests.rig report combine +``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/compose/docker-compose.test.yaml b/tests/compose/docker-compose.test.yaml new file mode 100644 index 0000000000..f99e54de34 --- /dev/null +++ b/tests/compose/docker-compose.test.yaml @@ -0,0 +1,33 @@ +# E2E test overlay for docker/docker-compose.yaml. +# +# Used by tests/rig/runtime.py's ComposeRuntime: +# docker compose -f docker/docker-compose.yaml \ +# -f tests/compose/docker-compose.test.yaml up -d --wait +# +# Keep this file minimal so e2e tests run against images as close to production +# as possible. Override only what the test stack actually needs (test creds, +# pinned image tags, ENVIRONMENT=test sentinel). + +services: + backend: + # Default to `latest` so contributors can run without setting + # UNSTRACT_TEST_VERSION; CI sets it to the build SHA so e2e runs against + # the freshly built image. + image: unstract/backend:${UNSTRACT_TEST_VERSION:-latest} + environment: + - ENVIRONMENT=test + + prompt-service: + image: unstract/prompt-service:${UNSTRACT_TEST_VERSION:-latest} + environment: + - ENVIRONMENT=test + + platform-service: + image: unstract/platform-service:${UNSTRACT_TEST_VERSION:-latest} + environment: + - ENVIRONMENT=test + + runner: + image: unstract/runner:${UNSTRACT_TEST_VERSION:-latest} + environment: + - ENVIRONMENT=test diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..8f0c067ca6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Top-level conftest for tests/ tree. + +Registers shared markers and re-exports e2e fixtures so any test under +``tests/`` can ``import`` from ``tests.e2e.fixtures`` without path gymnastics. +""" + +from __future__ import annotations + + +def pytest_configure(config) -> None: + # Markers are also registered in the root pyproject.toml; declaring them + # here too lets ad-hoc invocations (`pytest tests/`) succeed without + # importing pyproject ini-options when pytest is run from a sub-workdir. + config.addinivalue_line("markers", "unit: marks tests as unit (no external services)") + config.addinivalue_line("markers", "integration: marks tests as integration") + config.addinivalue_line( + "markers", "e2e: marks tests as end-to-end (require platform)" + ) + config.addinivalue_line("markers", "critical: marks tests covering a critical path") + config.addinivalue_line("markers", "slow: marks tests as slow") diff --git a/tests/critical_paths.yaml b/tests/critical_paths.yaml new file mode 100644 index 0000000000..5ea4512f49 --- /dev/null +++ b/tests/critical_paths.yaml @@ -0,0 +1,64 @@ +# Unstract critical paths registry. +# +# A "critical path" is an end-to-end user or system flow whose failure would +# constitute a production incident. The rig reports: +# ✅ covered — at least one group in `covered_by` ran green this build +# ⚠️ gap — `covered_by` is empty OR no group covering it ran +# ❌ regression — a path that was ✅ on the cached main baseline is now not ✅ +# +# We intentionally do NOT chase 100% coverage. Focus on filling these gaps first. +version: 1 + +paths: + - id: auth-login + description: "User can log in and obtain a session cookie." + entry: "POST /api/v1/auth/login" + covered_by: [e2e-smoke] + + - id: adapter-register-llm + description: "Register and validate an LLM adapter." + entry: "POST /api/v1/adapter/" + # Honest declaration: unit-backend is currently optional/gated and + # e2e-smoke only hits /health/. Track as a gap until a real adapter test + # exists (likely under tests/e2e/smoke/ or a new tests/e2e/adapters/ group). + covered_by: [] + + - id: workflow-create-execute + description: "Create a workflow, configure source+destination, execute, poll, fetch result." + entry: "POST /api/v1/workflow/{id}/execute/" + covered_by: [e2e-workflow] + + - id: api-deployment-run + description: "Deploy a workflow as an API, POST a document, receive structured JSON." + entry: "POST /deployment/api/{org}/{name}/" + covered_by: [e2e-api-deployment] + + - id: prompt-studio-fetch-response + description: "Prompt Studio: create project, add prompt, run single-pass, get response." + entry: "POST /api/v1/prompt-studio/prompt-studio-tool/{id}/fetch_response/" + covered_by: [e2e-prompt-studio] + + - id: pipeline-etl-execute + description: "Run an ETL pipeline from source connector to destination." + entry: "POST /api/v1/pipeline/{id}/execute/" + covered_by: [] # gap + + - id: tool-sandbox-exec + description: "Tool image runs in sandbox container and emits structured output." + entry: "internal: tool-registry → runner → docker run" + covered_by: [unit-runner] + + - id: usage-token-tracking + description: "Per-execution token usage is recorded and retrievable." + entry: "GET /api/v1/usage/get_token_usage/" + covered_by: [] # gap + + - id: workflow-execution-fan-out + description: "Multi-file workflow execution fans out to file-processing workers and rejoins." + entry: "internal: backend → rabbitmq → workers/file_processing" + covered_by: [] # gap + + - id: callback-result-delivery + description: "Async results are posted back via the callback worker." + entry: "internal: workers/callback → backend /internal endpoints" + covered_by: [] # gap diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000000..b184df9641 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,26 @@ +"""E2E fixtures: a session-scoped ``platform`` fixture that yields URLs/creds. + +The rig brings the platform up at the *rig* level (once per ``run`` invocation, +not per pytest session) and propagates URLs into pytest via env vars. This +conftest reads those env vars; if they're missing, e2e tests are skipped with +a clear message rather than spuriously failing. +""" + +from __future__ import annotations + +import os + +import pytest + +from tests.rig.runtime import PlatformEndpoints + + +@pytest.fixture(scope="session") +def platform() -> PlatformEndpoints: + if not os.environ.get("UNSTRACT_BACKEND_URL"): + pytest.skip( + "platform URLs not set in env; run via " + "`python -m tests.rig run --tier e2e ...` or export " + "UNSTRACT_BACKEND_URL (etc.) yourself." + ) + return PlatformEndpoints.from_env() diff --git a/tests/e2e/smoke/__init__.py b/tests/e2e/smoke/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/smoke/test_smoke.py b/tests/e2e/smoke/test_smoke.py new file mode 100644 index 0000000000..4e496196fa --- /dev/null +++ b/tests/e2e/smoke/test_smoke.py @@ -0,0 +1,35 @@ +"""Single placeholder e2e smoke test. + +Exercises the rig's e2e plumbing end-to-end (platform up → URL injection → +pytest discovery → result aggregation → platform down) without depending on +auth state or seed data. Real workflow + API-deployment tests will land in +adjacent files (tests/e2e/workflows/, tests/e2e/api_deployment/, ...). +""" + +from __future__ import annotations + +import os + +import pytest +import requests + +pytestmark = [pytest.mark.e2e, pytest.mark.critical] + + +def test_rig_session_sentinel_present() -> None: + """The rig stamps ``UNSTRACT_RIG_SESSION_ID`` into every group's env. + Its absence means this test ran outside the rig (or with stale shell env + that didn't get the sentinel), in which case the backend URL is suspect. + """ + assert os.environ.get("UNSTRACT_RIG_SESSION_ID"), ( + "UNSTRACT_RIG_SESSION_ID not set — either this test wasn't launched " + "via `python -m tests.rig run`, or the rig failed to propagate the " + "sentinel. Re-run via the rig; if launched manually, set the env var " + "yourself to assert acknowledgement that you've audited the platform " + "URLs in your shell." + ) + + +def test_backend_health(platform) -> None: + response = requests.get(f"{platform.backend_url.rstrip('/')}/health/", timeout=10) + assert response.status_code < 500, f"backend /health returned {response.status_code}" diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/groups.yaml b/tests/groups.yaml new file mode 100644 index 0000000000..298cfdc637 --- /dev/null +++ b/tests/groups.yaml @@ -0,0 +1,181 @@ +# Unstract test rig — group definitions. +# +# Each group is the unit of selection. A group's `paths` are passed to pytest +# (or another runner). `depends_on` is expanded transitively before execution. +# +# Add new groups by appending to this file. Validate with: +# python -m tests.rig validate +# python -m tests.rig list-groups +# python -m tests.rig expand +version: 1 + +defaults: + timeout_seconds: 600 + parallel: true + runner: pytest + +groups: + # ── Unit tier: pure pytest, no external services ─────────────────────────── + unit-rig: + # Self-tests for the rig's pure logic (dep-graph, evaluate, junit parse). + # Run from the repo root so the `tests.rig` package import resolves. + tier: unit + workdir: . + paths: [tests/rig/tests] + coverage_source: tests/rig + + unit-sdk1: + tier: unit + workdir: unstract/sdk1 + paths: [tests] + markers: "not slow and not integration" + uv_sync_group: test + coverage_source: src/unstract/sdk1 + + unit-runner: + tier: unit + workdir: runner + # Runner co-locates its tests under src/. Pytest recurses from here. + # The project has no `test` uv group, so deps are pip-installed inline. + paths: [src] + pip_install: + - "flask~=3.1.0" + - "docker==6.1.3" + - "redis~=5.2.1" + - "python-dotenv>=1.0.0" + - "kubernetes" + install_editable: true + coverage_source: src/unstract/runner + + unit-prompt-service: + tier: unit + workdir: prompt-service + paths: [src/unstract/prompt_service/tests/unit] + uv_sync_group: test + # The parent conftest pulls in heavy adapter chains (pinecone etc.); unit + # tests must not depend on integration fixtures. + pytest_extra: ["--noconftest"] + markers: "not slow" + coverage_source: src/unstract/prompt_service + + unit-platform-service: + tier: unit + workdir: platform-service + paths: [tests] + uv_sync_group: test + coverage_source: src + optional: true # service has only a couple of tests today + + unit-workers: + tier: unit + workdir: workers + paths: [tests, shared/tests] + markers: "unit" + uv_sync_group: test + coverage_source: shared + + unit-backend: + tier: unit + workdir: backend + # List paths explicitly. `[.]` recurses into every test_*.py in backend/, + # including vendored fixtures and pluggable-app tests that don't belong + # in the OSS rig — keep this list scoped to the apps actually under test. + paths: + - account_v2/tests + - adapter_processor_v2/tests + - api_deployment_v2/tests + - connector_v2/tests + - dashboard_metrics/tests + - file_management/tests + - project/tests + - prompt_studio/tests + - tenant_account_v2/tests + - usage_v2/tests + - utils/tests + - workflow_manager/endpoint_v2/tests + uv_sync_group: test + env: + DJANGO_SETTINGS_MODULE: backend.settings.test_cases + # Backend ORM imports require a real Postgres; rig provisions it via + # testcontainers or compose when this group is selected. + requires_services: [postgres, redis] + coverage_source: . + optional: true # gated until backend test_cases settings are complete + + unit-connectors: + tier: unit + workdir: unstract/connectors + paths: [tests] + uv_sync_group: test + coverage_source: src + optional: true + + unit-core: + tier: unit + workdir: unstract/core + paths: [tests] + # No `test` uv group in unstract/core today; rig still injects pytest plugins. + install_editable: true + coverage_source: src + optional: true + + unit-tool-registry: + tier: unit + workdir: unstract/tool-registry + paths: [tests] + uv_sync_group: test + coverage_source: src + optional: true + + # ── Integration tier: needs infra but not full platform ──────────────────── + integration-workflow-execution: + tier: integration + paths: [tests/integration/workflow_execution] + requires_services: [postgres, redis, minio] + depends_on: [unit-sdk1, unit-workers] + optional: true # placeholder until tests are authored + + # ── E2E tier: real HTTP against running platform ─────────────────────────── + # Every group below has `requires_platform: true` and must (transitively) + # depend on `e2e-smoke` — the rig's `_validate_platform_groups_depend_on_gate` + # enforces this so smoke failures cleanly skip dependents rather than running + # them against a half-up stack. + e2e-smoke: + tier: e2e + paths: [tests/e2e/smoke] + requires_platform: true + critical: true + timeout_seconds: 1200 + depends_on: [unit-sdk1, unit-workers] + + e2e-workflow: + tier: e2e + paths: [tests/e2e/workflows] + requires_platform: true + critical: true + depends_on: [e2e-smoke] + optional: true + + e2e-api-deployment: + tier: e2e + paths: [tests/e2e/api_deployment] + requires_platform: true + critical: true + depends_on: [e2e-smoke] + optional: true + + e2e-prompt-studio: + tier: e2e + paths: [tests/e2e/prompt_studio] + requires_platform: true + critical: true + depends_on: [e2e-smoke] + optional: true + + e2e-hurl: + tier: e2e + runner: hurl + paths: [tests/e2e/hurl] + requires_platform: true + depends_on: [e2e-smoke] + optional: true diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/rig/__init__.py b/tests/rig/__init__.py new file mode 100644 index 0000000000..58585086ca --- /dev/null +++ b/tests/rig/__init__.py @@ -0,0 +1,21 @@ +"""Unstract test rig — entry point package. + +The rig is invoked as ``python -m tests.rig `` (see :mod:`tests.rig.cli`). +It is the single dispatcher behind ``tox`` envs, the pre-commit hook, and CI. +""" + +from tests.rig.critical_paths import ( + CriticalPath, + CriticalPathRegistry, + load_critical_paths, +) +from tests.rig.groups import GroupDefinition, GroupManifest, load_groups + +__all__ = [ + "GroupDefinition", + "GroupManifest", + "load_groups", + "CriticalPath", + "CriticalPathRegistry", + "load_critical_paths", +] diff --git a/tests/rig/__main__.py b/tests/rig/__main__.py new file mode 100644 index 0000000000..5229fd2e6e --- /dev/null +++ b/tests/rig/__main__.py @@ -0,0 +1,4 @@ +from tests.rig.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/rig/cli.py b/tests/rig/cli.py new file mode 100644 index 0000000000..2866a417f3 --- /dev/null +++ b/tests/rig/cli.py @@ -0,0 +1,753 @@ +"""Rig CLI — the single entry point for tox, pre-commit, and CI. + +Subcommands: + run Execute selected groups (with dep-expansion). + list-groups Print known groups, tiers, and dep edges. + list-critical-paths Print critical-path coverage status (best-effort). + expand Print the topo-sorted set of groups that ``run`` would execute. + validate Validate manifests; non-zero on schema errors. + platform ``up | down | status`` the e2e platform stack. + report ``combine`` — re-aggregate reports/ after the fact. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +import uuid +from functools import lru_cache +from pathlib import Path +from xml.sax import saxutils + +from tests.rig import critical_paths as cp +from tests.rig.coverage import combine_and_report, coverage_env +from tests.rig.groups import ( + REPO_ROOT, + TIERS, + GroupDefinition, + load_groups, +) +from tests.rig.reporting import GroupResult, parse_junit, write_summary +from tests.rig.runtime import PlatformEndpoints, PlatformRuntime, pick_runtime +from tests.rig.selection import resolve + +# Pytest exit codes that the rig treats as non-failure for aggregation: +# 0 — all tests passed +# 5 — no tests collected (optional placeholders, empty hurl group, etc.) +_NON_FAILING_PYTEST_EXIT_CODES = (0, 5) + + +@lru_cache(maxsize=1) +def _rig_session_id() -> str: + """Stable per-invocation sentinel, computed once. + + Stamped into ``UNSTRACT_RIG_SESSION_ID`` for every group's pytest env so + e2e tests can prove the rig ran. URL ownership is intentionally cooperative + — the rig sets ``UNSTRACT_*_URL`` via ``setdefault``, so a developer's + pre-set value wins (see tests/README.md). The session id is the rig's + signature, not a claim that the rig owns the URLs. + """ + return uuid.uuid4().hex + + +def _subprocess_env() -> dict[str, str]: + """Base environment for every group's ``uv``/pytest subprocess. + + Drops ``VIRTUAL_ENV`` so ``uv run`` does clean per-group project discovery + (each group runs against its own workdir's ``.venv``). When the rig runs + under tox, tox exports ``VIRTUAL_ENV=.tox/``, which doesn't match any + group's project and makes ``uv`` emit a "does not match the project + environment path" warning before ignoring it anyway. Stripping it removes + the noise and the ambiguity. ``UV_PROJECT_ENVIRONMENT`` is dropped for the + same reason. + """ + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + env.pop("UV_PROJECT_ENVIRONMENT", None) + return env + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="tests.rig", description="Unstract unified test rig") + sub = p.add_subparsers(dest="command", required=True) + + pr = sub.add_parser("run", help="Run selected groups") + pr.add_argument("groups", nargs="*", help="Group names, or 'all'.") + pr.add_argument("--from-file", type=Path, help="File with one group name per line.") + pr.add_argument("--tier", choices=TIERS) + pr.add_argument("--marker", help="Pytest -m marker expression to forward.") + pr.add_argument( + "--paths", help="Comma-separated pytest paths/nodeids (overrides group paths)." + ) + pr.add_argument("--runtime", choices=["compose", "testcontainers", "local"]) + pr.add_argument("--coverage", dest="coverage", action="store_true", default=True) + pr.add_argument("--no-coverage", dest="coverage", action="store_false") + pr.add_argument("--parallel", dest="parallel", action="store_true", default=True) + pr.add_argument("--no-parallel", dest="parallel", action="store_false") + pr.add_argument( + "--workers", + default="auto", + help="pytest-xdist worker count (default: auto).", + ) + pr.add_argument("--timeout", type=int, help="Per-group timeout override in seconds.") + pr.add_argument("--reports-dir", type=Path, default=REPO_ROOT / "reports") + pr.add_argument( + "--fail-on-critical-gap", + action="store_true", + help="Treat uncovered critical paths as a build failure.", + ) + pr.add_argument( + "--changed-only", + action="store_true", + help="Auto-select groups overlapping `git diff origin/main...HEAD`.", + ) + pr.add_argument("--changed-base", default="origin/main") + pr.add_argument( + "--baseline", + type=Path, + default=REPO_ROOT / "reports" / "previous-summary.json", + ) + pr.add_argument( + "--update-baseline", + action="store_true", + help=( + "On green main builds, merge this build's covered paths into the " + "baseline. Merging (not overwriting) preserves coverage recorded by " + "earlier tier invocations in the same workflow." + ), + ) + pr.add_argument("--dry-run", action="store_true", help="Plan only; do not execute.") + pr.set_defaults(func=cmd_run) + + pl = sub.add_parser("list-groups", help="List all defined groups.") + pl.set_defaults(func=cmd_list_groups) + + pc = sub.add_parser("list-critical-paths", help="Print critical-path status table.") + pc.add_argument( + "--baseline", + type=Path, + default=REPO_ROOT / "reports" / "previous-summary.json", + ) + pc.set_defaults(func=cmd_list_critical) + + pe = sub.add_parser("expand", help="Show what `run` would execute, in topo order.") + pe.add_argument("groups", nargs="*") + pe.add_argument("--from-file", type=Path) + pe.add_argument("--tier", choices=TIERS) + pe.add_argument("--changed-only", action="store_true") + pe.add_argument("--changed-base", default="origin/main") + pe.set_defaults(func=cmd_expand) + + pv = sub.add_parser("validate", help="Validate manifests.") + pv.set_defaults(func=cmd_validate) + + pp = sub.add_parser("platform", help="Manage the e2e platform stack.") + pp.add_argument("action", choices=["up", "down", "status"]) + pp.add_argument("--runtime", choices=["compose", "testcontainers", "local"]) + pp.set_defaults(func=cmd_platform) + + pre = sub.add_parser("report", help="Re-aggregate existing reports/.") + pre.add_argument("action", choices=["combine"]) + pre.add_argument("--reports-dir", type=Path, default=REPO_ROOT / "reports") + pre.set_defaults(func=cmd_report) + + return p + + +# ── subcommands ─────────────────────────────────────────────────────────────── + + +def cmd_list_groups(_args: argparse.Namespace) -> int: + manifest = load_groups() + for name in manifest.names(): + g = manifest.get(name) + deps = ", ".join(g.depends_on) or "—" + flags: list[str] = [] + if g.critical: + flags.append("critical") + if g.requires_platform: + flags.append("platform") + if g.optional: + flags.append("optional") + if g.requires_services: + flags.append("svc:" + "+".join(g.requires_services)) + print( + f" {name:<32} tier={g.tier:<11} runner={g.runner:<7} " + f"deps=[{deps}] {' '.join(flags)}" + ) + return 0 + + +def cmd_list_critical(args: argparse.Namespace) -> int: + manifest = load_groups() + registry = cp.load_critical_paths() + errors = cp.validate_registry_against_manifest(registry, manifest) + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + try: + baseline = cp.load_baseline(args.baseline) + except cp.BaselineCorruptError as exc: + print(f"[rig] {exc}", file=sys.stderr) + baseline = None + statuses = cp.evaluate(registry, groups_run_green=set(), baseline=baseline) + icons = {"covered": "✅", "gap": "⚠️", "regression": "❌"} + for s in statuses: + cov = ", ".join(s.path.covered_by) or "—" + print(f" {icons[s.state]} {s.path.id:<28} declared coverage: {cov}") + return 1 if errors else 0 + + +def cmd_expand(args: argparse.Namespace) -> int: + manifest = load_groups() + ordered = resolve( + manifest, + positional=args.groups or [], + from_file=args.from_file, + tier=args.tier, + changed_only=args.changed_only, + changed_base=args.changed_base, + ) + if not ordered: + print("(no groups selected)", file=sys.stderr) + return 1 + for name in ordered: + g = manifest.get(name) + print(f" {name} (tier={g.tier}, runner={g.runner})") + return 0 + + +def cmd_validate(_args: argparse.Namespace) -> int: + """Validate both manifests. Schema/path/cycle errors come from + ``load_groups`` (raised); cross-manifest errors come from + ``validate_registry_against_manifest``. + """ + try: + manifest = load_groups() + except (ValueError, FileNotFoundError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + try: + registry = cp.load_critical_paths() + except (ValueError, FileNotFoundError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + errors = cp.validate_registry_against_manifest(registry, manifest) + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + if errors: + return 1 + print(f"OK — {len(manifest.names())} groups, {len(registry.paths)} critical paths") + return 0 + + +def cmd_platform(args: argparse.Namespace) -> int: + runtime = pick_runtime(args.runtime) + if args.action == "up": + try: + endpoints = runtime.up() + except Exception: + # If up() raised mid-way (e.g. one testcontainer started, the next + # failed), down() cleans up the partial stack. + runtime.down() + raise + print(f"Platform up via runtime={runtime.name}:") + print(f" backend : {endpoints.backend_url}") + print(f" prompt-service : {endpoints.prompt_service_url}") + print(f" platform-service: {endpoints.platform_service_url}") + print(f" runner : {endpoints.runner_url}") + return 0 + if args.action == "down": + runtime.down() + return 0 + if args.action == "status": + print( + f"runtime={runtime.name} (status check is best-effort; " + f"see `docker compose ps` for compose)" + ) + return 0 + return 2 + + +def cmd_report(args: argparse.Namespace) -> int: + reports_dir: Path = args.reports_dir + combine_and_report(reports_dir) + manifest = load_groups() + registry = cp.load_critical_paths() + group_results: list[GroupResult] = [] + for name in manifest.names(): + tier = manifest.get(name).tier + result = parse_junit(name, tier, reports_dir) + if result is not None: + group_results.append(result) + green = _green_group_names(group_results) + baseline_corrupt = False + try: + baseline = cp.load_baseline(reports_dir / "previous-summary.json") + except cp.BaselineCorruptError as exc: + print(f"[rig] {exc}", file=sys.stderr) + baseline = None + baseline_corrupt = True + statuses = cp.evaluate(registry, groups_run_green=green, baseline=baseline) + write_summary( + reports_dir=reports_dir, + group_results=group_results, + critical_statuses=statuses, + baseline_corrupt=baseline_corrupt, + ) + print(f"Wrote {reports_dir / 'summary.md'}") + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + manifest = load_groups() + registry = cp.load_critical_paths() + errs = cp.validate_registry_against_manifest(registry, manifest) + for err in errs: + print(f"ERROR: {err}", file=sys.stderr) + if errs: + return 2 + + ordered = resolve( + manifest, + positional=args.groups or [], + from_file=args.from_file, + tier=args.tier, + changed_only=args.changed_only, + changed_base=args.changed_base, + ) + if not ordered: + print( + "ERROR: no groups selected. Pass group names, `all`, --tier, or --from-file.", + file=sys.stderr, + ) + return 2 + + runnable = [n for n in ordered if not _is_missing_placeholder(manifest.get(n))] + skipped = [n for n in ordered if n not in runnable] + for n in skipped: + print(f"SKIP {n} (optional + workdir/paths absent)") + # Scope of this invocation = every group we will actually run AFTER + # dep-expansion (includes dependencies the user didn't ask for directly). + # Skipped optional placeholders are excluded: their critical paths were + # never going to be exercised here, so they must classify as gap, not + # regression. evaluate() uses this to distinguish "this path's group ran + # red" (regression) from "this path belongs to a tier we weren't running + # this time" (gap). + scope_groups = set(runnable) + + reports_dir: Path = args.reports_dir + reports_dir.mkdir(parents=True, exist_ok=True) + + needs_platform = any(manifest.get(n).requires_platform for n in runnable) + runtime: PlatformRuntime | None = None + endpoints: PlatformEndpoints | None = None + group_results: list[GroupResult] = [] + overall_exit = 0 + + try: + if needs_platform and not args.dry_run: + runtime = pick_runtime(args.runtime) + print(f"[rig] bringing platform up via runtime={runtime.name}") + # `up()` is inside the try so a failure here still triggers `down()` + # in the finally, cleaning up any partial stack. + endpoints = runtime.up() + + for name in runnable: + group = manifest.get(name) + print( + f"\n[rig] running group: {name} " + f"(tier={group.tier}, runner={group.runner})" + ) + if args.dry_run: + continue + result, exit_code = _execute_group( + group, + reports_dir=reports_dir, + marker=args.marker, + paths_override=args.paths, + coverage=args.coverage, + parallel=args.parallel and group.parallel, + workers=args.workers, + timeout=args.timeout or group.timeout_seconds, + endpoints=endpoints, + ) + if result is not None: + group_results.append(result) + # `optional: true` groups run and surface their result in the + # summary, but never gate the overall exit. This honors the + # developer intent for groups that need infra we don't provision in + # CI (live-DB connector tests) or that are pluggable/cloud-only: + # red shows in the report, merge isn't blocked. Both exit-folds + # below are gated on `not group.optional` so the skip is consistent + # whether the failure came via exit code or junit attestation. + # + # Always fold the exit code into overall_exit, even when junit.xml + # was never written (segfault/OOM/missing binary). Otherwise the + # rig silently returns 0 for catastrophic group failures. + if ( + exit_code not in _NON_FAILING_PYTEST_EXIT_CODES + and not group.optional + and overall_exit == 0 + ): + overall_exit = exit_code + # Belt-and-braces: if the junit attests to errors/failures the exit + # code didn't (truncated junit → errors=1 with exit 0), the report + # shows ❌ but exit would otherwise stay 0. Keep them in sync. + if ( + result is not None + and (result.errors or result.failed) + and not group.optional + and overall_exit == 0 + ): + overall_exit = 1 + finally: + if runtime is not None and not args.dry_run: + print(f"[rig] tearing platform down (runtime={runtime.name})") + # Don't let a teardown failure mask the in-flight exception. + # Python re-raises whatever exception we caught here if down() + # raises during a `finally`, hiding the real cause upstream. + try: + runtime.down() + except Exception as exc: + print( + f"[rig] teardown failed (runtime={runtime.name}): {exc}", + file=sys.stderr, + ) + + if args.coverage and not args.dry_run: + combine_and_report(reports_dir) + + green = _green_group_names(group_results) + # A corrupt baseline can't be silently treated as empty (that would turn + # the next build into a regression festival once a one-tier baseline gets + # written back). But we must still write the per-group summary so the + # developer can see what passed/failed — otherwise the reporting path + # silently fails on top of the baseline error. + baseline_corrupt = False + try: + baseline = cp.load_baseline(args.baseline) + except cp.BaselineCorruptError as exc: + print(f"[rig] ❌ {exc}", file=sys.stderr) + baseline = None + baseline_corrupt = True + + statuses = cp.evaluate( + registry, + groups_run_green=green, + baseline=baseline, + scope_groups=scope_groups, + ) + write_summary( + reports_dir=reports_dir, + group_results=group_results, + critical_statuses=statuses, + baseline_corrupt=baseline_corrupt, + ) + + # Surface baseline corruption regardless of whether the build was + # otherwise green or red. A red-then-fixed cycle without this flip would + # silently swallow the corrupt cache and disable regression detection on + # the next N builds. + if baseline_corrupt and overall_exit == 0: + overall_exit = 1 + + # In a dry run no groups executed, so every covered path looks like a + # gap/regression. A dry run is plan-only: report but never fail (and never + # write a baseline) on the back of results that didn't happen. + if args.dry_run: + return overall_exit + + regressions = [s for s in statuses if s.state == "regression"] + if regressions: + print( + f"\n[rig] ❌ {len(regressions)} critical-path regression(s) detected", + file=sys.stderr, + ) + if overall_exit == 0: + overall_exit = 1 + + gaps = [s for s in statuses if s.state == "gap"] + if gaps and args.fail_on_critical_gap: + print( + f"\n[rig] ⚠️ {len(gaps)} critical-path gap(s) detected " + f"(fail-on-critical-gap)", + file=sys.stderr, + ) + if overall_exit == 0: + overall_exit = 1 + + if args.update_baseline and overall_exit == 0: + try: + cp.merge_into_baseline(statuses, args.baseline) + print(f"[rig] merged into baseline: {args.baseline}") + except cp.BaselineCorruptError as exc: + print(f"[rig] ❌ baseline write skipped: {exc}", file=sys.stderr) + overall_exit = 1 + + return overall_exit + + +# ── execution helpers ───────────────────────────────────────────────────────── + + +def _green_group_names(results: list[GroupResult]) -> set[str]: + return {r.name for r in results if r.status in ("pass", "empty")} + + +def _is_missing_placeholder(group: GroupDefinition) -> bool: + if not group.optional: + return False + wd = group.absolute_workdir() + if not wd.exists(): + return True + return not all(p.exists() for p in group.absolute_paths()) + + +def _execute_group( + group: GroupDefinition, + *, + reports_dir: Path, + marker: str | None, + paths_override: str | None, + coverage: bool, + parallel: bool, + workers: str, + timeout: int, + endpoints: PlatformEndpoints | None, +) -> tuple[GroupResult | None, int]: + group_reports = reports_dir / group.name + group_reports.mkdir(parents=True, exist_ok=True) + junit = group_reports / "junit.xml" + md_report = group_reports / "report.md" + + env = _subprocess_env() + env.update(group.env) + if endpoints is not None: + env.setdefault("UNSTRACT_BACKEND_URL", endpoints.backend_url) + env.setdefault("UNSTRACT_PROMPT_SERVICE_URL", endpoints.prompt_service_url) + env.setdefault("UNSTRACT_PLATFORM_SERVICE_URL", endpoints.platform_service_url) + env.setdefault("UNSTRACT_RUNNER_URL", endpoints.runner_url) + env.setdefault("UNSTRACT_X2TEXT_URL", endpoints.x2text_url) + # Stamp the run with a per-invocation sentinel so e2e tests can + # distinguish "rig brought the platform up" from "stale shell env + # leaked in". `setdefault` would let a leaked sentinel win, which + # defeats the purpose — set unconditionally. + env["UNSTRACT_RIG_SESSION_ID"] = _rig_session_id() + if coverage and group.coverage_source: + env.update(coverage_env(group.name, reports_dir)) + + # Best-effort dep prep. Each `uv` call uses check=False so a transient + # install failure (e.g. network blip during `uv pip install`) doesn't kill + # the whole rig; pytest will surface a real missing-module error if so. + # If you're debugging "ModuleNotFoundError" in a group, scroll up for the + # uv warnings — they're the smoking gun. + _prepare_group_env(group, env=env) + + workdir = group.absolute_workdir() + + if group.runner == "hurl": + cmd = _hurl_command(group, workdir) + exit_code = _spawn(cmd, env=env, cwd=workdir, timeout=timeout) + # Match the exit.txt write's defensive handling: a read-only reports + # dir or full disk shouldn't abort the whole run and orphan completed + # groups before the summary renders. + try: + _write_synthetic_junit(junit, group.name, exit_code) + except OSError as err: + print( + f"[rig] could not write synthetic junit for {group.name}: {err}", + file=sys.stderr, + ) + else: + cmd = _pytest_command( + group, + workdir=workdir, + junit=junit, + md_report=md_report, + marker=marker, + paths_override=paths_override, + coverage=coverage, + parallel=parallel, + workers=workers, + timeout=timeout, + ) + exit_code = _spawn(cmd, env=env, cwd=workdir, timeout=timeout) + + try: + (group_reports / "exit.txt").write_text(str(exit_code)) + except OSError as err: + print( + f"[rig] could not write exit.txt for {group.name}: {err}", + file=sys.stderr, + ) + + return parse_junit(group.name, group.tier, reports_dir), exit_code + + +RIG_PYTEST_PLUGINS = ( + "pytest>=8.0.1", + "pytest-md-report>=0.6.2", + "pytest-timeout>=2.3.1", + "pytest-xdist>=3.5.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", +) + + +def _prepare_group_env(group: GroupDefinition, *, env: dict[str, str]) -> None: + """Sync deps for a group. Mirrors what the old per-service tox envs did, + and ensures the rig's pytest plugins are present in the group's venv. + """ + workdir = group.absolute_workdir() + if not shutil.which("uv"): + return + if group.uv_sync_group: + subprocess.run( + ["uv", "sync", "--group", group.uv_sync_group], + cwd=workdir, + env=env, + check=False, + ) + if group.install_editable: + subprocess.run( + ["uv", "pip", "install", "-e", "."], + cwd=workdir, + env=env, + check=False, + ) + if group.pip_install: + subprocess.run( + ["uv", "pip", "install", *group.pip_install], + cwd=workdir, + env=env, + check=False, + ) + # NOTE: rig pytest plugins (pytest-timeout, pytest-md-report, etc.) are + # injected via `uv run --with ...` in _pytest_command, not installed here. + # That avoids losing them on the next `uv run` (which re-syncs the venv). + + +def _pytest_command( + group: GroupDefinition, + *, + workdir: Path, + junit: Path, + md_report: Path, + marker: str | None, + paths_override: str | None, + coverage: bool, + parallel: bool, + workers: str, + timeout: int, +) -> list[str]: + use_uv = shutil.which("uv") is not None + if use_uv: + # `uv run` re-syncs the project's venv each call, which would wipe any + # plugins added via `uv pip install`. `--with` injects them into the + # ephemeral run environment, surviving the sync. + with_args: list[str] = [] + for spec in RIG_PYTEST_PLUGINS: + with_args += ["--with", spec] + base: list[str] = ["uv", "run", *with_args, "pytest"] + else: + base = [sys.executable, "-m", "pytest"] + + cmd = [ + *base, + "-v", + f"--junitxml={junit}", + f"--timeout={timeout}", + ] + # pytest-md-report does not aggregate worker output reliably under xdist. + # Emit markdown only on serial runs; junit + reporting.py's _render_markdown + # cover the parallel case. + if not parallel: + cmd += [ + "--md-report", + "--md-report-flavor=gfm", + f"--md-report-output={md_report}", + ] + if coverage and group.coverage_source: + cmd += [ + f"--cov={group.coverage_source}", + "--cov-report=", + ] + if parallel: + cmd += ["-n", workers] + effective_marker = marker or group.markers + if effective_marker: + cmd += ["-m", effective_marker] + cmd += list(group.pytest_extra) + + if paths_override: + cmd += [p.strip() for p in paths_override.split(",") if p.strip()] + else: + # Paths are relative to workdir so pytest runs as `cd workdir && pytest `. + for p in group.paths: + cmd.append(p) + return cmd + + +def _hurl_command(group: GroupDefinition, workdir: Path) -> list[str]: + files: list[str] = [] + for p in group.paths: + target = workdir / p + if target.is_dir(): + files.extend(sorted(str(f) for f in target.rglob("*.hurl"))) + elif target.is_file(): + files.append(str(target)) + if not files: + # Surface as exit 5 ("no tests collected") for consistency with pytest. + return ["sh", "-c", "exit 5"] + return ["hurl", "--test", *files] + + +def _write_synthetic_junit(path: Path, group_name: str, exit_code: int) -> None: + """Synthesise a JUnit XML for hurl runs. + + Exit 5 ("no tests collected") must produce failures=0; otherwise an empty + hurl group would show ⚪ via :class:`GroupResult` while also being counted + as a failure in totals + critical-path evaluation. + + ``group_name`` is XML-escaped: a group key containing ``"``/``&``/``<`` + would otherwise produce malformed XML, which ``parse_junit`` then reads as + a phantom error on a green hurl run. + """ + is_failure = exit_code != 0 and exit_code != 5 + failures = 1 if is_failure else 0 + failure_tag = f'' if is_failure else "" + name = saxutils.escape(group_name, {'"': """}) + path.write_text( + '\n' + f'\n' + f' {failure_tag}' + f"\n" + f"\n" + ) + + +def _spawn(cmd: list[str], *, env: dict[str, str], cwd: Path, timeout: int) -> int: + start = time.monotonic() + try: + result = subprocess.run(cmd, cwd=cwd, env=env, timeout=timeout + 30) + return result.returncode + except subprocess.TimeoutExpired: + print( + f"[rig] TIMEOUT after {time.monotonic() - start:.0f}s: {' '.join(cmd)}", + file=sys.stderr, + ) + return 124 + except FileNotFoundError as exc: + print(f"[rig] command not found: {exc}", file=sys.stderr) + return 127 diff --git a/tests/rig/coverage.py b/tests/rig/coverage.py new file mode 100644 index 0000000000..710c4ade20 --- /dev/null +++ b/tests/rig/coverage.py @@ -0,0 +1,138 @@ +"""Coverage helpers. + +Each group runs with its own ``COVERAGE_FILE`` so parallel pytest invocations +don't trample each other. After all groups complete, ``combine_and_report`` +merges them into a single ``.coverage`` and emits XML/HTML. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from tests.rig.groups import REPO_ROOT + +log = logging.getLogger(__name__) + +# Cap each coverage subprocess. Runs after the per-group loop, so per-group +# timeouts don't apply; without this a slow `uv run --with` resolve could hang +# the job to the CI ceiling. +_COVERAGE_TIMEOUT_SECONDS = 300 + + +def _clean_env() -> dict[str, str]: + """Env for ``uv run`` that drops a leaked ``VIRTUAL_ENV`` (e.g. tox's + ``.tox/``) so uv resolves the project venv without a mismatch warning. + """ + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + env.pop("UV_PROJECT_ENVIRONMENT", None) + return env + + +def coverage_env(group_name: str, reports_dir: Path) -> dict[str, str]: + """Env vars to scope a group's coverage file under ``reports_dir``.""" + cov_file = reports_dir / f".coverage.{group_name}" + cov_file.parent.mkdir(parents=True, exist_ok=True) + return {"COVERAGE_FILE": str(cov_file)} + + +def _coverage_base() -> list[str]: + """Pick a runner for ``coverage`` that has the package available. + + Prefers ``uv run --with coverage`` so the same dependency-resolution + strategy as the test runs is used, and ``coverage`` doesn't need to be + installed in the parent interpreter. Falls back to ``python -m coverage`` + if uv isn't around. + """ + if shutil.which("uv"): + return ["uv", "run", "--with", "coverage[toml]", "coverage"] + return [sys.executable, "-m", "coverage"] + + +def combine_and_report(reports_dir: Path) -> None: + """Combine all per-group ``.coverage.`` files and emit xml + html. + + Combine runs in ``reports_dir`` so the .coverage files are colocated; xml + and html run from ``REPO_ROOT`` so coverage can resolve the original source + file paths (stored as repo-relative in ``COVERAGE_FILE`` thanks to + ``[tool.coverage.run].relative_files = true``). + + Errors are logged (not raised) because a coverage failure shouldn't drop + the test run's exit code. + """ + files = sorted(reports_dir.glob(".coverage.*")) + if not files: + return + + target = reports_dir / ".coverage" + if target.exists(): + target.unlink() + + base = _coverage_base() + clean_env = _clean_env() + if not _run_coverage( + [*base, "combine", *[str(p) for p in files]], + cwd=reports_dir, + env=clean_env, + ): + return + + combined = reports_dir / ".coverage" + xml_cmd = [ + *base, + "xml", + "--data-file", + str(combined), + "-o", + str(reports_dir / "coverage.xml"), + ] + html_cmd = [ + *base, + "html", + "--data-file", + str(combined), + "-d", + str(reports_dir / "htmlcov"), + ] + for cmd in (xml_cmd, html_cmd): + _run_coverage(cmd, cwd=REPO_ROOT, env=clean_env) + + +def _run_coverage(cmd: list[str], *, cwd: Path, env: dict[str, str]) -> bool: + """Run a coverage subprocess with a timeout. Returns True on success. + + A timeout is essential: ``uv run --with coverage[toml]`` can resolve over a + slow index, and this runs AFTER the per-group loop so the rig's per-group + timeouts don't apply — without a cap it could hang the job to the CI limit. + Failures are logged, not raised (coverage is best-effort reporting). + """ + try: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + env=env, + timeout=_COVERAGE_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + log.warning( + "coverage step timed out after %ds: %s", + _COVERAGE_TIMEOUT_SECONDS, + " ".join(cmd), + ) + return False + if result.returncode != 0: + log.warning( + "%s failed (exit %d): %s", + " ".join(cmd), + result.returncode, + (result.stderr or result.stdout).strip(), + ) + return False + return True diff --git a/tests/rig/critical_paths.py b/tests/rig/critical_paths.py new file mode 100644 index 0000000000..b0cbbd0596 --- /dev/null +++ b/tests/rig/critical_paths.py @@ -0,0 +1,216 @@ +"""Load ``tests/critical_paths.yaml`` and compute gaps + regressions.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Collection +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +import yaml + +from tests.rig.groups import REPO_ROOT, GroupManifest + +log = logging.getLogger(__name__) + +CriticalPathState = Literal["covered", "gap", "regression"] + +DEFAULT_REGISTRY = REPO_ROOT / "tests" / "critical_paths.yaml" + + +class BaselineCorruptError(RuntimeError): + """Raised when the baseline file exists but cannot be parsed. + + The rig refuses to silently treat a corrupt baseline as empty because that + would (a) demote real regressions to gaps and (b) wipe the other tier's + coverage on the next merge. The CI workflow should delete the cache and + retry, surfacing the corruption explicitly. + """ + + +@dataclass(frozen=True) +class CriticalPath: + id: str + description: str + entry: str + covered_by: tuple[str, ...] + + +@dataclass(frozen=True) +class CriticalPathRegistry: + paths: tuple[CriticalPath, ...] + _by_id: dict[str, CriticalPath] = field(default_factory=dict, init=False, repr=False) + + def __post_init__(self) -> None: + # Duplicate ids would silently last-wins in the lookup while both rows + # still render — fail loudly at load time instead. + seen: set[str] = set() + dupes = sorted({p.id for p in self.paths if p.id in seen or seen.add(p.id)}) + if dupes: + raise ValueError(f"duplicate critical-path ids: {dupes}") + # `frozen=True` blocks direct assignment; route through object.__setattr__. + object.__setattr__(self, "_by_id", {p.id: p for p in self.paths}) + + def by_id(self, path_id: str) -> CriticalPath: + if path_id not in self._by_id: + raise KeyError(path_id) + return self._by_id[path_id] + + +@dataclass(frozen=True) +class CriticalPathStatus: + path: CriticalPath + state: CriticalPathState + covering_groups_run: tuple[str, ...] + notes: str = "" + + def __post_init__(self) -> None: + # Make the contradictory states unrepresentable rather than relying on + # evaluate()'s discipline: covered ⇒ at least one covering group ran; + # gap/regression ⇒ none did. + if self.state == "covered" and not self.covering_groups_run: + raise ValueError("state='covered' requires a non-empty covering_groups_run") + if self.state in ("gap", "regression") and self.covering_groups_run: + raise ValueError(f"state={self.state!r} must have empty covering_groups_run") + + +def load_critical_paths(path: Path | None = None) -> CriticalPathRegistry: + raw = yaml.safe_load((path or DEFAULT_REGISTRY).read_text()) + if not isinstance(raw, dict) or "paths" not in raw: + raise ValueError(f"{path or DEFAULT_REGISTRY}: expected top-level `paths:` list") + return CriticalPathRegistry( + paths=tuple( + CriticalPath( + id=str(p["id"]), + description=str(p.get("description", "")), + entry=str(p.get("entry", "")), + covered_by=tuple(p.get("covered_by") or ()), + ) + for p in raw["paths"] + ) + ) + + +def validate_registry_against_manifest( + registry: CriticalPathRegistry, manifest: GroupManifest +) -> list[str]: + """Return human-readable errors for unknown groups referenced in ``covered_by``.""" + errors: list[str] = [] + known = set(manifest.names()) + for path in registry.paths: + for g in path.covered_by: + if g not in known: + errors.append( + f"critical path {path.id!r}: " + f"covered_by references unknown group {g!r}" + ) + return errors + + +def evaluate( + registry: CriticalPathRegistry, + *, + groups_run_green: Collection[str], + baseline: dict[str, Any] | None, + scope_groups: Collection[str] | None = None, +) -> list[CriticalPathStatus]: + """Compute the status for each critical path against this build's results. + + Args: + registry: parsed critical-paths registry. + groups_run_green: names of groups that ran AND passed in this build. + baseline: parsed previous-summary.json from the main-branch cache, or None. + Expected shape: ``{"covered_paths": ["auth-login", ...]}``. + scope_groups: collection of every group the caller considered running + this invocation (including dep-expanded deps and skipped + optional placeholders). When a critical path's ``covered_by`` + is fully outside ``scope_groups``, the path is classified as + ``gap`` rather than ``regression`` — running only the unit + tier shouldn't flag e2e-tier paths as regressed. If ``None``, + no scoping is applied (back-compat). + + Returns: + Statuses in the original registry order. + """ + previously_covered: set[str] = set((baseline or {}).get("covered_paths") or []) + # Convert to sets internally so per-path membership checks stay O(1) even + # when callers pass lists/tuples; the public signature accepts Collection + # to leave that choice to them. + green = set(groups_run_green) + scope = None if scope_groups is None else set(scope_groups) + statuses: list[CriticalPathStatus] = [] + for path in registry.paths: + covering = tuple(g for g in path.covered_by if g in green) + in_scope = scope is None or any(g in scope for g in path.covered_by) + state: CriticalPathState + if covering: + state = "covered" + note = "" + elif path.id in previously_covered and in_scope: + state = "regression" + note = "Was covered on the cached baseline; not covered in this build." + else: + state = "gap" + note = ( + "Out of scope for this invocation." + if not in_scope + else "No group covering this path ran green in this build." + ) + statuses.append( + CriticalPathStatus( + path=path, + state=state, + covering_groups_run=covering, + notes=note, + ) + ) + return statuses + + +def merge_into_baseline(statuses: list[CriticalPathStatus], destination: Path) -> None: + """Merge this build's green critical paths into the cached baseline. + + Two tiers run in separate processes (unit, then integration; then e2e in a + separate workflow). Each invocation only knows about the paths covered by + *its* groups. A naive overwrite would erase the other tier's coverage, so + we union with whatever's already on disk. + + A corrupt baseline raises :class:`BaselineCorruptError` rather than being + treated as empty: silently dropping previously-covered paths would erase + the other tier's contribution and turn the next build into a regression + festival. CI should delete the cache and retry on this exception. + """ + existing: set[str] = set() + if destination.exists(): + try: + parsed = json.loads(destination.read_text()) + existing = set(parsed.get("covered_paths") or []) + except (json.JSONDecodeError, OSError) as exc: + raise BaselineCorruptError( + f"refusing to merge into corrupt baseline {destination}: {exc}. " + "Delete the cache entry and re-run." + ) from exc + fresh = {s.path.id for s in statuses if s.state == "covered"} + payload = {"covered_paths": sorted(existing | fresh)} + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(payload, indent=2)) + + +def load_baseline(source: Path) -> dict[str, Any] | None: + """Load the cached baseline. + + Returns None if the file doesn't exist (first build / fresh cache). + Raises :class:`BaselineCorruptError` if the file exists but is unreadable + or unparseable — see :func:`merge_into_baseline` for the rationale. + """ + if not source.exists(): + return None + try: + return json.loads(source.read_text()) + except (json.JSONDecodeError, OSError) as exc: + raise BaselineCorruptError( + f"baseline at {source} is unreadable: {exc}. " + "Delete the cache entry and re-run." + ) from exc diff --git a/tests/rig/groups.py b/tests/rig/groups.py new file mode 100644 index 0000000000..4c7328a8b5 --- /dev/null +++ b/tests/rig/groups.py @@ -0,0 +1,246 @@ +"""Load and validate the ``tests/groups.yaml`` manifest. + +The manifest is the single source of truth for what a group is, how to run it, +and what it depends on. Cycles fail at load time. Path existence is checked +unless the group is marked ``optional: true`` (which is how we declare +"placeholder for future tests" without breaking the rig today). +""" + +from __future__ import annotations + +import graphlib +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal, get_args + +import yaml + +Tier = Literal["unit", "integration", "e2e"] +Runner = Literal["pytest", "hurl"] + +TIERS: tuple[Tier, ...] = get_args(Tier) +RUNNERS: tuple[Runner, ...] = get_args(Runner) + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MANIFEST = REPO_ROOT / "tests" / "groups.yaml" + + +@dataclass(frozen=True) +class GroupDefinition: + name: str + tier: Tier + paths: tuple[str, ...] + runner: Runner = "pytest" + workdir: str = "." + markers: str | None = None + pytest_extra: tuple[str, ...] = () + env: Mapping[str, str] = field(default_factory=dict) + uv_sync_group: str | None = None + pip_install: tuple[str, ...] = () + install_editable: bool = False + requires_services: tuple[str, ...] = () + requires_platform: bool = False + depends_on: tuple[str, ...] = () + critical: bool = False + coverage_source: str | None = None + timeout_seconds: int = 600 + parallel: bool = True + optional: bool = False + + def __post_init__(self) -> None: + # Freeze env: a frozen dataclass with a mutable dict still lets callers + # scribble onto the shared manifest record (group.env[k] = v). Coerce + # to a read-only proxy so the record is genuinely immutable. + if not isinstance(self.env, MappingProxyType): + object.__setattr__(self, "env", MappingProxyType(dict(self.env))) + + def absolute_workdir(self) -> Path: + return (REPO_ROOT / self.workdir).resolve() + + def absolute_paths(self) -> list[Path]: + return [(self.absolute_workdir() / p).resolve() for p in self.paths] + + +@dataclass(frozen=True) +class GroupManifest: + groups: dict[str, GroupDefinition] + + def get(self, name: str) -> GroupDefinition: + if name not in self.groups: + raise KeyError( + f"Unknown test group: {name!r}. " + "Run `python -m tests.rig list-groups` to see options." + ) + return self.groups[name] + + def names(self) -> list[str]: + return sorted(self.groups) + + def names_by_tier(self, tier: Tier) -> list[str]: + return sorted(n for n, g in self.groups.items() if g.tier == tier) + + def expand(self, selected: list[str]) -> list[str]: + """Return ``selected`` plus the transitive closure of their ``depends_on``, + in topological order (dependencies before dependents). + """ + for name in selected: + self.get(name) # raises on unknown + sorter: graphlib.TopologicalSorter[str] = graphlib.TopologicalSorter() + seen: set[str] = set() + frontier = list(selected) + while frontier: + name = frontier.pop() + if name in seen: + continue + seen.add(name) + group = self.get(name) + sorter.add(name, *group.depends_on) + for dep in group.depends_on: + if dep not in seen: + frontier.append(dep) + return list(sorter.static_order()) + + +def load_groups(path: Path | None = None) -> GroupManifest: + """Parse the YAML manifest and validate it.""" + manifest_path = path or DEFAULT_MANIFEST + raw = yaml.safe_load(manifest_path.read_text()) + if not isinstance(raw, dict) or "groups" not in raw: + raise ValueError(f"{manifest_path}: expected top-level `groups:` mapping") + + defaults = raw.get("defaults") or {} + groups: dict[str, GroupDefinition] = {} + for name, spec in (raw["groups"] or {}).items(): + groups[name] = _build_group(name, spec, defaults) + + _validate_no_cycles(groups) + _validate_dep_targets_exist(groups) + _validate_paths(groups) + smoke_gate = defaults.get("platform_gate_group", "e2e-smoke") + _validate_platform_groups_depend_on_gate(groups, gate=smoke_gate) + return GroupManifest(groups=groups) + + +def _build_group( + name: str, spec: dict[str, Any], defaults: dict[str, Any] +) -> GroupDefinition: + tier = spec.get("tier") + if tier not in TIERS: + raise ValueError(f"group {name!r}: `tier` must be one of {TIERS} (got {tier!r})") + runner = spec.get("runner", defaults.get("runner", "pytest")) + if runner not in RUNNERS: + raise ValueError( + f"group {name!r}: `runner` must be one of {RUNNERS} (got {runner!r})" + ) + paths = spec.get("paths") or [] + if not paths: + raise ValueError(f"group {name!r}: at least one `paths` entry is required") + try: + timeout = int(spec.get("timeout_seconds", defaults.get("timeout_seconds", 600))) + except (TypeError, ValueError) as exc: + raise ValueError( + f"group {name!r}: `timeout_seconds` must be an integer " + f"(got {spec.get('timeout_seconds')!r})" + ) from exc + + return GroupDefinition( + name=name, + tier=tier, + paths=tuple(paths), + runner=runner, + workdir=spec.get("workdir", "."), + markers=spec.get("markers"), + pytest_extra=tuple(spec.get("pytest_extra") or ()), + env=dict(spec.get("env") or {}), + uv_sync_group=spec.get("uv_sync_group"), + pip_install=tuple(spec.get("pip_install") or ()), + install_editable=bool(spec.get("install_editable", False)), + requires_services=tuple(spec.get("requires_services") or ()), + requires_platform=bool(spec.get("requires_platform", False)), + depends_on=tuple(spec.get("depends_on") or ()), + critical=bool(spec.get("critical", False)), + coverage_source=spec.get("coverage_source"), + timeout_seconds=timeout, + parallel=bool(spec.get("parallel", defaults.get("parallel", True))), + optional=bool(spec.get("optional", False)), + ) + + +def _validate_no_cycles(groups: dict[str, GroupDefinition]) -> None: + sorter: graphlib.TopologicalSorter[str] = graphlib.TopologicalSorter() + for name, g in groups.items(): + sorter.add(name, *g.depends_on) + try: + sorter.prepare() + except graphlib.CycleError as exc: + raise ValueError(f"dependency cycle in groups.yaml: {exc.args[1]}") from exc + + +def _validate_dep_targets_exist(groups: dict[str, GroupDefinition]) -> None: + for name, g in groups.items(): + for dep in g.depends_on: + if dep not in groups: + raise ValueError(f"group {name!r} depends_on unknown group {dep!r}") + + +def _validate_paths(groups: dict[str, GroupDefinition]) -> None: + for name, g in groups.items(): + # Optional groups may be placeholders whose paths don't exist yet. + # The rig skips them at runtime; don't fail validation here. + if g.optional: + continue + wd = g.absolute_workdir() + if not wd.exists(): + raise ValueError(f"group {name!r}: workdir does not exist: {wd}") + for p in g.absolute_paths(): + if not p.exists(): + raise ValueError(f"group {name!r}: test path does not exist: {p}") + + +def _validate_platform_groups_depend_on_gate( + groups: dict[str, GroupDefinition], *, gate: str +) -> None: + """Every non-gate ``requires_platform`` group must transitively depend on + the named gate group. The gate is the smoke test: if it fails, dependent + groups skip cleanly rather than running against a half-up stack and + reporting misleading failures. + + If the manifest declares ``requires_platform`` groups but doesn't actually + define the gate, that's a manifest error — silently disabling the check + would defeat the invariant. The gate name is overridable via + ``defaults.platform_gate_group`` in ``groups.yaml`` for forks that rename it. + """ + platform_groups = [n for n, g in groups.items() if g.requires_platform and n != gate] + if not platform_groups: + return # No platform-dependent groups; nothing to enforce. + if gate not in groups: + raise ValueError( + f"`requires_platform` groups present ({sorted(platform_groups)}) " + f"but the platform gate {gate!r} is not defined. Either define it, " + f"or set `defaults.platform_gate_group` in groups.yaml." + ) + for name in platform_groups: + if not _transitively_depends_on(name, gate, groups): + raise ValueError( + f"group {name!r} requires_platform but does not (transitively) " + f"depend on {gate!r}; add it to depends_on so smoke gates this group" + ) + + +def _transitively_depends_on( + name: str, target: str, groups: dict[str, GroupDefinition] +) -> bool: + seen: set[str] = set() + frontier = [name] + while frontier: + current = frontier.pop() + if current in seen: + continue + seen.add(current) + for dep in groups[current].depends_on: + if dep == target: + return True + frontier.append(dep) + return False diff --git a/tests/rig/reporting.py b/tests/rig/reporting.py new file mode 100644 index 0000000000..8bff0a00cb --- /dev/null +++ b/tests/rig/reporting.py @@ -0,0 +1,281 @@ +"""Aggregate per-group results into a single human + machine summary. + +Per group, the rig writes: + reports//junit.xml — pytest --junitxml + reports//report.md — pytest-md-report + reports//exit.txt — single integer pytest exit code + +This module aggregates those into: + reports/summary.json — machine-readable + reports/summary.md — human-readable, PR-comment friendly + reports/combined-test-report.md — backward-compatible alias for the + existing sticky-comment workflow. +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal + +from tests.rig.critical_paths import CriticalPathStatus + +log = logging.getLogger(__name__) + +ResultStatus = Literal["pass", "empty", "fail"] + +_STATUS_ICONS: dict[ResultStatus, str] = { + "pass": "✅", + "empty": "⚪", + "fail": "❌", +} + + +@dataclass(frozen=True) +class GroupResult: + name: str + tier: str + exit_code: int + passed: int + failed: int + errors: int + skipped: int + duration_seconds: float + + @property + def status(self) -> ResultStatus: + if self.exit_code == 5: # pytest "no tests collected" + return "empty" + if self.exit_code == 0 and self.failed == 0 and self.errors == 0: + return "pass" + return "fail" + + @property + def status_icon(self) -> str: + return _STATUS_ICONS[self.status] + + +def parse_junit(group_name: str, tier: str, reports_dir: Path) -> GroupResult | None: + """Parse a group's junit.xml + exit.txt. Returns ``None`` if junit.xml is + missing or unparseable, ``GroupResult`` with errors=1 if the XML lacks the + expected counter attributes (which would otherwise look spuriously green). + """ + junit_path = reports_dir / group_name / "junit.xml" + exit_path = reports_dir / group_name / "exit.txt" + if not junit_path.exists(): + return None + + exit_code = _read_exit_code(exit_path) + + try: + tree = ET.parse(junit_path) + except ET.ParseError as exc: + log.warning("malformed junit.xml for group %r: %s", group_name, exc) + return GroupResult( + name=group_name, + tier=tier, + exit_code=exit_code or -1, + passed=0, + failed=0, + errors=1, + skipped=0, + duration_seconds=0.0, + ) + + root = tree.getroot() + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + + passed = failed = errors = skipped = 0 + duration = 0.0 + saw_attributes = False + for s in suites: + if "tests" in s.attrib: + saw_attributes = True + try: + total = int(s.attrib.get("tests", "0")) + f = int(s.attrib.get("failures", "0")) + e = int(s.attrib.get("errors", "0")) + sk = int(s.attrib.get("skipped", "0")) + duration += float(s.attrib.get("time") or 0) + except (TypeError, ValueError) as exc: + log.warning( + "junit.xml for group %r has non-numeric counters: %s", group_name, exc + ) + return GroupResult( + name=group_name, + tier=tier, + exit_code=exit_code or -1, + passed=0, + failed=0, + errors=1, + skipped=0, + duration_seconds=duration, + ) + failed += f + errors += e + skipped += sk + passed += max(total - f - e - sk, 0) + + if not saw_attributes: + # Junit that parses but has no counters anywhere is almost certainly a + # truncated write. Don't count it as green. + log.warning( + "junit.xml for group %r has no counter attributes; treating as error", + group_name, + ) + errors = max(errors, 1) + + return GroupResult( + name=group_name, + tier=tier, + exit_code=exit_code, + passed=passed, + failed=failed, + errors=errors, + skipped=skipped, + duration_seconds=duration, + ) + + +def _read_exit_code(exit_path: Path) -> int: + if not exit_path.exists(): + return -1 + try: + return int(exit_path.read_text().strip()) + except (OSError, ValueError) as exc: + log.warning("could not read exit code from %s: %s", exit_path, exc) + return -1 + + +def write_summary( + *, + reports_dir: Path, + group_results: list[GroupResult], + critical_statuses: list[CriticalPathStatus], + baseline_corrupt: bool = False, +) -> None: + """Write the per-build summary in JSON and Markdown. + + ``baseline_corrupt=True`` flags a build where the cached baseline could + not be parsed. The Markdown summary surfaces a banner so reviewers + reading the durable artifact (sticky PR comment, CI step summary) know + that regression detection was disabled and any "gap" entries here might + actually be regressions. + """ + summary_json = reports_dir / "summary.json" + summary_md = reports_dir / "summary.md" + combined_md = reports_dir / "combined-test-report.md" + + import json + + summary_json.write_text( + json.dumps( + { + "groups": [asdict(r) for r in group_results], + "critical_paths": [ + { + "id": s.path.id, + "state": s.state, + "covering_groups_run": list(s.covering_groups_run), + } + for s in critical_statuses + ], + "baseline_corrupt": baseline_corrupt, + }, + indent=2, + ) + ) + + md = _render_markdown(group_results, critical_statuses, baseline_corrupt) + summary_md.write_text(md) + # Backward-compat alias for the existing sticky-comment workflow. + combined_md.write_text(md) + + +def _render_markdown( + group_results: list[GroupResult], + critical_statuses: list[CriticalPathStatus], + baseline_corrupt: bool = False, +) -> str: + lines: list[str] = ["# Unstract test results", ""] + if baseline_corrupt: + lines.extend( + [ + "> ⚠️ **Baseline cache was corrupt; regression detection " + "disabled this run.** Paths classified below as `gap` may " + "actually be regressions. Clear the baseline cache and " + "re-run to re-validate.", + "", + ] + ) + + if group_results: + lines.append("## Per-group results") + lines.append("") + lines.append( + "| Status | Group | Tier | Passed | Failed | Errors | Skipped | Duration (s) |" + ) + lines.append("|---|---|---|---:|---:|---:|---:|---:|") + for r in group_results: + lines.append( + f"| {r.status_icon} | `{r.name}` | {r.tier} | {r.passed} | {r.failed} " + f"| {r.errors} | {r.skipped} | {r.duration_seconds:.1f} |" + ) + totals = _totals(group_results) + lines.append( + f"| | **TOTAL** | | **{totals['passed']}** | **{totals['failed']}** " + f"| **{totals['errors']}** | **{totals['skipped']}** " + f"| **{totals['duration']:.1f}** |" + ) + lines.append("") + else: + lines.append("_No groups ran in this build._\n") + + if critical_statuses: + regressions = [s for s in critical_statuses if s.state == "regression"] + gaps = [s for s in critical_statuses if s.state == "gap"] + covered = [s for s in critical_statuses if s.state == "covered"] + + lines.append("## Critical paths") + lines.append("") + if regressions: + lines.append("### ❌ Regressions (must be zero)") + lines.append("") + for s in regressions: + lines.append( + f"- **{s.path.id}** — {s.path.description} (entry: `{s.path.entry}`)" + ) + lines.append("") + if gaps: + lines.append("### ⚠️ Critical paths not yet covered") + lines.append("") + for s in gaps: + covers = ", ".join(s.path.covered_by) or "_no groups declared_" + lines.append( + f"- **{s.path.id}** — {s.path.description} " + f"(entry: `{s.path.entry}`; declared coverage: {covers})" + ) + lines.append("") + if covered: + lines.append("
✅ Covered critical paths") + lines.append("") + for s in covered: + groups_str = ", ".join(s.covering_groups_run) + lines.append(f"- **{s.path.id}** — covered by {groups_str}") + lines.append("") + lines.append("
") + lines.append("") + + return "\n".join(lines) + + +def _totals(results: list[GroupResult]) -> dict[str, float]: + return { + "passed": sum(r.passed for r in results), + "failed": sum(r.failed for r in results), + "errors": sum(r.errors for r in results), + "skipped": sum(r.skipped for r in results), + "duration": sum(r.duration_seconds for r in results), + } diff --git a/tests/rig/runtime.py b/tests/rig/runtime.py new file mode 100644 index 0000000000..97be22d748 --- /dev/null +++ b/tests/rig/runtime.py @@ -0,0 +1,294 @@ +"""Platform runtime drivers for e2e tests. + +Two strategies share a small protocol so the rig can pick by env/CLI flag: + + ComposeRuntime — CI default. Reuses docker/docker-compose.yaml + + tests/compose/docker-compose.test.yaml overlay. + TestcontainersRuntime — local default. Spins up Postgres/Redis/RabbitMQ/MinIO + via testcontainers; backend/prompt/platform/runner + are NOT auto-launched today (stub — see class docstring). + +A third mode, ``LocalRuntime``, assumes the developer already has services up +(e.g. via ``run-platform.sh``) and only collects their URLs from env. Useful +when iterating quickly. + +The driver exposes URLs via ``PlatformEndpoints`` and is consumed by the +``platform`` pytest fixture in ``tests/e2e/conftest.py``. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import time +from dataclasses import dataclass, field +from typing import ClassVar, Protocol + +from tests.rig.groups import REPO_ROOT + +log = logging.getLogger(__name__) + +COMPOSE_OVERLAY = REPO_ROOT / "tests" / "compose" / "docker-compose.test.yaml" +BASE_COMPOSE = REPO_ROOT / "docker" / "docker-compose.yaml" + + +@dataclass(frozen=True) +class InfraEndpoints: + """Named handles for stateful infra started by ``TestcontainersRuntime``. + + For fields that come in host/port pairs (redis, rabbitmq), both must be + set together — ``__post_init__`` enforces this so a partial spec doesn't + silently land in downstream config. Postgres collapses host+port+creds + into a single URL string, and MinIO uses a combined ``host:port`` + endpoint, so no pairing applies to those. + """ + + postgres_url: str | None = None + redis_host: str | None = None + redis_port: int | None = None + rabbitmq_host: str | None = None + rabbitmq_port: int | None = None + minio_endpoint: str | None = None + + def __post_init__(self) -> None: + for host, port, label in ( + (self.redis_host, self.redis_port, "redis"), + (self.rabbitmq_host, self.rabbitmq_port, "rabbitmq"), + ): + if (host is None) != (port is None): + raise ValueError( + f"InfraEndpoints: {label}_host and {label}_port must be " + f"set together (got host={host!r}, port={port!r})" + ) + + +@dataclass(frozen=True) +class PlatformEndpoints: + backend_url: str + prompt_service_url: str + platform_service_url: str + runner_url: str + x2text_url: str + admin_user: str = "unstract" + admin_password: str = "unstract" + infra: InfraEndpoints = field(default_factory=InfraEndpoints) + + @classmethod + def from_env(cls, *, infra: InfraEndpoints | None = None) -> PlatformEndpoints: + """Build a ``PlatformEndpoints`` from ``UNSTRACT_*`` env vars. + + Used by every runtime so the lookup logic stays single-sourced. + Defaults match the dev compose stack at ``docker/docker-compose.yaml``. + """ + return cls( + backend_url=os.environ.get("UNSTRACT_BACKEND_URL", "http://localhost:8000"), + prompt_service_url=os.environ.get( + "UNSTRACT_PROMPT_SERVICE_URL", "http://localhost:3003" + ), + platform_service_url=os.environ.get( + "UNSTRACT_PLATFORM_SERVICE_URL", "http://localhost:3001" + ), + runner_url=os.environ.get("UNSTRACT_RUNNER_URL", "http://localhost:5002"), + x2text_url=os.environ.get("UNSTRACT_X2TEXT_URL", "http://localhost:3004"), + admin_user=os.environ.get("UNSTRACT_ADMIN_USER", "unstract"), + admin_password=os.environ.get("UNSTRACT_ADMIN_PASSWORD", "unstract"), + infra=infra or InfraEndpoints(), + ) + + +class PlatformRuntime(Protocol): + name: ClassVar[str] + + def up(self) -> PlatformEndpoints: ... + def down(self) -> None: ... + + +class LocalRuntime: + """Assume a developer-managed stack; just collect endpoints from env.""" + + name: ClassVar[str] = "local" + + def up(self) -> PlatformEndpoints: + return PlatformEndpoints.from_env() + + def down(self) -> None: + return None + + +class ComposeRuntime: + """Bring the platform up via docker compose with a test overlay.""" + + name: ClassVar[str] = "compose" + + def __init__(self, *, project_name: str = "unstract-test") -> None: + self.project_name = project_name + + def up(self) -> PlatformEndpoints: + if shutil.which("docker") is None: + raise RuntimeError("ComposeRuntime requires the `docker` CLI on PATH") + files = ["-f", str(BASE_COMPOSE)] + if COMPOSE_OVERLAY.exists(): + files += ["-f", str(COMPOSE_OVERLAY)] + _run(["docker", "compose", "-p", self.project_name, *files, "up", "-d", "--wait"]) + endpoints = PlatformEndpoints.from_env() + _wait_ready(endpoints) + return endpoints + + def down(self) -> None: + if shutil.which("docker") is None: + return + files = ["-f", str(BASE_COMPOSE)] + if COMPOSE_OVERLAY.exists(): + files += ["-f", str(COMPOSE_OVERLAY)] + _run( + [ + "docker", + "compose", + "-p", + self.project_name, + *files, + "down", + "-v", + "--remove-orphans", + ], + check=False, + ) + + +class TestcontainersRuntime: + """Spin up stateful infra via testcontainers; services run locally. + + This is a stub today: it stands up Postgres/Redis/RabbitMQ/MinIO so that + ``unit-backend`` and ``integration-*`` groups can run, but does NOT + auto-launch backend/prompt-service/etc. as subprocesses yet — that is + layered on once each service has a tested test-mode entrypoint. + + For full platform e2e against testcontainers, use ``ComposeRuntime`` for now + or set ``UNSTRACT_E2E_RUNTIME=local`` after running ``run-platform.sh``. + """ + + name: ClassVar[str] = "testcontainers" + + def __init__(self) -> None: + self._stack: list[object] = [] # container handles for teardown + + def up(self) -> PlatformEndpoints: + # Lazy import — list-groups/expand/validate don't need testcontainers. + from testcontainers.minio import MinioContainer + from testcontainers.postgres import PostgresContainer + from testcontainers.rabbitmq import RabbitMqContainer + from testcontainers.redis import RedisContainer + + # Construct InfraEndpoints + return inside the same try so the + # __post_init__ invariant (host without port) is self-cleaning; + # otherwise a partial spec leaks the four containers we just started. + try: + pg = PostgresContainer("pgvector/pgvector:pg15") + pg.start() + self._stack.append(pg) + redis = RedisContainer("redis:7.2.3").start() + self._stack.append(redis) + rabbit = RabbitMqContainer("rabbitmq:3.13-management").start() + self._stack.append(rabbit) + minio = MinioContainer("minio/minio:latest").start() + self._stack.append(minio) + + return PlatformEndpoints.from_env( + infra=InfraEndpoints( + postgres_url=pg.get_connection_url(), + redis_host=redis.get_container_host_ip(), + redis_port=redis.get_exposed_port(6379), + rabbitmq_host=rabbit.get_container_host_ip(), + rabbitmq_port=rabbit.get_exposed_port(5672), + minio_endpoint=( + f"{minio.get_container_host_ip()}:{minio.get_exposed_port(9000)}" + ), + ), + ) + except Exception: + self.down() + raise + + def down(self) -> None: + while self._stack: + container = self._stack.pop() + stop = getattr(container, "stop", None) + if not callable(stop): + continue + try: + stop() + except Exception as exc: + # Best-effort teardown. We still log because leaked containers + # block the next run with port conflicts and the failure cause + # is otherwise invisible. + log.warning("testcontainers stop() failed for %r: %s", container, exc) + + +def pick_runtime(name: str | None) -> PlatformRuntime: + """Resolve a runtime by name, falling back to env then default.""" + chosen = ( + name or os.environ.get("UNSTRACT_E2E_RUNTIME") or _default_runtime_name() + ).lower() + if chosen == "compose": + return ComposeRuntime() + if chosen == "testcontainers": + return TestcontainersRuntime() + if chosen == "local": + return LocalRuntime() + raise ValueError( + f"unknown runtime: {chosen!r} (expected compose|testcontainers|local)" + ) + + +def _default_runtime_name() -> str: + return "compose" if os.environ.get("CI") else "testcontainers" + + +def _run(cmd: list[str], *, check: bool = True) -> None: + try: + subprocess.run(cmd, check=check, cwd=REPO_ROOT) + except subprocess.CalledProcessError as exc: + # Re-raise with the command tail so CI logs name what failed. + raise RuntimeError( + f"command failed (exit {exc.returncode}): {' '.join(cmd)}" + ) from exc + + +def _wait_ready(endpoints: PlatformEndpoints, *, timeout_seconds: int = 300) -> None: + """Poll each service's /health endpoint until all respond or timeout. + + If ``requests`` isn't importable (e.g. running the rig on a bare interpreter + just to list groups), readiness probing is skipped. That's safe because the + only caller, ``ComposeRuntime.up``, is on the e2e path where requests is in + the rig's deps; getting here without requests installed implies a broken + install and the developer will surface it shortly. + """ + try: + import requests + except ImportError: + log.warning("`requests` not installed; skipping platform readiness probe") + return + + targets = [ + endpoints.backend_url.rstrip("/") + "/health/", + endpoints.prompt_service_url.rstrip("/") + "/health", + endpoints.platform_service_url.rstrip("/") + "/health", + endpoints.runner_url.rstrip("/") + "/health", + endpoints.x2text_url.rstrip("/") + "/health", + ] + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if all(_responds(t, requests) for t in targets): + return + time.sleep(2) + raise TimeoutError(f"services not ready within {timeout_seconds}s: {targets}") + + +def _responds(url: str, requests_mod) -> bool: + try: + resp = requests_mod.get(url, timeout=2) + return resp.status_code < 500 + except requests_mod.RequestException: + return False diff --git a/tests/rig/selection.py b/tests/rig/selection.py new file mode 100644 index 0000000000..a1daac001a --- /dev/null +++ b/tests/rig/selection.py @@ -0,0 +1,154 @@ +"""Resolve user-supplied group selections to a concrete, ordered list to run. + +Resolution order (de-duped, then dep-expanded, then topo-sorted by the manifest): + + positional GROUPS ∪ ``--from-file`` lines ∪ (``--tier`` filter) ∪ (``--changed-only`` heuristic) + +The literal ``all`` expands to every group in the manifest. When the result is +empty, callers should treat that as "do nothing" rather than silently running +everything — the CLI surfaces a clear error. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +from pathlib import Path + +from tests.rig.groups import REPO_ROOT, GroupManifest, Tier + +log = logging.getLogger(__name__) + + +def resolve( + manifest: GroupManifest, + *, + positional: list[str], + from_file: Path | None = None, + tier: Tier | None = None, + changed_only: bool = False, + changed_base: str = "origin/main", +) -> list[str]: + selected: set[str] = set() + + for name in positional: + if name == "all": + selected.update(manifest.names()) + else: + selected.add(name) + + if from_file is not None: + for line in from_file.read_text().splitlines(): + entry = line.split("#", 1)[0].strip() + if not entry: + continue + if entry == "all": + selected.update(manifest.names()) + else: + selected.add(entry) + + if tier is not None: + # `all` shouldn't bypass a tier filter: when both are passed, intersect. + tier_set = set(manifest.names_by_tier(tier)) + if "all" in positional or ( + from_file is not None + and any( + ln.split("#", 1)[0].strip() == "all" + for ln in from_file.read_text().splitlines() + ) + ): + selected &= tier_set + else: + selected.update(tier_set) + + if changed_only: + selected.update(_groups_for_changed_paths(manifest, base=changed_base)) + + return manifest.expand(sorted(selected)) if selected else [] + + +def _groups_for_changed_paths(manifest: GroupManifest, *, base: str) -> set[str]: + """Pick groups whose ``paths`` overlap any file in ``git diff base...HEAD``. + + ``--changed-only`` is designed for PR branches. On a ``push: main`` event + the checked-out commit *is* ``origin/main``, so ``base...HEAD`` is empty and + nothing would be selected. We detect ``HEAD == base`` and fall back to + ``HEAD~1...HEAD`` (the merge commit's delta) so the heuristic still picks + something useful on main. + """ + diff_range = f"{base}...HEAD" + if _same_commit(base, "HEAD"): + print( + f"[rig] --changed-only: HEAD == {base}; falling back to HEAD~1...HEAD " + "(this selector is intended for PR branches).", + file=sys.stderr, + ) + diff_range = "HEAD~1...HEAD" + try: + result = subprocess.run( + ["git", "diff", "--name-only", diff_range], + check=True, + capture_output=True, + text=True, + cwd=REPO_ROOT, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ) + except FileNotFoundError: + print( + "[rig] --changed-only: `git` not found on PATH; skipping", + file=sys.stderr, + ) + return set() + except subprocess.CalledProcessError as exc: + # Most likely cause: shallow CI clone or missing remote tracking branch. + # Surface stderr so the user can act on it. + stderr = (exc.stderr or "").strip() + print( + f"[rig] --changed-only: git diff failed (exit {exc.returncode}); " + f"no groups will be auto-selected from changed files.\n" + f" git stderr: {stderr}", + file=sys.stderr, + ) + return set() + + changed = [ + REPO_ROOT / line.strip() for line in result.stdout.splitlines() if line.strip() + ] + picked: set[str] = set() + for name, group in manifest.groups.items(): + if group.optional and not group.absolute_workdir().exists(): + continue + roots = [group.absolute_workdir()] + group.absolute_paths() + for f in changed: + if any(_is_within(f, root) for root in roots): + picked.add(name) + break + return picked + + +def _is_within(child: Path, parent: Path) -> bool: + try: + child.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def _same_commit(ref_a: str, ref_b: str) -> bool: + """True if both refs resolve to the same commit. Conservative on error: + returns False so the caller uses the normal ``base...HEAD`` range. + """ + try: + out = subprocess.run( + ["git", "rev-parse", ref_a, ref_b], + check=True, + capture_output=True, + text=True, + cwd=REPO_ROOT, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ).stdout.split() + except (FileNotFoundError, subprocess.CalledProcessError): + return False + return len(out) == 2 and out[0] == out[1] diff --git a/tests/rig/tests/__init__.py b/tests/rig/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/rig/tests/conftest.py b/tests/rig/tests/conftest.py new file mode 100644 index 0000000000..8ac21c9f23 --- /dev/null +++ b/tests/rig/tests/conftest.py @@ -0,0 +1,8 @@ +"""Pytest config for rig self-tests. + +The rig's path validation in :func:`tests.rig.groups._validate_paths` asserts +that every non-optional group's workdir + paths exist on disk. The self-test +fixtures construct synthetic manifests pointing at temp directories, so we +bypass path validation by always marking synthetic groups as ``optional`` (or +by creating the directories the test references). +""" diff --git a/tests/rig/tests/test_cli.py b/tests/rig/tests/test_cli.py new file mode 100644 index 0000000000..a975f43d62 --- /dev/null +++ b/tests/rig/tests/test_cli.py @@ -0,0 +1,467 @@ +"""Self-tests for the CLI's run-time wiring (scope_groups, teardown safety). + +The bulk of the rig is tested via the manifest + evaluate + reporting helpers. +These tests exist for the parts of ``cmd_run`` that are hard to exercise from +pure unit tests — specifically, how it passes ``scope_groups`` through to +``evaluate`` and how it shields the in-flight exception from a teardown failure. + +These tests monkeypatch module-level constants (``DEFAULT_MANIFEST``, +``DEFAULT_REGISTRY``) because ``cmd_run`` reads them at call time. Safe today +because the read is synchronous; if manifest loading ever becomes async (or +the CLI starts caching a parsed manifest at import time), prefer passing the +path explicitly through CLI args rather than expanding this patching pattern. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from tests.rig.runtime import PlatformEndpoints + + +def test_cmd_run_passes_scope_to_evaluate(tmp_path: Path, monkeypatch) -> None: + """``cmd_run`` must pass the runnable dep-expanded selection (not just + groups_run_green) as ``scope_groups``. Without this plumbing, scope-aware + regression filtering has no effect — a future refactor that drops the + kwarg would silently reintroduce cross-tier regression false positives + where the unit-tier baseline lights up the e2e-tier paths as regressed. + + ``unit-x`` is given a real workdir/path so it survives the + ``_is_missing_placeholder`` filter and lands in ``scope_groups``; the + companion test ``test_cmd_run_excludes_missing_placeholders_from_scope`` + covers the opposite case. + """ + test_dir = Path(__file__).parent + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-x:\n" + " tier: unit\n" + f" workdir: {test_dir}\n" + " paths: [.]\n" + ) + cp_yaml = "version: 1\npaths: []\n" + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text(cp_yaml) + + # Redirect the rig's manifest paths to the tmp_path fixtures. + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + + # Spy on evaluate to capture scope_groups. + captured: dict[str, Any] = {} + real_evaluate = cp_mod.evaluate + + def spy_evaluate(*args, **kwargs): + captured["scope_groups"] = kwargs.get("scope_groups") + return real_evaluate(*args, **kwargs) + + monkeypatch.setattr(cli_mod.cp, "evaluate", spy_evaluate) + + # --dry-run avoids spawning subprocesses; the scope wiring runs regardless. + args = cli_mod._build_parser().parse_args( + [ + "run", + "unit-x", + "--dry-run", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(tmp_path / "reports"), + "--baseline", + str(tmp_path / "reports" / "previous-summary.json"), + ] + ) + exit_code = cli_mod.cmd_run(args) + assert exit_code == 0 + assert captured["scope_groups"] == {"unit-x"}, ( + "cmd_run must pass the full dep-expanded selection as scope_groups; " + f"got {captured.get('scope_groups')}" + ) + + +def test_cmd_run_excludes_missing_placeholders_from_scope( + tmp_path: Path, monkeypatch +) -> None: + """An ``optional: true`` group whose paths/workdir are absent is skipped + and must NOT appear in ``scope_groups``. If it leaked into scope, its + critical paths would classify as ``regression`` (in scope, not green) + instead of ``gap`` — exactly the cross-tier false positive Fix 2 prevents. + """ + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-absent:\n" + " tier: unit\n" + " paths: [definitely-not-on-disk]\n" + " optional: true\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + + captured: dict[str, Any] = {} + real_evaluate = cp_mod.evaluate + + def spy_evaluate(*args, **kwargs): + captured["scope_groups"] = kwargs.get("scope_groups") + return real_evaluate(*args, **kwargs) + + monkeypatch.setattr(cli_mod.cp, "evaluate", spy_evaluate) + + args = cli_mod._build_parser().parse_args( + [ + "run", + "unit-absent", + "--dry-run", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(tmp_path / "reports"), + "--baseline", + str(tmp_path / "reports" / "previous-summary.json"), + ] + ) + exit_code = cli_mod.cmd_run(args) + assert exit_code == 0 + assert captured["scope_groups"] == set(), ( + "skipped optional placeholders must be excluded from scope_groups; " + f"got {captured.get('scope_groups')}" + ) + + +def test_optional_group_failure_does_not_block_overall_exit( + tmp_path: Path, monkeypatch +) -> None: + """A failing ``optional: true`` group surfaces its red result in the + summary but must NOT gate the overall exit code. This honors the developer + intent for groups that need infra we don't provision in CI (live-DB + connector tests) or that are pluggable/cloud-only — red shows in the + report, merge isn't blocked. + """ + from tests.rig.reporting import GroupResult + + test_dir = Path(__file__).parent + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-opt:\n" + " tier: unit\n" + f" workdir: {test_dir}\n" + " paths: [.]\n" + " optional: true\n" + " unit-req:\n" + " tier: unit\n" + f" workdir: {test_dir}\n" + " paths: [.]\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + + def fake_execute_group(group, **kwargs): + # The optional group fails; the required one passes. + failed = 1 if group.optional else 0 + exit_code = 1 if group.optional else 0 + result = GroupResult( + name=group.name, + tier=group.tier, + exit_code=exit_code, + passed=0 if group.optional else 1, + failed=failed, + errors=0, + skipped=0, + duration_seconds=0.01, + ) + return result, exit_code + + monkeypatch.setattr(cli_mod, "_execute_group", fake_execute_group) + + args = cli_mod._build_parser().parse_args( + [ + "run", + "unit-opt", + "unit-req", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(tmp_path / "reports"), + "--baseline", + str(tmp_path / "reports" / "previous-summary.json"), + ] + ) + exit_code = cli_mod.cmd_run(args) + assert exit_code == 0, ( + "a failing optional group must not gate the overall exit; " + f"got exit_code={exit_code}" + ) + + +def test_cmd_run_teardown_failure_does_not_mask_up_failure( + tmp_path: Path, monkeypatch +) -> None: + """If a runtime's ``up()`` raises and ``down()`` ALSO raises in the + finally, the rig must surface the original up() exception rather than + swap it for the teardown error. + """ + # The smoke group's paths/workdir must exist on disk because the validator + # only skips path checks for `optional: true` groups, and an optional + # group also gets filtered out by `_is_missing_placeholder` so the + # runtime never gets called. Point it at this test file's directory. + test_dir = Path(__file__).parent + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " e2e-smoke:\n" + " tier: e2e\n" + f" workdir: {test_dir}\n" + " paths: [.]\n" + " requires_platform: true\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + + class BrokenRuntime: + name = "broken" + + def up(self) -> PlatformEndpoints: + raise RuntimeError("backend OOM during up") + + def down(self) -> None: + raise RuntimeError("teardown bug") + + monkeypatch.setattr(cli_mod, "pick_runtime", lambda _: BrokenRuntime()) + + args = cli_mod._build_parser().parse_args( + [ + "run", + "e2e-smoke", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(tmp_path / "reports"), + "--baseline", + str(tmp_path / "reports" / "previous-summary.json"), + ] + ) + # The original up() error must reach the test runner, not the down() one. + with pytest.raises(RuntimeError, match="backend OOM during up"): + cli_mod.cmd_run(args) + + +def test_cmd_run_writes_summary_even_on_corrupt_baseline( + tmp_path: Path, monkeypatch +) -> None: + """A corrupt baseline must not skip ``write_summary`` — the per-group + reporting still needs to land on disk so the developer can see what + passed/failed. Otherwise the rig silently swallows results on the build + that needs them most. + """ + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-x:\n" + " tier: unit\n" + " paths: [x]\n" + " optional: true\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + reports_dir = tmp_path / "reports" + reports_dir.mkdir() + baseline = reports_dir / "previous-summary.json" + baseline.write_text("{not valid json") # corrupt + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + + args = cli_mod._build_parser().parse_args( + [ + "run", + "unit-x", + "--dry-run", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(reports_dir), + "--baseline", + str(baseline), + ] + ) + exit_code = cli_mod.cmd_run(args) + # Build is red because baseline is corrupt, but summary.md must exist. + assert exit_code != 0 + summary_md = reports_dir / "summary.md" + assert summary_md.exists(), ( + "write_summary must run even when load_baseline raises; otherwise " + "developers lose all per-group visibility on the build that hit a " + "corrupt cache." + ) + # And the durable artifact must SAY the baseline was corrupt so reviewers + # don't read its "gap" entries as first-time gaps when they're actually + # regressions hidden by the cache failure. + content = summary_md.read_text() + assert "Baseline cache was corrupt" in content, ( + "summary.md must surface baseline corruption so reviewers reading " + "the sticky PR comment know regression detection was disabled. " + f"Got:\n{content}" + ) + + +def test_cmd_run_does_not_update_baseline_on_red_build( + tmp_path: Path, monkeypatch +) -> None: + """``--update-baseline`` must skip the write when the build is red. + Otherwise red-build state bakes into the cache and masks the next real + regression. A refactor dropping the ``overall_exit == 0`` guard would + silently reintroduce that footgun. + """ + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-x:\n" + " tier: unit\n" + " paths: [x]\n" + " optional: true\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + reports_dir = tmp_path / "reports" + reports_dir.mkdir() + baseline = reports_dir / "previous-summary.json" + baseline.write_text("{not valid json") # corrupt → red build + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + merge_calls: list[Any] = [] + monkeypatch.setattr( + cli_mod.cp, + "merge_into_baseline", + lambda statuses, dest: merge_calls.append((statuses, dest)), + ) + + args = cli_mod._build_parser().parse_args( + [ + "run", + "unit-x", + "--dry-run", + "--update-baseline", + "--no-coverage", + "--no-parallel", + "--reports-dir", + str(reports_dir), + "--baseline", + str(baseline), + ] + ) + exit_code = cli_mod.cmd_run(args) + assert exit_code != 0 + assert merge_calls == [], ( + "merge_into_baseline must NOT be called when overall_exit != 0; " + "writing red-build state to the baseline cache hides regressions " + f"on the next build. Got calls: {merge_calls}" + ) + + +def test_synthetic_junit_escapes_group_name(tmp_path: Path) -> None: + """A group key containing XML metacharacters must not break the synthetic + junit — otherwise parse_junit reads malformed XML as a phantom error on a + green hurl run. + """ + import xml.etree.ElementTree as ET + + import tests.rig.cli as cli_mod + + junit = tmp_path / "junit.xml" + cli_mod._write_synthetic_junit(junit, 'hurl-api & docs <"x">', exit_code=0) + # Must parse without raising and round-trip the name intact. + root = ET.parse(junit).getroot() + assert root.attrib["name"] == 'hurl-api & docs <"x">' + + +def test_synthetic_junit_exit_5_is_not_a_failure(tmp_path: Path) -> None: + import xml.etree.ElementTree as ET + + import tests.rig.cli as cli_mod + + junit = tmp_path / "junit.xml" + cli_mod._write_synthetic_junit(junit, "g", exit_code=5) + root = ET.parse(junit).getroot() + assert root.attrib["failures"] == "0" + + +def test_cmd_report_re_aggregates_existing_junit(tmp_path: Path, monkeypatch) -> None: + """`report combine` re-parses each group's junit + writes all three summary + artifacts. It's the CI-retry / manual entrypoint and was previously + untested. + """ + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " unit-x:\n" + " tier: unit\n" + " paths: [x]\n" + " optional: true\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text("version: 1\npaths: []\n") + reports_dir = tmp_path / "reports" + (reports_dir / "unit-x").mkdir(parents=True) + (reports_dir / "unit-x" / "junit.xml").write_text( + '' + '' + ) + (reports_dir / "unit-x" / "exit.txt").write_text("0") + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + # Skip the coverage subprocess; we only care about report aggregation here. + monkeypatch.setattr(cli_mod, "combine_and_report", lambda _d: None) + + args = cli_mod._build_parser().parse_args( + ["report", "combine", "--reports-dir", str(reports_dir)] + ) + exit_code = cli_mod.cmd_report(args) + assert exit_code == 0 + for artifact in ("summary.md", "summary.json", "combined-test-report.md"): + assert (reports_dir / artifact).exists(), f"missing {artifact}" + assert "unit-x" in (reports_dir / "summary.md").read_text() diff --git a/tests/rig/tests/test_critical_paths.py b/tests/rig/tests/test_critical_paths.py new file mode 100644 index 0000000000..b1798d3c1b --- /dev/null +++ b/tests/rig/tests/test_critical_paths.py @@ -0,0 +1,172 @@ +"""Self-tests for critical-path evaluation: covered / gap / regression.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.rig.critical_paths import ( + BaselineCorruptError, + CriticalPath, + CriticalPathRegistry, + evaluate, + load_baseline, + merge_into_baseline, +) + + +def _registry(*ids_and_covers: tuple[str, tuple[str, ...]]) -> CriticalPathRegistry: + return CriticalPathRegistry( + paths=tuple( + CriticalPath(id=i, description="", entry="", covered_by=c) + for i, c in ids_and_covers + ) + ) + + +def test_covered_when_covering_group_is_green() -> None: + registry = _registry(("p1", ("g1",))) + statuses = evaluate(registry, groups_run_green={"g1"}, baseline=None) + assert statuses[0].state == "covered" + assert statuses[0].covering_groups_run == ("g1",) + + +def test_gap_when_no_covering_group_and_no_baseline() -> None: + registry = _registry(("p1", ("g1",))) + statuses = evaluate(registry, groups_run_green=set(), baseline=None) + assert statuses[0].state == "gap" + + +def test_gap_when_covered_by_is_empty() -> None: + registry = _registry(("p1", ())) + statuses = evaluate( + registry, + groups_run_green={"unrelated"}, + baseline={"covered_paths": []}, + ) + assert statuses[0].state == "gap" + + +def test_regression_when_baseline_covered_but_now_not() -> None: + registry = _registry(("p1", ("g1",))) + statuses = evaluate( + registry, + groups_run_green=set(), + baseline={"covered_paths": ["p1"]}, + ) + assert statuses[0].state == "regression" + + +def test_baseline_merge_unions_with_existing(tmp_path: Path) -> None: + """Two tier runs in sequence must both contribute to the baseline.""" + baseline = tmp_path / "previous-summary.json" + registry_a = _registry(("p1", ("g1",))) + statuses_a = evaluate( + registry_a, groups_run_green={"g1"}, baseline=None + ) + merge_into_baseline(statuses_a, baseline) + + registry_b = _registry(("p2", ("g2",))) + statuses_b = evaluate( + registry_b, groups_run_green={"g2"}, baseline=load_baseline(baseline) + ) + merge_into_baseline(statuses_b, baseline) + + final = load_baseline(baseline) or {} + assert sorted(final["covered_paths"]) == ["p1", "p2"] + + +def test_by_id_lookup_caches() -> None: + registry = _registry(("p1", ("g1",)), ("p2", ())) + # Two lookups must return identical instances; tests both correctness and + # that the dict cache was actually built in __post_init__. + assert registry.by_id("p1") is registry.by_id("p1") + assert registry.by_id("p1").id == "p1" + + +def test_duplicate_path_ids_rejected() -> None: + """Two paths with the same id silently last-wins in the lookup while both + still render. Fail at construction instead. + """ + with pytest.raises(ValueError, match="duplicate critical-path ids"): + _registry(("p1", ("g1",)), ("p1", ("g2",))) + + +def test_critical_path_status_rejects_contradictions() -> None: + """Make the contradictory states unrepresentable.""" + from tests.rig.critical_paths import CriticalPath, CriticalPathStatus + + path = CriticalPath(id="p", description="", entry="", covered_by=("g",)) + with pytest.raises(ValueError, match="covered.*non-empty"): + CriticalPathStatus(path=path, state="covered", covering_groups_run=()) + with pytest.raises(ValueError, match="empty covering_groups_run"): + CriticalPathStatus(path=path, state="gap", covering_groups_run=("g",)) + # Valid combinations must not raise. + CriticalPathStatus(path=path, state="covered", covering_groups_run=("g",)) + CriticalPathStatus(path=path, state="gap", covering_groups_run=()) + + +def test_load_baseline_raises_on_corrupt_file(tmp_path: Path) -> None: + """A corrupt baseline must not be silently treated as empty — that would + demote real regressions to gaps on the build that needs detection most. + """ + baseline = tmp_path / "previous-summary.json" + baseline.write_text("{not valid json") + with pytest.raises(BaselineCorruptError): + load_baseline(baseline) + + +def test_merge_raises_on_corrupt_existing_baseline(tmp_path: Path) -> None: + """merge_into_baseline must not silently overwrite a corrupt file — that + would erase the other tier's previously-covered paths. + """ + baseline = tmp_path / "previous-summary.json" + baseline.write_text("{partial") + registry = _registry(("p1", ("g1",))) + statuses = evaluate(registry, groups_run_green={"g1"}, baseline=None) + with pytest.raises(BaselineCorruptError): + merge_into_baseline(statuses, baseline) + + +def test_scope_demotes_out_of_scope_regressions_to_gaps() -> None: + """A unit-only invocation should NOT flag e2e-covered paths as regressed + just because the baseline lists them — those paths are out of scope for + this invocation and belong to the e2e workflow's baseline instead. + + The "straddling" path is the discriminator: ``covered_by=[unit-g, e2e-g]`` + with only ``unit-g`` in scope must still be IN scope (because at least one + of its declared groups is). A weaker implementation that checks against + ``groups_run_green`` would mis-classify it. + """ + registry = _registry( + ("unit-path", ("unit-g",)), + ("e2e-path", ("e2e-g",)), + ("straddle-path", ("unit-g", "e2e-g")), + ) + statuses = evaluate( + registry, + groups_run_green=set(), # nothing passed + baseline={ + "covered_paths": ["unit-path", "e2e-path", "straddle-path"] + }, + scope_groups={"unit-g"}, # only unit groups in scope this run + ) + by_id = {s.path.id: s for s in statuses} + assert by_id["unit-path"].state == "regression" # fully in scope + assert by_id["e2e-path"].state == "gap" # fully out of scope + assert by_id["straddle-path"].state == "regression" # partially in scope + + +def test_scope_none_preserves_legacy_behavior() -> None: + """scope_groups=None disables scope-filtering so callers that don't pass it + keep the old "everything in baseline counts" semantics. + """ + registry = _registry(("p1", ("g1",))) + statuses = evaluate( + registry, + groups_run_green=set(), + baseline={"covered_paths": ["p1"]}, + scope_groups=None, + ) + assert statuses[0].state == "regression" diff --git a/tests/rig/tests/test_groups.py b/tests/rig/tests/test_groups.py new file mode 100644 index 0000000000..62b3d20513 --- /dev/null +++ b/tests/rig/tests/test_groups.py @@ -0,0 +1,206 @@ +"""Self-tests for the rig's manifest loader and dep-graph expansion.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.rig.groups import GroupDefinition, load_groups + + +def _write_manifest(tmp_path: Path, body: str) -> Path: + p = tmp_path / "groups.yaml" + p.write_text(body) + return p + + +def test_cycle_detection(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + a: + tier: unit + paths: [x] + depends_on: [b] + optional: true + b: + tier: unit + paths: [y] + depends_on: [a] + optional: true + """, + ) + with pytest.raises(ValueError, match="cycle"): + load_groups(manifest) + + +def test_unknown_dep_target(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + a: + tier: unit + paths: [x] + depends_on: [does-not-exist] + optional: true + """, + ) + with pytest.raises(ValueError, match="unknown group"): + load_groups(manifest) + + +def test_expand_topological_order(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + leaf: + tier: unit + paths: [x] + optional: true + mid: + tier: unit + paths: [x] + depends_on: [leaf] + optional: true + root: + tier: e2e + paths: [x] + depends_on: [mid] + optional: true + """, + ) + expanded = load_groups(manifest).expand(["root"]) + assert expanded == ["leaf", "mid", "root"] + + +def test_invalid_tier_rejected(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + a: + tier: bogus + paths: [x] + optional: true + """, + ) + with pytest.raises(ValueError, match="tier"): + load_groups(manifest) + + +def test_invalid_runner_rejected(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + a: + tier: unit + runner: cargo-test + paths: [x] + optional: true + """, + ) + with pytest.raises(ValueError, match="runner"): + load_groups(manifest) + + +def test_platform_group_must_depend_on_smoke(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + e2e-smoke: + tier: e2e + paths: [x] + requires_platform: true + optional: true + e2e-rogue: + tier: e2e + paths: [x] + requires_platform: true + optional: true + """, + ) + with pytest.raises(ValueError, match="depend on 'e2e-smoke'"): + load_groups(manifest) + + +def test_platform_groups_without_gate_definition_fails(tmp_path: Path) -> None: + """If a manifest declares platform groups but never defines the gate + (``e2e-smoke`` by default), validation must fail — silently no-oping the + smoke-gate check would defeat the whole invariant. + """ + manifest = _write_manifest( + tmp_path, + """ + version: 1 + groups: + e2e-orphan: + tier: e2e + paths: [x] + requires_platform: true + optional: true + """, + ) + with pytest.raises(ValueError, match="platform gate 'e2e-smoke' is not defined"): + load_groups(manifest) + + +def test_custom_gate_name_via_defaults(tmp_path: Path) -> None: + """Forks can rename the gate via defaults.platform_gate_group.""" + manifest = _write_manifest( + tmp_path, + """ + version: 1 + defaults: + platform_gate_group: my-custom-smoke + groups: + my-custom-smoke: + tier: e2e + paths: [x] + requires_platform: true + optional: true + downstream: + tier: e2e + paths: [x] + requires_platform: true + depends_on: [my-custom-smoke] + optional: true + """, + ) + # Must not raise. + assert "downstream" in load_groups(manifest).names() + + +def test_real_manifest_is_valid() -> None: + """The committed groups.yaml + critical_paths.yaml must always pass loading.""" + manifest = load_groups() + assert "e2e-smoke" in manifest.names() + # Every platform group depends transitively on smoke. + for name in manifest.names(): + g = manifest.get(name) + if name != "e2e-smoke" and g.requires_platform: + assert "e2e-smoke" in manifest.expand([name]) + + +def test_group_env_is_frozen() -> None: + """A frozen GroupDefinition with a mutable env dict still lets callers + scribble onto the shared record. __post_init__ coerces to a read-only + proxy so that's no longer possible. + """ + group = GroupDefinition( + name="g", tier="unit", paths=("x",), env={"A": "1"} + ) + assert group.env["A"] == "1" + with pytest.raises(TypeError): + group.env["B"] = "2" # type: ignore[index] diff --git a/tests/rig/tests/test_reporting.py b/tests/rig/tests/test_reporting.py new file mode 100644 index 0000000000..dca890267b --- /dev/null +++ b/tests/rig/tests/test_reporting.py @@ -0,0 +1,106 @@ +"""Self-tests for junit parsing and group result classification.""" + +from __future__ import annotations + +from pathlib import Path + +from tests.rig.reporting import GroupResult, parse_junit + + +def _write_junit(group_dir: Path, content: str, exit_code: int) -> None: + group_dir.mkdir(parents=True, exist_ok=True) + (group_dir / "junit.xml").write_text(content) + (group_dir / "exit.txt").write_text(str(exit_code)) + + +def test_parse_testsuites_wrapper_sums_across_suites(tmp_path: Path) -> None: + """pytest can emit either or a bare ; both must + aggregate correctly. Otherwise multi-suite failures get undercounted and + a broken run reports green. + """ + _write_junit( + tmp_path / "g", + """ + + + + + """, + exit_code=1, + ) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert result.passed == 2 # (3-1) + (2-2) + assert result.failed == 3 + assert result.status == "fail" + + +def test_parse_single_testsuite_root(tmp_path: Path) -> None: + _write_junit( + tmp_path / "g", + """ + + """, + exit_code=0, + ) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert result.passed == 2 + assert result.status == "pass" + + +def test_exit_5_classified_as_empty_not_fail(tmp_path: Path) -> None: + """pytest exit 5 = no tests collected. Optional placeholders and empty + hurl groups both hit this; treating them as failures would falsely flag + the whole build red. + """ + _write_junit( + tmp_path / "g", + """ + + """, + exit_code=5, + ) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert result.status == "empty" + + +def test_missing_counters_flagged_as_error(tmp_path: Path) -> None: + """A junit.xml that parses but has no counter attributes (truncated write, + partial flush after kill) must NOT be treated as a green zero-test run. + """ + _write_junit( + tmp_path / "g", + '', + exit_code=139, # segfault + ) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert result.errors >= 1 + assert result.status == "fail" + + +def test_malformed_xml_returns_error_result(tmp_path: Path) -> None: + _write_junit(tmp_path / "g", "= 1 + + +def test_missing_junit_returns_none(tmp_path: Path) -> None: + """When a group never wrote junit.xml at all (e.g. segfault before write), + parse_junit returns None and the CLI is responsible for surfacing the + exit code separately. + """ + result = parse_junit("g", "unit", tmp_path) + assert result is None + + +def test_status_icon_round_trips() -> None: + pass_result = GroupResult("g", "unit", 0, 1, 0, 0, 0, 0.0) + fail_result = GroupResult("g", "unit", 1, 0, 1, 0, 0, 0.0) + empty_result = GroupResult("g", "unit", 5, 0, 0, 0, 0, 0.0) + assert pass_result.status_icon == "✅" + assert fail_result.status_icon == "❌" + assert empty_result.status_icon == "⚪" diff --git a/tests/rig/tests/test_runtime.py b/tests/rig/tests/test_runtime.py new file mode 100644 index 0000000000..5570e77d9c --- /dev/null +++ b/tests/rig/tests/test_runtime.py @@ -0,0 +1,64 @@ +"""Self-tests for runtime types. + +Covers the small but load-bearing invariants on ``InfraEndpoints`` / +``PlatformEndpoints`` — the runtime drivers themselves (compose, testcontainers) +need a real Docker daemon to exercise and live outside this unit-rig group. +""" + +from __future__ import annotations + +import pytest + +from tests.rig.runtime import InfraEndpoints, PlatformEndpoints + + +def test_infra_endpoints_rejects_partial_redis_pair() -> None: + """Host without port (or vice versa) silently lands in downstream + config if not rejected here. ``__post_init__`` is the only guard. + """ + with pytest.raises(ValueError, match="redis_host and redis_port"): + InfraEndpoints(redis_host="localhost") + with pytest.raises(ValueError, match="redis_host and redis_port"): + InfraEndpoints(redis_port=6379) + + +def test_infra_endpoints_rejects_partial_rabbitmq_pair() -> None: + with pytest.raises(ValueError, match="rabbitmq_host and rabbitmq_port"): + InfraEndpoints(rabbitmq_host="localhost") + with pytest.raises(ValueError, match="rabbitmq_host and rabbitmq_port"): + InfraEndpoints(rabbitmq_port=5672) + + +def test_infra_endpoints_allows_fully_specified_pairs() -> None: + """Both halves of each pair present is the canonical happy path.""" + endpoints = InfraEndpoints( + redis_host="localhost", + redis_port=6379, + rabbitmq_host="localhost", + rabbitmq_port=5672, + ) + assert endpoints.redis_host == "localhost" + assert endpoints.redis_port == 6379 + + +def test_infra_endpoints_allows_all_none() -> None: + """No infra specified is also valid — that's the LocalRuntime case.""" + InfraEndpoints() # must not raise + + +def test_platform_endpoints_from_env_uses_defaults(monkeypatch) -> None: + """``from_env`` is the canonical constructor; an empty env should land + on the dev-compose defaults rather than crash or produce empty URLs. + """ + for key in ( + "UNSTRACT_BACKEND_URL", + "UNSTRACT_PROMPT_SERVICE_URL", + "UNSTRACT_PLATFORM_SERVICE_URL", + "UNSTRACT_RUNNER_URL", + "UNSTRACT_X2TEXT_URL", + ): + monkeypatch.delenv(key, raising=False) + endpoints = PlatformEndpoints.from_env() + assert endpoints.backend_url == "http://localhost:8000" + assert endpoints.runner_url == "http://localhost:5002" + assert endpoints.admin_user == "unstract" diff --git a/tests/rig/tests/test_selection.py b/tests/rig/tests/test_selection.py new file mode 100644 index 0000000000..d0ee7ef410 --- /dev/null +++ b/tests/rig/tests/test_selection.py @@ -0,0 +1,78 @@ +"""Self-tests for selection resolution: union vs. intersect, dep expansion.""" + +from __future__ import annotations + +from pathlib import Path + +from tests.rig.groups import load_groups +from tests.rig.selection import resolve + + +def _manifest(tmp_path: Path) -> Path: + p = tmp_path / "groups.yaml" + p.write_text( + """ + version: 1 + groups: + unit-a: + tier: unit + paths: [x] + optional: true + unit-b: + tier: unit + paths: [x] + optional: true + e2e-smoke: + tier: e2e + paths: [x] + requires_platform: true + optional: true + e2e-other: + tier: e2e + paths: [x] + requires_platform: true + depends_on: [e2e-smoke] + optional: true + """ + ) + return p + + +def test_positional_group_expands_deps(tmp_path: Path) -> None: + manifest = load_groups(_manifest(tmp_path)) + ordered = resolve(manifest, positional=["e2e-other"]) + assert ordered == ["e2e-smoke", "e2e-other"] + + +def test_empty_selection_returns_empty_list(tmp_path: Path) -> None: + """No positional, no file, no tier → must NOT default to 'run everything'. + The CLI relies on this to fail loudly rather than surprise the user. + """ + manifest = load_groups(_manifest(tmp_path)) + assert resolve(manifest, positional=[]) == [] + + +def test_from_file_merges_with_positional(tmp_path: Path) -> None: + manifest = load_groups(_manifest(tmp_path)) + selection_file = tmp_path / "selection" + selection_file.write_text("unit-b\n# a comment line\n\nunit-a\n") + ordered = resolve( + manifest, positional=["e2e-smoke"], from_file=selection_file + ) + assert set(ordered) == {"unit-a", "unit-b", "e2e-smoke"} + + +def test_all_plus_tier_intersects(tmp_path: Path) -> None: + """`tox -e e2e -- all` historically expanded `all` to every group and let + the tier env name lie about the scope. The rig now intersects when both + are supplied so the env name matches what actually runs. + """ + manifest = load_groups(_manifest(tmp_path)) + ordered = resolve(manifest, positional=["all"], tier="e2e") + assert set(ordered) == {"e2e-smoke", "e2e-other"} + + +def test_tier_only_selects_that_tier_and_deps(tmp_path: Path) -> None: + manifest = load_groups(_manifest(tmp_path)) + ordered = resolve(manifest, positional=[], tier="unit") + assert set(ordered) == {"unit-a", "unit-b"} diff --git a/tox.ini b/tox.ini index c684321ed7..318539029e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,62 +1,79 @@ [tox] -env_list = py{312}, runner, sdk1, prompt-service -requires = - tox-uv>=0.2.0 - +# Tier-shaped envs. Each env delegates to the rig at tests/rig/cli.py, which +# reads tests/groups.yaml and dispatches per-group pytest invocations. +# +# tox -e unit # all unit groups +# tox -e integration # cross-service groups (needs infra) +# tox -e e2e # full platform e2e +# tox -e groups -- unit-backend e2e-smoke +# tox -e groups -- --from-file .test-selection --no-coverage +# tox -e groups -- all # everything (equivalent to unit,integration,e2e) +# +# Pre-existing service envs (runner, sdk1, prompt-service) remain as thin +# aliases so existing CI commands continue to work during the migration. +env_list = unit, integration, e2e +requires = tox-uv>=0.2.0 isolated_build = True [testenv] +# Shared base: install the rig itself + its python deps. Each tier env reuses +# this base so the rig CLI is importable everywhere. install_command = uv pip install {opts} {packages} -deps = uv skip_install = true - -[testenv:runner] -changedir = runner -setenv = - PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python -deps = uv -allowlist_externals= - sh +deps = + uv + pyyaml>=6.0 + requests>=2.31.0 + testcontainers[postgres,redis,minio,rabbitmq]>=4.7.0 + pytest>=8.0.1 + pytest-xdist>=3.5.0 + pytest-cov>=4.1.0 + pytest-md-report>=0.6.2 + pytest-timeout>=2.3.1 + coverage[toml]>=7.4.0 +allowlist_externals = uv + sh pytest -commands_pre = - # Try to install dependencies from various requirements files - sh -c '[ -f cloud_requirements.txt ] && uv pip install -r cloud_requirements.txt || echo "cloud_requirements.txt not found"' - # Install dependencies from pyproject.toml - uv pip install -e . - # Install the Flask dependency explicitly (in case the above doesn't work) - uv pip install flask~=3.1.0 docker==6.1.3 redis~=5.2.1 python-dotenv>=1.0.0 kubernetes - # Install test dependencies - uv pip install pytest pytest-cov pytest-md-report pytest-mock -commands = - pytest -v --md-report-verbose=1 --md-report --md-report-flavor gfm --md-report-output ../runner-report.md + docker + hurl +passenv = + CI + GITHUB_* + DOCKER_HOST + UNSTRACT_* +changedir = {toxinidir} + +[testenv:unit] +commands = python -m tests.rig run --tier unit {posargs} + +[testenv:integration] +commands = python -m tests.rig run --tier integration {posargs} + +[testenv:e2e] +commands = python -m tests.rig run --tier e2e {posargs} + +[testenv:groups] +# Catch-all: pass whatever you want to the rig. +# tox -e groups -- unit-backend e2e-smoke +# tox -e groups -- --from-file .test-selection --no-coverage +commands = python -m tests.rig run {posargs} + +[testenv:rig] +# Utility env: list-groups, expand, validate, etc. +# tox -e rig -- list-groups +# tox -e rig -- expand e2e-workflow +commands = python -m tests.rig {posargs:list-groups} + +# ── Legacy aliases ───────────────────────────────────────────────────────────── +# These mirror the pre-rig envs so existing scripts / CI snippets keep working +# during the migration. They delegate to the corresponding rig group. + +[testenv:runner] +commands = python -m tests.rig run unit-runner {posargs} [testenv:sdk1] -changedir = unstract/sdk1 -deps = uv -allowlist_externals= - sh - uv - pytest -commands_pre = - # System dependency required: libmagic1 - # Install with: sudo apt-get install -y libmagic1 (Ubuntu/Debian) - # Install dependencies with test group - uv sync --group test -commands = - uv run pytest -v -m "not slow" --cov=src/unstract/sdk1 --cov-report=term --cov-report=html --md-report-verbose=1 --md-report --md-report-flavor gfm --md-report-output ../../sdk1-report.md +commands = python -m tests.rig run unit-sdk1 {posargs} [testenv:prompt-service] -changedir = prompt-service -deps = uv -allowlist_externals= - sh - uv - pytest -commands_pre = - uv sync --group test -commands = - # --noconftest is required because the parent tests/conftest.py imports - # Flask blueprints which trigger the full adapter chain (pinecone etc.). - # Unit tests must not depend on integration fixtures. - uv run pytest src/unstract/prompt_service/tests/unit/ -v -m "not slow" --noconftest --md-report-verbose=1 --md-report --md-report-flavor gfm --md-report-output ../prompt-service-report.md +commands = python -m tests.rig run unit-prompt-service {posargs} diff --git a/uv.lock b/uv.lock index 1d7419fb3d..919436eaba 100644 --- a/uv.lock +++ b/uv.lock @@ -165,6 +165,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -210,28 +243,28 @@ wheels = [ [[package]] name = "authlib" -version = "1.7.1" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/f2/e05664d5275ce811fd4e9df0a2b3f0086ee19a8a80358d95499fa82fd50c/authlib-1.7.1.tar.gz", hash = "sha256:8c09b0f9d080c823e594b52316af70f79a1fa4eed64d0363a076233c04ef063a", size = 175884, upload-time = "2026-05-04T08:11:25.033Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] name = "azure-core" -version = "1.40.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/d9/6f5972b44761277394527a3a76af5ae2ef82fc5f20ce351abf0c826eca67/azure_core-1.40.0.tar.gz", hash = "sha256:ecf5b6ddf2564471fae9d576147b7e77a4da285958b2d9f4fd6c3af104f3e9d7", size = 380057, upload-time = "2026-05-01T00:59:45.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/c9/25edc67692fb17523c7d29c73898be649b4d3c7ae13cc0f74f5c91938022/azure_core-1.40.0-py3-none-any.whl", hash = "sha256:7f3ea02579b1bb1d34e45043423b650621d11d7c2ea3b05e5554010080b78dfd", size = 220450, upload-time = "2026-05-01T00:59:47.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, ] [[package]] @@ -266,7 +299,7 @@ wheels = [ [[package]] name = "azure-storage-blob" -version = "12.28.0" +version = "12.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -274,9 +307,9 @@ dependencies = [ { name = "isodate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823, upload-time = "2026-05-15T03:35:01.837Z" }, ] [[package]] @@ -483,6 +516,15 @@ 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 = "chardet" +version = "6.0.0.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798, upload-time = "2026-02-22T15:09:17.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245, upload-time = "2026-02-22T15:09:15.876Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -510,14 +552,14 @@ wheels = [ [[package]] name = "click" -version = "8.1.8" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -566,6 +608,30 @@ 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.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + [[package]] name = "cron-descriptor" version = "1.4.0" @@ -627,13 +693,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] +[[package]] +name = "dataproperty" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mbstrdecoder" }, + { name = "typepy", extra = ["datetime"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/6f/a801320bb388d965be9c370ec753cc33120e6cbe0069fa05644f05821975/dataproperty-1.1.1.tar.gz", hash = "sha256:a83af82a234edda5378a36fb092bc90dd554646c5e58202a310acf468ae81bc8", size = 42954, upload-time = "2026-05-09T10:33:42.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/41/eab7fe313820578b341a2a1d6aeeedd2c38ec1e3f3d51e57e2735b5beac0/dataproperty-1.1.1-py3-none-any.whl", hash = "sha256:cf026aa002dbd6c57c619ec6741ffd61ae7bf2f20481951d8af2dff44480340e", size = 27691, upload-time = "2026-05-09T10:33:40.468Z" }, +] + [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -843,6 +922,32 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/97/15/6d8f4c3033ad2bc364b8bb613c52c96653f2268f32ecff4f3ab5f1d7c19b/dropboxdrivefs-1.4.1.tar.gz", hash = "sha256:6f3c6061d045813553ce91ed0e2b682f1d70bec74011943c92b3181faacefd34", size = 7413, upload-time = "2024-05-27T14:04:37.648Z" } +[[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 = "faiss-cpu" +version = "1.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -970,7 +1075,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.195.0" +version = "2.196.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -979,9 +1084,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, ] [[package]] @@ -1002,28 +1107,28 @@ wheels = [ [[package]] name = "google-auth-httplib2" -version = "0.3.1" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "httplib2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, ] [[package]] name = "google-auth-oauthlib" -version = "1.3.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "requests-oauthlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, + { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, ] [[package]] @@ -1048,15 +1153,15 @@ wheels = [ [[package]] name = "google-cloud-core" -version = "2.5.1" +version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, ] [[package]] @@ -1105,26 +1210,26 @@ wheels = [ [[package]] name = "google-resumable-media" -version = "2.8.2" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534, upload-time = "2026-05-07T08:04:44.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507, upload-time = "2026-05-07T08:03:23.809Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.74.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [package.optional-dependencies] @@ -1349,7 +1454,7 @@ http2 = [ [[package]] name = "huggingface-hub" -version = "1.13.0" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -1362,9 +1467,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/ff/ec7ed2eb43bd7ce8bb2233d109cc235c3e807ffe5e469dc09db261fac05e/huggingface_hub-1.13.0.tar.gz", hash = "sha256:f6df2dac5abe82ce2fe05873d10d5ff47bc677d616a2f521f4ee26db9415d9d0", size = 781788, upload-time = "2026-04-30T11:57:33.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/db/4b1cdae9460ae1f3ca020cd767f013430ce23eb1d9c890ae3a0609b38d26/huggingface_hub-1.13.0-py3-none-any.whl", hash = "sha256:e942cb50d6a08dd5306688b1ac05bda157fd2fcc88b63dae405f7bd0d3234005", size = 660643, upload-time = "2026-04-30T11:57:31.802Z" }, + { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602, upload-time = "2026-05-15T11:42:50.487Z" }, ] [[package]] @@ -1387,11 +1492,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1629,7 +1734,7 @@ wheels = [ [[package]] name = "llama-index" -version = "0.14.21" +version = "0.14.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-index-core" }, @@ -1637,14 +1742,14 @@ dependencies = [ { name = "llama-index-llms-openai" }, { name = "nltk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/8a/472a6ae2c8b4cea977913509e034837ae9f030dcab7027c33ddd4418d746/llama_index-0.14.21.tar.gz", hash = "sha256:99244cbdc7f486aa329c7007faa168085b19eff786ee0c4d246db1cba0f4922b", size = 8565, upload-time = "2026-04-21T00:18:46.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/89/3b6f3318ea2249158daab3ff22777ef5ffa87a63c011659a6cfc55e54c35/llama_index-0.14.22.tar.gz", hash = "sha256:c2c9b31f50d2815abdc191085db4acaf96b7c01851ac66b2e4cc82be8cde589e", size = 8565, upload-time = "2026-05-14T20:22:21.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/e4/caa044a84834198363c7342df1480bade423d862509c620846ef3ed2116e/llama_index-0.14.21-py3-none-any.whl", hash = "sha256:d3a13b7d4dde35688d2295a0381d65d1c8f3b69b51bed07c7158e027c00e9480", size = 7114, upload-time = "2026-04-21T00:18:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/59/fd/f0837c4ce049d8ece7525bbf64564e93e3f16333856c2a0b47fecb58f317/llama_index-0.14.22-py3-none-any.whl", hash = "sha256:14b4bdd799112062e38288eab6aa16643f29d7532505ab174b0b6d5b0817fe94", size = 7115, upload-time = "2026-05-14T20:22:19.611Z" }, ] [[package]] name = "llama-index-core" -version = "0.14.21" +version = "0.14.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1676,9 +1781,9 @@ dependencies = [ { name = "typing-inspect" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/43/d6d2a368865e68c25d3400c017fb772daab71427f08c4e36c591f729dbc3/llama_index_core-0.14.21.tar.gz", hash = "sha256:29706defbe2f429d28330a4eea010f9d92d42db92539382f8c800e19590cae45", size = 11581087, upload-time = "2026-04-21T00:18:10.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/7f/94a4b940ef0d069840df0fd6d361a2aa832a2dd73b4cecdf86e8f8c353c8/llama_index_core-0.14.22.tar.gz", hash = "sha256:1384410f89bdbd32349aab444ef4f5c828c338787bc65bd1ffd8e86dfb44ac41", size = 11584786, upload-time = "2026-05-14T20:21:37.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/23/55ec5f35a5c7f35b60d3928bcd2e867076440036a280cf4d07481719c249/llama_index_core-0.14.21-py3-none-any.whl", hash = "sha256:4a807d31e54d066068e076eb4d066efbf95e2d2a00dcbe0eba3d9340a04cad42", size = 11916624, upload-time = "2026-04-21T00:18:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/e1a26d8d56aa55fa07587a3e9c7e85294d2df5af6c2229193019bc549ef6/llama_index_core-0.14.22-py3-none-any.whl", hash = "sha256:9cfffde46fd5b7937101e1c0c9bb5c21bd7ff8c8a56937810b87ba3542f31225", size = 11920774, upload-time = "2026-05-14T20:21:40.409Z" }, ] [[package]] @@ -1709,15 +1814,15 @@ wheels = [ [[package]] name = "llama-index-llms-openai" -version = "0.7.7" +version = "0.7.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llama-index-core" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/38/3d55e9f9af237df6c92e75b824ad85039d6dac386237b519b0dab619288c/llama_index_llms_openai-0.7.7.tar.gz", hash = "sha256:ae9d6fa5ff1982e218d404c328b883a276c12374e1b12d81101ac254cbe569d1", size = 27474, upload-time = "2026-04-28T19:09:34.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/d5/2de9c05f1f1d21eb678a6044c59e943063e70099ac39b8b6f835e6e39511/llama_index_llms_openai-0.7.8.tar.gz", hash = "sha256:3352aed617ee5b7aefeb12719609ff84b4b590a1f49aa1e2e9c383d67ea88b0e", size = 27539, upload-time = "2026-05-08T20:02:09.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/66/fc6818ea699c8cfd0ae780a1c0f04389458bab8a895ac5ff17bfc47e7559/llama_index_llms_openai-0.7.7-py3-none-any.whl", hash = "sha256:d7ee68235d2a86c249d7f22e8608aac9425c14ae894140e70498db9aa3b376b5", size = 28544, upload-time = "2026-04-28T19:09:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/32/49/4250108a76f4f7622109ecb9c57861829f508aba0ffdc502b27134378505/llama_index_llms_openai-0.7.8-py3-none-any.whl", hash = "sha256:967aac1f4ceff99185b2cc425c2757d4fefaf3fac0a35ace247f87a212a29359", size = 28617, upload-time = "2026-05-08T20:02:10.583Z" }, ] [[package]] @@ -1830,14 +1935,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.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" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -1871,6 +1976,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "mbstrdecoder" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/9c/dd6e38d747a62ead27f9abef32f4ca4311d4e40ac28e76bcc9ffb5dd0329/mbstrdecoder-1.1.5.tar.gz", hash = "sha256:8cbfba26938befd8a35e3cc06ca0632f61320b7b2be7df32550b895e1725b1ce", size = 14529, upload-time = "2026-05-05T04:17:58.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/eb/711270faab7b7df702339a2c68b31fd3ed4fffc68b0e99e5bdf49b1e87e4/mbstrdecoder-1.1.5-py3-none-any.whl", hash = "sha256:4a50fe113d4abecfd86e8f716b2e413cce03d63af83ec3c7cdbe81dec0e519ed", size = 7966, upload-time = "2026-05-05T04:17:56.78Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1882,16 +1999,33 @@ wheels = [ [[package]] name = "milvus-lite" -version = "2.5.1" +version = "3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tqdm" }, + { name = "faiss-cpu" }, + { name = "grpcio" }, + { name = "numpy" }, + { name = "pyarrow" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a3/9a/d80d260e6fe1246818a8ef782c374ba9c6ca46ca3b987c14eabe914ef805/milvus_lite-3.0.tar.gz", hash = "sha256:2c35d0d046b1faae3402cde1fb73d65f51ee8c6aba65f54de1dda46f7bb18b9b", size = 589749, upload-time = "2026-05-13T07:14:05.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/b2/acc5024c8e8b6a0b034670b8e8af306ebd633ede777dcbf557eac4785937/milvus_lite-2.5.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6b014453200ba977be37ba660cb2d021030375fa6a35bc53c2e1d92980a0c512", size = 27934713, upload-time = "2025-06-30T04:23:37.028Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451, upload-time = "2025-06-30T04:23:51.747Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cf/3d1fee5c16c7661cf53977067a34820f7269ed8ba99fe9cf35efc1700866/milvus_lite-2.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a13277e9bacc6933dea172e42231f7e6135bd3bdb073dd2688ee180418abd8d9", size = 45337093, upload-time = "2025-06-30T04:24:06.706Z" }, - { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911, upload-time = "2025-06-30T04:24:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/59/42/dee08ae3bfc1731572f193d00b248c9370b0b9dff12becb0ffd8b2ee8d56/milvus_lite-3.0-py3-none-any.whl", hash = "sha256:d9a094eab84bdaa4253da3721482282c939da1cce6f4e1759f947e8d3e53406e", size = 230490, upload-time = "2026-05-13T07:14:00.816Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, ] [[package]] @@ -2018,21 +2152,21 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, ] [[package]] @@ -2077,7 +2211,7 @@ wheels = [ [[package]] name = "openai" -version = "2.24.0" +version = "2.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2089,9 +2223,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" }, ] [[package]] @@ -2142,7 +2276,7 @@ wheels = [ [[package]] name = "paramiko" -version = "4.0.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -2150,9 +2284,9 @@ dependencies = [ { name = "invoke" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, + { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" }, ] [[package]] @@ -2164,6 +2298,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + [[package]] name = "pdfminer-six" version = "20251230" @@ -2203,6 +2346,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, ] +[[package]] +name = "pika" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/15/b6a706f1e886335aedc1e21d23913fe1a0fdaadd597d7721b26f11fe306a/pika-1.4.0.tar.gz", hash = "sha256:84aa6d0cf60bbdb79d5780544a4a4e1799392760127bf9de2a03d3c3b92f5f1a", size = 154264, upload-time = "2026-05-06T18:04:32.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/07/b7269c5367995648897a40ae37054780fc68b71afcc51c169a739c653d4b/pika-1.4.0-py3-none-any.whl", hash = "sha256:937d8576f92a1ce3673d442161fefef614ac557583e10b9d84c14a6e228ed6a7", size = 164964, upload-time = "2026-05-06T18:04:31.343Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -2325,38 +2477,40 @@ wheels = [ [[package]] name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "proto-plus" -version = "1.27.2" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, ] [[package]] @@ -2468,9 +2622,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2478,38 +2651,39 @@ dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" 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/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/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" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -2680,6 +2854,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" }, ] +[[package]] +name = "pytablewriter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dataproperty" }, + { name = "mbstrdecoder" }, + { name = "pathvalidate" }, + { name = "setuptools" }, + { name = "tabledata" }, + { name = "tcolorpy" }, + { name = "typepy", extra = ["datetime"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/a1/617730f290f04d347103ab40bf67d317df6691b14746f6e1ea039fb57062/pytablewriter-1.2.1.tar.gz", hash = "sha256:7bd0f4f397e070e3b8a34edcf1b9257ccbb18305493d8350a5dbc9957fced959", size = 619241, upload-time = "2025-01-01T15:37:00.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/4c/c199512f01c845dfe5a7840ab3aae6c60463b5dc2a775be72502dfd9170a/pytablewriter-1.2.1-py3-none-any.whl", hash = "sha256:e906ff7ff5151d70a5f66e0f7b75642a7f2dce8d893c265b79cc9cf6bc04ddb4", size = 91083, upload-time = "2025-01-01T15:36:55.63Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -2696,6 +2888,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, +] + +[[package]] +name = "pytest-md-report" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytablewriter" }, + { name = "pytest" }, + { name = "tcolorpy" }, + { name = "typepy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/2e/14711f6af84b1f637167b52aef179e307a580dfb54f7da8b0c06c3125453/pytest_md_report-0.8.0.tar.gz", hash = "sha256:c8e3b7f1f91a0e8e7d1b946e1b224f4f39187da0df2f812731361a436a17f472", size = 289649, upload-time = "2026-05-04T04:30:34.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/47/c847f9095e466e5933570d3b603eee78389b3bfc534d017b43f6cc62fa1a/pytest_md_report-0.8.0-py3-none-any.whl", hash = "sha256:d2ba54b4be2071ea91bf3a17b215f70093fd5b8148356cb33f9a7a9ac53f177a", size = 17185, upload-time = "2026-05-04T04:30:32.556Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[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-crontab" version = "3.3.0" @@ -2719,15 +2989,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] [[package]] @@ -2865,26 +3135,26 @@ wheels = [ [[package]] name = "regex" -version = "2026.4.4" +version = "2026.5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, - { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, - { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, - { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, ] [[package]] @@ -2977,27 +3247,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -3211,6 +3481,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/92/d0c83f63d3518e5f0b8a311937c31347349ec9a47b209ddc17f7566f58fc/stone-3.3.1-py3-none-any.whl", hash = "sha256:e15866fad249c11a963cce3bdbed37758f2e88c8ff4898616bc0caeb1e216047", size = 162257, upload-time = "2022-01-25T21:32:15.155Z" }, ] +[[package]] +name = "tabledata" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dataproperty" }, + { name = "typepy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/65/2f54f0dedd775dde48e300023d20e13ad329a51e33dcadb6d47b4dc95768/tabledata-1.3.5.tar.gz", hash = "sha256:98c64d0ad6b520846b41000fb3f5b2f42fa7ca2675c2c669e5ccab6b93082a36", size = 25396, upload-time = "2026-05-11T12:03:26.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/86/37fa0e1437089f08b8b1b8c8ad93f6b57e9427753f002914299323300a9e/tabledata-1.3.5-py3-none-any.whl", hash = "sha256:a1e57afc4767b51bef551114c0df31f205d712dbb75e3caf9be7834a79f23136", size = 11919, upload-time = "2026-05-11T12:03:24.907Z" }, +] + +[[package]] +name = "tcolorpy" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/cc/44f2d81d8f9093aad81c3467a5bf5718d2b5f786e887b6e4adcfc17ec6b9/tcolorpy-0.1.7.tar.gz", hash = "sha256:0fbf6bf238890bbc2e32662aa25736769a29bf6d880328f310c910a327632614", size = 299437, upload-time = "2024-12-29T15:24:23.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a2/ed023f2edd1e011b4d99b6727bce8253842d66c3fbf9ed0a26fc09a92571/tcolorpy-0.1.7-py3-none-any.whl", hash = "sha256:26a59d52027e175a37e0aba72efc99dda43f074db71f55b316d3de37d3251378", size = 8096, upload-time = "2024-12-29T15:24:21.33Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -3220,6 +3512,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "testcontainers" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, +] + +[package.optional-dependencies] +minio = [ + { name = "minio" }, +] +rabbitmq = [ + { name = "pika" }, +] +redis = [ + { name = "redis" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -3276,11 +3595,11 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] [[package]] @@ -3295,6 +3614,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "typepy" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mbstrdecoder" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/9f/ae119b0e0fd0fe8dcb0e1eeebfeb62f37fdc0b467267cff15cdb746ba38b/typepy-1.3.5.tar.gz", hash = "sha256:a1c5f54c41860f89bab175f512b11e8c9a57cfe7b8b3d5ae5d52d828b756b6dd", size = 39883, upload-time = "2026-05-04T14:04:32.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/71/75cf08c49b64a9419f1f2cef9be072ac34f6b784da2851489470b7c7ba15/typepy-1.3.5-py3-none-any.whl", hash = "sha256:de361b59609c7503efc2edbe9d7a4e053ae71307bf90ae1678ec4d6bcd807922", size = 31530, upload-time = "2026-05-04T14:04:31.46Z" }, +] + +[package.optional-dependencies] +datetime = [ + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "pytz" }, +] + [[package]] name = "typer" version = "0.23.1" @@ -3312,23 +3650,23 @@ wheels = [ [[package]] name = "types-cffi" -version = "2.0.0.20260506" +version = "2.0.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d0/b54c7338ae45580c56daed5f7d468e359a415b73637affed164f33d83a76/types_cffi-2.0.0.20260506.tar.gz", hash = "sha256:8cf63d7006bf0fec825cc5a70fa637ed783b25ef0d0980d09f27606600123f75", size = 17718, upload-time = "2026-05-06T05:17:56.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/0b/b352742758a6054d1053783887bf8cfb739deda1102fda8722294bdc01f7/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472", size = 17790, upload-time = "2026-05-18T06:06:50.672Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/cf/b562968181a5374c9bddaf5f543ddd857d1072c578e77f63744d8a8d3a17/types_cffi-2.0.0.20260506-py3-none-any.whl", hash = "sha256:43472f8e31f8dc7abbf0c4119828f79c3f4a048b5edcebd19538ee6cf3ca69ed", size = 20195, upload-time = "2026-05-06T05:17:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/d3b4aafa20a3f76384ba19a513d39272add13746dcfe0409d8d4974fd464/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084", size = 20198, upload-time = "2026-05-18T06:06:49.83Z" }, ] [[package]] name = "types-pymysql" -version = "1.1.0.20260408" +version = "1.1.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/04/c3570f05ebab083f28698c829dddf754ffefc30aae4e29915610848e44db/types_pymysql-1.1.0.20260408.tar.gz", hash = "sha256:b784dc37908479e3767e2d794ab507b3674adb1c686ca3d13fc9e2960dbcb9ec", size = 22344, upload-time = "2026-04-08T04:27:47.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e0/43201060de33285af04263d9bd8e8c6b007bd8e0180bd46df8fe6576842e/types_pymysql-1.1.0.20260518.tar.gz", hash = "sha256:39a2448c4267dc4551e0824d2bfaecf7dfd171e89e6dbba90f4d4d45d55e4342", size = 22427, upload-time = "2026-05-18T06:02:31.239Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/b3/15dee33878709705a4cc83bcc1bb30e00e95bbe038b472cb1207a15b50a1/types_pymysql-1.1.0.20260408-py3-none-any.whl", hash = "sha256:da630647eaaa7a926a3907794f4067f269cd245b2c202c74aa3c6a3bd660a9db", size = 23071, upload-time = "2026-04-08T04:27:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/db02b5e6633fbe49eaf4e3194bc64ec031e6436a0cfcc610cbda4f1b6a24/types_pymysql-1.1.0.20260518-py3-none-any.whl", hash = "sha256:cf697ce4e44124fc859e8e8a7f047c1dc864745c3c628b85a51b3ee01502ef98", size = 23071, upload-time = "2026-05-18T06:02:30.36Z" }, ] [[package]] @@ -3346,20 +3684,20 @@ wheels = [ [[package]] name = "types-pytz" -version = "2026.2.0.20260506" +version = "2026.2.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/2e/a545aae2c4d2af7e3b7b967049c2f271b2f5338d96a58224fe1ee53a54f3/types_pytz-2026.2.0.20260506.tar.gz", hash = "sha256:fc6a0de6a1b7da82a748fb4065e152372dac3016559cb1eef5e8af1e338eb627", size = 10844, upload-time = "2026-05-06T05:17:51.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/d9/9fa4019d2235bd374293e1fd4153879b28b6ae1d2bae98addd352c9713f2/types_pytz-2026.2.0.20260518.tar.gz", hash = "sha256:e5d254329e9c4e91f0781b22c43a4bb2d10bb044d97b24c4b05d45567b0eae16", size = 10871, upload-time = "2026-05-18T06:02:45.789Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/cd/df3e4ccccb2a5a0b7e59c9fb2baafb6dac0817e80799e4c9854fe4d2eba3/types_pytz-2026.2.0.20260506-py3-none-any.whl", hash = "sha256:58ab5307c20885f9bcd42ff106616eb0e32710791f8cbdc770aee2ea0c4f01fb", size = 10120, upload-time = "2026-05-06T05:17:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125, upload-time = "2026-05-18T06:02:44.968Z" }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20260408" +version = "6.0.12.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] @@ -3389,11 +3727,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "82.0.0.20260408" +version = "82.0.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/bc/73c2c27e047e42f114ac50fb3bdef986c56cbdb68096f8690eeafb839a93/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07", size = 44999, upload-time = "2026-05-18T06:02:55.642Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/d5e2d493f09a7a98c95619edda1cb37cee377626c0a869d53274c26f2858/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20", size = 68444, upload-time = "2026-05-18T06:02:54.582Z" }, ] [[package]] @@ -3525,6 +3863,19 @@ hook-check-django-migrations = [ { name = "unstract-tool-sandbox" }, { name = "unstract-workflow-execution" }, ] +test-rig = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "pytest-md-report" }, + { name = "pytest-mock" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "testcontainers", extra = ["minio", "rabbitmq", "redis"] }, +] workers = [ { name = "unstract-workers" }, ] @@ -3569,6 +3920,19 @@ hook-check-django-migrations = [ { name = "unstract-tool-sandbox", editable = "unstract/tool-sandbox" }, { name = "unstract-workflow-execution", editable = "unstract/workflow-execution" }, ] +test-rig = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.4.0" }, + { name = "pytest", specifier = ">=8.0.1" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-django", specifier = ">=4.8.0" }, + { name = "pytest-md-report", specifier = ">=0.6.2" }, + { name = "pytest-mock", specifier = ">=3.12.0" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, + { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "testcontainers", extras = ["postgres", "redis", "minio", "rabbitmq"], specifier = ">=4.7.0" }, +] workers = [{ name = "unstract-workers", editable = "workers" }] [[package]] @@ -3934,7 +4298,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.1" +version = "21.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3942,9 +4306,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] @@ -4060,18 +4424,18 @@ wheels = [ [[package]] name = "zipp" -version = "3.23.1" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] [[package]] name = "zipstream-ng" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963, upload-time = "2025-08-29T01:03:36.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/33/fce793430e56888cfe3d61199b0116fa42b95d54c2e0fe87b85829507d10/zipstream_ng-1.9.2.tar.gz", hash = "sha256:116b7304b00f3251328cb300fa90f0f09d523b7faf2a06b3eaf7277dcb82cc3e", size = 32446, upload-time = "2026-05-17T16:09:26.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852, upload-time = "2025-08-29T01:03:35.046Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/103ba2f47ae052a9d91e4c3d4d7b1d45128045675325d99a476e9171fa2e/zipstream_ng-1.9.2-py3-none-any.whl", hash = "sha256:7292efc812a437ec688cef2c9523a4e710cd669e4b5abc7d5a15eb4d5e68e4ea", size = 23407, upload-time = "2026-05-17T16:09:25.874Z" }, ]