diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..757918a --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,60 @@ +name: Dependabot auto-merge + +# Daily autonomous Dependabot auto-merger. Runs DRY-RUN by default; merging +# requires DBR_DRY_RUN=false AND a merge-capable token (see the token note below). +on: + # Daily schedule DISABLED for now — running manual TUI sweeps first + # (`python -m dependabot_batch_review.monitor hypothesis --execute`). + # Re-enable by uncommenting the two lines below. + # schedule: + # - cron: "0 7 * * 1-5" # 07:00 UTC, weekdays (avoid weekend deploys / no on-call) + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (no merges)" + type: boolean + default: true + +# The *workflow's* GITHUB_TOKEN stays read-only; merges use the dedicated token +# below so the merge push re-triggers each repo's CI + deploy workflows. +permissions: + contents: read + +concurrency: + group: dependabot-automerge + cancel-in-progress: false + +jobs: + automerge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install --only main + + - name: Run auto-merger + env: + # IMPORTANT: do NOT use secrets.GITHUB_TOKEN here — a push by that token + # does not trigger downstream workflows, so a Tier-1 merge would land + # without deploying. Provision a GitHub App / fine-grained PAT with + # contents:write + pull_requests:write as DEPENDABOT_AUTOMERGE_TOKEN. + GITHUB_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} + SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} + SLACK_CHANNEL: ${{ vars.DEPSBOT_SLACK_CHANNEL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + NEW_RELIC_API_KEY: ${{ secrets.NEW_RELIC_API_KEY }} + NEW_RELIC_ACCOUNT_ID: ${{ vars.NEW_RELIC_ACCOUNT_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # Manual dispatch overrides dry-run explicitly; scheduled runs leave the + # variable empty so automation.yml decides (env would otherwise always win). + DBR_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run && 'true' || 'false') || '' }} + run: poetry run python -m dependabot_batch_review.automerge hypothesis diff --git a/.gitignore b/.gitignore index cb6a2b1..dc4b37a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__/ *.xlsx *.csv -.DS_Store \ No newline at end of file +.DS_Store +scratch/ +sweep-audit-*.jsonl diff --git a/Makefile b/Makefile index 745bee2..a15fd54 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ .PHONY: qa -qa: checkformat typecheck lint +qa: checkformat typecheck lint test PYTHON_SRCS=dependabot_batch_review +.PHONY: test +test: + poetry run pytest -q + .PHONY: checkformat checkformat: poetry run ruff format --check $(PYTHON_SRCS) diff --git a/README.md b/README.md index 8f783f2..f13ece0 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Generate a spreadsheet report of Dependabot PRs: Generate a Markdown report: ```sh -./review.sh hypothesis --output-md +./review.sh hypothesis --output-md report.md ``` ### Launch the Dashboard @@ -180,3 +180,55 @@ Start the local web dashboard to browse, group, and manage alerts interactively: ``` Then open in your browser. + +## Autonomous auto-merge + +On top of the human-in-the-loop review tools above, the package includes an +**autonomous layer** that merges safe Dependabot PRs on a schedule, verifies +production health after deploying, and rolls back automatically when a deploy +degrades. See [`dependabot-automation-plan.md`](./dependabot-automation-plan.md) +for the design and findings. + +Everything defaults to **dry-run** — nothing merges until you set `dry_run: false` +(or pass `--no-dry-run` / `DBR_DRY_RUN=false`) **and** provision a merge-capable +token. Configure it in [`automation.yml`](./automation.yml). + +### Risk tiers + +| Tier | What | Action | +|---|---|---| +| 0 | Bumps that never deploy to prod (dev/tooling, lockfiles, non-prod patches) | auto-merge on CI pass | +| 1 | Patch/minor **production** deps that deploy | auto-merge, then Sentry + New Relic health gate; auto-rollback on failure | +| 2 | Major bumps, security-sensitive runtime libs, CI-failing, conflicted | escalate to humans (Slack digest + Claude triage) | + +### Commands + +```sh +# Daily auto-merger (dry-run by default — prints would-merge / escalate / skip) +poetry run python -m dependabot_batch_review.automerge hypothesis + +# Burn down the whole backlog in waves (Tier 0 first) +poetry run python -m dependabot_batch_review.bulk hypothesis --dry-run --tier 0 + +# Local curses monitor of the sweep (dry-run plan by default) +poetry run python -m dependabot_batch_review.monitor hypothesis + +# Live TUI sweep: merge Tier 0/1, health-gate, auto-rollback. Writes a JSONL +# audit trail (sweep-audit-.jsonl; override with --audit-log). +poetry run python -m dependabot_batch_review.monitor hypothesis --execute +``` + +The daily run ships as a GitHub Action in +[`.github/workflows/automerge.yml`](./.github/workflows/automerge.yml) +(`schedule` + manual `workflow_dispatch`). **Note:** merges must use a GitHub App / +fine-grained PAT (`DEPENDABOT_AUTOMERGE_TOKEN`), not the default `GITHUB_TOKEN` — +a push by the default token does not re-trigger the deploy workflows. + +### Configuration & secrets + +`min_age_days` (default 3), `tiers_enabled`, repo allow/deny, and health +thresholds live in `automation.yml`; env vars override them (`DBR_DRY_RUN`, +`DBR_TIERS_ENABLED`, `DBR_MIN_AGE_DAYS`, …). Live Tier-1 health-gating needs +`SENTRY_AUTH_TOKEN`/`SENTRY_ORG`, `NEW_RELIC_API_KEY`/`NEW_RELIC_ACCOUNT_ID`, +`ANTHROPIC_API_KEY` (Claude triage — optional, degrades gracefully), and +`SLACK_TOKEN`/`SLACK_CHANNEL`. diff --git a/automation.yml b/automation.yml new file mode 100644 index 0000000..f4e257d --- /dev/null +++ b/automation.yml @@ -0,0 +1,58 @@ +# Configuration for the autonomous Dependabot auto-merger. +# Loaded by dependabot_batch_review.automerge / bulk / monitor. +# Environment variables override these (env always wins). dry_run is fail-safe: +# only DBR_DRY_RUN=false (or false/0/no here) ever enables real merges. + +organization: hypothesis + +# Only auto-merge PRs at least this many days old (gives humans a window). +min_age_days: 3 + +# SAFE DEFAULT. Set to false (or pass --no-dry-run / DBR_DRY_RUN=false) to merge. +dry_run: true + +# Phase 1 starts with Tier 0 only (non-deploying bumps). Add 1 once the Sentry / +# New Relic health gate is provisioned and piloted. +tiers_enabled: [0] + +# Hard cap on merges per run (excess is reported as skipped). +max_merges_per_run: 10 + +# Slack channel id for digests / alerts (or set SLACK_CHANNEL). +slack_channel: null + +labels: [dependencies] + +repo_allow: [] # empty = all org repos +repo_deny: + - workflows # shared reusable workflows — change with care + - cookiecutters + - deployment + +# npm-publishing frontend libs (no EB deploy.yml) whose npm bumps still ship. +# Treated as production-deploying: their bumps classify Tier 1, never Tier 0. +publish_on_merge_repos: + - client + - frontend-shared + +health: + sentry_org: hypothesis + # repo -> Sentry project slug (defaults to the repo name when omitted) + sentry_projects: {} + # repo -> New Relic appName (defaults to " (prod)" when omitted) + newrelic_apps: {} + deploy_wait_timeout_s: 1800 # wait up to 30 min for the EB deploy to settle + deploy_poll_interval_s: 20 + health_window_min: 15 # sample 15 min of post-deploy health + baseline_window_min: 60 # compare against the prior 60 min + # Wait after the deploy settles so the sampled window is post-deploy traffic + # (defaults to health_window_min when omitted). + # post_deploy_soak_min: 15 + thresholds: + error_delta_pct: 50.0 # fail if post-deploy error rate is >50% above baseline + min_crash_free_pct: 99.0 # fail if Sentry crash-free sessions drop below this + new_issue_fail_count: 1 # fail on >=1 brand-new unresolved Sentry issue + nr_error_count_abs: 5 # cold-start guard when baseline traffic is ~0 + # Missing Sentry session data counts as unverifiable (fail closed); set + # false for services that don't report sessions (new-issues alone decides). + require_crash_free: true diff --git a/dependabot-automation-plan.md b/dependabot-automation-plan.md new file mode 100644 index 0000000..46d1125 --- /dev/null +++ b/dependabot-automation-plan.md @@ -0,0 +1,182 @@ +# Dependabot Auto-Merge Automation — Findings & Architecture + +**Org:** hypothesis · **Date:** 2026-06-02 · **Author:** investigation by Claude (23-repo fan-out) + +> Goal: stop the team from hand-merging Dependabot PRs and babysitting 30-minute +> pipelines. Make the safe ones merge themselves daily, gate the risky ones to a +> human, verify production health after deploy via Sentry/New Relic, and roll back +> automatically with an alert when something breaks. + +--- + +## 1. Executive summary + +- **203 open Dependabot PRs across 23 repos** right now (real number is higher; org + search caps at 200). Risk mix: **116 low / 36 medium / 51 high**. CI: **188 passing + / 11 failing / 4 none**. **50 PRs are >30 days stale.** +- **~60 PRs are safely auto-mergeable today** (CI-passing + low-risk + ≥3 days old): + dev tooling (ruff/mypy/black/pytest/coverage/pylint), lockfile-only bumps, + `pip`/`pip-tools`/`wheel`, and patch bumps. These never touch production runtime. +- **Merging to `main` auto-deploys to production.** 12 of 23 repos are deployed + services where `push:main` → Docker Hub build → Elastic Beanstalk **staging** → + **production**. So "auto-merge" literally means "auto-deploy to prod" for runtime + dependency bumps. This is the central fact of the design. +- **⚠ Branch protection is mostly absent or unverifiable.** Multiple repos have **no + required status checks and no required approvals** on `main`, and `allow_auto_merge` + is enabled. The CI gate you'd assume exists at the GitHub level largely does not — + **the auto-merger must enforce CI-pass itself**, and we should separately harden + branch protection. +- **Monitoring is real and usable:** Sentry in 12 repos, New Relic in 10 (the deployed + services have both — `sentry-sdk`+`h-pyramid-sentry`, `newrelic-admin run-program + gunicorn`). This makes a post-deploy health gate feasible. +- **There's already a strong rollback primitive:** the shared deploy workflow supports + `operation: redeploy` and every service has a `redeploy.yml` — we can roll back to the + previous EB version without a code revert. + +## 2. The deploy-coupling model (why this is high-stakes) + +Representative `deploy.yml` (lms, h, bouncer, …): + +``` +on: + push: { branches: [main], paths-ignore: [ requirements/*, '!requirements/prod.txt', docs/*, '*.md', tox.ini, tests/* ... ] } +jobs: docker_hub -> staging (EB) -> production (EB) # prod needs staging to pass +``` + +Consequence — a merged Dependabot PR's blast radius depends on **what file it touches**: + +| Bump kind | Touches | Deploys to prod? | +|---|---|---| +| Dev tool (ruff, mypy, black, pytest, coverage, pylint, isort) | `requirements/dev.txt` | **No** (path-ignored) | +| Prod Python dep | `requirements/prod.txt` / `requirements.txt` | **Yes** (un-ignored) | +| Docker base image | `Dockerfile` | **Yes** | +| Frontend/npm (service repos) | `package.json`/lockfile | **Yes** (not ignored) | +| `pip`/`pip-tools`/`wheel` lockfile tooling | lock files only | **No** | + +This is exactly why risk-tiering must distinguish *deploying* bumps from *non-deploying* +ones, and why the Sentry/New Relic gate + auto-rollback are mandatory for the deploying tier. + +## 3. Fleet inventory + +| repo | kind | prod-deploy | open PRs | auto-mergeable | Sentry | NewRelic | +|---|---|:--:|:--:|:--:|:--:|:--:| +| bouncer | service | ✅ | 18 | 5 | ✅ | ✅ | +| h | service | ✅ | 16 | 7 | ✅ | ✅ | +| viahtml | service | ✅ | 15 | 6 | ✅ | ✅ | +| checkmate | service | ✅ | 14 | 3 | ✅ | ✅ | +| lms | service | ✅ | 14 | 5 | ✅ | ✅ | +| via | service | ✅ | 13 | 5 | ✅ | ✅ | +| h-periodic | service | ✅ | 12 | 4 | ✅ | ✅ | +| frontend-shared | frontend-lib | ✅* | 11 | 8 | – | – | +| browser-extension | frontend-lib | – | 11 | 6 | ✅ | – | +| client | frontend-lib | ✅* | 11 | 7 | ✅ | – | +| exam-notes | service | ✅ | 11 | 1 | ✅ | ✅ | +| test-pyapp | tooling | – | 11 | 2 | – | – | +| annotation-ui | frontend-lib | – | 10 | 0 | – | – | +| report | service | ✅ | 10 | 2 | ✅ | ✅ | +| frontend-build | frontend-lib | – | 9 | 3 | – | – | +| test-pyramid-app | service | ✅ | 4 | 1 | ✅ | ✅ | +| frontend-testing | frontend-lib | – | 3 | 0 | – | – | +| biotome | infra | – | 3 | 0 | – | – | +| dependabot-batch-review | tooling | – | 2 | 0 | – | – | +| commando | library | – | 2 | 0 | ✅ | – | +| websocket-tester | tooling | – | 1 | 0 | ✅ | – | +| cookiecutters | tooling | – | 1 | 0 | – | – | +| workflows | infra | – | 1 | 0 | – | – | + +\* frontend libs publish to npm; "prod-deploy" there means a release/publish job, not EB. + +**30 high-risk + CI-passing + prod-touching PRs** (the human-review pile) — e.g. +`newrelic 11→13`, `gunicorn 23→26`, `cryptography 44→46`, `marshmallow 3→4`, +`urllib3 2.5→2.7`, `requests`, `pyjwt`, `node 25→26-alpine`, `zope-sqlalchemy 3→4`. + +**11 CI-failing** (do not merge; triage): `h#10112 pyjwt`, `lms#7367 types-xmltodict`, +eslint-group failures across `frontend-shared/client/annotation-ui/frontend-build`, +`annotation-ui#181 typescript 5→6`, `browser-extension#1907 babel-plugin-istanbul`, +`checkmate#1083/1084 pylint/black`. + +**Caveat — risk model consistency:** these tiers came from 23 independent agents and are +~90% consistent but not identical (e.g. a dev-tool major like `black 25→26` was "high" in +one repo, "low" in another). The real tool must compute risk from **one deterministic +function**, not per-repo heuristics. + +## 4. Proposed architecture + +A **centralized orchestrator** (extends this `dependabot-batch-review` package) that +operates across the org via the GitHub API — no per-repo workflow changes required. Five +components: + +### A. Daily auto-merger (`automerge.py` + scheduled GitHub Action) +- Runs on `schedule: cron` daily + `workflow_dispatch`. +- Config (env / `automerge.yml`): `min_age_days` (default **3**), per-tier enable flags, + repo allow/deny lists, per-ecosystem rules, `dry_run`. +- **Eligibility engine** (deterministic): a PR is auto-merge-eligible iff + CI **passing** AND age ≥ `min_age_days` AND `mergeStateStatus` is clean (no conflict) + AND its **risk tier** is enabled. +- **Risk tiers:** + - **Tier 0 — never deploys** (dev deps, lockfile/tooling, patch non-prod): auto-merge. + - **Tier 1 — deploys, low blast** (patch/minor prod dep, CI green): auto-merge **with + health gate** (component B). + - **Tier 2 — needs human** (major bumps, security-sensitive runtime libs, Docker/node + base major, CI-failing, merge-conflicted): never auto-merge → escalate (component C). +- Cross-repo grouping (reuse existing group logic) so the same bump across N repos is one + decision/one report line. + +### B. Post-deploy health gate (`health.py`) +- Only for Tier 1 (deploying) merges. After merge, wait for the EB deploy to settle + (poll the GitHub Environments/deploy run), then query: + - **Sentry:** new unresolved issues / error-count delta for the project since the + release, and/or release-health crash-free rate. (`GET /api/0/organizations/{org}/ + issues/?query=is:unresolved&statsPeriod=...` or sessions API.) + - **New Relic:** NRQL via GraphQL — `SELECT count(*) FROM TransactionError WHERE + appName='' SINCE 10 minutes ago` vs a baseline. +- Verdict: healthy → done; degraded → trigger component C. + +### C. Rollback + alert (`rollback.py` + `slack.py`) +- **Rollback options (pick policy):** + 1. **EB redeploy previous version** via the existing `redeploy.yml` / shared + `operation: redeploy` — fastest, no code change. *(recommended for prod incidents)* + 2. **`git revert` the merge commit** on `main` — re-deploys clean code, self-documenting. + 3. **Alert-only** — page a human, no automated action. +- Alerts go to **Slack** (reuse `slack.py`): the merge, the health verdict, the rollback + action taken, and a link. Tier-2 escalations post a digest of PRs needing human review. +- Optional **Claude Agent SDK** triage: summarize a risky PR's changelog/diff + advisory + and post a recommended action, so humans decide in seconds not minutes. + +### D. Bulk backlog tool (`bulk.py` CLI) +- One-shot sweep of the *entire* remaining backlog (not just last-N-days): same eligibility + engine, batched by wave (low → medium → high), `--dry-run`, `--max-per-wave`, `--repo`, + `--tier`. This is how we burn down the current 203 → ~baseline. + +### E. Local TUI monitor (`monitor.py`, curses) +- A live "kickoff + watch" dashboard for running the sweep locally: per-PR rows with + live CI status, merge state, health verdict, rollbacks; keybinds to approve/skip/abort. + Complements (doesn't replace) the existing web dashboard + the GitHub Actions logs. + +### Reuse map (what already exists) +- `github_client.py` (GraphQL) · `review.py::fetch_dependency_prs/analyze_risk/merge_pr` + · grouping · `slack.py` · web dashboard (`server.py`). New code is the *autonomous + layer*, not a rewrite. + +## 5. Phased rollout (de-risked) + +1. **Phase 0 — observe (dry-run):** daily Action runs the eligibility engine, posts "would + merge / would escalate" to Slack. No merges. Validate the risk model against reality for ~1 week. +2. **Phase 1 — Tier 0 only:** auto-merge non-deploying bumps (dev/tooling/lockfile). Zero + prod blast radius. Burn down ~60 candidates with the bulk tool. +3. **Phase 2 — Tier 1 + health gate:** enable patch/minor prod bumps with Sentry/New Relic + post-deploy verification + auto-rollback on a 1–2 repo pilot (e.g. bouncer, viahtml), + then widen. +4. **Phase 3 — escalation polish:** Claude-SDK triage digests for Tier 2; tune thresholds. +5. **Parallel hardening:** turn on branch protection (require CI + restrict who can merge) + so GitHub enforces the gate the tool relies on. + +## 6. Open decisions (need owner input) + +1. **Blast-radius / auto-merge scope** — start at Tier 0 only, or go to Tier 1 (deploying + bumps behind the health gate) once piloted? +2. **Rollback policy** — EB `redeploy` previous version, `git revert`, or alert-only? +3. **Monitoring tokens** — can we provision a Sentry API token + New Relic API key (user + key + account/app IDs) to CI secrets? Which is the primary health signal? +4. **Alerts / escalation channel** — which Slack channel; do we want Claude-SDK PR-risk + triage in the digest? diff --git a/dependabot_batch_review/__main__.py b/dependabot_batch_review/__main__.py index 3a163c9..e1d78d4 100644 --- a/dependabot_batch_review/__main__.py +++ b/dependabot_batch_review/__main__.py @@ -93,7 +93,19 @@ def main() -> int: if args.output_xlsx: try: - template_path = Path("./hypothesis_dependabot_alerts_tracker_example.xlsx") + # The template ships next to the repo, not the cwd. (It is gitignored + # via *.xlsx, so a fresh clone needs it provided.) + template_path = ( + Path(__file__).resolve().parent.parent + / "hypothesis_dependabot_alerts_tracker_example.xlsx" + ) + if not template_path.exists(): + print( + f"XLSX template not found: {template_path} — place the tracker " + "template there to enable --output-xlsx", + file=sys.stderr, + ) + return 1 output_path = Path(args.output_xlsx) generate_xlsx_report(updates, template_path, output_path) except Exception as e: diff --git a/dependabot_batch_review/audit.py b/dependabot_batch_review/audit.py new file mode 100644 index 0000000..0cf972b --- /dev/null +++ b/dependabot_batch_review/audit.py @@ -0,0 +1,41 @@ +""" +Append-only JSONL audit trail for sweeps. + +Each consequential step of a sweep — the classified plan, every merge, every +health verdict, every rollback — is appended as one JSON object per line, so a +TUI session (or any live run) leaves a durable record of what the automation +did and why, independent of Slack or the terminal scrollback. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + + +class AuditLog: + """Append-only JSON-lines log; one ``record`` call per event.""" + + def __init__(self, path: Path) -> None: + self._path = path + if path.parent != Path(""): + path.parent.mkdir(parents=True, exist_ok=True) + + @property + def path(self) -> Path: + return self._path + + def record(self, event: str, **fields: object) -> None: + entry: dict[str, object] = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "event": event, + } + entry.update(fields) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, default=str) + "\n") + + +def default_audit_path(now: datetime | None = None) -> Path: + stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%d-%H%M%S") + return Path(f"sweep-audit-{stamp}.jsonl") diff --git a/dependabot_batch_review/automation_types.py b/dependabot_batch_review/automation_types.py new file mode 100644 index 0000000..985c921 --- /dev/null +++ b/dependabot_batch_review/automation_types.py @@ -0,0 +1,68 @@ +""" +Shared data types for the autonomous Dependabot automation layer. + +Kept in a dedicated, dependency-light module so the engine (``automerge`` / +``bulk``), the health gate (``health``), the rollback path (``rollback``) and the +TUI (``monitor``) can share types without import cycles. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum + +from .review import DependencyUpdatePR + + +class Tier(IntEnum): + """Auto-merge risk tier. Higher = more dangerous.""" + + TIER_0 = 0 # never deploys to prod -> auto-merge on CI pass + TIER_1 = 1 # deploys to prod (patch/minor) -> auto-merge behind health gate + TIER_2 = 2 # major / security-sensitive / broken -> human review only + + +@dataclass +class MergeOutcome: + """The result of merging one PR; the input to the post-deploy health gate.""" + + pr: DependencyUpdatePR + owner: str + repo: str + tier: Tier + merged: bool + dry_run: bool + merge_commit_sha: str | None = None + merged_at: str | None = None + + +@dataclass +class SignalResult: + """One monitoring signal's contribution to a health verdict.""" + + source: str # "sentry" | "newrelic" + healthy: bool + metric: str + observed: float | None = None + baseline: float | None = None + threshold: float | None = None + detail: str = "" + # The signal could not be sampled (API error, no data). Never counts as + # healthy; whether it triggers rollback or escalation is decided above. + unknown: bool = False + + +@dataclass +class HealthVerdict: + """ + Aggregate verdict from all monitoring signals after a Tier-1 deploy. + + Three-state: ``healthy`` (verified good), degraded (``healthy=False, + unknown=False`` — roll back), or ``unknown=True`` (could not verify — + escalate to humans, never auto-pass and never auto-rollback). + """ + + healthy: bool + signals: dict[str, SignalResult] = field(default_factory=dict) + reasons: list[str] = field(default_factory=list) + unknown: bool = False diff --git a/dependabot_batch_review/automerge.py b/dependabot_batch_review/automerge.py new file mode 100644 index 0000000..6bdd531 --- /dev/null +++ b/dependabot_batch_review/automerge.py @@ -0,0 +1,425 @@ +""" +The autonomous auto-merge engine. + +Fetches open Dependabot PRs, classifies each into a tier (``risk.classify``), +decides an action under the configured policy, and — unless ``dry_run`` — merges +the eligible ones. Tier-1 (production-deploying) merges are returned as +``health_watch`` outcomes for the post-deploy health gate to verify. + +Run as the daily GitHub Action entry point: +``python -m dependabot_batch_review.automerge hypothesis``. +""" + +from __future__ import annotations + +import os +import sys +from argparse import ArgumentParser +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from .automation_types import MergeOutcome, Tier +from .config import Config, load_config +from .deploy_model import DeployModelCache +from .github_client import GitHubClient +from .review import CheckStatus, DependencyUpdatePR, fetch_dependency_prs, merge_pr +from .risk import Classification, classify + +# Merge states that mean "not mergeable right now" (conflict, branch protection, +# behind base, or draft). CLEAN / UNSTABLE / UNKNOWN / null pass (CI is gated +# separately via check_status). Checked at merge time via _PREMERGE_QUERY — the +# org-wide search omits mergeStateStatus (it 502s at that volume), so the field +# is always None during classification. +_BLOCKED_MERGE_STATES = frozenset({"DIRTY", "BLOCKED", "BEHIND", "DRAFT"}) + +# Live re-verification of a single PR immediately before merging. The org-wide +# search snapshot can be minutes old; everything security-relevant is re-read +# here: head OID (pinned via expectedHeadOid), commit authorship + signature, +# CI rollup, merge state, and head-commit age. +_PREMERGE_QUERY = """ +query($id: ID!) { + node(id: $id) { + ... on PullRequest { + state + headRefOid + mergeStateStatus + mergeable + commits(last: 1) { + totalCount + nodes { + commit { + committedDate + statusCheckRollup { state } + signature { isValid } + author { email } + } + } + } + } + } +} +""" + +_DEPENDABOT_EMAIL_SUFFIX = "dependabot[bot]@users.noreply.github.com" + + +class PreMergeCheckError(Exception): + """A PR failed live re-verification just before merging.""" + + def __init__(self, reason: str, *, escalate: bool = True) -> None: + super().__init__(reason) + self.escalate = escalate + + +Action = str # "merge" | "merge+health" | "escalate" | "skip" + + +@dataclass +class Decision: + pr: DependencyUpdatePR + classification: Classification + action: Action + eligible: bool + skip_reason: str | None = None + outcome: MergeOutcome | None = None + + +@dataclass +class RunResult: + decisions: list[Decision] = field(default_factory=list) + dry_run: bool = True + + @property + def merged(self) -> list[Decision]: + return [d for d in self.decisions if d.action in ("merge", "merge+health")] + + @property + def health_watch(self) -> list[MergeOutcome]: + return [ + d.outcome + for d in self.decisions + if d.action == "merge+health" and d.outcome is not None + ] + + @property + def escalated(self) -> list[Decision]: + return [d for d in self.decisions if d.action == "escalate"] + + @property + def skipped(self) -> list[Decision]: + return [d for d in self.decisions if d.action == "skip"] + + +def _iso_age_days(timestamp: str | None, now: datetime) -> float | None: + """Age in days of an ISO-8601 timestamp, or ``None`` if unparseable.""" + if not timestamp: + return None + try: + moment = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + return None + return (now - moment).total_seconds() / 86400.0 + + +def age_days(pr: DependencyUpdatePR, now: datetime) -> float | None: + """Age of the PR in days, or ``None`` if the creation time is unknown.""" + return _iso_age_days(pr.created_at, now) + + +def _age_or(pr: DependencyUpdatePR, now: datetime, default: float) -> float: + age = age_days(pr, now) + return default if age is None else age + + +def is_eligible( + pr: DependencyUpdatePR, cls: Classification, cfg: Config, now: datetime +) -> tuple[bool, str | None]: + """ + Decide whether a PR may be auto-merged under the configured policy. + + The tool enforces the CI-passing gate itself (branch protection is largely + absent across the fleet), plus an age floor, a clean merge state, and the + tier/repo allow-lists. + """ + if pr.check_status != CheckStatus.SUCCESS: + return False, f"CI {pr.check_status.description}" + if not cfg.tier_enabled(int(cls.tier)): + return False, f"tier {int(cls.tier)} not enabled" + if not cfg.repo_permitted(pr.repo or ""): + return False, "repo not permitted by allow/deny list" + age = age_days(pr, now) + if age is None: + return False, "unknown PR age" + if age < cfg.min_age_days: + return False, f"too new ({age:.1f}d < {cfg.min_age_days}d)" + if pr.merge_state_status in _BLOCKED_MERGE_STATES: + return False, f"merge state {pr.merge_state_status}" + if pr.mergeable == "CONFLICTING": + return False, "merge conflict" + return True, None + + +def decide( + pr: DependencyUpdatePR, cache: DeployModelCache, cfg: Config, now: datetime +) -> Decision: + """Classify a PR and choose an action.""" + model = cache.get(pr.repo or "") + cls = classify(pr, model) + eligible, reason = is_eligible(pr, cls, cfg, now) + + if cls.tier == Tier.TIER_2: + return Decision(pr, cls, "escalate", eligible=False) + if eligible: + action: Action = "merge+health" if cls.tier == Tier.TIER_1 else "merge" + return Decision(pr, cls, action, eligible=True) + # Not eligible and not Tier 2: a failing/conflicted PR needs a human; anything + # else (too new, behind, tier disabled) is just skipped until a later run. + if pr.check_status == CheckStatus.FAILED or pr.mergeable == "CONFLICTING": + return Decision(pr, cls, "escalate", eligible=False, skip_reason=reason) + return Decision(pr, cls, "skip", eligible=False, skip_reason=reason) + + +def verify_premerge( + gh: GitHubClient, pr: DependencyUpdatePR, cfg: Config, now: datetime +) -> str: + """ + Re-verify a PR against live GitHub state and return its head OID. + + Raises :class:`PreMergeCheckError` unless the PR is open, consists of exactly + one commit authored by Dependabot with a valid (GitHub-made) signature, has a + passing CI rollup *now*, is in a mergeable state, and its head commit — not + just the PR — satisfies the age floor (a force-pushed new version restarts + the quarantine clock). + """ + info = gh.query(_PREMERGE_QUERY, variables={"id": pr.id}) + node = (info or {}).get("node") or {} + if node.get("state") != "OPEN": + raise PreMergeCheckError( + f"not open at merge time ({node.get('state') or 'missing'})", + escalate=False, + ) + + commits = node.get("commits") or {} + total = commits.get("totalCount") or 0 + nodes = commits.get("nodes") or [] + if total != 1 or not nodes: + raise PreMergeCheckError( + f"expected exactly one Dependabot commit, found {total} " + "(manual commits on a Dependabot branch need human review)" + ) + commit = nodes[0].get("commit") or {} + + email = ((commit.get("author") or {}).get("email") or "").lower() + if not email.endswith(_DEPENDABOT_EMAIL_SUFFIX): + raise PreMergeCheckError( + f"head commit not authored by dependabot ({email or 'unknown author'})" + ) + if not (commit.get("signature") or {}).get("isValid"): + # Dependabot commits are GitHub-signed; a forged author email can't be. + raise PreMergeCheckError("head commit signature missing or invalid") + + rollup = (commit.get("statusCheckRollup") or {}).get("state") + if rollup != "SUCCESS": + raise PreMergeCheckError( + f"CI rollup {rollup or 'missing'} at merge time", + escalate=rollup not in (None, "PENDING", "EXPECTED"), + ) + + if node.get("mergeStateStatus") in _BLOCKED_MERGE_STATES: + raise PreMergeCheckError( + f"merge state {node.get('mergeStateStatus')}", escalate=False + ) + if node.get("mergeable") == "CONFLICTING": + raise PreMergeCheckError("merge conflict") + + head_age = _iso_age_days(commit.get("committedDate"), now) + if head_age is None: + raise PreMergeCheckError("unknown head commit age") + if head_age < cfg.min_age_days: + raise PreMergeCheckError( + f"head commit too new ({head_age:.1f}d < {cfg.min_age_days}d)", + escalate=False, + ) + + head_oid = node.get("headRefOid") + if not head_oid: + raise PreMergeCheckError("missing head OID") + return str(head_oid) + + +def monitoring_configured(cfg: Config) -> bool: + """Is at least one health-gate signal (Sentry or New Relic) credentialed?""" + health = cfg.health + return bool( + health.sentry_token or (health.newrelic_token and health.newrelic_account_id) + ) + + +def _merge_and_capture( + gh: GitHubClient, decision: Decision, cfg: Config, now: datetime | None = None +) -> MergeOutcome: + """Re-verify, merge pinned to the verified head, and capture the merge SHA.""" + pr = decision.pr + head_oid = verify_premerge(gh, pr, cfg, now or datetime.now(timezone.utc)) + merged = merge_pr( + gh, pr_id=pr.id, merge_method=pr.merge_method, expected_head_oid=head_oid + ) + commit = merged.get("mergeCommit") or {} + return MergeOutcome( + pr=pr, + owner=cfg.organization, + repo=pr.repo or "", + tier=decision.classification.tier, + merged=True, + dry_run=False, + merge_commit_sha=commit.get("oid"), + merged_at=merged.get("mergedAt"), + ) + + +def gather_decisions( + gh: GitHubClient, + cfg: Config, + now: datetime, + repos: list[str] | None = None, +) -> list[Decision]: + """Fetch and classify the org's open Dependabot PRs (shared by all CLIs).""" + prs = fetch_dependency_prs(gh, organization=cfg.organization, labels=cfg.labels) + if repos: + wanted = set(repos) + prs = [p for p in prs if p.repo in wanted] + cache = DeployModelCache( + gh, cfg.organization, publish_on_merge_repos=cfg.publish_on_merge_repos + ) + return [decide(pr, cache, cfg, now) for pr in prs] + + +def run(gh: GitHubClient, cfg: Config, now: datetime | None = None) -> RunResult: + """Fetch, classify, decide, and (unless dry-run) merge eligible PRs.""" + now = now or datetime.now(timezone.utc) + decisions = gather_decisions(gh, cfg, now) + + # Drain safest + oldest first: Tier 0 before Tier 1, then oldest PR first. + mergeable = [d for d in decisions if d.action in ("merge", "merge+health")] + mergeable.sort(key=lambda d: (int(d.classification.tier), -_age_or(d.pr, now, 0.0))) + + can_watch_health = monitoring_configured(cfg) + merged_count = 0 + for d in mergeable: + if merged_count >= cfg.max_merges_per_run: + d.action = "skip" + d.skip_reason = f"max_merges_per_run cap ({cfg.max_merges_per_run}) reached" + continue + if d.action == "merge+health" and not cfg.dry_run and not can_watch_health: + # Without Sentry/New Relic credentials the gate would be blind; + # refuse the merge rather than deploy unverifiable code. + d.action = "skip" + d.skip_reason = ( + "tier 1 needs SENTRY_AUTH_TOKEN or NEW_RELIC_* credentials " + "(health gate would have no signals)" + ) + continue + if cfg.dry_run: + merged_count += 1 # would merge + continue + try: + d.outcome = _merge_and_capture(gh, d, cfg, now) + merged_count += 1 + except PreMergeCheckError as exc: + d.action = "escalate" if exc.escalate else "skip" + d.skip_reason = f"pre-merge verification: {exc}" + except Exception as exc: # noqa: BLE001 - report and continue the batch + d.action = "skip" + d.skip_reason = f"merge failed: {exc!r}" + + return RunResult(decisions=decisions, dry_run=cfg.dry_run) + + +def format_summary(result: RunResult, cfg: Config) -> str: + """Human-readable run summary for Action logs / stdout.""" + verb = "Would merge" if result.dry_run else "Merged" + lines: list[str] = [] + mode = "DRY-RUN" if result.dry_run else "LIVE" + lines.append( + f"Dependabot auto-merge [{mode}] org={cfg.organization} " + f"tiers={cfg.tiers_enabled} min_age={cfg.min_age_days}d" + ) + merged = result.merged + lines.append(f"\n{verb} {len(merged)} PR(s):") + for d in sorted(merged, key=lambda x: (x.pr.repo or "", x.pr.group_name)): + tag = "T1/health" if d.action == "merge+health" else "T0" + lines.append(f" [{tag}] {d.pr.repo}: {d.pr.group_name} -> {d.pr.url}") + + escalated = result.escalated + lines.append(f"\nEscalate to humans ({len(escalated)}):") + for d in sorted(escalated, key=lambda x: (x.pr.repo or "", x.pr.group_name)): + why = "; ".join(d.classification.reasons) or d.skip_reason or "needs review" + lines.append(f" {d.pr.repo}: {d.pr.group_name} ({why}) -> {d.pr.url}") + + skipped = result.skipped + lines.append(f"\nSkipped ({len(skipped)}): not yet eligible") + for d in sorted(skipped, key=lambda x: (x.pr.repo or "", x.pr.group_name)): + lines.append(f" {d.pr.repo}: {d.pr.group_name} ({d.skip_reason})") + return "\n".join(lines) + + +def main() -> int: + parser = ArgumentParser(description="Daily Dependabot auto-merger") + parser.add_argument("organization", nargs="?", default=None) + parser.add_argument("--config", default="automation.yml") + parser.add_argument( + "--no-dry-run", + dest="dry_run", + action="store_false", + default=None, + help="Actually merge (default: dry-run). Requires a merge-capable token.", + ) + parser.add_argument("--max-merges", type=int, default=None) + args = parser.parse_args() + + cfg = load_config(args.config) + if args.organization: + cfg.organization = args.organization + if args.dry_run is False: + cfg.dry_run = False + if args.max_merges is not None: + cfg.max_merges_per_run = args.max_merges + + gh = GitHubClient.init() + result = run(gh, cfg) + print(format_summary(result, cfg)) + + _maybe_post_slack(result, cfg) + _maybe_run_health_gate(gh, result, cfg) + return 0 + + +def _maybe_post_slack(result: RunResult, cfg: Config) -> None: + token = os.environ.get("SLACK_TOKEN") + if not (token and cfg.slack_channel): + return + try: + from .slack import SlackClient + from .slack_messages import format_run_digest + + SlackClient(token).post_message( + cfg.slack_channel, format_run_digest(result, cfg) + ) + except Exception as exc: # noqa: BLE001 - Slack failures must not fail the run + print(f"Slack post failed: {exc!r}", file=sys.stderr) + + +def _maybe_run_health_gate(gh: GitHubClient, result: RunResult, cfg: Config) -> None: + """For live Tier-1 merges, verify post-deploy health and roll back on failure.""" + if result.dry_run or not result.health_watch: + return + # Imported here (not at module top) only to keep the engine unit-testable + # without the health/rollback dependency graph; an import failure is a real + # bug and must crash the run, never silently skip the gate. + from .orchestrator import health_gate_outcomes + + health_gate_outcomes(gh, result.health_watch, cfg) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dependabot_batch_review/bulk.py b/dependabot_batch_review/bulk.py new file mode 100644 index 0000000..185aaa1 --- /dev/null +++ b/dependabot_batch_review/bulk.py @@ -0,0 +1,150 @@ +""" +One-shot backlog burn-down CLI. + +Sweeps the *entire* open Dependabot backlog (not just the last N days) in waves — +Tier 0, then Tier 1, then a Tier 2 report — reusing the same classification and +eligibility engine as the daily auto-merger. Defaults to dry-run. + +Usage: + python -m dependabot_batch_review.bulk hypothesis --dry-run + python -m dependabot_batch_review.bulk hypothesis --execute --tier 0 --max-per-wave 20 +""" + +from __future__ import annotations + +import sys +import time +from argparse import ArgumentParser +from datetime import datetime, timezone + +from .automation_types import MergeOutcome +from .automerge import ( + Decision, + PreMergeCheckError, + _merge_and_capture, + gather_decisions, + monitoring_configured, +) +from .config import Config, load_config +from .github_client import GitHubClient + + +def _select(decisions: list[Decision], tier: int) -> list[Decision]: + return [ + d + for d in decisions + if int(d.classification.tier) == tier and d.action in ("merge", "merge+health") + ] + + +def run_bulk( + gh: GitHubClient, + cfg: Config, + *, + tiers: list[int], + max_per_wave: int, + repos: list[str] | None, + wave_pause_s: float, + now: datetime | None = None, +) -> int: + now = now or datetime.now(timezone.utc) + decisions = gather_decisions(gh, cfg, now, repos=repos) + + mode = "DRY-RUN" if cfg.dry_run else "LIVE" + print(f"[{mode}] {len(decisions)} open PRs in {cfg.organization}; waves={tiers}\n") + + if 1 in tiers and not cfg.dry_run and not monitoring_configured(cfg): + print( + "Tier 1 wave disabled: no SENTRY_AUTH_TOKEN or NEW_RELIC_* credentials, " + "so the health gate would have no signals.\n" + ) + tiers = [t for t in tiers if t != 1] + + total_merged = 0 + health_watch: list[MergeOutcome] = [] + for tier in tiers: + wave = _select(decisions, tier) + label = {0: "Tier 0 (no deploy)", 1: "Tier 1 (health-gated)"}.get( + tier, f"Tier {tier}" + ) + print(f"== Wave {tier}: {label} — {len(wave)} eligible ==") + for index, d in enumerate(wave, start=1): + if index > max_per_wave: + print(f" … {len(wave) - max_per_wave} more held (--max-per-wave)") + break + bump = d.classification.bump.name.lower() + head = f" [{index}/{min(len(wave), max_per_wave)}] {d.pr.repo}: {d.pr.group_name} ({bump})" + if cfg.dry_run: + print(f"{head} -> would merge") + continue + try: + outcome = _merge_and_capture(gh, d, cfg, now) + total_merged += 1 + if d.action == "merge+health": + health_watch.append(outcome) + print(f"{head} -> merged ✓") + except PreMergeCheckError as exc: + print(f"{head} -> HELD: {exc}") + except Exception as exc: # noqa: BLE001 + print(f"{head} -> FAILED: {exc!r}") + if wave_pause_s and not cfg.dry_run and tier != tiers[-1]: + print(f" pausing {wave_pause_s:.0f}s for Dependabot rebases…") + time.sleep(wave_pause_s) + print() + + if health_watch: + from .orchestrator import health_gate_outcomes + + print(f"== Health gate: verifying {len(health_watch)} Tier-1 deploy(s) ==") + health_gate_outcomes(gh, health_watch, cfg) + print() + + escalations = [d for d in decisions if d.action == "escalate"] + print(f"== Needs human review: {len(escalations)} ==") + for d in sorted(escalations, key=lambda x: (x.pr.repo or "", x.pr.group_name)): + why = "; ".join(d.classification.reasons) or d.skip_reason or "review" + print(f" {d.pr.repo}: {d.pr.group_name} ({why})") + + if not cfg.dry_run: + print(f"\nMerged {total_merged} PR(s).") + return 0 + + +def main() -> int: + parser = ArgumentParser(description="Bulk Dependabot backlog burn-down") + parser.add_argument("organization", nargs="?", default=None) + parser.add_argument("--config", default="automation.yml") + parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True) + parser.add_argument("--execute", dest="dry_run", action="store_false") + parser.add_argument("--max-per-wave", type=int, default=20) + parser.add_argument("--repo", action="append", help="restrict to repo(s)") + parser.add_argument( + "--tier", type=int, action="append", help="tier(s) to merge (default: 0)" + ) + parser.add_argument("--ignore-age", action="store_true") + parser.add_argument("--wave-pause", type=float, default=0.0) + args = parser.parse_args() + + cfg = load_config(args.config) + if args.organization: + cfg.organization = args.organization + cfg.dry_run = args.dry_run + if args.ignore_age: + cfg.min_age_days = 0 + tiers = sorted(set(args.tier)) if args.tier else [0] + # Enable the requested tiers so eligibility doesn't filter them out. + cfg.tiers_enabled = sorted(set(cfg.tiers_enabled) | set(tiers)) + + gh = GitHubClient.init() + return run_bulk( + gh, + cfg, + tiers=tiers, + max_per_wave=args.max_per_wave, + repos=args.repo, + wave_pause_s=args.wave_pause, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dependabot_batch_review/config.py b/dependabot_batch_review/config.py new file mode 100644 index 0000000..d06edba --- /dev/null +++ b/dependabot_batch_review/config.py @@ -0,0 +1,181 @@ +""" +Configuration for the autonomous Dependabot automation layer. + +Loads from an optional ``automation.yml`` file, then applies environment-variable +overrides (env always wins). The ``dry_run`` flag is fail-safe: only the explicit +strings ``false`` / ``0`` / ``no`` disable it, so a typo never turns merging on. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +def _as_bool(value: str | None, default: bool) -> bool: + # Empty counts as unset: the Action exports DBR_DRY_RUN="" on scheduled runs + # so that automation.yml keeps the final say. + if value is None or not value.strip(): + return default + return value.strip().lower() not in {"false", "0", "no", "off"} + + +def _yaml_bool(value: Any, default: bool) -> bool: + # Fail-safe YAML bool: `dry_run:` (null) or a garbage value must keep the + # default; bool(None) would silently flip dry_run off. + if value is None: + return default + if isinstance(value, bool): + return value + return _as_bool(str(value), default) + + +def _as_int(value: str | None, default: int) -> int: + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + +@dataclass +class Thresholds: + """Health-gate pass/fail thresholds (all tunable per pilot).""" + + error_delta_pct: float = 50.0 # post-deploy error-rate increase that fails + min_crash_free_pct: float = 99.0 # Sentry release-health floor + new_issue_fail_count: int = 1 # >= this many brand-new issues fails + nr_error_count_abs: int = 5 # absolute error floor when baseline traffic ~0 + # Missing Sentry session data counts as unverifiable (fail closed). Set + # False for repos that don't report sessions, so new-issues alone decides. + require_crash_free: bool = True + + +@dataclass +class HealthConfig: + sentry_org: str = "hypothesis" + sentry_token: str | None = None + sentry_projects: dict[str, str] = field(default_factory=dict) + newrelic_account_id: str | None = None + newrelic_token: str | None = None + newrelic_apps: dict[str, str] = field(default_factory=dict) + deploy_wait_timeout_s: int = 1800 # 30 min — matches the EB pipeline length + deploy_poll_interval_s: int = 20 + health_window_min: int = 15 # sampling window after deploy settles + baseline_window_min: int = 60 # pre-deploy comparison window + # Wait this long after the deploy settles before sampling, so the window + # contains new-release traffic. None => health_window_min. + post_deploy_soak_min: int | None = None + thresholds: Thresholds = field(default_factory=Thresholds) + + def sentry_project_for(self, repo: str) -> str: + return self.sentry_projects.get(repo, repo) + + def newrelic_app_for(self, repo: str) -> str: + return self.newrelic_apps.get(repo, f"{repo} (prod)") + + +@dataclass +class Config: + organization: str = "hypothesis" + min_age_days: int = 3 + dry_run: bool = True # SAFE DEFAULT — never merges unless explicitly disabled + tiers_enabled: list[int] = field(default_factory=lambda: [0]) + repo_allow: list[str] = field(default_factory=list) # empty => all repos + repo_deny: list[str] = field(default_factory=list) + max_merges_per_run: int = 10 # hard cap per invocation + slack_channel: str | None = None + labels: list[str] = field(default_factory=lambda: ["dependencies"]) + # npm-publishing frontend libs (no EB deploy.yml) whose npm bumps still ship. + publish_on_merge_repos: list[str] = field(default_factory=list) + health: HealthConfig = field(default_factory=HealthConfig) + + def tier_enabled(self, tier: int) -> bool: + return tier in self.tiers_enabled + + def repo_permitted(self, repo: str) -> bool: + if self.repo_allow and repo not in self.repo_allow: + return False + return repo not in self.repo_deny + + +def _load_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + loaded: Any = yaml.safe_load(path.read_text(encoding="utf-8")) + return loaded if isinstance(loaded, dict) else {} + + +def load_config(path: str | None = "automation.yml") -> Config: + """Build a :class:`Config` from YAML (if present) plus env overrides.""" + raw = _load_yaml(Path(path)) if path else {} + + health_raw = raw.get("health", {}) if isinstance(raw.get("health"), dict) else {} + thresholds_raw = ( + health_raw.get("thresholds", {}) + if isinstance(health_raw.get("thresholds"), dict) + else {} + ) + thresholds = Thresholds( + error_delta_pct=float(thresholds_raw.get("error_delta_pct", 50.0)), + min_crash_free_pct=float(thresholds_raw.get("min_crash_free_pct", 99.0)), + new_issue_fail_count=int(thresholds_raw.get("new_issue_fail_count", 1)), + nr_error_count_abs=int(thresholds_raw.get("nr_error_count_abs", 5)), + require_crash_free=_yaml_bool(thresholds_raw.get("require_crash_free"), True), + ) + health = HealthConfig( + sentry_org=str(health_raw.get("sentry_org", "hypothesis")), + sentry_token=os.environ.get("SENTRY_AUTH_TOKEN"), + sentry_projects=dict(health_raw.get("sentry_projects", {})), + newrelic_account_id=os.environ.get("NEW_RELIC_ACCOUNT_ID"), + newrelic_token=os.environ.get("NEW_RELIC_API_KEY"), + newrelic_apps=dict(health_raw.get("newrelic_apps", {})), + deploy_wait_timeout_s=int(health_raw.get("deploy_wait_timeout_s", 1800)), + deploy_poll_interval_s=int(health_raw.get("deploy_poll_interval_s", 20)), + health_window_min=int(health_raw.get("health_window_min", 15)), + baseline_window_min=int(health_raw.get("baseline_window_min", 60)), + post_deploy_soak_min=( + int(raw_soak) + if (raw_soak := health_raw.get("post_deploy_soak_min")) is not None + else None + ), + thresholds=thresholds, + ) + if "SENTRY_ORG" in os.environ: + health.sentry_org = os.environ["SENTRY_ORG"] + + config = Config( + organization=str(raw.get("organization", "hypothesis")), + min_age_days=int(raw.get("min_age_days", 3)), + dry_run=_yaml_bool(raw.get("dry_run"), True), + tiers_enabled=[int(t) for t in raw.get("tiers_enabled", [0])], + repo_allow=[str(r) for r in raw.get("repo_allow", [])], + repo_deny=[str(r) for r in raw.get("repo_deny", [])], + max_merges_per_run=int(raw.get("max_merges_per_run", 10)), + slack_channel=raw.get("slack_channel"), + labels=[str(label) for label in raw.get("labels", ["dependencies"])], + publish_on_merge_repos=[str(r) for r in raw.get("publish_on_merge_repos", [])], + health=health, + ) + + # Environment overrides (env always wins). + config.organization = os.environ.get("DBR_ORG", config.organization) + config.min_age_days = _as_int( + os.environ.get("DBR_MIN_AGE_DAYS"), config.min_age_days + ) + config.dry_run = _as_bool(os.environ.get("DBR_DRY_RUN"), config.dry_run) + config.max_merges_per_run = _as_int( + os.environ.get("DBR_MAX_MERGES"), config.max_merges_per_run + ) + if os.environ.get("DBR_TIERS_ENABLED"): + config.tiers_enabled = [ + int(t) for t in os.environ["DBR_TIERS_ENABLED"].split(",") if t.strip() + ] + config.slack_channel = os.environ.get("SLACK_CHANNEL", config.slack_channel) + + return config diff --git a/dependabot_batch_review/deploy_model.py b/dependabot_batch_review/deploy_model.py new file mode 100644 index 0000000..a5d4f7a --- /dev/null +++ b/dependabot_batch_review/deploy_model.py @@ -0,0 +1,239 @@ +""" +Per-repo deploy-coupling model. + +Determines whether merging a Dependabot PR will trigger a *production* deploy, by +fetching and parsing the repo's ``.github/workflows/deploy.yml`` once (memoized) +and applying its ``paths-ignore`` semantics to the file(s) the PR is expected to +touch. + +This is the backbone of the Tier-0 / Tier-1 split: a bump that provably does not +deploy is safe to auto-merge (Tier 0); a bump that deploys to production must pass +the post-deploy health gate (Tier 1). +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field +from typing import Any + +import yaml + +from .github_client import GitHubClient +from .review import DependencyUpdatePR + +_DEPLOY_FILE_QUERY = """ +query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + object(expression: "HEAD:.github/workflows/deploy.yml") { + ... on Blob { text } + } + } +} +""" + +_DEPLOY_BRANCHES = frozenset({"main", "master"}) + + +def _glob_to_regex(pattern: str) -> str: + """ + Translate a GitHub Actions path filter glob to a regex. + + Mirrors GitHub's semantics: ``*`` matches any character except ``/``; ``**`` + matches any character including ``/``; ``?`` matches a single non-``/`` char. + """ + out: list[str] = [] + i = 0 + while i < len(pattern): + char = pattern[i] + if char == "*": + if pattern[i + 1 : i + 2] == "*": + out.append(".*") + i += 2 + continue + out.append("[^/]*") + elif char == "?": + out.append("[^/]") + else: + out.append(re.escape(char)) + i += 1 + return "".join(out) + + +def _path_matches(path: str, glob: str) -> bool: + return re.fullmatch(_glob_to_regex(glob), path) is not None + + +@dataclass +class DeployModel: + """The deploy behaviour of one repository's default branch.""" + + repo: str + is_deploying_service: bool + deploy_branches: list[str] = field(default_factory=list) + paths_ignore: list[str] = field(default_factory=list) + raw_present: bool = False + + def path_triggers_deploy(self, changed_path: str) -> bool: + """ + Apply GitHub's ``paths-ignore`` semantics to a single changed path. + + A path triggers the deploy unless it matches an ignore glob. Negated globs + (``!foo``) re-include a path; patterns are evaluated in order and the last + matching pattern wins (mirroring GitHub's filter evaluation). + """ + if not self.is_deploying_service: + return False + if not self.paths_ignore: + return True + + triggers = True + for pattern in self.paths_ignore: + negated = pattern.startswith("!") + glob = pattern[1:] if negated else pattern + if _path_matches(changed_path, glob): + triggers = negated + return triggers + + def _prod_requirement_files(self) -> list[str]: + """Files re-included by a ``!`` negation are the repo's prod lock files.""" + return [p[1:] for p in self.paths_ignore if p.startswith("!")] + + def infer_changed_paths( + self, pr: DependencyUpdatePR, is_dev_tool: bool + ) -> list[str]: + """ + Best-effort map a PR's ecosystem + branch to representative changed paths. + + The exact prod requirements filename varies per repo (``prod.txt`` vs + ``requirements.txt``); we read it from the deploy.yml ``!`` negation when + present, falling back to common defaults. + """ + ecosystem = pr.package_type + if ecosystem == "docker": + return ["Dockerfile"] + if ecosystem == "npm_and_yarn": + return ["package.json"] + if ecosystem == "github_actions": + return [".github/workflows/ci.yml"] + if ecosystem == "pip": + if is_dev_tool: + return ["requirements/dev.txt"] + prod_files = self._prod_requirement_files() + return prod_files or ["requirements/prod.txt", "requirements.txt"] + # Unknown ecosystem: be conservative and assume it could deploy. + return ["requirements.txt"] + + def deploys_to_prod(self, pr: DependencyUpdatePR, is_dev_tool: bool) -> bool: + """Does merging this PR trigger a production deploy of this repo?""" + if not self.is_deploying_service: + return False + if pr.package_type == "github_actions": + # Workflow-file bumps never change the deployed artifact. + return False + # Prefer the PR's real changed files; the ecosystem inference is only a + # fallback for PRs fetched without the files connection. + paths = pr.changed_files or self.infer_changed_paths(pr, is_dev_tool) + return any(self.path_triggers_deploy(path) for path in paths) + + +def parse_deploy_yaml(repo: str, text: str | None) -> DeployModel: + """ + Build a :class:`DeployModel` from the raw text of a ``deploy.yml`` file. + + ``text`` is ``None`` when the repo has no ``deploy.yml`` (a non-deploying + library/tooling repo). If the file exists but cannot be parsed we fail *safe*: + treat the repo as deploying with no path filter, so bumps land in Tier 1 + (health-gated) rather than being wrongly auto-merged as Tier 0. + """ + if text is None: + return DeployModel(repo=repo, is_deploying_service=False, raw_present=False) + + try: + data: Any = yaml.safe_load(text) + except yaml.YAMLError as exc: + print(f"deploy.yml parse error for {repo}: {exc}", file=sys.stderr) + return DeployModel(repo=repo, is_deploying_service=True, raw_present=True) + + if not isinstance(data, dict): + return DeployModel(repo=repo, is_deploying_service=True, raw_present=True) + + # YAML 1.1 parses the bare key `on:` as the boolean True, so check both. + on_section = data.get("on", data.get(True)) + + # A push trigger can be spelled `on: push`, `on: [push]`, a bare `push:` key, + # or a `push:` mapping. All forms without a `branches:` filter fire on every + # branch — including main — so they must count as deploying (fail-safe). + push: Any = None + push_present = False + if isinstance(on_section, str): + push_present = on_section == "push" + elif isinstance(on_section, list): + push_present = "push" in on_section + elif isinstance(on_section, dict): + push_present = "push" in on_section + push = on_section.get("push") + + branches: list[str] = [] + paths_ignore: list[str] = [] + if isinstance(push, dict): + raw_branches = push.get("branches") or [] + if isinstance(raw_branches, list): + branches = [str(b) for b in raw_branches] + raw_ignore = push.get("paths-ignore") or [] + if isinstance(raw_ignore, list): + paths_ignore = [str(p) for p in raw_ignore] + + if push_present and not branches: + deploying = True + else: + deploying = any(b in _DEPLOY_BRANCHES for b in branches) + return DeployModel( + repo=repo, + is_deploying_service=deploying, + deploy_branches=branches, + paths_ignore=paths_ignore, + raw_present=True, + ) + + +class DeployModelCache: + """Fetches and memoizes the :class:`DeployModel` for each repo in an org.""" + + def __init__( + self, + gh: GitHubClient, + organization: str, + publish_on_merge_repos: list[str] | None = None, + ) -> None: + self._gh = gh + self._organization = organization + self._publish_on_merge = frozenset(publish_on_merge_repos or []) + self._cache: dict[str, DeployModel] = {} + + def get(self, repo: str) -> DeployModel: + if repo not in self._cache: + if repo in self._publish_on_merge: + # npm-publishing libs ship on merge despite having no deploy.yml; + # treat every bump as production-deploying (Tier 1). + self._cache[repo] = DeployModel( + repo=repo, is_deploying_service=True, raw_present=False + ) + else: + self._cache[repo] = self._fetch(repo) + return self._cache[repo] + + def _fetch(self, repo: str) -> DeployModel: + try: + result = self._gh.query( + _DEPLOY_FILE_QUERY, + variables={"owner": self._organization, "repo": repo}, + ) + except Exception as exc: # network / permissions -> fail safe (deploying) + print(f"deploy.yml fetch failed for {repo}: {exc}", file=sys.stderr) + return DeployModel(repo=repo, is_deploying_service=True, raw_present=False) + + obj = (result or {}).get("repository", {}).get("object") + text = obj.get("text") if isinstance(obj, dict) else None + return parse_deploy_yaml(repo, text) diff --git a/dependabot_batch_review/github_client.py b/dependabot_batch_review/github_client.py index 1d140de..70a0af6 100644 --- a/dependabot_batch_review/github_client.py +++ b/dependabot_batch_review/github_client.py @@ -1,6 +1,7 @@ from getpass import getpass import json import os +import time from subprocess import CalledProcessError, run from typing import Any, Self import sys # Added import @@ -19,19 +20,45 @@ def __init__(self, token: str) -> None: self.token = token self.endpoint = "https://api.github.com/graphql" - def query(self, query: str, variables: dict[str, Any] = {}) -> Any: - data = {"query": query, "variables": variables} - result = requests.post( - url=self.endpoint, - headers={"Authorization": f"Bearer {self.token}"}, - data=json.dumps(data), - ) - body = result.json() - result.raise_for_status() - if "errors" in body: - errors = body["errors"] - raise Exception(f"Query failed: {json.dumps(errors)}") - return body["data"] + def query( + self, + query: str, + variables: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Any: + data = {"query": query, "variables": variables or {}} + headers = {"Authorization": f"Bearer {self.token}"} + if extra_headers: + headers.update(extra_headers) + + # GitHub's GraphQL endpoint intermittently returns transient 5xx / HTML + # (e.g. a 502 when a heavy `bodyHTML` search times out). Retry those with + # backoff so the daily automation doesn't fall over on a blip. A request + # timeout ensures a hung connection can't stall a scheduled run forever. + last_error = "unknown error" + for attempt in range(4): + result = requests.post( + url=self.endpoint, + headers=headers, + data=json.dumps(data), + timeout=30, + ) + if result.status_code >= 500: + last_error = f"HTTP {result.status_code}" + time.sleep(2**attempt) + continue + try: + body = result.json() + except ValueError: + last_error = "non-JSON response from GitHub" + time.sleep(2**attempt) + continue + result.raise_for_status() + if "errors" in body: + raise Exception(f"Query failed: {json.dumps(body['errors'])}") + return body["data"] + + raise Exception(f"GitHub GraphQL request failed after retries: {last_error}") @classmethod def init(cls) -> Self: diff --git a/dependabot_batch_review/health.py b/dependabot_batch_review/health.py new file mode 100644 index 0000000..1e3f9b8 --- /dev/null +++ b/dependabot_batch_review/health.py @@ -0,0 +1,384 @@ +""" +Post-deploy health gate for Tier-1 (production-deploying) merges. + +After a Tier-1 PR merges and the production deploy settles, this samples two +independent monitoring signals — Sentry (new unresolved issues + release-health +crash-free rate) and New Relic (error rate vs a pre-deploy baseline). Per the +configured policy, **either** signal degrading marks the deploy unhealthy, which +triggers an auto-rollback. + +External I/O is via ``requests`` (already a dependency). ``now`` and ``sleep`` are +injectable so the deploy-wait loop is deterministic under test. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable + +import requests + +from .automation_types import HealthVerdict, MergeOutcome, SignalResult +from .config import HealthConfig +from .github_client import GitHubClient + +_NowFn = Callable[[], datetime] +_SleepFn = Callable[[float], None] + +_DEPLOY_QUERY = """ +query($owner: String!, $name: String!, $sha: GitObjectID!) { + repository(owner: $owner, name: $name) { + object(oid: $sha) { + ... on Commit { + deployments(first: 10, environments: ["Production", "production", "prod"]) { + nodes { environment state latestStatus { state logUrl } } + } + } + } + } +} +""" + +_DEPLOY_SUCCESS = frozenset({"SUCCESS", "ACTIVE"}) +_DEPLOY_FAILURE = frozenset({"FAILURE", "ERROR"}) + + +@dataclass +class DeployResult: + state: str # "success" | "failure" | "timeout" | "none" + log_url: str | None = None + + +# --------------------------------------------------------------------------- Sentry + + +class SentryClient: + def __init__( + self, token: str, org: str, base_url: str = "https://sentry.io/api/0" + ) -> None: + self._token = token + self._org = org + self._base = base_url.rstrip("/") + self._project_ids: dict[str, str] = {} + + def _get(self, path: str, params: dict[str, Any]) -> Any: + response = requests.get( + f"{self._base}{path}", + headers={"Authorization": f"Bearer {self._token}"}, + params=params, + timeout=30, + ) + response.raise_for_status() + return response.json() + + def resolve_project_id(self, slug: str) -> str: + if slug not in self._project_ids: + projects = self._get(f"/organizations/{self._org}/projects/", {}) + for project in projects: + self._project_ids[str(project["slug"])] = str(project["id"]) + return self._project_ids.get(slug, slug) + + def new_issues(self, project_id: str, since_min: int) -> int: + issues = self._get( + f"/organizations/{self._org}/issues/", + { + "project": project_id, + "query": f"is:unresolved firstSeen:-{since_min}m", + "statsPeriod": f"{since_min}m", + }, + ) + return len(issues) if isinstance(issues, list) else 0 + + def crash_free_rate(self, project_id: str, window_min: int) -> float | None: + data = self._get( + f"/organizations/{self._org}/sessions/", + { + "project": project_id, + "field": "crash_free_rate(session)", + "statsPeriod": f"{window_min}m", + "interval": "1m", + }, + ) + groups = data.get("groups") if isinstance(data, dict) else None + if not groups: + return None + totals = groups[0].get("totals", {}) + rate = totals.get("crash_free_rate(session)") + return float(rate) * 100.0 if rate is not None else None + + +def sample_sentry( + client: SentryClient, config: HealthConfig, repo: str +) -> SignalResult: + window = config.health_window_min + thresholds = config.thresholds + try: + project = client.resolve_project_id(config.sentry_project_for(repo)) + new_issues = client.new_issues(project, window) + crash_free = client.crash_free_rate(project, window) + except requests.RequestException as exc: + # Fail closed: an unreachable monitor is an UNVERIFIED deploy, not a + # healthy one (expired token, rate limit, Sentry outage). + return SignalResult( + "sentry", False, "sentry", detail=f"query failed: {exc}", unknown=True + ) + + issues_ok = new_issues < thresholds.new_issue_fail_count + crash_str = f"{crash_free:.2f}%" if crash_free is not None else "n/a" + detail = f"crash-free {crash_str}, {new_issues} new issue(s)" + + if not issues_ok: + # New issues are hard evidence of degradation regardless of crash data. + return SignalResult( + source="sentry", + healthy=False, + metric="new issues / crash-free", + observed=float(new_issues), + threshold=float(thresholds.new_issue_fail_count), + detail=detail, + ) + if crash_free is None and thresholds.require_crash_free: + # Fail closed: sessions not reporting means the crash-free floor was + # never checked. Repos without session tracking can opt out via + # thresholds.require_crash_free. + return SignalResult( + "sentry", + False, + "new issues / crash-free", + observed=float(new_issues), + detail=f"{detail} — sessions not reporting", + unknown=True, + ) + healthy = crash_free is None or crash_free >= thresholds.min_crash_free_pct + return SignalResult( + source="sentry", + healthy=healthy, + metric="new issues / crash-free", + observed=float(new_issues), + threshold=float(thresholds.new_issue_fail_count), + detail=detail, + ) + + +# ------------------------------------------------------------------------ New Relic + + +class NewRelicClient: + def __init__( + self, + token: str, + account_id: int, + endpoint: str = "https://api.newrelic.com/graphql", + ) -> None: + self._token = token + self._account_id = account_id + self._endpoint = endpoint + + def nrql(self, query: str) -> list[dict[str, Any]]: + graphql = ( + "query($id: Int!, $q: Nrql!) { actor { account(id: $id) { " + "nrql(query: $q) { results } } } }" + ) + response = requests.post( + self._endpoint, + headers={"API-Key": self._token, "Content-Type": "application/json"}, + json={"query": graphql, "variables": {"id": self._account_id, "q": query}}, + timeout=30, + ) + response.raise_for_status() + body = response.json() + results = ( + body.get("data", {}) + .get("actor", {}) + .get("account", {}) + .get("nrql", {}) + .get("results") + ) + return results if isinstance(results, list) else [] + + def error_rate(self, app: str, window_min: int, until_min: int = 0) -> float | None: + since = window_min + until_min + query = ( + "SELECT percentage(count(*), WHERE error IS true) AS rate " + f"FROM Transaction WHERE appName = '{app}' " + f"SINCE {since} minutes ago UNTIL {until_min} minutes ago" + ) + results = self.nrql(query) + if not results: + return None + rate = results[0].get("rate") + return float(rate) if rate is not None else None + + def error_count(self, app: str, window_min: int) -> int: + query = ( + "SELECT count(*) AS c FROM TransactionError " + f"WHERE appName = '{app}' SINCE {window_min} minutes ago" + ) + results = self.nrql(query) + if not results: + return 0 + return int(results[0].get("c", 0)) + + +def sample_newrelic( + client: NewRelicClient, config: HealthConfig, repo: str +) -> SignalResult: + app = config.newrelic_app_for(repo) + window = config.health_window_min + thresholds = config.thresholds + try: + post_rate = client.error_rate(app, window, until_min=0) + baseline_rate = client.error_rate( + app, config.baseline_window_min, until_min=window + ) + post_count = client.error_count(app, window) + except requests.RequestException as exc: + return SignalResult( + "newrelic", False, "newrelic", detail=f"query failed: {exc}", unknown=True + ) + + if post_rate is None: + # No transaction data for the app name almost always means a config + # mismatch, not a healthy idle service — treat as unverifiable. + return SignalResult( + "newrelic", False, "newrelic", detail="no data", unknown=True + ) + + baseline = baseline_rate or 0.0 + ceiling = baseline * (1.0 + thresholds.error_delta_pct / 100.0) + # The relative spike check only makes sense against real baseline traffic; + # with baseline ~0 the ceiling is 0 and a single error would read as a + # spike. Low/no-traffic services are judged by the absolute error floor. + near_zero_baseline = baseline <= 0.01 + spiked = not near_zero_baseline and post_rate > ceiling + cold_start = near_zero_baseline and post_count >= thresholds.nr_error_count_abs + healthy = not (spiked or cold_start) + return SignalResult( + source="newrelic", + healthy=healthy, + metric="error rate", + observed=post_rate, + baseline=baseline, + threshold=ceiling, + detail=f"error-rate {post_rate:.2f}% vs {baseline:.2f}% baseline, {post_count} errors", + ) + + +# ------------------------------------------------------------------------- deploy wait + + +def wait_for_deploy( + gh: GitHubClient, + outcome: MergeOutcome, + config: HealthConfig, + now: _NowFn, + sleep: _SleepFn, +) -> DeployResult: + """Poll GitHub Deployments for the Production env until the merge SHA settles.""" + if not outcome.merge_commit_sha: + return DeployResult(state="none") + deadline = now().timestamp() + config.deploy_wait_timeout_s + while now().timestamp() < deadline: + try: + result = gh.query( + _DEPLOY_QUERY, + variables={ + "owner": outcome.owner, + "name": outcome.repo, + "sha": outcome.merge_commit_sha, + }, + ) + except Exception: # noqa: BLE001 - transient; keep polling until deadline + sleep(config.deploy_poll_interval_s) + continue + obj = (result or {}).get("repository", {}).get("object") or {} + nodes = (obj.get("deployments") or {}).get("nodes") or [] + for node in nodes: + state = str(node.get("state", "")).upper() + log_url = (node.get("latestStatus") or {}).get("logUrl") + if state in _DEPLOY_SUCCESS: + return DeployResult(state="success", log_url=log_url) + if state in _DEPLOY_FAILURE: + return DeployResult(state="failure", log_url=log_url) + sleep(config.deploy_poll_interval_s) + return DeployResult(state="timeout") + + +# ------------------------------------------------------------------------- the gate + + +def check_health( + gh: GitHubClient, + outcome: MergeOutcome, + config: HealthConfig, + now: _NowFn | None = None, + sleep: _SleepFn | None = None, +) -> HealthVerdict: + """Wait for the deploy, sample Sentry + New Relic, and combine into a verdict.""" + now = now or (lambda: datetime.now(timezone.utc)) + sleep = sleep or time.sleep + + deploy = wait_for_deploy(gh, outcome, config, now, sleep) + if deploy.state == "failure": + return HealthVerdict( + healthy=False, + reasons=[f"production deploy failed ({deploy.log_url or 'no log'})"], + ) + if deploy.state in ("timeout", "none"): + # We never saw the new code go live; sampling now would measure the OLD + # release and pass vacuously. Cannot verify -> escalate, don't guess. + why = ( + "deploy did not settle within timeout" + if deploy.state == "timeout" + else "no merge commit SHA / no Production deployment found" + ) + return HealthVerdict( + healthy=False, reasons=[f"{why}; health not verifiable"], unknown=True + ) + + has_sentry = bool(config.sentry_token) + has_newrelic = bool(config.newrelic_token and config.newrelic_account_id) + if not (has_sentry or has_newrelic): + return HealthVerdict( + healthy=False, + reasons=["no monitoring signals configured; health not verifiable"], + unknown=True, + ) + + # Soak so the sampling window contains post-deploy traffic; without this the + # backwards-looking windows would mostly measure the previous release. + soak_min = ( + config.health_window_min + if config.post_deploy_soak_min is None + else config.post_deploy_soak_min + ) + if soak_min > 0: + sleep(soak_min * 60) + + signals: dict[str, SignalResult] = {} + if has_sentry and config.sentry_token: + signals["sentry"] = sample_sentry( + SentryClient(config.sentry_token, config.sentry_org), config, outcome.repo + ) + if has_newrelic and config.newrelic_token and config.newrelic_account_id: + signals["newrelic"] = sample_newrelic( + NewRelicClient(config.newrelic_token, int(config.newrelic_account_id)), + config, + outcome.repo, + ) + + reasons = [ + f"{signal.source}: {signal.detail}" + for signal in signals.values() + if not signal.healthy + ] + # Hard evidence of degradation outranks an unverifiable sibling signal; + # otherwise any unknown signal makes the whole verdict unknown. + degraded = any(not s.healthy and not s.unknown for s in signals.values()) + unknown = not degraded and any(s.unknown for s in signals.values()) + healthy = all(signal.healthy for signal in signals.values()) + return HealthVerdict( + healthy=healthy, signals=signals, reasons=reasons, unknown=unknown + ) diff --git a/dependabot_batch_review/monitor.py b/dependabot_batch_review/monitor.py new file mode 100644 index 0000000..15db845 --- /dev/null +++ b/dependabot_batch_review/monitor.py @@ -0,0 +1,455 @@ +""" +Curses TUI "kickoff + watch" monitor for a Dependabot sweep. + +Shows a live table of PRs (repo / package / tier / CI / state / health) plus a log +pane, driven by the same engine as ``automerge`` via a worker thread that feeds a +queue of events to the main (drawing) thread. Defaults to dry-run; ``--execute`` +runs the real sweep (merge, then health-gate + rollback for Tier 1). + +The non-curses parts — building rows and reducing events — are pure functions so +the state machine is testable without a TTY. +""" + +from __future__ import annotations + +import curses +import queue +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Callable, Iterable + +from .audit import AuditLog, default_audit_path +from .automation_types import MergeOutcome +from .automerge import ( + Decision, + PreMergeCheckError, + _merge_and_capture, + gather_decisions, + monitoring_configured, +) +from .config import Config, load_config +from .github_client import GitHubClient + + +class PRState(Enum): + PENDING = "PENDING" + MERGING = "MERGING" + MERGED = "MERGED" + DEPLOYING = "DEPLOYING" + CHECKING = "CHECKING_HEALTH" + HEALTHY = "HEALTHY" + ROLLING_BACK = "ROLLING_BACK" + ROLLED_BACK = "ROLLED_BACK" + SKIPPED = "SKIPPED" + ESCALATED = "ESCALATED" + FAILED = "FAILED" + WOULD_MERGE = "WOULD_MERGE" + + +@dataclass +class RowState: + repo: str + package: str + tier: int + ci: str + state: PRState + health: str = "" + action: str = "" + url: str = "" + + +@dataclass +class MonitorEvent: + kind: str # "row" | "log" | "done" + index: int = -1 + state: PRState | None = None + health: str | None = None + action: str | None = None + message: str = "" + + +@dataclass +class MonitorModel: + rows: list[RowState] + logs: list[str] = field(default_factory=list) + done: bool = False + + +def build_rows(decisions: list[Decision]) -> list[RowState]: + """Map engine decisions to initial TUI rows.""" + initial = { + "merge": PRState.PENDING, + "merge+health": PRState.PENDING, + "escalate": PRState.ESCALATED, + "skip": PRState.SKIPPED, + } + rows: list[RowState] = [] + for d in decisions: + rows.append( + RowState( + repo=d.pr.repo or "?", + package=d.pr.group_name, + tier=int(d.classification.tier), + ci=d.pr.check_status.description, + state=initial.get(d.action, PRState.PENDING), + action=d.action, + health="; ".join(d.classification.reasons)[:40], + url=d.pr.url, + ) + ) + return rows + + +def apply_event(model: MonitorModel, event: MonitorEvent) -> MonitorModel: + """Pure reducer: apply one event to the model (used by the draw loop + tests).""" + if event.kind == "log": + model.logs.append(event.message) + elif event.kind == "done": + model.done = True + elif event.kind == "row" and 0 <= event.index < len(model.rows): + row = model.rows[event.index] + if event.state is not None: + row.state = event.state + if event.health is not None: + row.health = event.health + if event.action is not None: + row.action = event.action + return model + + +# --------------------------------------------------------------------- worker + +Emit = Callable[[MonitorEvent], None] + + +def dry_run_worker( + decisions: list[Decision], +) -> Callable[[Emit, threading.Event], None]: + """A worker that just animates the classified plan to its terminal state.""" + + def work(emit: Emit, abort: threading.Event) -> None: + final = { + "merge": (PRState.WOULD_MERGE, "would merge (T0)"), + "merge+health": (PRState.WOULD_MERGE, "would merge + health-gate (T1)"), + "escalate": (PRState.ESCALATED, "needs human"), + "skip": (PRState.SKIPPED, ""), + } + for index, d in enumerate(decisions): + if abort.is_set(): + emit(MonitorEvent("log", message="aborted")) + break + state, note = final.get(d.action, (PRState.SKIPPED, "")) + emit(MonitorEvent("row", index=index, state=state)) + if note: + emit( + MonitorEvent( + "log", message=f"{d.pr.repo}/{d.pr.group_name}: {note}" + ) + ) + emit(MonitorEvent("done")) + + return work + + +def live_worker( + gh: GitHubClient, + cfg: Config, + decisions: list[Decision], + audit: AuditLog | None = None, +) -> Callable[[Emit, threading.Event], None]: + """ + The real sweep: merge eligible PRs, then health-gate Tier-1 merges and roll + back degraded deploys — emitting row/log events as each PR advances. + """ + + def work(emit: Emit, abort: threading.Event) -> None: + from .health import check_health + from .rollback import revert_merge + + def record(event: str, d: Decision, **fields: object) -> None: + if audit is not None: + audit.record( + event, + repo=d.pr.repo, + package=d.pr.group_name, + tier=int(d.classification.tier), + url=d.pr.url, + **fields, + ) + + def log(message: str) -> None: + emit(MonitorEvent("log", message=message)) + + def row(index: int, state: PRState, health: str | None = None) -> None: + emit(MonitorEvent("row", index=index, state=state, health=health)) + + now = datetime.now(timezone.utc) + can_watch = monitoring_configured(cfg) + watch: list[tuple[int, MergeOutcome]] = [] + merged_count = 0 + for index, d in enumerate(decisions): + if abort.is_set(): + log("aborted") + break + if d.action not in ("merge", "merge+health"): + continue + if merged_count >= cfg.max_merges_per_run: + row(index, PRState.SKIPPED, "max_merges_per_run cap") + record("skipped", d, reason="max_merges_per_run cap") + continue + if d.action == "merge+health" and not can_watch: + row(index, PRState.SKIPPED, "no monitoring credentials") + log(f"{d.pr.repo}/{d.pr.group_name}: held — health gate has no signals") + record("skipped", d, reason="no monitoring credentials") + continue + row(index, PRState.MERGING) + try: + outcome = _merge_and_capture(gh, d, cfg, now) + merged_count += 1 + except PreMergeCheckError as exc: + row(index, PRState.ESCALATED, str(exc)[:40]) + log(f"{d.pr.repo}/{d.pr.group_name}: held — {exc}") + record("held", d, reason=str(exc)) + continue + except Exception as exc: # noqa: BLE001 - show and continue the sweep + row(index, PRState.FAILED, str(exc)[:40]) + log(f"{d.pr.repo}/{d.pr.group_name}: merge failed: {exc!r}") + record("merge_failed", d, reason=repr(exc)) + continue + row(index, PRState.MERGED) + log(f"{d.pr.repo}/{d.pr.group_name}: merged") + record("merged", d, merge_commit_sha=outcome.merge_commit_sha) + if d.action == "merge+health": + watch.append((index, outcome)) + + for index, outcome in watch: + if abort.is_set(): + log("aborted before health gate completed") + break + row(index, PRState.CHECKING) + verdict = check_health(gh, outcome, cfg.health) + record( + "health", + decisions[index], + healthy=verdict.healthy, + unknown=verdict.unknown, + reasons=verdict.reasons, + ) + if verdict.healthy: + row(index, PRState.HEALTHY) + continue + if verdict.unknown: + row(index, PRState.ESCALATED, "health unverified") + log(f"{outcome.repo}: health not verifiable — check manually") + continue + row(index, PRState.ROLLING_BACK, "; ".join(verdict.reasons)[:40]) + if not outcome.merge_commit_sha: + row(index, PRState.FAILED, "no merge SHA; manual rollback") + record( + "rollback", decisions[index], performed=False, reason="no merge SHA" + ) + continue + rollback = revert_merge( + gh, + outcome.owner, + outcome.repo, + outcome.merge_commit_sha, + original_title=outcome.pr.group_name, + original_pr_url=outcome.pr.url, + dry_run=cfg.dry_run, + merge_method=outcome.pr.merge_method, + ) + record( + "rollback", + decisions[index], + performed=rollback.performed, + revert_pr_url=rollback.revert_pr_url, + reason=rollback.reason, + ) + if rollback.performed: + row(index, PRState.ROLLED_BACK, (rollback.revert_pr_url or "")[:40]) + log(f"{outcome.repo}: rolled back -> {rollback.revert_pr_url}") + else: + row(index, PRState.FAILED, (rollback.reason or "rollback failed")[:40]) + log(f"{outcome.repo}: rollback NOT performed: {rollback.reason}") + emit(MonitorEvent("done")) + + return work + + +# ----------------------------------------------------------------------- curses + + +_TIER_COLOR = {0: 2, 1: 3, 2: 1} # green / yellow / red (init below) + + +def _init_colors() -> None: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_RED, -1) + curses.init_pair(2, curses.COLOR_GREEN, -1) + curses.init_pair(3, curses.COLOR_YELLOW, -1) + curses.init_pair(4, curses.COLOR_CYAN, -1) + + +def _draw(stdscr: Any, model: MonitorModel, scroll: int, dry_run: bool) -> None: + stdscr.erase() + height, width = stdscr.getmaxyx() + mode = "DRY-RUN" if dry_run else "LIVE" + clock = datetime.now(timezone.utc).strftime("%H:%M:%S") + header = f" Dependabot sweep [{mode}] {len(model.rows)} PRs {clock} " + stdscr.addnstr(0, 0, header.ljust(width), width, curses.A_REVERSE) + + cols = f" {'REPO':18}{'PACKAGE':26}{'TIER':5}{'CI':9}{'STATE':16}{'NOTE'}" + stdscr.addnstr(1, 0, cols.ljust(width), width, curses.A_BOLD) + + table_height = max(3, height - 7) + visible = model.rows[scroll : scroll + table_height] + for offset, row in enumerate(visible): + color = curses.color_pair(_TIER_COLOR.get(row.tier, 0)) + line = ( + f" {row.repo[:17]:18}{row.package[:25]:26}T{row.tier:<4}" + f"{row.ci[:8]:9}{row.state.value:16}{row.health[:30]}" + ) + stdscr.addnstr(2 + offset, 0, line.ljust(width), width, color) + + log_top = 2 + table_height + stdscr.addnstr(log_top, 0, " log ".ljust(width, "─"), width, curses.A_DIM) + for offset, message in enumerate(model.logs[-(height - log_top - 2) :]): + stdscr.addnstr(log_top + 1 + offset, 0, f" {message}", width) + + status = " [q]uit [A]bort [j/k] scroll " + if model.done: + status += " — done" + stdscr.addnstr(height - 1, 0, status.ljust(width), width, curses.A_REVERSE) + stdscr.refresh() + + +def run_monitor( + stdscr: Any, + model: MonitorModel, + worker: Callable[[Emit, threading.Event], None], + dry_run: bool, +) -> None: + """Main draw loop: drains worker events, handles keys, redraws.""" + _init_colors() + curses.curs_set(0) + stdscr.nodelay(True) + + events: queue.Queue[MonitorEvent] = queue.Queue() + abort = threading.Event() + thread = threading.Thread(target=worker, args=(events.put, abort), daemon=True) + thread.start() + + scroll = 0 + while True: + while True: + try: + apply_event(model, events.get_nowait()) + except queue.Empty: + break + _draw(stdscr, model, scroll, dry_run) + + key = stdscr.getch() + if key in (ord("q"), 27): + abort.set() + break + if key == ord("A"): + abort.set() + elif key in (ord("j"), curses.KEY_DOWN): + scroll = min(scroll + 1, max(0, len(model.rows) - 1)) + elif key in (ord("k"), curses.KEY_UP): + scroll = max(0, scroll - 1) + if model.done and not thread.is_alive() and events.empty(): + # Keep the final view until the user quits. + pass + time.sleep(0.05) + + +def main() -> int: + from argparse import ArgumentParser + + parser = ArgumentParser(description="Curses monitor for a Dependabot sweep") + parser.add_argument("organization", nargs="?", default=None) + parser.add_argument("--config", default="automation.yml") + parser.add_argument( + "--execute", + action="store_true", + help="Run the real sweep: merge, health-gate Tier 1, roll back on failure " + "(default: dry-run animation of the plan).", + ) + parser.add_argument( + "--audit-log", + default=None, + help="Path for the JSONL audit trail (default: sweep-audit-.jsonl)", + ) + args = parser.parse_args() + + cfg = load_config(args.config) + if args.organization: + cfg.organization = args.organization + # The TUI goes live only with the explicit flag — config/env alone can't. + cfg.dry_run = not args.execute + + from pathlib import Path + + audit = AuditLog(Path(args.audit_log) if args.audit_log else default_audit_path()) + + gh = GitHubClient.init() + decisions = gather_decisions(gh, cfg, datetime.now(timezone.utc)) + audit.record( + "sweep_start", + organization=cfg.organization, + dry_run=cfg.dry_run, + prs=len(decisions), + ) + for d in decisions: + audit.record( + "plan", + repo=d.pr.repo, + package=d.pr.group_name, + tier=int(d.classification.tier), + action=d.action, + ci=d.pr.check_status.description, + reasons=d.classification.reasons, + skip_reason=d.skip_reason, + url=d.pr.url, + ) + + model = MonitorModel(rows=build_rows(decisions)) + if cfg.dry_run: + worker = dry_run_worker(decisions) + else: + worker = live_worker(gh, cfg, decisions, audit=audit) + curses.wrapper( + lambda stdscr: run_monitor(stdscr, model, worker, dry_run=cfg.dry_run) + ) + audit.record("sweep_end", dry_run=cfg.dry_run) + print(f"Audit log: {audit.path}") + return 0 + + +def collect_events( + worker: Callable[[Emit, threading.Event], None], +) -> list[MonitorEvent]: + """Run a worker synchronously and collect its events (test helper).""" + collected: list[MonitorEvent] = [] + worker(collected.append, threading.Event()) + return collected + + +def reduce_events(rows: list[RowState], events: Iterable[MonitorEvent]) -> MonitorModel: + """Apply a sequence of events to fresh rows (test helper).""" + model = MonitorModel(rows=rows) + for event in events: + apply_event(model, event) + return model + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/dependabot_batch_review/orchestrator.py b/dependabot_batch_review/orchestrator.py new file mode 100644 index 0000000..eabf05b --- /dev/null +++ b/dependabot_batch_review/orchestrator.py @@ -0,0 +1,71 @@ +""" +Post-merge orchestration for live Tier-1 merges. + +For each production-deploying merge, verify health and — if degraded — triage and +roll back, posting the verdict to Slack. Kept separate from ``automerge`` so the +engine stays unit-testable without the health/rollback dependencies. +""" + +from __future__ import annotations + +import os +import sys + +from .automation_types import MergeOutcome +from .config import Config +from .github_client import GitHubClient +from .health import check_health +from .rollback import RollbackResult, revert_merge +from .slack_messages import format_rollback, format_tier1_health +from .slack import SlackClient +from .triage import triage_pr + + +def health_gate_outcomes( + gh: GitHubClient, outcomes: list[MergeOutcome], cfg: Config +) -> None: + """Health-check each Tier-1 merge; roll back and alert on failure.""" + token = os.environ.get("SLACK_TOKEN") + channel = cfg.slack_channel + slack = SlackClient(token) if (token and channel) else None + + for outcome in outcomes: + verdict = check_health(gh, outcome, cfg.health) + + if verdict.healthy or verdict.unknown: + # Healthy: report and move on. Unknown: we could not verify (gate + # blind / deploy never seen) — escalate to humans, never rollback + # on absence of evidence. + message = format_tier1_health(outcome, verdict) + if slack and channel: + slack.post_message(channel, message) + else: + print(message, file=sys.stderr) + continue + + triage = triage_pr(outcome.pr) + if outcome.merge_commit_sha: + rollback = revert_merge( + gh, + outcome.owner, + outcome.repo, + outcome.merge_commit_sha, + original_title=outcome.pr.group_name, + original_pr_url=outcome.pr.url, + dry_run=cfg.dry_run, + merge_method=outcome.pr.merge_method, + ) + else: + rollback = RollbackResult( + performed=False, + revert_pr_url=None, + revert_commit_sha=None, + reason="no merge commit SHA captured; manual rollback required", + dry_run=cfg.dry_run, + ) + + message = format_rollback(outcome, verdict, rollback, triage) + if slack and channel: + slack.post_message(channel, message) + else: + print(message, file=sys.stderr) diff --git a/dependabot_batch_review/review.py b/dependabot_batch_review/review.py index 8bf1bb8..e15c8ae 100644 --- a/dependabot_batch_review/review.py +++ b/dependabot_batch_review/review.py @@ -3,7 +3,7 @@ from typing import Any, Optional, TextIO, Union from blessings import Terminal # type: ignore -from bs4 import BeautifulSoup, PageElement, Tag +from bs4 import BeautifulSoup, PageElement from openpyxl import load_workbook # type: ignore[import-untyped] from dataclasses import dataclass, field as dataclass_field from enum import Enum @@ -55,13 +55,13 @@ def write_heading(self, level: int, text: str) -> None: def write_list_item(self, text: str, indent_level: int = 0) -> None: prefix = " " * indent_level + "- " if self._file_handle: - self._file_handle.write(f"{prefix}{{text}}\n") + self._file_handle.write(f"{prefix}{text}\n") else: - self.write(f"{prefix}{{text}}") + self.write(f"{prefix}{text}") def write_code_block(self, code: str, lang: str = "") -> None: if self._file_handle: - self._file_handle.write(f"```\n{lang}\n{code}\n```\n") + self._file_handle.write(f"```{lang}\n{code}\n```\n") else: self.write(code) @@ -111,6 +111,17 @@ class DependencyUpdatePR: advisory_summary: Optional[str] = None advisory_url: Optional[str] = None reviewers: list[str] = dataclass_field(default_factory=list) + # Fields used by the autonomous auto-merge layer (automerge/risk/health). + # All optional with defaults so existing call sites are unaffected. + created_at: Optional[str] = None + merge_state_status: Optional[str] = None + mergeable: Optional[str] = None + head_ref_name: Optional[str] = None + number: Optional[int] = None + repo: Optional[str] = None + # Real changed paths from the PR (empty when not fetched / truncated, in + # which case the deploy model falls back to ecosystem inference). + changed_files: list[str] = dataclass_field(default_factory=list) @dataclass @@ -127,18 +138,15 @@ class RiskAssessment: def analyze_risk(pr: DependencyUpdatePR) -> RiskAssessment: + # Local import: semver imports DependencyUpdate from this module. + from .semver import BumpKind, classify_bump + reasons = [] level = "Low" for u in pr.updates: if u.from_version and u.to_version: - from_parts = u.from_version.split(".") - to_parts = u.to_version.split(".") - if ( - len(from_parts) > 0 - and len(to_parts) > 0 - and from_parts[0] != to_parts[0] - ): + if classify_bump(u.from_version, u.to_version) == BumpKind.MAJOR: level = "High" reasons.append( f"Major version bump from {u.from_version} to {u.to_version}" @@ -185,31 +193,6 @@ def map_risk_to_priority(risk_level: str) -> str: return "P3" -def _extract_ghsa_details( - soup: BeautifulSoup, -) -> tuple[Optional[str], Optional[str], Optional[str]]: - ghsa_id = None - advisory_summary = None - advisory_url = None - - details_element = soup.find( - "details", id=lambda x: x and x.startswith("ghsa-details-") - ) - if isinstance(details_element, Tag): - ghsa_id_element = details_element.find( - "a", href=lambda x: x and "github.com/advisories" in x - ) - if isinstance(ghsa_id_element, Tag): - ghsa_id = ghsa_id_element.text.strip() - advisory_url = str(ghsa_id_element["href"]) - - summary_element = details_element.find("summary") - if isinstance(summary_element, Tag): - advisory_summary = summary_element.text.strip() - - return ghsa_id, advisory_summary, advisory_url - - def parse_dependabot_pr(title: str, body: str) -> DependencyUpdateDetails: soup = BeautifulSoup(body, "html.parser") title_re = r"Bump (\S+) from (\S+) to (\S+)" @@ -322,10 +305,13 @@ def fetch_dependency_prs( author { login } id + number title bodyHTML headRefName reviewDecision + createdAt + mergeable url assignees(first: 10) { @@ -349,6 +335,11 @@ def fetch_dependency_prs( } } } + + files(first: 100) { + totalCount + nodes { path } + } } } } @@ -357,6 +348,10 @@ def fetch_dependency_prs( label_terms = " ".join(f"label:{label}" for label in labels) query = f"org:{organization} {label_terms} is:pr is:open author:app/dependabot" + # NB: we intentionally do not request `mergeStateStatus` here. It is a preview + # field that 502s when computed for ~100 PRs org-wide in one search; `mergeable` + # (GA) is enough to detect conflicts, and a blocked/behind PR simply fails the + # merge attempt, which the engine catches and skips. result = gh.query(query=dependencies_query, variables={"query": query}) pull_requests = result["search"]["nodes"] @@ -374,12 +369,6 @@ def fetch_dependency_prs( "statusCheckRollup" ] package_type = parse_package_type_from_branch_name(pr["headRefName"]) - - soup = BeautifulSoup(pr["bodyHTML"], "html.parser") - ghsa_id, advisory_summary, advisory_url = _extract_ghsa_details( - soup - ) # This will now return None, None, None - except ValueError as exc: print(f"Failed to parse details from {pr['url']}: {exc}", file=sys.stderr) continue @@ -407,16 +396,36 @@ def fetch_dependency_prs( merge_method=pr["repository"]["viewerDefaultMergeMethod"], package_type=package_type, url=pr["url"], - ghsa_id=ghsa_id, - advisory_summary=advisory_summary, - advisory_url=advisory_url, reviewers=_extract_reviewers(pr), + created_at=pr.get("createdAt"), + merge_state_status=pr.get("mergeStateStatus"), + mergeable=pr.get("mergeable"), + head_ref_name=pr.get("headRefName"), + number=pr.get("number"), + repo=pr["repository"]["name"], + changed_files=_extract_changed_files(pr), ) ) return updates +def _extract_changed_files(pr: dict[str, Any]) -> list[str]: + """ + The PR's real changed paths, or ``[]`` when unavailable or truncated. + + An empty list makes the deploy model fall back to its conservative + ecosystem-based inference, so truncation can only widen the safety net. + """ + files = pr.get("files") or {} + nodes = files.get("nodes") or [] + paths = [str(n["path"]) for n in nodes if n and n.get("path")] + total = files.get("totalCount") + if total is not None and total > len(paths): + return [] + return paths + + def _extract_reviewers(pr: dict[str, Any]) -> list[str]: """Collect unique reviewer / assignee logins from a PR node.""" people: list[str] = [] @@ -439,20 +448,38 @@ def _extract_reviewers(pr: dict[str, Any]) -> list[str]: return unique -def merge_pr(gh: GitHubClient, pr_id: str, merge_method: str = "MERGE") -> None: +def merge_pr( + gh: GitHubClient, + pr_id: str, + merge_method: str = "MERGE", + expected_head_oid: Optional[str] = None, +) -> dict[str, Any]: + """ + Merge a PR and return the mutation's ``pullRequest`` payload. + + ``expected_head_oid`` makes GitHub refuse the merge if the branch head moved + after we verified it (closes the classify-then-merge TOCTOU window). The + merge commit SHA is returned in the payload so callers need no follow-up + query — a failure after this point can no longer mislabel a merged PR. + """ merge_query = """ mutation mergePullRequest($input: MergePullRequestInput!) { mergePullRequest(input: $input) { pullRequest { merged + mergedAt url + mergeCommit { oid } } } } """ - gh.query( - merge_query, {"input": {"pullRequestId": pr_id, "mergeMethod": merge_method}} - ) + merge_input: dict[str, Any] = {"pullRequestId": pr_id, "mergeMethod": merge_method} + if expected_head_oid: + merge_input["expectedHeadOid"] = expected_head_oid + result = gh.query(merge_query, {"input": merge_input}) + pull_request = ((result or {}).get("mergePullRequest") or {}).get("pullRequest") + return pull_request if isinstance(pull_request, dict) else {} class PromptAbortError(Exception): @@ -499,19 +526,19 @@ def get_package_diff(package_type: str, update: DependencyUpdate) -> str | None: "--diff", f"{update.name}@{update.to_version}", ] - print(f"Running command: {{{' '.join(cmd)}}}") + print(f"Running command: {' '.join(cmd)}") sys.stdout.flush() result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: return result.stdout else: - return f"Error running npm diff: {{{result.stderr}}}" + return f"Error running npm diff: {result.stderr}" except subprocess.TimeoutExpired: return "Error: npm diff command timed out" except FileNotFoundError: return "Error: npm command not found. Please ensure npm is installed and in your PATH." except Exception as e: - return f"Error running npm diff: {{{str(e)}}}" + return f"Error running npm diff: {str(e)}" case _: return None @@ -586,7 +613,7 @@ def review_updates( prs_by_group_name[pr.group_name].append(pr) for group_name, group_prs in prs_by_group_name.items(): - output_writer.write_heading(1, f"Dependency: {{{group_name}}}") + output_writer.write_heading(1, f"Dependency: {group_name}") prs_by_version: dict[str, list[DependencyUpdatePR]] = {} for pr in group_prs: @@ -605,34 +632,34 @@ def review_updates( prs_by_version[version_key].append(pr) for version_key, version_prs in prs_by_version.items(): - output_writer.write_heading(2, f"Version: {{{version_key}}}") + output_writer.write_heading(2, f"Version: {version_key}") for pr in version_prs: - output_writer.write_heading(3, f"PR: {{{pr.url}}}") + output_writer.write_heading(3, f"PR: {pr.url}") risk = analyze_risk(pr) output_writer.write_heading(4, "Risk Analysis") - output_writer.write(f"**Level:** {{{risk.level}}}") + output_writer.write(f"**Level:** {risk.level}") output_writer.write("**Reasons:**") for reason in risk.reasons: output_writer.write_list_item(reason) output_writer.write("") output_writer.write( - f"**CI Status:** {{{pr.check_status.description}}}" + f"**CI Status:** {pr.check_status.description}" ) output_writer.write("") for u in pr.updates: if u.notes: output_writer.write_heading( - 4, f"Release Notes for {{{u.name}}}" + 4, f"Release Notes for {u.name}" ) output_writer.write_code_block(u.notes) for u in pr.updates: if diff_output := get_package_diff(pr.package_type, u): - output_writer.write_heading(4, f"Diff for {{{u.name}}}") + output_writer.write_heading(4, f"Diff for {u.name}") output_writer.write_code_block(diff_output, lang="diff") return diff --git a/dependabot_batch_review/risk.py b/dependabot_batch_review/risk.py new file mode 100644 index 0000000..5420b8c --- /dev/null +++ b/dependabot_batch_review/risk.py @@ -0,0 +1,148 @@ +""" +Deterministic auto-merge tier classification. + +Replaces the per-repo heuristics in ``review.analyze_risk`` for the autonomous +layer with a single, testable function. ``classify`` answers *"how risky is this +bump"* independent of CI/merge state (those are handled by eligibility and +escalation routing in ``automerge``), so a dry-run report can show the tier of a +PR even when its CI is failing. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .automation_types import Tier +from .deploy_model import DeployModel +from .review import DependencyUpdatePR +from .semver import BumpKind, classify_bump, riskiest_bump + +# Tooling / dev-only dependencies — assumed not to ship to production and never a +# Tier-2 escalation on their own (a *major* bump still escalates, see classify). +DEV_TOOLS = frozenset( + { + "ruff", + "mypy", + "black", + "pytest", + "pytest-cov", + "pytest-factoryboy", + "pylint", + "isort", + "flake8", + "tox", + "pre-commit", + "pyflakes", + "pycodestyle", + "coverage", + "ipython", + "factory-boy", + "faker", + "sphinx", + "sphinx-autobuild", + "supervisor", + "cookiecutter", + } +) +LOCKFILE_TOOLING = frozenset({"pip", "pip-tools", "wheel", "setuptools", "poetry"}) + +# Security-sensitive runtime libraries. Any bump escalates to human review. +SECURITY_ANY_BUMP = frozenset( + { + "cryptography", + "gunicorn", + "pyjwt", + "certifi", + "urllib3", + "pyopenssl", + "joserfc", + "oauthlib", + "pyasn1", + } +) +# Security-sensitive runtime libraries where only a *major* bump escalates. +SECURITY_MAJOR_ONLY = frozenset( + { + "requests", + "sqlalchemy", + "marshmallow", + "zope-sqlalchemy", + } +) + + +@dataclass(frozen=True) +class Classification: + tier: Tier + deploys_to_prod: bool + bump: BumpKind + reasons: list[str] = field(default_factory=list) + + +def normalize_name(name: str) -> str: + """PEP 503-ish normalization for matching against the package sets.""" + normalized = name.strip().lower() + if "[" in normalized: # drop extras, e.g. "coverage[toml]" + normalized = normalized.split("[", 1)[0] + return normalized.replace("_", "-").replace(".", "-") + + +def is_dev_tool(name: str) -> bool: + normalized = normalize_name(name) + return ( + normalized in DEV_TOOLS + or normalized in LOCKFILE_TOOLING + or normalized.startswith("types-") + ) + + +def classify(pr: DependencyUpdatePR, model: DeployModel) -> Classification: + """ + Classify a Dependabot PR into an auto-merge tier. + + Tier 2 (human) if: any major bump, an unparseable version (fail-safe), a + security-sensitive runtime lib (any bump), or one of the major-only sensitive + libs bumped at major. Otherwise Tier 1 if it deploys to production, else + Tier 0. + """ + bump = riskiest_bump(pr.updates) + names = [normalize_name(u.name) for u in pr.updates] + all_dev_tools = bool(pr.updates) and all(is_dev_tool(u.name) for u in pr.updates) + deploys = model.deploys_to_prod(pr, is_dev_tool=all_dev_tools) + + escalations: list[str] = [] + if bump == BumpKind.MAJOR: + escalations.append("major version bump") + if bump == BumpKind.UNKNOWN: + escalations.append("could not parse version (fail-safe)") + if any(name in SECURITY_ANY_BUMP for name in names): + escalations.append("security-sensitive runtime library") + for update in pr.updates: + if ( + normalize_name(update.name) in SECURITY_MAJOR_ONLY + and classify_bump(update.from_version, update.to_version) == BumpKind.MAJOR + ): + escalations.append(f"{update.name} major bump (security-sensitive)") + + if escalations: + return Classification( + tier=Tier.TIER_2, + deploys_to_prod=deploys, + bump=bump, + reasons=escalations, + ) + + if not deploys: + reasons = ["does not deploy to production"] + if all_dev_tools: + reasons.append("dev/tooling dependency") + return Classification( + tier=Tier.TIER_0, deploys_to_prod=False, bump=bump, reasons=reasons + ) + + return Classification( + tier=Tier.TIER_1, + deploys_to_prod=True, + bump=bump, + reasons=["patch/minor production dependency (health-gated)"], + ) diff --git a/dependabot_batch_review/rollback.py b/dependabot_batch_review/rollback.py new file mode 100644 index 0000000..f08778d --- /dev/null +++ b/dependabot_batch_review/rollback.py @@ -0,0 +1,220 @@ +""" +Auto-rollback by reverting a merged Dependabot commit. + +Primary mechanism: a local ``git revert`` (matches the subprocess usage already in +``review.py``), which produces a correct revert for both merge commits +(``-m 1``) and squash/rebase single-parent commits. The revert is pushed to a new +branch and opened as a PR that is auto-merged, re-deploying clean code. + +Idempotent (keyed by the revert branch name) and dry-run aware: in dry-run it does +all the reads but performs no branch push / PR creation / merge. +""" + +from __future__ import annotations + +import re +import subprocess +import tempfile +from dataclasses import dataclass +from typing import Any + +from .github_client import GitHubClient +from .review import merge_pr + +# Matches the credential in an authenticated clone URL so it never reaches logs. +_TOKEN_RE = re.compile(r"x-access-token:[^@/\s]+@") + + +def _redact(text: str) -> str: + return _TOKEN_RE.sub("x-access-token:***@", text) + + +_REPO_QUERY = """ +query($owner: String!, $name: String!, $branch: String!) { + repository(owner: $owner, name: $name) { + id + defaultBranchRef { name } + pullRequests(headRefName: $branch, first: 1, states: [OPEN, MERGED]) { + nodes { url merged } + } + } +} +""" + +_CREATE_PR = """ +mutation($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { pullRequest { id url } } +} +""" + + +@dataclass +class RollbackResult: + performed: bool + revert_pr_url: str | None + revert_commit_sha: str | None + reason: str + dry_run: bool + + +def _run(cmd: list[str], cwd: str) -> str: + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + # Redact the embedded clone token from both the command and stderr so it + # can't leak into exception messages / logs / Slack on the fallback path. + command = _redact(" ".join(cmd)) + stderr = _redact(result.stderr.strip()) + raise RuntimeError(f"`{command}` failed: {stderr}") + return result.stdout.strip() + + +def _existing_revert( + gh: GitHubClient, owner: str, repo: str, branch: str +) -> tuple[dict[str, Any], dict[str, Any] | None]: + result = gh.query( + _REPO_QUERY, variables={"owner": owner, "name": repo, "branch": branch} + ) + repository: dict[str, Any] = (result or {}).get("repository") or {} + nodes = (repository.get("pullRequests") or {}).get("nodes") or [] + existing: dict[str, Any] | None = nodes[0] if nodes else None + return repository, existing + + +def revert_merge( + gh: GitHubClient, + owner: str, + repo: str, + merge_commit_sha: str, + *, + original_title: str = "", + original_pr_url: str = "", + auto_merge: bool = True, + dry_run: bool = False, + merge_method: str = "SQUASH", +) -> RollbackResult: + """Revert ``merge_commit_sha`` on ``owner/repo`` via a new auto-merged PR.""" + short = merge_commit_sha[:7] + branch = f"revert-dependabot-{short}" + + repository, existing_pr = _existing_revert(gh, owner, repo, branch) + if existing_pr is not None: + return RollbackResult( + performed=bool(existing_pr.get("merged")), + revert_pr_url=str(existing_pr.get("url")), + revert_commit_sha=None, + reason="revert PR already exists (idempotent)", + dry_run=dry_run, + ) + + if dry_run: + return RollbackResult( + performed=False, + revert_pr_url=None, + revert_commit_sha=None, + reason=f"dry-run: would `git revert` {short} on {owner}/{repo}", + dry_run=True, + ) + + repo_id = repository.get("id") + base = (repository.get("defaultBranchRef") or {}).get("name") or "main" + if not isinstance(repo_id, str): + return _manual_fallback( + owner, repo, merge_commit_sha, "could not resolve repo id" + ) + + try: + revert_sha = _local_revert(gh, owner, repo, base, branch, merge_commit_sha) + except Exception as exc: # noqa: BLE001 - fall back to a manual instruction + return _manual_fallback(owner, repo, merge_commit_sha, str(exc)) + + title = f'Revert "{original_title}" (auto-rollback)'.strip() + body = ( + f"Automated rollback of {original_pr_url or merge_commit_sha}.\n\n" + f"The merge `{short}` degraded production health, so it was reverted " + f"automatically. Investigate before re-attempting." + ) + created = gh.query( + _CREATE_PR, + variables={ + "input": { + "repositoryId": repo_id, + "baseRefName": base, + "headRefName": branch, + "title": title, + "body": body, + } + }, + ) + pr = (((created or {}).get("createPullRequest") or {}).get("pullRequest")) or {} + pr_id = pr.get("id") + pr_url = pr.get("url") + + if auto_merge and isinstance(pr_id, str): + try: + merge_pr(gh, pr_id=pr_id, merge_method=merge_method) + except Exception as exc: # noqa: BLE001 - PR is open; a human can merge it + return RollbackResult( + performed=False, + revert_pr_url=pr_url, + revert_commit_sha=revert_sha, + reason=f"revert PR opened but auto-merge failed: {exc!r}", + dry_run=False, + ) + + return RollbackResult( + performed=True, + revert_pr_url=pr_url, + revert_commit_sha=revert_sha, + reason="reverted and merged", + dry_run=False, + ) + + +def _local_revert( + gh: GitHubClient, owner: str, repo: str, base: str, branch: str, sha: str +) -> str: + """Clone, revert (merge-aware), and push the revert branch. Returns its SHA.""" + token = gh.token + url = f"https://x-access-token:{token}@github.com/{owner}/{repo}.git" + with tempfile.TemporaryDirectory() as workdir: + _run(["git", "clone", "--depth", "50", "--branch", base, url, "."], workdir) + _run(["git", "config", "user.name", "dependabot-automerge[bot]"], workdir) + _run( + [ + "git", + "config", + "user.email", + "dependabot-automerge[bot]@users.noreply.github.com", + ], + workdir, + ) + _run(["git", "checkout", "-b", branch], workdir) + + parents = _run( + ["git", "rev-list", "--parents", "-n", "1", sha], workdir + ).split() + is_merge_commit = len(parents) > 2 + revert_cmd = ["git", "revert", "--no-edit"] + if is_merge_commit: + revert_cmd += ["-m", "1"] + revert_cmd.append(sha) + _run(revert_cmd, workdir) + + revert_sha = _run(["git", "rev-parse", "HEAD"], workdir) + _run(["git", "push", "origin", branch], workdir) + return revert_sha + + +def _manual_fallback(owner: str, repo: str, sha: str, why: str) -> RollbackResult: + short = sha[:7] + return RollbackResult( + performed=False, + revert_pr_url=None, + revert_commit_sha=None, + reason=( + f"automated revert failed ({why}). Manual: " + f"`git revert -m 1 {short}` in {owner}/{repo}, or roll back the " + f"Elastic Beanstalk environment via redeploy.yml (operation: redeploy)." + ), + dry_run=False, + ) diff --git a/dependabot_batch_review/semver.py b/dependabot_batch_review/semver.py new file mode 100644 index 0000000..7c45e4f --- /dev/null +++ b/dependabot_batch_review/semver.py @@ -0,0 +1,92 @@ +""" +Semantic-version bump classification for dependency updates. + +Tolerant of the messy version strings Dependabot produces: ``v1.2.3``, +``25.2-alpine``, ``2024.10.3``, ``0.1.32``, ``4.17.21``. Used by the risk engine +to decide whether a bump is patch / minor / major, with a pre-1.0 rule that +treats a ``0.y`` change as breaking (major). +""" + +from __future__ import annotations + +import re +from enum import IntEnum + +from .review import DependencyUpdate + +# Leading dotted-numeric prefix, ignoring any 'v' prefix and trailing suffixes +# like '-alpine', 'rc1' or '.x'. +_VERSION_RE = re.compile(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?") + + +class BumpKind(IntEnum): + """Ordered so that ``max(...)`` yields the riskiest bump in a group.""" + + UNKNOWN = 0 + PATCH = 1 + MINOR = 2 + MAJOR = 3 + + +def parse_version(version: str | None) -> tuple[int, int, int] | None: + """ + Extract a ``(major, minor, patch)`` tuple from a version string. + + Returns ``None`` if no leading numeric component can be found. Missing minor + or patch components default to ``0`` (e.g. ``"26"`` -> ``(26, 0, 0)``, + ``"25.2-alpine"`` -> ``(25, 2, 0)``). + """ + if not version: + return None + match = _VERSION_RE.match(version.strip()) + if not match: + return None + major = int(match.group(1)) + minor = int(match.group(2)) if match.group(2) is not None else 0 + patch = int(match.group(3)) if match.group(3) is not None else 0 + return (major, minor, patch) + + +def classify_bump(from_version: str | None, to_version: str | None) -> BumpKind: + """ + Classify a single version transition. + + A change in the major component is ``MAJOR``. For pre-1.0 versions + (``major == 0``) a change in the minor component is also treated as ``MAJOR``, + since ``0.y`` releases routinely ship breaking changes. Otherwise a + minor-component change is ``MINOR`` and a patch-only change is ``PATCH``. + Unparseable versions yield ``UNKNOWN`` (the risk engine escalates these to + human review as a fail-safe). + """ + parsed_from = parse_version(from_version) + parsed_to = parse_version(to_version) + if parsed_from is None or parsed_to is None: + return BumpKind.UNKNOWN + + from_major, from_minor, _ = parsed_from + to_major, to_minor, _ = parsed_to + + if from_major != to_major: + return BumpKind.MAJOR + if from_major == 0: + # Pre-1.0: a minor change is potentially breaking. + return BumpKind.MAJOR if from_minor != to_minor else BumpKind.PATCH + if from_minor != to_minor: + return BumpKind.MINOR + return BumpKind.PATCH + + +def riskiest_bump(updates: list[DependencyUpdate]) -> BumpKind: + """ + Return the riskiest bump across a (possibly grouped) PR's updates. + + A grouped PR is only as safe as its riskiest single update. + """ + if not updates: + return BumpKind.UNKNOWN + bumps = [classify_bump(u.from_version, u.to_version) for u in updates] + if BumpKind.UNKNOWN in bumps: + # UNKNOWN sorts lowest, so max() alone would let a parseable sibling mask + # an unparseable update; one unparseable member taints the whole group. + return BumpKind.UNKNOWN + return max(bumps) diff --git a/dependabot_batch_review/slack_messages.py b/dependabot_batch_review/slack_messages.py new file mode 100644 index 0000000..62ae01e --- /dev/null +++ b/dependabot_batch_review/slack_messages.py @@ -0,0 +1,117 @@ +""" +Slack message formatters (mrkdwn) for the automation layer. + +Pure functions returning Slack ``mrkdwn`` strings; posting is done by the existing +``slack.SlackClient.post_message``. Mirrors ``alerts.format_slack_message``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .automation_types import HealthVerdict, MergeOutcome + +if TYPE_CHECKING: + from .automerge import Decision, RunResult + from .config import Config + from .rollback import RollbackResult + from .triage import TriageResult + + +def _pr_link(url: str, number: int | None) -> str: + label = f"#{number}" if number is not None else "PR" + return f"<{url}|{label}>" + + +def _group_escalations(decisions: list[Decision]) -> dict[str, list[Decision]]: + grouped: dict[str, list[Decision]] = {} + for decision in decisions: + grouped.setdefault(decision.pr.group_name, []).append(decision) + return grouped + + +def format_run_digest(result: RunResult, cfg: Config) -> str: + """The daily digest posted after an auto-merge run.""" + mode = "DRY-RUN" if result.dry_run else "LIVE" + verb = "Would merge" if result.dry_run else "Merged" + parts: list[str] = [f"*Dependabot auto-merge — {cfg.organization}* [{mode}]"] + + merged = result.merged + merged_lines = [f"*✅ {verb} {len(merged)} PR(s)*"] + for d in sorted(merged, key=lambda x: (x.pr.repo or "", x.pr.group_name)): + tag = "T1/health" if d.action == "merge+health" else "T0" + merged_lines.append( + f"• [{tag}] `{d.pr.repo}`: {d.pr.group_name} {_pr_link(d.pr.url, d.pr.number)}" + ) + parts.append("\n".join(merged_lines)) + + escalated = result.escalated + esc_lines = [f"*🔍 {len(escalated)} need human review*"] + for group, items in sorted(_group_escalations(escalated).items()): + repos = ", ".join( + f"`{d.pr.repo}` {_pr_link(d.pr.url, d.pr.number)}" + for d in sorted(items, key=lambda x: x.pr.repo or "") + ) + reason = "; ".join(items[0].classification.reasons) or "review" + esc_lines.append(f"• *{group}* ({len(items)}): {repos} — _{reason}_") + parts.append("\n".join(esc_lines)) + + parts.append(f"_⏳ {len(result.skipped)} skipped (not yet eligible)_") + return "\n\n".join(parts) + + +def _signal_lines(verdict: HealthVerdict) -> list[str]: + lines: list[str] = [] + for signal in verdict.signals.values(): + icon = "⚠️" if signal.unknown else ("✅" if signal.healthy else "❌") + lines.append(f" • {signal.source}: {signal.detail} {icon}") + return lines + + +def format_tier1_health(outcome: MergeOutcome, verdict: HealthVerdict) -> str: + if verdict.healthy: + status = "*HEALTHY ✅*" + elif verdict.unknown: + status = "*UNVERIFIED ⚠️ — manual check required*" + else: + status = "*UNHEALTHY ❌*" + sha = (outcome.merge_commit_sha or "")[:7] + lines = [ + f"*🚀 Tier-1 merged & deployed: `{outcome.owner}/{outcome.repo}`*", + f"{_pr_link(outcome.pr.url, outcome.pr.number)} {outcome.pr.group_name} · merge `{sha}`", + f"Health gate: {status}", + ] + lines.extend(_signal_lines(verdict)) + for reason in verdict.reasons: + lines.append(f" • {reason}") + return "\n".join(lines) + + +def format_rollback( + outcome: MergeOutcome, + verdict: HealthVerdict, + rollback: RollbackResult, + triage: TriageResult | None, +) -> str: + sha = (outcome.merge_commit_sha or "")[:7] + lines = [ + f"*⛔ AUTO-ROLLBACK: `{outcome.owner}/{outcome.repo}`*", + f"{_pr_link(outcome.pr.url, outcome.pr.number)} {outcome.pr.group_name} " + f"deployed and degraded production.", + "Health verdict: *UNHEALTHY*", + ] + lines.extend(_signal_lines(verdict)) + if rollback.dry_run or not rollback.performed: + lines.append(f"Action: *not performed* — {rollback.reason}") + elif rollback.revert_pr_url: + lines.append( + f"Action: reverted merge `{sha}` → <{rollback.revert_pr_url}|revert PR> ✅" + ) + else: + lines.append(f"Action: {rollback.reason}") + if triage is not None: + lines.append( + f"*Claude triage:* {triage.summary} " + f"(recommend: {triage.recommendation}, confidence: {triage.confidence})" + ) + return "\n".join(lines) diff --git a/dependabot_batch_review/triage.py b/dependabot_batch_review/triage.py new file mode 100644 index 0000000..edade3b --- /dev/null +++ b/dependabot_batch_review/triage.py @@ -0,0 +1,110 @@ +""" +Claude-powered risk triage for escalated / rolled-back PRs. + +Produces a terse risk summary + recommended action for Slack. Uses the Anthropic +Messages API for a single-shot summarization. Degrades gracefully to the +deterministic ``review.analyze_risk`` when ``anthropic`` is not installed or no +``ANTHROPIC_API_KEY`` is configured, so callers always get a usable result. + +To enable live triage: ``poetry add anthropic`` and set ``ANTHROPIC_API_KEY``. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + +from .review import DependencyUpdatePR, analyze_risk + +_DEFAULT_MODEL = "claude-opus-4-8" + +_SYSTEM_PROMPT = ( + "You are a release-safety reviewer for Python/npm dependency bumps that " + "auto-deploy to production. Be terse and concrete. Respond with strict JSON: " + '{"summary": str, "recommendation": "merge"|"hold"|"manual-review", ' + '"confidence": "high"|"medium"|"low"}. Flag major bumps, breaking changes, and ' + "security fixes." +) + +_REC_FROM_LEVEL = {"High": "manual-review", "Medium": "hold", "Low": "merge"} + + +@dataclass +class TriageResult: + summary: str + recommendation: str # "merge" | "hold" | "manual-review" + confidence: str # "high" | "medium" | "low" + degraded: bool = False + + +def _degraded(pr: DependencyUpdatePR) -> TriageResult: + """Fallback summary derived from the deterministic risk heuristic.""" + risk = analyze_risk(pr) + return TriageResult( + summary="; ".join(risk.reasons) or "no specific risk factors identified", + recommendation=_REC_FROM_LEVEL.get(risk.level, "manual-review"), + confidence="low", + degraded=True, + ) + + +def _build_user_prompt(pr: DependencyUpdatePR, package_diff: str | None) -> str: + lines = [f"Package group: {pr.group_name}", f"Ecosystem: {pr.package_type}"] + for update in pr.updates: + lines.append(f"- {update.name}: {update.from_version} -> {update.to_version}") + if pr.advisory_summary: + lines.append(f"Security advisory: {pr.advisory_summary} ({pr.ghsa_id})") + notes = "\n".join(u.notes for u in pr.updates if u.notes)[:6000] + if notes: + lines.append(f"\nChangelog / release notes:\n{notes}") + if package_diff: + lines.append(f"\nPackage diff (truncated):\n{package_diff[:8000]}") + return "\n".join(lines) + + +def _extract_json(text: str) -> str: + start = text.find("{") + end = text.rfind("}") + return text[start : end + 1] if start != -1 and end != -1 else text + + +def triage_pr( + pr: DependencyUpdatePR, + package_diff: str | None = None, + model: str = _DEFAULT_MODEL, + api_key: str | None = None, +) -> TriageResult: + """Summarize a PR's risk, falling back to the deterministic heuristic.""" + key = api_key or os.environ.get("ANTHROPIC_API_KEY") + if not key: + return _degraded(pr) + try: + import anthropic # type: ignore[import-not-found] + except ImportError: + return _degraded(pr) + + try: + client = anthropic.Anthropic(api_key=key) + response = client.messages.create( + model=model, + max_tokens=400, + system=_SYSTEM_PROMPT, + messages=[ + {"role": "user", "content": _build_user_prompt(pr, package_diff)} + ], + ) + text = "".join( + getattr(block, "text", "") + for block in response.content + if getattr(block, "type", None) == "text" + ) + data = json.loads(_extract_json(text)) + return TriageResult( + summary=str(data.get("summary", "")).strip() or "(no summary)", + recommendation=str(data.get("recommendation", "manual-review")), + confidence=str(data.get("confidence", "low")), + degraded=False, + ) + except Exception: # noqa: BLE001 - any SDK/parse failure -> deterministic fallback + return _degraded(pr) diff --git a/poetry.lock b/poetry.lock index 2857ce0..7977b2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,17 +2,18 @@ [[package]] name = "beautifulsoup4" -version = "4.12.3" +version = "4.14.3" description = "Screen-scraping library" optional = false -python-versions = ">=3.6.0" +python-versions = ">=3.7.0" files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, + {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, + {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, ] [package.dependencies] -soupsieve = ">1.2" +soupsieve = ">=1.6.1" +typing-extensions = ">=4.0.0" [package.extras] cchardet = ["cchardet"] @@ -38,112 +39,162 @@ six = "*" [[package]] name = "certifi" -version = "2024.7.4" +version = "2026.5.20" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, ] [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] @@ -159,151 +210,288 @@ files = [ [[package]] name = "idna" -version = "3.7" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.11.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, + {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, + {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, + {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, + {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, + {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, + {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, + {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, + {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, + {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, + {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, + {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, + {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, + {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, + {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, + {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, + {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, + {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, + {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, + {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, + {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, + {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, + {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, + {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, + {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, + {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, + {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, + {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, + {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, + {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, + {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, + {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, + {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, + {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, + {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, + {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, + {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, ] [[package]] name = "mypy" -version = "1.8.0" +version = "1.20.2" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, + {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, + {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, + {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, + {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, + {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, + {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, + {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, + {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, + {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, + {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, + {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, + {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, + {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, + {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, + {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, + {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, + {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, + {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, + {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, + {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, + {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, + {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, ] [package.dependencies] -mypy-extensions = ">=1.0.0" -typing-extensions = ">=4.1.0" +librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] +native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] name = "numpy" -version = "2.4.1" +version = "2.4.6" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" files = [ - {file = "numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5"}, - {file = "numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425"}, - {file = "numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba"}, - {file = "numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501"}, - {file = "numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a"}, - {file = "numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509"}, - {file = "numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc"}, - {file = "numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82"}, - {file = "numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0"}, - {file = "numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574"}, - {file = "numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73"}, - {file = "numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2"}, - {file = "numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8"}, - {file = "numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a"}, - {file = "numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0"}, - {file = "numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c"}, - {file = "numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02"}, - {file = "numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162"}, - {file = "numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9"}, - {file = "numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f"}, - {file = "numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87"}, - {file = "numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8"}, - {file = "numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b"}, - {file = "numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f"}, - {file = "numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9"}, - {file = "numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e"}, - {file = "numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5"}, - {file = "numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8"}, - {file = "numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c"}, - {file = "numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2"}, - {file = "numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d"}, - {file = "numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb"}, - {file = "numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5"}, - {file = "numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7"}, - {file = "numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d"}, - {file = "numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15"}, - {file = "numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9"}, - {file = "numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2"}, - {file = "numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505"}, - {file = "numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2"}, - {file = "numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4"}, - {file = "numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510"}, - {file = "numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261"}, - {file = "numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc"}, - {file = "numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3"}, - {file = "numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220"}, - {file = "numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee"}, - {file = "numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556"}, - {file = "numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844"}, - {file = "numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3"}, - {file = "numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205"}, - {file = "numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745"}, - {file = "numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d"}, - {file = "numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df"}, - {file = "numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f"}, - {file = "numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0"}, - {file = "numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c"}, - {file = "numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93"}, - {file = "numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42"}, - {file = "numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01"}, - {file = "numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b"}, - {file = "numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a"}, - {file = "numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2"}, - {file = "numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be"}, - {file = "numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33"}, - {file = "numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, + {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, + {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, + {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, + {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, + {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, + {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, + {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, + {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, + {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, + {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, + {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, + {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, + {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, + {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, + {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, + {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, + {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, + {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, + {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, ] [[package]] @@ -320,61 +508,72 @@ files = [ [package.dependencies] et-xmlfile = "*" +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + [[package]] name = "pandas" -version = "3.0.0" +version = "3.0.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.11" files = [ - {file = "pandas-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850"}, - {file = "pandas-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2"}, - {file = "pandas-3.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5"}, - {file = "pandas-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae"}, - {file = "pandas-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9"}, - {file = "pandas-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d"}, - {file = "pandas-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd"}, - {file = "pandas-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b"}, - {file = "pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd"}, - {file = "pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740"}, - {file = "pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801"}, - {file = "pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a"}, - {file = "pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb"}, - {file = "pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f"}, - {file = "pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1"}, - {file = "pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0"}, - {file = "pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6"}, - {file = "pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f"}, - {file = "pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70"}, - {file = "pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e"}, - {file = "pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3"}, - {file = "pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e"}, - {file = "pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e"}, - {file = "pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be"}, - {file = "pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98"}, - {file = "pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327"}, - {file = "pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb"}, - {file = "pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812"}, - {file = "pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08"}, - {file = "pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c"}, - {file = "pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa"}, - {file = "pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b"}, - {file = "pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe"}, - {file = "pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70"}, - {file = "pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d"}, - {file = "pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986"}, - {file = "pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49"}, - {file = "pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7"}, - {file = "pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8"}, - {file = "pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73"}, - {file = "pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2"}, - {file = "pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a"}, - {file = "pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084"}, - {file = "pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721"}, - {file = "pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac"}, - {file = "pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb"}, - {file = "pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa"}, - {file = "pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f"}, + {file = "pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98"}, + {file = "pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639"}, + {file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2"}, + {file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27"}, + {file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824"}, + {file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938"}, + {file = "pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea"}, + {file = "pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a"}, + {file = "pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09"}, + {file = "pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4"}, + {file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c"}, + {file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9"}, + {file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf"}, + {file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c"}, + {file = "pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc"}, + {file = "pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49"}, + {file = "pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa"}, + {file = "pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7"}, + {file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8"}, + {file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a"}, + {file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb"}, + {file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2"}, + {file = "pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44"}, + {file = "pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e"}, + {file = "pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d"}, + {file = "pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066"}, + {file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd"}, + {file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085"}, + {file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870"}, + {file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f"}, + {file = "pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13"}, + {file = "pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac"}, + {file = "pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f"}, + {file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb"}, + {file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a"}, + {file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360"}, + {file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76"}, + {file = "pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5"}, + {file = "pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977"}, + {file = "pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04"}, + {file = "pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6"}, + {file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c"}, + {file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028"}, + {file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d"}, + {file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a"}, + {file = "pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1"}, + {file = "pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1"}, + {file = "pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc"}, ] [package.dependencies] @@ -386,7 +585,7 @@ python-dateutil = ">=2.8.2" tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""} [package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2024.2)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2020.1)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] aws = ["s3fs (>=2024.10.0)"] clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"] compression = ["zstandard (>=0.23.0)"] @@ -408,9 +607,92 @@ pyarrow = ["pyarrow (>=13.0.0)"] spss = ["pyreadstat (>=1.2.8)"] sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"] test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)"] -timezone = ["pytz (>=2024.2)"] +timezone = ["pytz (>=2020.1)"] xml = ["lxml (>=5.3.0)"] +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -425,26 +707,127 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "requests" -version = "2.32.4" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "responses" +version = "0.25.8" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, + {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "ruff" @@ -474,35 +857,35 @@ files = [ [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] name = "soupsieve" -version = "2.5" +version = "2.8.4" description = "A modern CSS selector implementation for Beautiful Soup." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, - {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, + {file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"}, + {file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"}, ] [[package]] name = "types-beautifulsoup4" -version = "4.12.0.20240106" +version = "4.12.0.20250516" description = "Typing stubs for beautifulsoup4" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "types-beautifulsoup4-4.12.0.20240106.tar.gz", hash = "sha256:98d628985b71b140bd3bc22a8cb0ab603c2f2d08f20d37925965eb4a21739be8"}, - {file = "types_beautifulsoup4-4.12.0.20240106-py3-none-any.whl", hash = "sha256:cbdd60ab8aeac737ac014431b6e921b43e84279c0405fdd25a6900bb0e71da5b"}, + {file = "types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee"}, + {file = "types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e"}, ] [package.dependencies] @@ -510,60 +893,85 @@ types-html5lib = "*" [[package]] name = "types-html5lib" -version = "1.1.11.20240222" +version = "1.1.11.20260518" description = "Typing stubs for html5lib" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +files = [ + {file = "types_html5lib-1.1.11.20260518-py3-none-any.whl", hash = "sha256:9baa7912224ebb37027c5ccb7e3768e43ea47b1dfdd977e7ddc4b0a4a550584d"}, + {file = "types_html5lib-1.1.11.20260518.tar.gz", hash = "sha256:4f33c087cb1119d65c4c80eca4323c2b501f9eaf8af9616b8b732ed4d8eae8fa"}, +] + +[package.dependencies] +types-webencodings = "*" + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.10" files = [ - {file = "types-html5lib-1.1.11.20240222.tar.gz", hash = "sha256:d9517ec6ba2fa1f63113e2930a59b60722a976cc983b94d7fd772f14865e1152"}, - {file = "types_html5lib-1.1.11.20240222-py3-none-any.whl", hash = "sha256:86b2dcbbebca846e68d2eac46b2717980e632de4b5d8f62ccd23d8333d2e7647"}, + {file = "types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd"}, + {file = "types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466"}, ] [[package]] name = "types-requests" -version = "2.31.0.20240218" +version = "2.33.0.20260518" description = "Typing stubs for requests" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "types-requests-2.31.0.20240218.tar.gz", hash = "sha256:f1721dba8385958f504a5386240b92de4734e047a08a40751c1654d1ac3349c5"}, - {file = "types_requests-2.31.0.20240218-py3-none-any.whl", hash = "sha256:a82807ec6ddce8f00fe0e949da6d6bc1fbf1715420218a9640d695f70a9e5a9b"}, + {file = "types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0"}, + {file = "types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e"}, ] [package.dependencies] urllib3 = ">=2" +[[package]] +name = "types-webencodings" +version = "0.5.0.20260408" +description = "Typing stubs for webencodings" +optional = false +python-versions = ">=3.10" +files = [ + {file = "types_webencodings-0.5.0.20260408-py3-none-any.whl", hash = "sha256:19a2afe5c22d9b1e880b49ff823c7b531f473a390fe47ac903c0bdb5cd677dd9"}, + {file = "types_webencodings-0.5.0.20260408.tar.gz", hash = "sha256:28c596619f367e43eee393d85f63e8d2fdb6874c654a8d441c37f8afe29c6d0d"}, +] + [[package]] name = "typing-extensions" -version = "4.9.0" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] name = "urllib3" -version = "2.6.0" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, - {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] @@ -575,4 +983,4 @@ zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7807591d185b41ee8ff8da24a4153634c9a140aea7572b8636f9357d2673c542" +content-hash = "805001f02e55a6d0e0edbab08e96b89527f37c8b5c9bb28b5ce51bcd7780236b" diff --git a/pyproject.toml b/pyproject.toml index ed2de2c..6fb3e58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,20 @@ types-beautifulsoup4 = "^4.12.0.20240106" ruff = "^0.2.2" pandas = "^3.0.0" openpyxl = "^3.1.5" +PyYAML = "^6.0" [tool.poetry.group.dev.dependencies] mypy = "^1.8.0" types-requests = "^2.31.0.20240218" +types-PyYAML = "^6.0" +pytest = "^8.0" +responses = "^0.25" +pytest-mock = "^3.12" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..b427f53 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,65 @@ +"""Shared test helpers: builders for PRs and deploy models.""" + +from __future__ import annotations + +from dependabot_batch_review.deploy_model import DeployModel +from dependabot_batch_review.review import ( + CheckStatus, + DependencyUpdate, + DependencyUpdatePR, +) + +# A deploying service whose deploy.yml ignores requirements/* except prod.txt. +SERVICE_PATHS_IGNORE = ["requirements/*", "!requirements/prod.txt", "docs/*", "*.md"] + + +def service_model(repo: str = "bouncer") -> DeployModel: + return DeployModel( + repo=repo, + is_deploying_service=True, + deploy_branches=["main"], + paths_ignore=list(SERVICE_PATHS_IGNORE), + raw_present=True, + ) + + +def library_model(repo: str = "annotation-ui") -> DeployModel: + return DeployModel(repo=repo, is_deploying_service=False, raw_present=True) + + +def make_pr( + group: str = "pkg", + from_version: str = "1.0.0", + to_version: str = "1.0.1", + *, + updates: list[DependencyUpdate] | None = None, + package_type: str = "pip", + check: CheckStatus = CheckStatus.SUCCESS, + created_at: str = "2026-05-01T00:00:00Z", + head_ref: str | None = None, + merge_state: str = "CLEAN", + mergeable: str = "MERGEABLE", + repo: str = "bouncer", + number: int = 1, + merge_method: str = "SQUASH", + url: str | None = None, +) -> DependencyUpdatePR: + if updates is None: + updates = [DependencyUpdate(group, from_version, to_version, "")] + return DependencyUpdatePR( + id=f"PR_{repo}_{number}", + package_type=package_type, + is_group=len(updates) > 1, + group_name=group, + updates=updates, + url=url or f"https://github.com/hypothesis/{repo}/pull/{number}", + approved=False, + check_status=check, + merge_method=merge_method, + created_at=created_at, + merge_state_status=merge_state, + mergeable=mergeable, + head_ref_name=head_ref or f"dependabot/{package_type}/{group}-{to_version}", + number=number, + repo=repo, + ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..94e591d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,81 @@ +from pathlib import Path + +from dependabot_batch_review.config import load_config + +YAML = """ +organization: hypothesis +min_age_days: 5 +dry_run: true +tiers_enabled: [0, 1] +max_merges_per_run: 7 +repo_deny: [workflows] +health: + sentry_org: hypothesis + health_window_min: 20 + thresholds: + min_crash_free_pct: 98.5 +""" + + +def _write(tmp_path: Path) -> str: + path = tmp_path / "automation.yml" + path.write_text(YAML) + return str(path) + + +def test_yaml_load(tmp_path): + cfg = load_config(_write(tmp_path)) + assert cfg.min_age_days == 5 + assert cfg.tiers_enabled == [0, 1] + assert cfg.max_merges_per_run == 7 + assert cfg.repo_permitted("bouncer") is True + assert cfg.repo_permitted("workflows") is False + assert cfg.health.health_window_min == 20 + assert cfg.health.thresholds.min_crash_free_pct == 98.5 + + +def test_env_overrides_win(tmp_path, monkeypatch): + monkeypatch.setenv("DBR_MIN_AGE_DAYS", "1") + monkeypatch.setenv("DBR_TIERS_ENABLED", "0") + monkeypatch.setenv("DBR_MAX_MERGES", "3") + cfg = load_config(_write(tmp_path)) + assert cfg.min_age_days == 1 + assert cfg.tiers_enabled == [0] + assert cfg.max_merges_per_run == 3 + + +def test_dry_run_is_fail_safe(tmp_path, monkeypatch): + # Garbage value must NOT disable dry-run. + monkeypatch.setenv("DBR_DRY_RUN", "maybe") + assert load_config(_write(tmp_path)).dry_run is True + # Only explicit falses disable it. + monkeypatch.setenv("DBR_DRY_RUN", "false") + assert load_config(_write(tmp_path)).dry_run is False + + +def test_health_defaults_for_unmapped_repo(tmp_path): + cfg = load_config(_write(tmp_path)) + assert cfg.health.sentry_project_for("bouncer") == "bouncer" + assert cfg.health.newrelic_app_for("bouncer") == "bouncer (prod)" + + +def test_missing_file_uses_defaults(): + cfg = load_config("does-not-exist.yml") + assert cfg.dry_run is True + assert cfg.min_age_days == 3 + assert cfg.tiers_enabled == [0] + + +def test_dry_run_empty_env_var_is_unset(tmp_path, monkeypatch): + # The scheduled Action exports DBR_DRY_RUN="" so automation.yml decides. + path = tmp_path / "automation.yml" + path.write_text("dry_run: false\n") + monkeypatch.setenv("DBR_DRY_RUN", "") + assert load_config(str(path)).dry_run is False + + +def test_dry_run_null_yaml_stays_fail_safe(tmp_path): + # `dry_run:` (null) must not flip dry-run off (bool(None) would). + path = tmp_path / "automation.yml" + path.write_text("dry_run:\n") + assert load_config(str(path)).dry_run is True diff --git a/tests/test_deploy_model.py b/tests/test_deploy_model.py new file mode 100644 index 0000000..aae095d --- /dev/null +++ b/tests/test_deploy_model.py @@ -0,0 +1,107 @@ +import pytest + +from dependabot_batch_review.deploy_model import parse_deploy_yaml +from tests.helpers import make_pr + +DEPLOY_YAML = """ +name: Deploy +on: + push: + branches: + - main + paths-ignore: + - 'requirements/*' + - '!requirements/prod.txt' + - 'docs/*' + - '*.md' + - 'tests/*' +jobs: + production: + uses: hypothesis/workflows/.github/workflows/deploy.yml@main +""" + + +@pytest.fixture +def model(): + return parse_deploy_yaml("lms", DEPLOY_YAML) + + +@pytest.mark.parametrize( + "path,expected", + [ + ("requirements/dev.txt", False), # ignored + ("requirements/prod.txt", True), # ignored then re-included by negation + ("Dockerfile", True), # not ignored + ("package.json", True), # not ignored + ("tests/test_foo.py", False), # ignored + ("README.md", False), # *.md ignored + ], +) +def test_path_triggers_deploy(model, path, expected): + assert model.path_triggers_deploy(path) is expected + + +def test_yaml_on_key_parsed_despite_boolean_quirk(model): + # YAML 1.1 parses bare `on:` as True; parser must still find the push trigger. + assert model.is_deploying_service is True + assert "main" in model.deploy_branches + + +def test_no_deploy_file_means_non_deploying(): + model = parse_deploy_yaml("annotation-ui", None) + assert model.is_deploying_service is False + assert model.path_triggers_deploy("requirements/prod.txt") is False + + +def test_unparseable_deploy_yaml_fails_safe(): + model = parse_deploy_yaml("x", "::: not yaml :::\n - [") + assert model.is_deploying_service is True # conservative + + +def test_infer_changed_paths(model): + runtime = make_pr("urllib3", "2.5.0", "2.7.0", package_type="pip") + assert model.infer_changed_paths(runtime, is_dev_tool=False) == [ + "requirements/prod.txt" + ] + dev = make_pr("ruff", "0.1.0", "0.1.1", package_type="pip") + assert model.infer_changed_paths(dev, is_dev_tool=True) == ["requirements/dev.txt"] + docker = make_pr("node", "25", "25.1", package_type="docker") + assert model.infer_changed_paths(docker, is_dev_tool=False) == ["Dockerfile"] + npm = make_pr("preact", "10.0.0", "10.0.1", package_type="npm_and_yarn") + assert model.infer_changed_paths(npm, is_dev_tool=False) == ["package.json"] + + +@pytest.mark.parametrize( + "text", + [ + "name: D\non: push\njobs: {}", + "name: D\non: [push]\njobs: {}", + "name: D\non:\n push:\njobs: {}", + "name: D\non:\n push:\n paths-ignore: ['docs/**']\njobs: {}", + ], +) +def test_unrestricted_push_triggers_are_deploying(text): + # GitHub semantics: a push trigger without a branches filter fires on every + # branch, including main — these forms must classify as deploying. + assert parse_deploy_yaml("svc", text).is_deploying_service is True + + +def test_real_changed_files_override_inference(model): + # A dev-tool pip bump would be *inferred* to touch requirements/dev.txt + # (ignored), but the PR's real diff touches the prod lockfile. + pr = make_pr("coverage", "7.0.0", "7.0.1") + pr.changed_files = ["requirements/prod.txt"] + assert model.deploys_to_prod(pr, is_dev_tool=True) is True + + +def test_real_changed_files_all_ignored_is_not_deploying(model): + pr = make_pr("ruff", "0.1.0", "0.1.1") + pr.changed_files = ["requirements/dev.txt", "docs/notes.md"] + assert model.deploys_to_prod(pr, is_dev_tool=False) is False + + +def test_publish_on_merge_repo_is_deploying(): + from dependabot_batch_review.deploy_model import DeployModelCache + + cache = DeployModelCache(None, "hypothesis", publish_on_merge_repos=["client"]) + assert cache.get("client").is_deploying_service is True diff --git a/tests/test_eligibility.py b/tests/test_eligibility.py new file mode 100644 index 0000000..fd8c70a --- /dev/null +++ b/tests/test_eligibility.py @@ -0,0 +1,215 @@ +from datetime import datetime, timezone + +import pytest + +from dependabot_batch_review import automerge +from dependabot_batch_review.automerge import decide, is_eligible, run +from dependabot_batch_review.config import Config +from dependabot_batch_review.review import CheckStatus +from dependabot_batch_review.risk import classify +from tests.helpers import library_model, make_pr, service_model + +NOW = datetime(2026, 6, 2, tzinfo=timezone.utc) + + +class FakeCache: + def __init__(self, model): + self._model = model + + def get(self, repo): + return self._model + + +def test_eligible_low_risk_old_passing(): + pr = make_pr("ruff", "0.1.0", "0.1.1", created_at="2026-05-01T00:00:00Z") + cfg = Config(min_age_days=3, tiers_enabled=[0]) + ok, reason = is_eligible(pr, classify(pr, library_model()), cfg, NOW) + assert ok and reason is None + + +def test_not_eligible_ci_failing(): + pr = make_pr("ruff", "0.1.0", "0.1.1", check=CheckStatus.FAILED) + cfg = Config(tiers_enabled=[0]) + ok, reason = is_eligible(pr, classify(pr, library_model()), cfg, NOW) + assert not ok and reason is not None and "CI" in reason + + +def test_not_eligible_too_new(): + pr = make_pr("ruff", "0.1.0", "0.1.1", created_at="2026-06-01T00:00:00Z") + cfg = Config(min_age_days=3, tiers_enabled=[0]) + ok, reason = is_eligible(pr, classify(pr, library_model()), cfg, NOW) + assert not ok and reason is not None and "too new" in reason + + +def test_not_eligible_tier_disabled(): + pr = make_pr("sentry-sdk", "2.58.0", "2.61.1") # Tier 1 on a service + cfg = Config(tiers_enabled=[0]) + ok, reason = is_eligible(pr, classify(pr, service_model()), cfg, NOW) + assert not ok and reason is not None and "tier 1 not enabled" in reason + + +def test_not_eligible_merge_conflict(): + pr = make_pr("ruff", "0.1.0", "0.1.1", merge_state="DIRTY") + cfg = Config(tiers_enabled=[0]) + ok, _ = is_eligible(pr, classify(pr, library_model()), cfg, NOW) + assert not ok + + +def test_decide_escalates_failing_tier0(): + pr = make_pr("ruff", "0.1.0", "0.1.1", check=CheckStatus.FAILED) + decision = decide(pr, FakeCache(library_model()), Config(tiers_enabled=[0]), NOW) + assert decision.action == "escalate" + + +def test_run_dry_run_performs_no_merges(monkeypatch): + prs = [ + make_pr("ruff", "0.1.0", "0.1.1", repo="bouncer", number=1), + make_pr("newrelic", "11.0.1", "13.1.0", repo="bouncer", number=2), + ] + monkeypatch.setattr( + automerge, "fetch_dependency_prs", lambda gh, organization, labels: prs + ) + monkeypatch.setattr( + automerge, "DeployModelCache", lambda gh, org, **kw: FakeCache(service_model()) + ) + + def must_not_merge(*args, **kwargs): + raise AssertionError("merge_pr must not be called during a dry run") + + monkeypatch.setattr(automerge, "merge_pr", must_not_merge) + + cfg = Config(dry_run=True, tiers_enabled=[0, 1], min_age_days=3) + result = run(None, cfg, now=NOW) + + assert result.dry_run is True + assert len(result.merged) == 1 # ruff (Tier 0) + assert len(result.escalated) == 1 # newrelic major (Tier 2) + + +def test_run_respects_max_merges(monkeypatch): + prs = [make_pr("ruff", "0.1.0", "0.1.1", repo="lib", number=i) for i in range(5)] + monkeypatch.setattr( + automerge, "fetch_dependency_prs", lambda gh, organization, labels: prs + ) + monkeypatch.setattr( + automerge, "DeployModelCache", lambda gh, org, **kw: FakeCache(library_model()) + ) + cfg = Config(dry_run=True, tiers_enabled=[0], max_merges_per_run=2) + result = run(None, cfg, now=NOW) + + assert len(result.merged) == 2 + capped = [d for d in result.skipped if d.skip_reason and "cap" in d.skip_reason] + assert len(capped) == 3 + + +# ----------------------------------------------------------- pre-merge checks + +DEPENDABOT_EMAIL = "49699333+dependabot[bot]@users.noreply.github.com" + + +class PremergeGH: + def __init__(self, node): + self.node = node + + def query(self, query, variables=None, extra_headers=None): + return {"node": self.node} + + +def premerge_node(**overrides): + commit = { + "committedDate": "2026-05-01T00:00:00Z", + "statusCheckRollup": {"state": "SUCCESS"}, + "signature": {"isValid": True}, + "author": {"email": DEPENDABOT_EMAIL}, + } + commit.update(overrides.pop("commit", {})) + node = { + "state": "OPEN", + "headRefOid": "deadbeef", + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + "commits": {"totalCount": 1, "nodes": [{"commit": commit}]}, + } + node.update(overrides) + return node + + +def test_verify_premerge_ok(): + from dependabot_batch_review.automerge import verify_premerge + + pr = make_pr("ruff", "0.1.0", "0.1.1") + oid = verify_premerge(PremergeGH(premerge_node()), pr, Config(), NOW) + assert oid == "deadbeef" + + +def test_verify_premerge_rejects_foreign_author(): + from dependabot_batch_review.automerge import PreMergeCheckError, verify_premerge + + node = premerge_node(commit={"author": {"email": "attacker@example.com"}}) + pr = make_pr("ruff", "0.1.0", "0.1.1") + with pytest.raises(PreMergeCheckError) as exc: + verify_premerge(PremergeGH(node), pr, Config(), NOW) + assert exc.value.escalate is True + + +def test_verify_premerge_rejects_extra_commits(): + from dependabot_batch_review.automerge import PreMergeCheckError, verify_premerge + + node = premerge_node() + node["commits"]["totalCount"] = 2 + pr = make_pr("ruff", "0.1.0", "0.1.1") + with pytest.raises(PreMergeCheckError, match="exactly one"): + verify_premerge(PremergeGH(node), pr, Config(), NOW) + + +def test_verify_premerge_rejects_invalid_signature(): + from dependabot_batch_review.automerge import PreMergeCheckError, verify_premerge + + node = premerge_node(commit={"signature": {"isValid": False}}) + pr = make_pr("ruff", "0.1.0", "0.1.1") + with pytest.raises(PreMergeCheckError, match="signature"): + verify_premerge(PremergeGH(node), pr, Config(), NOW) + + +def test_verify_premerge_rejects_stale_ci(): + from dependabot_batch_review.automerge import PreMergeCheckError, verify_premerge + + node = premerge_node(commit={"statusCheckRollup": {"state": "FAILURE"}}) + pr = make_pr("ruff", "0.1.0", "0.1.1") + with pytest.raises(PreMergeCheckError) as exc: + verify_premerge(PremergeGH(node), pr, Config(), NOW) + assert exc.value.escalate is True + + +def test_verify_premerge_fresh_head_commit_restarts_quarantine(): + from dependabot_batch_review.automerge import PreMergeCheckError, verify_premerge + + # PR is old, but the head commit (force-pushed new version) is brand new. + node = premerge_node(commit={"committedDate": "2026-06-01T20:00:00Z"}) + pr = make_pr("ruff", "0.1.0", "0.1.1", created_at="2026-05-01T00:00:00Z") + with pytest.raises(PreMergeCheckError) as exc: + verify_premerge(PremergeGH(node), pr, Config(min_age_days=3), NOW) + assert exc.value.escalate is False # just wait; not suspicious by itself + + +def test_run_live_skips_tier1_without_monitoring(monkeypatch): + pr = make_pr("sentry-sdk", "2.58.0", "2.61.1", repo="bouncer", number=9) + monkeypatch.setattr( + automerge, "fetch_dependency_prs", lambda gh, organization, labels: [pr] + ) + monkeypatch.setattr( + automerge, "DeployModelCache", lambda gh, org, **kw: FakeCache(service_model()) + ) + + def must_not_merge(*args, **kwargs): + raise AssertionError("merge must not be attempted without a health gate") + + monkeypatch.setattr(automerge, "merge_pr", must_not_merge) + monkeypatch.setattr(automerge, "verify_premerge", must_not_merge) + + cfg = Config(dry_run=False, tiers_enabled=[0, 1], min_age_days=3) + result = run(None, cfg, now=NOW) + + assert result.merged == [] + skipped = [d for d in result.decisions if d.action == "skip"] + assert any("health gate" in (d.skip_reason or "") for d in skipped) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..12a74f3 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,313 @@ +from datetime import datetime, timezone + +import responses + +from dependabot_batch_review.automation_types import MergeOutcome, Tier +from dependabot_batch_review.config import HealthConfig +from dependabot_batch_review.health import ( + NewRelicClient, + SentryClient, + check_health, + sample_newrelic, + sample_sentry, + wait_for_deploy, +) +from tests.helpers import make_pr + +SENTRY = "https://sentry.io/api/0" +NR = "https://api.newrelic.com/graphql" + + +def _outcome(sha="abc1234"): + return MergeOutcome( + pr=make_pr("cryptography", "44.0.1", "44.0.2"), + owner="hypothesis", + repo="bouncer", + tier=Tier.TIER_1, + merged=True, + dry_run=False, + merge_commit_sha=sha, + ) + + +def _nr(results): + return {"data": {"actor": {"account": {"nrql": {"results": results}}}}} + + +@responses.activate +def test_sentry_healthy(): + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/projects/", + json=[{"slug": "bouncer", "id": "42"}], + ) + responses.add(responses.GET, f"{SENTRY}/organizations/hypothesis/issues/", json=[]) + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/sessions/", + json={"groups": [{"totals": {"crash_free_rate(session)": 0.9999}}]}, + ) + cfg = HealthConfig(sentry_org="hypothesis", sentry_token="t") + signal = sample_sentry(SentryClient("t", "hypothesis"), cfg, "bouncer") + assert signal.healthy is True + + +@responses.activate +def test_sentry_new_issue_fails(): + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/projects/", + json=[{"slug": "bouncer", "id": "42"}], + ) + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/issues/", + json=[{"id": "1", "title": "KeyError"}], + ) + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/sessions/", + json={"groups": [{"totals": {"crash_free_rate(session)": 0.999}}]}, + ) + cfg = HealthConfig(sentry_org="hypothesis", sentry_token="t") + signal = sample_sentry(SentryClient("t", "hypothesis"), cfg, "bouncer") + assert signal.healthy is False + + +@responses.activate +def test_newrelic_error_spike_fails(): + responses.add(responses.POST, NR, json=_nr([{"rate": 10.0}])) # post window + responses.add(responses.POST, NR, json=_nr([{"rate": 0.3}])) # baseline + responses.add(responses.POST, NR, json=_nr([{"c": 50}])) # error count + cfg = HealthConfig(newrelic_token="k", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("k", 1), cfg, "bouncer") + assert signal.healthy is False + + +@responses.activate +def test_newrelic_steady_state_ok(): + responses.add(responses.POST, NR, json=_nr([{"rate": 0.31}])) + responses.add(responses.POST, NR, json=_nr([{"rate": 0.30}])) + responses.add(responses.POST, NR, json=_nr([{"c": 2}])) + cfg = HealthConfig(newrelic_token="k", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("k", 1), cfg, "bouncer") + assert signal.healthy is True + + +class _FakeGH: + def __init__(self, states): + self.states = states + self.token = "x" + self._i = 0 + + def query(self, query, variables=None, extra_headers=None): + state = self.states[min(self._i, len(self.states) - 1)] + self._i += 1 + return { + "repository": { + "object": { + "deployments": { + "nodes": [{"state": state, "latestStatus": {"logUrl": "u"}}] + } + } + } + } + + +def _clock(): + counter = {"t": 1000.0} + + def now(): + return datetime.fromtimestamp(counter["t"], tz=timezone.utc) + + def sleep(seconds): + counter["t"] += max(seconds, 1.0) + + return now, sleep + + +def test_wait_for_deploy_success(): + gh = _FakeGH(["IN_PROGRESS", "SUCCESS"]) + now, sleep = _clock() + cfg = HealthConfig(deploy_poll_interval_s=1, deploy_wait_timeout_s=100) + result = wait_for_deploy(gh, _outcome(), cfg, now, sleep) + assert result.state == "success" + + +def test_wait_for_deploy_failure(): + gh = _FakeGH(["FAILURE"]) + now, sleep = _clock() + cfg = HealthConfig(deploy_poll_interval_s=1, deploy_wait_timeout_s=100) + result = wait_for_deploy(gh, _outcome(), cfg, now, sleep) + assert result.state == "failure" + + +def test_wait_for_deploy_timeout(): + gh = _FakeGH(["IN_PROGRESS"]) + now, sleep = _clock() + cfg = HealthConfig(deploy_poll_interval_s=1, deploy_wait_timeout_s=3) + result = wait_for_deploy(gh, _outcome(), cfg, now, sleep) + assert result.state == "timeout" + + +def test_check_health_deploy_failure_is_unhealthy(): + gh = _FakeGH(["FAILURE"]) + now, sleep = _clock() + cfg = HealthConfig(deploy_poll_interval_s=1, deploy_wait_timeout_s=10) + verdict = check_health(gh, _outcome(), cfg, now=now, sleep=sleep) + assert verdict.healthy is False + + +def test_check_health_no_signals_configured_is_unknown(): + gh = _FakeGH(["SUCCESS"]) + now, sleep = _clock() + cfg = HealthConfig(deploy_poll_interval_s=1, deploy_wait_timeout_s=10) + verdict = check_health(gh, _outcome(), cfg, now=now, sleep=sleep) + # No tokens => no signals => the gate cannot verify anything. Fail closed: + # not healthy, flagged unknown (escalate to humans, no auto-rollback). + assert verdict.healthy is False + assert verdict.unknown is True + assert any("no monitoring signals" in r for r in verdict.reasons) + + +@responses.activate +def test_sentry_api_error_is_unknown_not_healthy(): + responses.add( + responses.GET, f"{SENTRY}/organizations/hypothesis/projects/", status=401 + ) + cfg = HealthConfig(sentry_org="hypothesis", sentry_token="expired") + signal = sample_sentry(SentryClient("expired", "hypothesis"), cfg, "bouncer") + assert signal.healthy is False + assert signal.unknown is True + + +@responses.activate +def test_newrelic_api_error_is_unknown_not_healthy(): + responses.add(responses.POST, NR, status=500) + cfg = HealthConfig(newrelic_token="t", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("t", 1), cfg, "bouncer") + assert signal.healthy is False + assert signal.unknown is True + + +@responses.activate +def test_newrelic_no_data_is_unknown(): + responses.add(responses.POST, NR, json=_nr([])) + cfg = HealthConfig(newrelic_token="t", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("t", 1), cfg, "bouncer") + assert signal.healthy is False + assert signal.unknown is True + + +def test_check_health_deploy_timeout_is_unknown_without_sampling(): + gh = _FakeGH(["IN_PROGRESS"]) + now, sleep = _clock() + cfg = HealthConfig( + deploy_poll_interval_s=1, deploy_wait_timeout_s=3, sentry_token="t" + ) + verdict = check_health(gh, _outcome(), cfg, now=now, sleep=sleep) + # Old release would be measured; the gate must not claim healthy. + assert verdict.healthy is False + assert verdict.unknown is True + assert verdict.signals == {} + + +@responses.activate +def test_check_health_soaks_before_sampling(): + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/projects/", + json=[{"slug": "bouncer", "id": "42"}], + ) + responses.add(responses.GET, f"{SENTRY}/organizations/hypothesis/issues/", json=[]) + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/sessions/", + json={"groups": [{"totals": {"crash_free_rate(session)": 1.0}}]}, + ) + gh = _FakeGH(["SUCCESS"]) + now, _ = _clock() + sleeps: list[float] = [] + cfg = HealthConfig( + deploy_poll_interval_s=1, + deploy_wait_timeout_s=10, + sentry_token="t", + post_deploy_soak_min=2, + ) + verdict = check_health(gh, _outcome(), cfg, now=now, sleep=sleeps.append) + assert verdict.healthy is True + assert 120 in sleeps # soaked 2 minutes after the deploy settled + + +@responses.activate +def test_newrelic_zero_baseline_single_error_is_not_a_spike(): + # baseline ~0 => the relative spike check is meaningless; only the absolute + # error floor applies. One error on a low-traffic service must not roll back. + responses.add(responses.POST, NR, json=_nr([{"rate": 1.5}])) # post window + responses.add(responses.POST, NR, json=_nr([{"rate": 0.0}])) # baseline + responses.add(responses.POST, NR, json=_nr([{"c": 1}])) # error count + cfg = HealthConfig(newrelic_token="k", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("k", 1), cfg, "bouncer") + assert signal.healthy is True + + +@responses.activate +def test_newrelic_zero_baseline_many_errors_fails(): + responses.add(responses.POST, NR, json=_nr([{"rate": 5.0}])) + responses.add(responses.POST, NR, json=_nr([{"rate": 0.0}])) + responses.add(responses.POST, NR, json=_nr([{"c": 50}])) + cfg = HealthConfig(newrelic_token="k", newrelic_account_id="1") + signal = sample_newrelic(NewRelicClient("k", 1), cfg, "bouncer") + assert signal.healthy is False + + +def _sentry_no_sessions(): + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/projects/", + json=[{"slug": "bouncer", "id": "42"}], + ) + responses.add( + responses.GET, f"{SENTRY}/organizations/hypothesis/sessions/", json={"groups": []} + ) + + +@responses.activate +def test_sentry_missing_crash_free_is_unknown_by_default(): + _sentry_no_sessions() + responses.add(responses.GET, f"{SENTRY}/organizations/hypothesis/issues/", json=[]) + cfg = HealthConfig(sentry_org="hypothesis", sentry_token="t") + signal = sample_sentry(SentryClient("t", "hypothesis"), cfg, "bouncer") + assert signal.healthy is False + assert signal.unknown is True + + +@responses.activate +def test_sentry_missing_crash_free_opt_out_uses_issues_alone(): + from dependabot_batch_review.config import Thresholds + + _sentry_no_sessions() + responses.add(responses.GET, f"{SENTRY}/organizations/hypothesis/issues/", json=[]) + cfg = HealthConfig( + sentry_org="hypothesis", + sentry_token="t", + thresholds=Thresholds(require_crash_free=False), + ) + signal = sample_sentry(SentryClient("t", "hypothesis"), cfg, "bouncer") + assert signal.healthy is True + assert signal.unknown is False + + +@responses.activate +def test_sentry_new_issues_outrank_missing_crash_free(): + # Hard evidence of degradation must be degraded (rollback), not unknown. + _sentry_no_sessions() + responses.add( + responses.GET, + f"{SENTRY}/organizations/hypothesis/issues/", + json=[{"id": "1", "title": "KeyError"}], + ) + cfg = HealthConfig(sentry_org="hypothesis", sentry_token="t") + signal = sample_sentry(SentryClient("t", "hypothesis"), cfg, "bouncer") + assert signal.healthy is False + assert signal.unknown is False diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..ede0a13 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,104 @@ +from datetime import datetime, timezone + +from dependabot_batch_review.automerge import decide +from dependabot_batch_review.config import Config +from dependabot_batch_review.monitor import ( + PRState, + build_rows, + collect_events, + dry_run_worker, + reduce_events, +) +from tests.helpers import make_pr, service_model + +NOW = datetime(2026, 6, 2, tzinfo=timezone.utc) + + +class FakeCache: + def __init__(self, model): + self._model = model + + def get(self, repo): + return self._model + + +def _decisions(): + prs = [ + make_pr("ruff", "0.1.0", "0.1.1", number=1), + make_pr("newrelic", "11.0.1", "13.1.0", number=2), + ] + cfg = Config(tiers_enabled=[0]) + cache = FakeCache(service_model()) + return [decide(pr, cache, cfg, NOW) for pr in prs] + + +def test_build_rows(): + rows = build_rows(_decisions()) + assert len(rows) == 2 + assert {r.package for r in rows} == {"ruff", "newrelic"} + + +def test_worker_drives_state_machine_to_terminal(): + decisions = _decisions() + rows = build_rows(decisions) + events = collect_events(dry_run_worker(decisions)) + model = reduce_events(rows, events) + + assert model.done is True + states = {r.package: r.state for r in model.rows} + assert states["ruff"] is PRState.WOULD_MERGE # Tier 0 + assert states["newrelic"] is PRState.ESCALATED # Tier 2 + + +def test_live_worker_merges_audits_and_skips_unwatchable_tier1(monkeypatch, tmp_path): + import json + + from dependabot_batch_review import monitor + from dependabot_batch_review.audit import AuditLog + from dependabot_batch_review.automation_types import MergeOutcome, Tier + from dependabot_batch_review.monitor import live_worker + + t0 = make_pr("ruff", "0.1.0", "0.1.1", number=1, repo="lib") + t1 = make_pr("sentry-sdk", "2.58.0", "2.58.1", number=2, repo="bouncer") + cfg = Config(tiers_enabled=[0, 1], dry_run=False) + cache = FakeCache(service_model()) + decisions = [decide(t0, cache, cfg, NOW) for t0 in [t0]] + [ + decide(t1, cache, cfg, NOW) + ] + # Make the first decision a plain Tier-0 merge regardless of the fake model. + decisions[0].action = "merge" + + merged = [] + + def fake_merge(gh, d, c, now=None): + merged.append(d.pr.group_name) + return MergeOutcome( + pr=d.pr, + owner="hypothesis", + repo=d.pr.repo or "", + tier=Tier(int(d.classification.tier)), + merged=True, + dry_run=False, + merge_commit_sha="abc1234", + ) + + monkeypatch.setattr(monitor, "_merge_and_capture", fake_merge) + + audit = AuditLog(tmp_path / "audit.jsonl") + # No SENTRY/NEW_RELIC credentials => the Tier-1 PR must be held, not merged. + events = collect_events(live_worker(None, cfg, decisions, audit=audit)) + + states = [e.state for e in events if e.kind == "row"] + assert PRState.MERGED in states + assert PRState.SKIPPED in states + assert merged == ["ruff"] + + entries = [ + json.loads(line) + for line in (tmp_path / "audit.jsonl").read_text().splitlines() + ] + assert any(e["event"] == "merged" and e["package"] == "ruff" for e in entries) + assert any( + e["event"] == "skipped" and e["reason"] == "no monitoring credentials" + for e in entries + ) diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 0000000..319ec5f --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,67 @@ +import pytest + +from dependabot_batch_review.automation_types import Tier +from dependabot_batch_review.review import DependencyUpdate +from dependabot_batch_review.risk import classify +from tests.helpers import library_model, make_pr, service_model + +SVC = service_model() +LIB = library_model() + + +@pytest.mark.parametrize( + "pr,model,expected_tier,expected_deploys", + [ + (make_pr("newrelic", "11.0.1", "13.1.0"), SVC, Tier.TIER_2, True), + (make_pr("pytest", "9.0.1", "9.0.3"), SVC, Tier.TIER_0, False), + (make_pr("urllib3", "2.5.0", "2.7.0"), SVC, Tier.TIER_2, True), + (make_pr("sentry-sdk", "2.58.0", "2.61.1"), SVC, Tier.TIER_1, True), + (make_pr("black", "25.11.0", "26.5.1"), SVC, Tier.TIER_2, False), + (make_pr("gunicorn", "23.0.0", "26.0.0"), SVC, Tier.TIER_2, True), + (make_pr("requests", "2.32.0", "2.33.0"), SVC, Tier.TIER_1, True), + (make_pr("requests", "2.0.0", "3.0.0"), SVC, Tier.TIER_2, True), + (make_pr("cryptography", "44.0.2", "44.0.3"), SVC, Tier.TIER_2, True), + (make_pr("ruff", "0.14.7", "0.14.10"), SVC, Tier.TIER_0, False), + ( + make_pr("node", "25.2-alpine", "26.2-alpine", package_type="docker"), + SVC, + Tier.TIER_2, + True, + ), + ( + make_pr("typescript", "5.9.3", "6.0.3", package_type="npm_and_yarn"), + LIB, + Tier.TIER_2, + False, + ), + ( + make_pr("preact", "10.27.2", "10.28.4", package_type="npm_and_yarn"), + LIB, + Tier.TIER_0, + False, + ), + ], +) +def test_classify_table(pr, model, expected_tier, expected_deploys): + result = classify(pr, model) + assert result.tier is expected_tier + assert result.deploys_to_prod is expected_deploys + + +def test_grouped_pr_uses_riskiest(): + # ruff (patch, dev) + sqlalchemy (minor, runtime) -> Tier 1 on a service. + grouped = make_pr( + "backend", + updates=[ + DependencyUpdate("ruff", "0.14.7", "0.14.10", ""), + DependencyUpdate("sqlalchemy", "2.0.44", "2.1.0", ""), + ], + ) + assert classify(grouped, SVC).tier is Tier.TIER_1 + # Same group on a non-deploying library is Tier 0. + assert classify(grouped, LIB).tier is Tier.TIER_0 + + +def test_unparseable_is_fail_safe_tier2(): + pr = make_pr("weird", "garbage", "alsobad") + assert classify(pr, SVC).tier is Tier.TIER_2 diff --git a/tests/test_rollback.py b/tests/test_rollback.py new file mode 100644 index 0000000..d2f2dfc --- /dev/null +++ b/tests/test_rollback.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace + +import pytest + +from dependabot_batch_review import rollback +from dependabot_batch_review.rollback import _redact, _run, revert_merge + + +class FakeGH: + def __init__(self, existing_pr=None): + self.token = "tkn" + self.existing = existing_pr + self.calls: list[str] = [] + + def query(self, query, variables=None, extra_headers=None): + self.calls.append(query) + if "pullRequests(headRefName" in query: + nodes = [self.existing] if self.existing else [] + return { + "repository": { + "id": "REPO1", + "defaultBranchRef": {"name": "main"}, + "pullRequests": {"nodes": nodes}, + } + } + if "createPullRequest" in query: + return { + "createPullRequest": { + "pullRequest": { + "id": "REVERTPR", + "url": "https://github.com/hypothesis/bouncer/pull/999", + } + } + } + if "mergePullRequest" in query: + return {"mergePullRequest": {"pullRequest": {"merged": True, "url": "x"}}} + return {} + + +def _fake_run(cmd, cwd=None, capture_output=True, text=True): + out = "" + if "rev-list" in cmd: + out = "mergesha parentsha" # 2 tokens => single-parent (squash) commit + elif "rev-parse" in cmd: + out = "revertsha123" + return SimpleNamespace(returncode=0, stdout=out, stderr="") + + +def test_dry_run_does_nothing(monkeypatch): + def boom(*args, **kwargs): + raise AssertionError("subprocess must not run in dry-run") + + monkeypatch.setattr(rollback.subprocess, "run", boom) + result = revert_merge(FakeGH(), "hypothesis", "bouncer", "abc1234", dry_run=True) + assert result.performed is False + assert result.dry_run is True + assert "would `git revert`" in result.reason + + +def test_idempotent_when_revert_exists(): + existing = {"url": "https://github.com/hypothesis/bouncer/pull/500", "merged": True} + result = revert_merge(FakeGH(existing), "hypothesis", "bouncer", "abc1234") + assert result.performed is True + assert result.revert_pr_url.endswith("/500") + assert "already exists" in result.reason + + +def test_full_revert_opens_and_merges_pr(monkeypatch): + monkeypatch.setattr(rollback.subprocess, "run", _fake_run) + gh = FakeGH() + result = revert_merge( + gh, + "hypothesis", + "bouncer", + "abc1234", + original_title="cryptography 44 -> 46", + original_pr_url="https://github.com/hypothesis/bouncer/pull/1", + ) + assert result.performed is True + assert result.revert_pr_url.endswith("/999") + assert any("mergePullRequest" in call for call in gh.calls) + + +def test_revert_handles_git_failure(monkeypatch): + def failing_run(cmd, cwd=None, capture_output=True, text=True): + return SimpleNamespace(returncode=1, stdout="", stderr="conflict") + + monkeypatch.setattr(rollback.subprocess, "run", failing_run) + result = revert_merge(FakeGH(), "hypothesis", "bouncer", "abc1234") + assert result.performed is False + assert "git revert -m 1" in result.reason # manual fallback instructions + + +def test_redact_strips_clone_token(): + url = "https://x-access-token:ghp_SECRET123@github.com/hypothesis/bouncer.git" + redacted = _redact(f"git clone {url} .") + assert "ghp_SECRET123" not in redacted + assert "x-access-token:***@" in redacted + + +def test_run_error_does_not_leak_token(monkeypatch): + def failing_run(cmd, cwd=None, capture_output=True, text=True): + return SimpleNamespace( + returncode=1, + stdout="", + stderr="fatal: https://x-access-token:ghp_SECRET@github.com/x/y.git denied", + ) + + monkeypatch.setattr(rollback.subprocess, "run", failing_run) + with pytest.raises(RuntimeError) as excinfo: + _run( + [ + "git", + "clone", + "https://x-access-token:ghp_SECRET@github.com/x/y.git", + ".", + ], + "/tmp", + ) + assert "ghp_SECRET" not in str(excinfo.value) diff --git a/tests/test_semver.py b/tests/test_semver.py new file mode 100644 index 0000000..de336ce --- /dev/null +++ b/tests/test_semver.py @@ -0,0 +1,62 @@ +import pytest + +from dependabot_batch_review.review import DependencyUpdate +from dependabot_batch_review.semver import ( + BumpKind, + classify_bump, + parse_version, + riskiest_bump, +) + + +@pytest.mark.parametrize( + "from_version,to_version,expected", + [ + ("9.0.1", "9.0.3", BumpKind.PATCH), + ("2.5.0", "2.7.0", BumpKind.MINOR), + ("11.0.1", "13.1.0", BumpKind.MAJOR), + ("25.11.0", "26.5.1", BumpKind.MAJOR), + ("0.1.32", "0.1.33", BumpKind.PATCH), + ("0.1.0", "0.2.0", BumpKind.MAJOR), # pre-1.0 minor is breaking + ("v0.47.13", "v0.59.x", BumpKind.MAJOR), + ("25.2-alpine", "26.2-alpine", BumpKind.MAJOR), + ("1.2.3", "1.2.3", BumpKind.PATCH), + (None, "1.2.3", BumpKind.UNKNOWN), + ("1.2.3", None, BumpKind.UNKNOWN), + ("garbage", "1.0.0", BumpKind.UNKNOWN), + ], +) +def test_classify_bump(from_version, to_version, expected): + assert classify_bump(from_version, to_version) is expected + + +def test_parse_version(): + assert parse_version("v1.2.3") == (1, 2, 3) + assert parse_version("25.2-alpine") == (25, 2, 0) + assert parse_version("26") == (26, 0, 0) + assert parse_version("2024.10.3") == (2024, 10, 3) + assert parse_version("garbage") is None + assert parse_version(None) is None + assert parse_version("") is None + + +def test_riskiest_bump_takes_max(): + updates = [ + DependencyUpdate("a", "1.0.0", "1.0.1", ""), # patch + DependencyUpdate("b", "1.0.0", "2.0.0", ""), # major + ] + assert riskiest_bump(updates) is BumpKind.MAJOR + + +def test_riskiest_bump_empty(): + assert riskiest_bump([]) is BumpKind.UNKNOWN + + +def test_riskiest_bump_unknown_member_taints_group(): + # UNKNOWN sorts lowest in the IntEnum, so max() alone would mask it; an + # unparseable member must make the whole group UNKNOWN (fail-safe). + updates = [ + DependencyUpdate("a", "1.2.3", "1.2.4", ""), + DependencyUpdate("b", "garbage", "alsogarbage", ""), + ] + assert riskiest_bump(updates) == BumpKind.UNKNOWN diff --git a/tests/test_slack_messages.py b/tests/test_slack_messages.py new file mode 100644 index 0000000..f76326f --- /dev/null +++ b/tests/test_slack_messages.py @@ -0,0 +1,77 @@ +from dependabot_batch_review.automerge import Decision, RunResult +from dependabot_batch_review.automation_types import ( + HealthVerdict, + MergeOutcome, + SignalResult, + Tier, +) +from dependabot_batch_review.config import Config +from dependabot_batch_review.risk import classify +from dependabot_batch_review.rollback import RollbackResult +from dependabot_batch_review.slack_messages import format_rollback, format_run_digest +from dependabot_batch_review.triage import TriageResult +from tests.helpers import library_model, make_pr, service_model + + +def _decision(pr, model, action): + return Decision( + pr=pr, + classification=classify(pr, model), + action=action, + eligible=action.startswith("merge"), + ) + + +def test_run_digest_contains_sections_and_links(): + merged = _decision( + make_pr("ruff", "0.1.0", "0.1.1", number=1), library_model(), "merge" + ) + escalated = _decision( + make_pr("newrelic", "11.0.1", "13.1.0", number=2), service_model(), "escalate" + ) + result = RunResult(decisions=[merged, escalated], dry_run=True) + message = format_run_digest(result, Config()) + + assert "DRY-RUN" in message + assert "Would merge" in message + assert "newrelic" in message + assert "" in message + + +def test_rollback_message_has_diagnosis_and_triage(): + outcome = MergeOutcome( + pr=make_pr("cryptography", "44.0.1", "46.0.0", number=3), + owner="hypothesis", + repo="bouncer", + tier=Tier.TIER_1, + merged=True, + dry_run=False, + merge_commit_sha="abcdef1234567", + ) + verdict = HealthVerdict( + healthy=False, + signals={ + "newrelic": SignalResult( + "newrelic", False, "error rate", detail="error-rate 7% vs 0.3%" + ) + }, + reasons=["newrelic"], + ) + rollback = RollbackResult( + performed=True, + revert_pr_url="https://github.com/hypothesis/bouncer/pull/999", + revert_commit_sha="r1", + reason="reverted and merged", + dry_run=False, + ) + triage = TriageResult( + summary="major bump with API removals", + recommendation="manual-review", + confidence="high", + ) + message = format_rollback(outcome, verdict, rollback, triage) + + assert "AUTO-ROLLBACK" in message + assert "revert PR" in message + assert "Claude triage" in message + assert "error-rate 7%" in message diff --git a/tests/test_triage.py b/tests/test_triage.py new file mode 100644 index 0000000..6f49683 --- /dev/null +++ b/tests/test_triage.py @@ -0,0 +1,18 @@ +from dependabot_batch_review.triage import triage_pr +from tests.helpers import make_pr + + +def test_degrades_without_api_key(monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + result = triage_pr(make_pr("newrelic", "11.0.1", "13.1.0")) + assert result.degraded is True + # Major bump -> analyze_risk High -> manual-review. + assert result.recommendation == "manual-review" + + +def test_degrades_when_anthropic_unavailable(): + # anthropic is not a hard dependency; a passed key still degrades gracefully + # (ImportError or API failure both fall back to the deterministic heuristic). + result = triage_pr(make_pr("ruff", "0.1.0", "0.1.1"), api_key="fake-key") + assert result.degraded is True + assert result.summary