diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..8bc6ddb --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,5 @@ +extends: + - '@commitlint/config-conventional' + +rules: + header-max-length: [0, 'always', 100] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4a8424d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + groups: + uv-minor-patch: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "minor" + - "patch" + uv-major: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "major" + uv-security: + applies-to: "security-updates" + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions-minor-patch: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "minor" + - "patch" + actions-major: + applies-to: "version-updates" + patterns: + - "*" + update-types: + - "major" + actions-security: + applies-to: "security-updates" + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1aef3a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - main + paths-ignore: + - "data/**" + pull_request: + branches: + - main + paths-ignore: + - "data/**" + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.18" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Run tests + run: uv run --frozen pytest + + - name: Check formatting and lint + run: | + uv run --frozen ruff check . + uv run --frozen ruff format --check . + + - name: Check types + run: uv run --frozen mypy src tests + + - name: Check complexity + run: uv run --frozen complexipy src/github_trending --max-complexity-allowed 15 --sort desc + + - name: Check dependencies and imports + run: | + uv run --frozen deptry . + uv run --frozen lint-imports diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..fbdaf33 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,61 @@ +name: Commit checks + +on: + push: + branches: + - main + paths-ignore: + - "data/**" + pull_request: + branches: + - main + types: + - opened + - edited + - synchronize + - reopened + +concurrency: + group: commitlint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + commitlint: + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + || (github.event_name == 'push' && github.event.head_commit.author.name != 'dependabot[bot]') + runs-on: ubuntu-latest + + steps: + - name: Enforce single-commit PRs + if: github.event_name == 'pull_request' + env: + PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: test "$PR_COMMIT_COUNT" -eq 1 + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + package-manager-cache: false + + - name: Install commitlint + run: npm install --ignore-scripts --no-save --no-package-lock @commitlint/cli@21.2.3 @commitlint/config-conventional@21.2.3 + + - name: Validate PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\n' "$PR_TITLE" | ./node_modules/.bin/commitlint --verbose + + - name: Validate last commit + run: ./node_modules/.bin/commitlint --last --verbose diff --git a/.github/workflows/update-data.yml b/.github/workflows/update-data.yml new file mode 100644 index 0000000..d5c5390 --- /dev/null +++ b/.github/workflows/update-data.yml @@ -0,0 +1,64 @@ +name: Update data + +on: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-github-trending-data + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + env: + UV_PYTHON_PREFERENCE: only-system + UV_PYTHON_DOWNLOADS: never + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: .python-version + + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.18" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Generate dataset + id: generate + shell: bash + run: | + set +e + uv run --frozen github-trending --verbose + exit_code=$? + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + if [[ "$exit_code" != "0" && "$exit_code" != "2" ]]; then + exit "$exit_code" + fi + + - name: Commit generated data + if: steps.generate.outputs.exit_code == '0' || steps.generate.outputs.exit_code == '2' + shell: bash + run: | + git add data + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore(data): update $(date -u +'%Y-%m-%dT%H:%M:%SZ') [skip ci]" + git push + + - name: Mark degraded update as failed + if: steps.generate.outputs.exit_code == '2' + run: exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d968e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.complexipy_cache/ +.import_linter_cache/ +dist/ +__pycache__/ +*.py[cod] +.DS_Store + +.idea/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62d1185 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +## Agent skills + +### Issue tracker + +Issues and specs are tracked as local Markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Domain docs + +This repository uses a single-context domain documentation layout. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..cbe890f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,56 @@ +# GitHub Trending Dataset + +This context describes the vocabulary of observed GitHub Trending rankings and their history. + +## Language + +**Feed**: +A single GitHub Trending ranking for one language filter and one period. +_Avoid_: Endpoint, channel + +**Observation**: +One repository's appearance at a particular rank in a feed at a point in time. +_Avoid_: Record, event + +**Repository Identity**: +The case-insensitive owner and repository name that relates observations of the same repository. +_Avoid_: URL, GUID + +**Period Metric**: +The number of stars GitHub reports for a repository within a feed's period. It is optional because +GitHub may omit it from an otherwise valid observation. +_Avoid_: Total stars, growth rate + +**Snapshot**: +The ordered observations obtained from one successful reading of a feed. +_Avoid_: Current archive, batch + +**Snapshot Issue**: +A structured description of an unexpectedly missing or malformed field at its original source rank. +_Avoid_: Feed failure, validation error + +**Current Dataset**: +The latest successfully published snapshot of each feed. +_Avoid_: Cache, latest archive + +**Daily Rollup**: +The unique repositories observed in one feed during a UTC day, together with their latest values and daily rank summary. +_Avoid_: Daily snapshot, cumulative current + +**Archive**: +The date-addressed collection of daily rollups. +_Avoid_: Backup, snapshot history + +**Degraded Snapshot**: +A snapshot containing every identifiable observation from a recognized ranking while identifying +unexpectedly missing or malformed fields. An absent optional period metric does not cause +degradation; only source positions without repository identity are skipped. +_Avoid_: Partial failure, incomplete feed + +**Stale Feed**: +A feed whose latest read failed entirely, leaving its previously published snapshot unchanged. +_Avoid_: Empty feed, degraded snapshot + +**Update Run**: +One attempt to read every configured feed and publish each snapshot that was obtained successfully. +_Avoid_: Snapshot, deployment diff --git a/README.md b/README.md new file mode 100644 index 0000000..05dd014 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# GitHub Trending API + +Static JSON and RSS snapshots of GitHub Trending, refreshed four times per day by GitHub Actions. +The dataset covers `daily`, `weekly`, and `monthly` periods for `all`, `python`, `rust`, `go`, +`javascript`, and `typescript`. + +## Data + +`current` contains the latest published snapshot. `archive` contains one accumulated UTC daily +rollup per feed. + +```text +https://raw.githubusercontent.com///main/data/current/{period}/{language}.{json|xml} +https://raw.githubusercontent.com///main/data/archive/{YYYY}/{MM}/{DD}/{period}/{language}.{json|xml} +``` + +## Documentation + +- [Dataset behavior](docs/product/dataset.md) +- [JSON and RSS data contract](docs/product/data-contract.md) +- [Documentation index](docs/README.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5f13a46 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Documentation + +This directory is the entrypoint for durable documentation of the implemented system. + +## Product + +- [Product documentation](product/README.md) +- [Dataset behavior](product/dataset.md) +- [JSON and RSS data contract](product/data-contract.md) + +## Architecture and operations + +- [Architecture overview](architecture/overview.md) +- [Deployment and automation](architecture/deployment.md) +- [ADR-0001: Static dataset via GitHub Actions](adr/0001-static-dataset-via-github-actions.md) + +## Domain language + +- [GitHub Trending Dataset glossary](../CONTEXT.md) + +The project landing page remains in the root [README](../README.md). diff --git a/docs/adr/0001-static-dataset-via-github-actions.md b/docs/adr/0001-static-dataset-via-github-actions.md new file mode 100644 index 0000000..9da4dfd --- /dev/null +++ b/docs/adr/0001-static-dataset-via-github-actions.md @@ -0,0 +1,6 @@ +# Static dataset via GitHub Actions + +GitHub Trending API is delivered as generated JSON and RSS committed by a scheduled GitHub Actions +job and served through repository files and GitHub Raw, rather than through an application server +and database. This keeps operation minimal and makes every published change visible in Git history, +at the cost of update latency, repository write access for Actions, and no dynamic query interface. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..80451e7 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,35 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence or suggest creating them upfront. The `/domain-modeling` skill creates them lazily when terms or decisions actually get resolved. + +## File structure + +This is a single-context repo: + +```text +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, or a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding it: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..0d195cb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,18 @@ +# Issue tracker: Local Markdown + +Issues and specs for this repo live as Markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The spec is `.scratch//spec.md` +- Implementation issues are one file per ticket at `.scratch//issues/-.md`, numbered from `01`; never use a single combined tickets file +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//`, creating the directory if needed. + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or issue number directly. diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..ea5ad60 --- /dev/null +++ b/docs/architecture/deployment.md @@ -0,0 +1,82 @@ +# Deployment and Automation + +The repository uses GitHub Actions both for validation and for publishing the static dataset. The +runtime is Python 3.14 with dependencies locked by `uv.lock`. + +## Continuous integration + +The `CI` workflow runs on non-data-only pushes to `main` and non-data-only pull requests targeting +`main`, with read-only repository permissions. It uses commit-pinned checkout, Python setup, and uv +setup actions, then runs: + +- pytest; +- Ruff lint and format checks; +- strict mypy; +- Complexipy with a per-function ceiling of 15 and output sorted by descending complexity; +- Deptry; +- Import Linter architecture contracts. + +All commands use the frozen lockfile. + +## Pull request and dependency policy + +The `Commit checks` workflow validates pull request titles and head commits against Conventional +Commits and requires each pull request to contain exactly one commit. It reruns when the title is +edited, checks the latest commit on non-data-only pushes to `main`, and skips Dependabot pull +requests. The workflow installs its pinned commitlint packages temporarily and does not add a Node +project to the repository. + +Dependabot checks the `uv` and GitHub Actions ecosystems weekly. Minor and patch version updates are +grouped, major updates remain separate, and security updates use a separate group for each +ecosystem. Data-only pushes are excluded from the commit checks because generated commits are +owned by the Update data workflow. + +## Dataset updates + +The `Update data` workflow runs at `00:17`, `06:17`, `12:17`, and `18:17` UTC and supports manual +dispatch. A single concurrency group prevents overlapping Update Runs; an active run is not +cancelled when another is queued. + +The job has only `contents: write` permission and runs: + +```shell +uv run --frozen github-trending --verbose +``` + +Verbose progress appears in the Actions log. The final report is appended to +`GITHUB_STEP_SUMMARY`. + +## Exit and commit behavior + +| Exit code | Meaning | Workflow behavior | +| --- | --- | --- | +| `0` | Every Feed was complete. | Commit changed data, if any, and succeed. | +| `2` | At least one Snapshot was degraded or one Feed stayed stale. | Commit every successful change, then fail the job for notification. | +| `1` | Fatal configuration, filesystem, or persisted-data error. | Stop before the commit step. | + +Generated commits use the `github-actions[bot]` identity and a UTC timestamp. The commit step stages +only `data`; when no generated file changed, it exits without creating a commit. Partial publication +is intentional and documented in +[PDR-0003](../product/decisions/0003-partial-publication-and-stale-feeds.md). + +## Repository prerequisites + +- The workflow must run from the branch that owns the canonical data paths. +- Repository rules must permit the built-in `GITHUB_TOKEN` to push generated commits. +- Standard GitHub Actions web or email notifications should be enabled when degraded scheduled + runs require operator attention. + +A manual rerun is the recovery mechanism for transient failures. It updates the same UTC Daily +Rollup and counts another successful observation in `appearances`; it does not backfill older dates. + +## Local operation + +Generate into the default `data` directory or an isolated path: + +```shell +uv run --frozen github-trending --verbose +uv run --frozen github-trending --verbose --data-dir /tmp/github-trending-data +``` + +The static delivery architecture and its operational trade-offs are recorded in +[ADR-0001](../adr/0001-static-dataset-via-github-actions.md). diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..8d5ab0a --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,64 @@ +# Architecture Overview + +GitHub Trending API is a scheduled data pipeline packaged as a Python CLI. It reads public GitHub +Trending HTML, maps it into a typed domain model, and publishes static JSON and RSS files that Git +and GitHub Raw serve to consumers. There is no long-running process or database. + +The static publication model is recorded in +[ADR-0001](../adr/0001-static-dataset-via-github-actions.md). + +```mermaid +flowchart LR + Actions[GitHub Actions] --> CLI[CLI and dependency wiring] + CLI --> Service[Generation service] + Service --> Source[GitHub source adapter] + GitHub[GitHub Trending HTML] --> Source + Service --> Publisher[Filesystem publisher] + Publisher --> Current[data/current] + Publisher --> Archive[data/archive/YYYY/MM/DD] + Current --> Commit[Git commit and push] + Archive --> Commit + Commit --> Consumers[GitHub Raw consumers] +``` + +## Components and boundaries + +| Component | Responsibility | +| --- | --- | +| Domain | Feed, Snapshot, Snapshot Issue, Daily Rollup, statuses, and rollup merge rules. | +| Generation service | Traverse configured feeds, isolate source failures, publish successful Snapshots, and report run status. | +| GitHub source adapter | Build source URLs, pace and retry HTTP requests, recognize pages, and parse source rows. | +| Format codecs | Render JSON and RSS and decode existing JSON Daily Rollups. | +| Filesystem publisher | Derive current/archive paths, merge the current UTC rollup, and replace generated files. | +| CLI | Parse options, construct runtime dependencies, report progress and summaries, and map outcomes to exit codes. | +| GitHub Actions | Schedule Update Runs, execute quality checks, and commit generated data. | + +Dependencies point inward: domain code imports neither services nor adapters; the generation +service depends on its own source, publisher, and progress protocols; the CLI wires concrete +adapters. Import Linter enforces the domain and service boundaries. + +## Update flow + +1. The CLI constructs 18 feeds from six language filters and three periods. +2. The generation service processes feeds sequentially and reports optional verbose progress. +3. The source adapter waits two seconds between feeds. Each request allows up to three attempts for + network failures, HTTP 429, and server errors; numeric `Retry-After` is honored, otherwise an + exponential delay plus bounded jitter is used. +4. The parser accepts repository rows or an explicit GitHub empty-state marker. It produces a + complete or degraded Snapshot, or a normalized source failure for an unrecognized page. +5. The publisher renders one current JSON/RSS pair and one UTC Daily Rollup JSON/RSS pair. Existing + rollup JSON is decoded and merged before new files are rendered. +6. The CLI prints a final summary and, in GitHub Actions, appends it to the workflow step summary. + +Product semantics for this flow are defined in [Dataset Behavior](../product/dataset.md). + +## Failure boundaries + +Normalized source failures are isolated per Feed; later feeds continue. A malformed identifiable +row degrades its Snapshot without failing the Feed. Filesystem, rollup-decoding, and other fatal +value errors escape the generation service and terminate the CLI with exit code `1`. + +All four payloads for a Feed are rendered before writing begins. Each file is written through a +sibling temporary file and atomically replaced, but the four-file publication is not a transaction: +a process interruption can leave a Feed's JSON and RSS files from different observations. The next +successful Update Run replaces them. diff --git a/docs/product/README.md b/docs/product/README.md new file mode 100644 index 0000000..b11786c --- /dev/null +++ b/docs/product/README.md @@ -0,0 +1,20 @@ +# Product Documentation + +These documents describe the currently implemented behavior of the GitHub Trending Dataset. They +are authoritative for product rules; decision records preserve the reasons behind selected rules. + +## Capabilities + +- [Dataset behavior](dataset.md) +- [JSON and RSS data contract](data-contract.md) + +## Product decisions + +- [PDR-0001: Current snapshots and UTC daily rollups](decisions/0001-current-and-utc-daily-rollups.md) +- [PDR-0002: Publish JSON and RSS](decisions/0002-publish-json-and-rss.md) +- [PDR-0003: Partial publication and stale feeds](decisions/0003-partial-publication-and-stale-feeds.md) +- [PDR-0004: Preserve identifiable degraded observations](decisions/0004-preserve-identifiable-degraded-observations.md) +- [PDR-0005: Stable latest-only schema versioning](decisions/0005-stable-latest-only-schema-versioning.md) + +Architecture and operational concerns are documented in the [architecture overview](../architecture/overview.md) +and [deployment guide](../architecture/deployment.md). diff --git a/docs/product/data-contract.md b/docs/product/data-contract.md new file mode 100644 index 0000000..8857a15 --- /dev/null +++ b/docs/product/data-contract.md @@ -0,0 +1,96 @@ +# JSON and RSS Data Contract + +The canonical files are UTF-8, newline-terminated JSON and RSS 2.0 documents. JSON field names use +camelCase, timestamps use RFC 3339 UTC, collections are arrays, and nullable scalar values are +explicitly represented as `null`. + +## Paths + +```text +data/current/{period}/{language}.json +data/current/{period}/{language}.xml +data/archive/YYYY/MM/DD/{period}/{language}.json +data/archive/YYYY/MM/DD/{period}/{language}.xml +``` + +`period` is `daily`, `weekly`, or `monthly`. `language` is `all`, `python`, `rust`, `go`, +`javascript`, or `typescript`. + +## Snapshot JSON + +| Field | Type | Meaning | +| --- | --- | --- | +| `schemaVersion` | integer | Contract version; currently `1`. | +| `kind` | string | Always `snapshot`. | +| `period` | string | Feed period. | +| `language` | string | Feed language filter. | +| `sourceUrl` | string | Canonical GitHub Trending URL. | +| `observedAt` | timestamp | Time the source response was parsed. | +| `status` | string | `complete` or `degraded`. | +| `issues` | array | Structured Snapshot Issues. | +| `items` | array | Ranked repository observations. | + +A Snapshot item contains: + +| Field | Type | Meaning | +| --- | --- | --- | +| `rank` | integer | Original one-based source position. | +| `fullName` | string | Repository Identity as `owner/name`. | +| `url` | string | Canonical repository URL. | +| `description` | string or null | Normalized source description. | +| `programmingLanguage` | string or null | Source programming language. | +| `stars` | nonnegative integer or null | Total stars; null is accompanied by an issue. | +| `forks` | nonnegative integer or null | Total forks; null is accompanied by an issue. | +| `starsInPeriod` | nonnegative integer or null | Period Metric. Absence is valid; malformed values produce an issue. | + +## Daily Rollup JSON + +A Daily Rollup uses the common `schemaVersion`, `period`, `language`, and `sourceUrl` fields. Its +remaining top-level fields are: + +| Field | Type | Meaning | +| --- | --- | --- | +| `kind` | string | Always `daily-rollup`. | +| `date` | date | Owning UTC date. | +| `updatedAt` | timestamp | Time of the latest merged Snapshot. | +| `latestStatus` | string | Status of the latest merged Snapshot. | +| `latestIssues` | array | Issues from the latest merged Snapshot. | +| `items` | array | Unique repositories observed during the UTC date. | + +Rollup items replace `rank` with `bestRank`, `latestRank`, `firstSeenAt`, `lastSeenAt`, and +`appearances`. Their repository metadata and metrics are the latest observed values. A later +`null` replaces an earlier numeric metric rather than carrying old data forward. + +## Snapshot Issues + +Each issue contains `code`, original source `rank`, `field`, and `rawValue`. `rawValue` is the +source text when available and otherwise `null`. + +| Code | Meaning | +| --- | --- | +| `invalid_repository_link` | The row has no valid `/owner/repository` identity and is skipped. | +| `missing_field` | A required metric is absent. | +| `invalid_integer` | A metric was present but could not be parsed as an integer. | +| `out_of_range` | A metric was a negative integer. | + +One retained item may produce multiple issues. A missing `starsInPeriod` does not produce an issue; +if period text is present but invalid or negative, it does. + +## RSS 2.0 + +RSS documents use standard channel and item elements plus extensions in the +`urn:github-trending:v1` namespace, conventionally prefixed `ght`. Repository URLs are permanent +item GUIDs. + +Snapshot channels expose kind, Feed, timestamp, status, and issues. Rollup channels expose kind, +date, Feed, update timestamp, latest status, and latest issues. Repository items expose the same +metrics as JSON plus rank or rollup summary fields. A JSON `null` metric is an empty extension +element. Issue attributes omit `rawValue` when no source text exists. + +## Compatibility + +Schema version 1 is stable. Additive fields may appear without changing `schemaVersion`; consumers +must ignore unknown JSON fields and unknown RSS extension elements. A breaking change requires a +new schema version. Canonical paths always serve only the latest schema version, so prior versions +are not published in parallel. See +[PDR-0005](decisions/0005-stable-latest-only-schema-versioning.md). diff --git a/docs/product/dataset.md b/docs/product/dataset.md new file mode 100644 index 0000000..fa0ae07 --- /dev/null +++ b/docs/product/dataset.md @@ -0,0 +1,62 @@ +# Dataset Behavior + +The product is a static, read-only dataset derived from GitHub Trending. Each Feed is identified by +one period and one language filter. + +## Supported feeds + +The periods are `daily`, `weekly`, and `monthly`. The language filters are `all`, `python`, `rust`, +`go`, `javascript`, and `typescript`, producing 18 independent feeds per Update Run. + +GitHub is the source of ranking membership, order, repository metadata, total stars, forks, and the +optional Period Metric. The source rank is preserved even when another source position is skipped. +An explicit GitHub empty-state marker produces a valid empty Snapshot; an unrecognized empty page +is a Feed failure rather than an empty ranking. + +## Current Dataset and Archive + +The Current Dataset holds the latest successfully published Snapshot for each Feed. A Degraded +Snapshot is still publishable and therefore replaces the previous current document. + +The Archive is organized by UTC date. Each date contains one Daily Rollup for every Feed observed +on that date. An Update Run changes only its current UTC date; normal operation never rewrites past +dates and does not backfill dates before the first run. + +A Daily Rollup: + +- identifies repositories by case-insensitive `owner/name`; +- keeps each repository once, using its latest casing, metadata, and metrics, including latest + `null` values; +- stores its best and latest rank for the day; +- stores first-seen and last-seen timestamps; +- increments appearances for every successful observation, including manual recovery runs; +- orders repositories by best rank, first-seen time, and identity. + +This separation is intentional; see +[PDR-0001](decisions/0001-current-and-utc-daily-rollups.md). + +## Completeness and failures + +A Snapshot is complete when every unexpected field problem is absent. Missing descriptions, +programming languages, and Period Metrics are allowed and do not cause degradation. Zero is a +valid value for every metric. + +When an identifiable source row has a missing required metric, a malformed number, or a negative +metric, the repository remains in the Snapshot. The affected field is `null`, a Snapshot Issue +records the problem, and the Snapshot is degraded. Only a row without a valid Repository Identity +is skipped. See [PDR-0004](decisions/0004-preserve-identifiable-degraded-observations.md). + +A network, HTTP, or whole-page parsing failure makes that Feed stale: its current and archive files +remain unchanged. Other feeds continue and are published. The Update Run is degraded when any Feed +is stale or any published Snapshot is degraded. See +[PDR-0003](decisions/0003-partial-publication-and-stale-feeds.md). + +## Publication formats + +Every current Snapshot and Daily Rollup is published in JSON and RSS 2.0. Both representations are +generated from the same observation data. JSON is also the persisted representation used to merge +later observations into the current UTC day's Daily Rollup. + +The exact paths, fields, nullability, issue codes, and compatibility rules are defined by the +[data contract](data-contract.md). The dual-format decision is recorded in +[PDR-0002](decisions/0002-publish-json-and-rss.md). diff --git a/docs/product/decisions/0001-current-and-utc-daily-rollups.md b/docs/product/decisions/0001-current-and-utc-daily-rollups.md new file mode 100644 index 0000000..a59733d --- /dev/null +++ b/docs/product/decisions/0001-current-and-utc-daily-rollups.md @@ -0,0 +1,15 @@ +# Current snapshots and UTC daily rollups + +Status: Accepted +Date: 2026-09-26 + +The Current Dataset is kept separately from date-addressed UTC Daily Rollups. Each Update Run +replaces current files and merges observations into the current UTC date instead of moving the +previous current tree into an archive, giving consumers one stable latest path and one compact path +per Feed and day. + +## Consequences + +The current UTC date changes during the day, past dates remain unchanged in normal operation, and +manual reruns increase `appearances`. The Archive records daily aggregates rather than every raw +run snapshot. diff --git a/docs/product/decisions/0002-publish-json-and-rss.md b/docs/product/decisions/0002-publish-json-and-rss.md new file mode 100644 index 0000000..dff9527 --- /dev/null +++ b/docs/product/decisions/0002-publish-json-and-rss.md @@ -0,0 +1,8 @@ +# Publish JSON and RSS + +Status: Accepted +Date: 2026-09-26 + +Every Current Dataset and Archive document is published as both JSON and RSS 2.0 from the same +domain data. JSON provides a typed machine-readable contract and persisted rollup input, while RSS +supports feed consumers without requiring a separate service. diff --git a/docs/product/decisions/0003-partial-publication-and-stale-feeds.md b/docs/product/decisions/0003-partial-publication-and-stale-feeds.md new file mode 100644 index 0000000..e7b05b8 --- /dev/null +++ b/docs/product/decisions/0003-partial-publication-and-stale-feeds.md @@ -0,0 +1,9 @@ +# Partial publication and stale feeds + +Status: Accepted +Date: 2026-09-26 + +An Update Run publishes and commits every successfully obtained Feed even when another Feed fails. +Failed feeds retain their previous files as Stale Feeds, and the workflow fails only after committing +successful changes so consumers receive the freshest available data while operators still receive +a failure notification. diff --git a/docs/product/decisions/0004-preserve-identifiable-degraded-observations.md b/docs/product/decisions/0004-preserve-identifiable-degraded-observations.md new file mode 100644 index 0000000..fa3c639 --- /dev/null +++ b/docs/product/decisions/0004-preserve-identifiable-degraded-observations.md @@ -0,0 +1,15 @@ +# Preserve identifiable degraded observations + +Status: Accepted +Date: 2026-09-26 + +Every source row with a valid Repository Identity is retained even when required metrics are +missing, malformed, or negative. Affected metrics become `null` and structured Snapshot Issues +preserve source details; only unidentifiable rows are skipped, and an absent optional Period Metric +is not degradation. + +## Consequences + +Consumers keep complete ranking membership and original ranks but must handle nullable metrics. +Numeric fields never mix strings and integers; malformed source text is available through +`rawValue` instead. diff --git a/docs/product/decisions/0005-stable-latest-only-schema-versioning.md b/docs/product/decisions/0005-stable-latest-only-schema-versioning.md new file mode 100644 index 0000000..81cc64f --- /dev/null +++ b/docs/product/decisions/0005-stable-latest-only-schema-versioning.md @@ -0,0 +1,13 @@ +# Stable latest-only schema versioning + +Status: Accepted +Date: 2026-09-26 + +Schema version 1 is a stable public JSON and RSS contract. Compatible additive fields may be added +within v1, breaking changes require a new schema version, and canonical paths publish only the +latest version rather than maintaining parallel historical schemas. + +## Consequences + +Consumers must inspect `schemaVersion` and ignore unknown additive JSON fields and RSS extensions. +A consumer that cannot adopt a future breaking version must preserve its own v1 data or adapter. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..106a5cd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "github-trending-api" +version = "0.1.0" +description = "Static JSON and RSS snapshots of GitHub Trending" +readme = "README.md" +requires-python = ">=3.14,<3.15" +dependencies = [ + "beautifulsoup4>=4.13,<5", + "httpx>=0.28,<1", +] + +[project.scripts] +github-trending = "github_trending.cli:main" + +[dependency-groups] +dev = [ + "complexipy>=4,<5", + "deptry>=0.23,<1", + "import-linter>=2.3,<3", + "mypy>=1.18,<2", + "pytest>=8.4,<10", + "ruff>=0.13,<1", + "types-beautifulsoup4>=4.12,<5", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/github_trending"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py314" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E4", "E7", "E9", "F", "I", "UP", "B", "A", "ASYNC", "BLE", "C4", "C90", + "D", "DTZ", "ERA", "G", "LOG", "N", "PERF", "PIE", "PGH", "PTH", "PT", "RET", + "RSE", "RUF", "S", "SIM", "T10", "TC", "TID", "PLE", "PLW", "PLR0911", "PLR0912", + "PLR0913", "PLR0915", "TRY004", "TRY201", "TRY203", "TRY400", "TRY401", +] +ignore = ["D100", "D104", "D105", "D107"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D", "S101", "S105", "S106", "S108"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-returns = 6 +max-statements = 50 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.mypy] +strict = true +disallow_any_explicit = true +warn_unreachable = true +warn_unused_configs = true +show_error_codes = true + +[tool.complexipy] +max-complexity-allowed = 15 +exclude = ["tests/**"] + +[tool.deptry] +known_first_party = ["github_trending"] + +[tool.importlinter] +root_packages = ["github_trending"] +include_external_packages = true + +[[tool.importlinter.contracts]] +name = "Domain is independent" +type = "forbidden" +source_modules = ["github_trending.domain"] +forbidden_modules = [ + "github_trending.service", + "github_trending.adapters", + "github_trending.cli", +] + +[[tool.importlinter.contracts]] +name = "Service depends on ports, not adapters" +type = "forbidden" +source_modules = ["github_trending.service"] +forbidden_modules = ["github_trending.adapters", "github_trending.cli"] + diff --git a/src/github_trending/__init__.py b/src/github_trending/__init__.py new file mode 100644 index 0000000..49acc65 --- /dev/null +++ b/src/github_trending/__init__.py @@ -0,0 +1 @@ +"""Generate static GitHub Trending datasets.""" diff --git a/src/github_trending/adapters/__init__.py b/src/github_trending/adapters/__init__.py new file mode 100644 index 0000000..0179346 --- /dev/null +++ b/src/github_trending/adapters/__init__.py @@ -0,0 +1 @@ +"""Concrete external I/O adapters.""" diff --git a/src/github_trending/adapters/filesystem.py b/src/github_trending/adapters/filesystem.py new file mode 100644 index 0000000..de6f676 --- /dev/null +++ b/src/github_trending/adapters/filesystem.py @@ -0,0 +1,55 @@ +from typing import TYPE_CHECKING + +from github_trending.domain import Snapshot, merge_snapshot +from github_trending.formats import ( + decode_rollup_json, + render_rollup_json, + render_rollup_xml, + render_snapshot_json, + render_snapshot_xml, +) + +if TYPE_CHECKING: + from datetime import date + from pathlib import Path + + +class FilesystemDatasetPublisher: + """Publish current snapshots and UTC daily rollups under a data root.""" + + def __init__(self, data_root: Path) -> None: + self._data_root = data_root + + def publish(self, snapshot: Snapshot, archive_date: date) -> None: + """Write matching JSON and RSS documents for one feed.""" + current_base = ( + self._data_root / "current" / snapshot.feed.period.value / snapshot.feed.language + ) + archive_base = ( + self._data_root + / "archive" + / f"{archive_date:%Y/%m/%d}" + / snapshot.feed.period.value + / snapshot.feed.language + ) + archive_json_path = archive_base.with_suffix(".json") + existing = None + if archive_json_path.exists(): + existing = decode_rollup_json(archive_json_path.read_text(encoding="utf-8")) + rollup = merge_snapshot(existing, snapshot, archive_date) + + documents = { + current_base.with_suffix(".json"): render_snapshot_json(snapshot), + current_base.with_suffix(".xml"): render_snapshot_xml(snapshot), + archive_json_path: render_rollup_json(rollup), + archive_base.with_suffix(".xml"): render_rollup_xml(rollup), + } + for path, payload in documents.items(): + _replace_text(path, payload) + + +def _replace_text(path: Path, payload: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(f"{path.suffix}.tmp") + temporary.write_text(payload, encoding="utf-8") + temporary.replace(path) diff --git a/src/github_trending/adapters/github.py b/src/github_trending/adapters/github.py new file mode 100644 index 0000000..3ab977f --- /dev/null +++ b/src/github_trending/adapters/github.py @@ -0,0 +1,203 @@ +import re +from typing import TYPE_CHECKING + +import httpx +from bs4 import BeautifulSoup, Tag + +from github_trending.domain import ( + DocumentStatus, + Feed, + Period, + RepositoryItem, + Snapshot, + SnapshotIssue, +) +from github_trending.service import SourceError + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + +_REPOSITORY_PATH = re.compile(r"^/[^/\s]+/[^/\s]+$") +_PERIOD_STARS = re.compile(r"(?P.+?)\s+stars\s+(?:today|this week|this month)") + + +class _ItemError(ValueError): + def __init__(self, code: str, field: str, raw_value: str | None = None) -> None: + super().__init__(f"{code}: {field}") + self.code = code + self.field = field + self.raw_value = raw_value + + +class GitHubTrendingSource: + """Fetch GitHub Trending feeds through an injected HTTP client.""" + + def __init__( + self, + client: httpx.Client, + clock: Callable[[], datetime], + sleep: Callable[[float], None], + jitter: Callable[[], float], + ) -> None: + self._client = client + self._clock = clock + self._sleep = sleep + self._jitter = jitter + self._has_fetched = False + + def fetch(self, feed: Feed) -> Snapshot: + """Fetch and parse one feed with bounded retries and request pacing.""" + if self._has_fetched: + self._sleep(2.0) + self._has_fetched = True + + url = source_url(feed) + response = self._request(url) + return parse_snapshot(response.text, feed, url, self._clock()) + + def _request(self, url: str) -> httpx.Response: + detail = "request failed" + for attempt in range(3): + response: httpx.Response | None = None + try: + response = self._client.get(url) + if response.is_success: + return response + detail = f"HTTP {response.status_code}" + if response.status_code != 429 and response.status_code < 500: + break + except httpx.RequestError as exc: + detail = type(exc).__name__ + + if attempt < 2: + self._sleep(self._retry_delay(attempt, response)) + + raise SourceError(category="fetch_failed", detail=detail) + + def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float: + if response is not None and response.status_code == 429: + retry_after = response.headers.get("Retry-After") + if retry_after is not None: + try: + return float(retry_after) + except ValueError: + pass + return 2.0**attempt + self._jitter() * 0.25 + + +def source_url(feed: Feed) -> str: + """Return the canonical GitHub Trending URL for a feed.""" + language_path = "" if feed.language == "all" else f"/{feed.language}" + url = f"https://github.com/trending{language_path}" + if feed.period is Period.DAILY: + return url + return f"{url}?since={feed.period.value}" + + +def parse_snapshot( + html: str, + feed: Feed, + source_url: str, + observed_at: datetime, +) -> Snapshot: + """Parse one GitHub Trending HTML response into a typed snapshot.""" + soup = BeautifulSoup(html, "html.parser") + rows = soup.select("article.Box-row") + if not rows: + heading = soup.select_one(".blankslate-heading") + if heading is None or "have any trending repositories" not in heading.get_text(" "): + raise SourceError( + category="parse_failed", + detail="expected repository rows or empty-state marker", + ) + + items: list[RepositoryItem] = [] + issues: list[SnapshotIssue] = [] + for rank, row in enumerate(rows, start=1): + try: + item, item_issues = _parse_item(row, rank) + except _ItemError as exc: + issues.append(SnapshotIssue(exc.code, rank, exc.field, exc.raw_value)) + continue + items.append(item) + issues.extend(item_issues) + + status = DocumentStatus.DEGRADED if issues else DocumentStatus.COMPLETE + return Snapshot( + feed=feed, + source_url=source_url, + observed_at=observed_at, + status=status, + issues=tuple(issues), + items=tuple(items), + ) + + +def _parse_item(row: Tag, rank: int) -> tuple[RepositoryItem, tuple[SnapshotIssue, ...]]: + repo_link = row.select_one("h2 a[href]") + href = None if repo_link is None else repo_link.get("href") + if not isinstance(href, str) or _REPOSITORY_PATH.fullmatch(href) is None: + raw_value = href if isinstance(href, str) else None + raise _ItemError("invalid_repository_link", "fullName", raw_value) + + full_name = href.removeprefix("/") + issues: list[SnapshotIssue] = [] + item = RepositoryItem( + rank=rank, + full_name=full_name, + url=f"https://github.com/{full_name}", + description=_optional_text(row, "p"), + programming_language=_optional_text(row, '[itemprop="programmingLanguage"]'), + stars=_link_integer(row, f"{href}/stargazers", rank, "stars", issues), + forks=_link_integer(row, f"{href}/forks", rank, "forks", issues), + stars_in_period=_period_stars(row, rank, issues), + ) + return item, tuple(issues) + + +def _optional_text(row: Tag, selector: str) -> str | None: + element = row.select_one(selector) + if element is None: + return None + normalized = " ".join(element.get_text(" ", strip=True).split()) + return normalized or None + + +def _link_integer( + row: Tag, + href: str, + rank: int, + field: str, + issues: list[SnapshotIssue], +) -> int | None: + element = row.select_one(f'a[href="{href}"]') + if element is None: + issues.append(SnapshotIssue("missing_field", rank, field)) + return None + return _parse_integer(element.get_text(strip=True), rank, field, issues) + + +def _period_stars(row: Tag, rank: int, issues: list[SnapshotIssue]) -> int | None: + for text in row.stripped_strings: + match = _PERIOD_STARS.fullmatch(text) + if match is not None: + return _parse_integer(match.group("count"), rank, "starsInPeriod", issues) + return None + + +def _parse_integer( + value: str, + rank: int, + field: str, + issues: list[SnapshotIssue], +) -> int | None: + try: + parsed = int(value.replace(",", "")) + except ValueError: + issues.append(SnapshotIssue("invalid_integer", rank, field, value)) + return None + if parsed < 0: + issues.append(SnapshotIssue("out_of_range", rank, field, value)) + return None + return parsed diff --git a/src/github_trending/cli.py b/src/github_trending/cli.py new file mode 100644 index 0000000..c719915 --- /dev/null +++ b/src/github_trending/cli.py @@ -0,0 +1,140 @@ +import argparse +import os +import random +import sys +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +import httpx + +from github_trending.adapters.filesystem import FilesystemDatasetPublisher +from github_trending.adapters.github import GitHubTrendingSource +from github_trending.domain import Feed, Period, RunReport, RunStatus, Snapshot +from github_trending.service import GenerationService, ProgressReporter, SourceError + +if TYPE_CHECKING: + from collections.abc import Sequence + +LANGUAGES = ("all", "python", "rust", "go", "javascript", "typescript") +DEFAULT_FEEDS = tuple(Feed(period, language) for language in LANGUAGES for period in Period) + + +class GenerationRunner(Protocol): + """Run one complete dataset update.""" + + def run(self, *, progress: ProgressReporter | None = None) -> RunReport: + """Return the update report.""" + ... + + +@dataclass(frozen=True, slots=True) +class CliDeps: + """Behavioral dependencies supplied to the command boundary.""" + + generator: GenerationRunner + + +class _ConsoleProgressReporter: + def fetching(self, position: int, total: int, feed: Feed) -> None: + print(f"[{position}/{total}] {feed.key}: fetching", flush=True) + + def published(self, position: int, total: int, snapshot: Snapshot) -> None: + print( + f"[{position}/{total}] {snapshot.feed.key}: published " + f"repositories={len(snapshot.items)} status={snapshot.status.value} " + f"issues={len(snapshot.issues)}", + flush=True, + ) + + def failed(self, position: int, total: int, feed: Feed, error: SourceError) -> None: + print( + f"[{position}/{total}] {feed.key}: failed " + f"category={error.category} detail={error.detail}", + flush=True, + ) + + +def main(argv: Sequence[str] | None = None, deps: CliDeps | None = None) -> int: + """Generate the dataset and return a stable process exit code.""" + parser = argparse.ArgumentParser( + prog="github-trending", + description="Generate current and archived GitHub Trending JSON/RSS datasets.", + ) + parser.add_argument( + "--data-dir", + type=Path, + default=Path("data"), + metavar="PATH", + help="Output directory (default: data).", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Print progress for every feed.", + ) + args = parser.parse_args(argv) + progress = _ConsoleProgressReporter() if args.verbose else None + + try: + report = ( + deps.generator.run(progress=progress) + if deps is not None + else _run_default(args.data_dir, progress) + ) + except (OSError, ValueError) as exc: + print(f"github-trending: {exc}", file=sys.stderr) + return 1 + + summary = _format_summary(report) + print(summary, end="") + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path is not None: + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write(summary) + return 0 if report.status is RunStatus.COMPLETE else 2 + + +def _run_default(data_dir: Path, progress: ProgressReporter | None) -> RunReport: + repository = os.environ.get("GITHUB_REPOSITORY", "github-trending-api") + headers = { + "Accept": "text/html,application/xhtml+xml", + "User-Agent": f"github-trending-api/1 (+https://github.com/{repository})", + } + timeout = httpx.Timeout(20.0, connect=5.0) + with httpx.Client(headers=headers, timeout=timeout, follow_redirects=True) as client: + source = GitHubTrendingSource( + client=client, + clock=lambda: datetime.now(UTC), + sleep=time.sleep, + jitter=random.random, + ) + publisher = FilesystemDatasetPublisher(data_dir) + return GenerationService(source, publisher, DEFAULT_FEEDS).run(progress=progress) + + +def _format_summary(report: RunReport) -> str: + lines = [ + "# GitHub Trending update", + "", + f"- Status: {report.status.value}", + f"- Started: {report.started_at.astimezone(UTC).isoformat()}", + f"- Published feeds: {len(report.published)}", + ] + if report.failures: + lines.extend(("", "## Stale feeds", "")) + for failure in report.failures: + detail = failure.detail.replace("|", "\\|") + lines.append(f"- `{failure.feed.key}`: {failure.category} — {detail}") + if report.degraded: + lines.extend(("", "## Degraded feeds", "")) + for degradation in report.degraded: + lines.extend( + (f"- `{degradation.feed.key}`: {issue.code} at rank {issue.rank} ({issue.field})") + for issue in degradation.issues + ) + lines.append("") + return "\n".join(lines) diff --git a/src/github_trending/domain.py b/src/github_trending/domain.py new file mode 100644 index 0000000..dc4638b --- /dev/null +++ b/src/github_trending/domain.py @@ -0,0 +1,198 @@ +from dataclasses import dataclass +from datetime import UTC, date, datetime +from enum import StrEnum + +SCHEMA_VERSION = 1 + + +class Period(StrEnum): + """A time window exposed by GitHub Trending.""" + + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +class DocumentStatus(StrEnum): + """Completeness of a successfully parsed document.""" + + COMPLETE = "complete" + DEGRADED = "degraded" + + +class RunStatus(StrEnum): + """Overall result of one generation run.""" + + COMPLETE = "complete" + DEGRADED = "degraded" + + +@dataclass(frozen=True, slots=True) +class Feed: + """One language and period combination.""" + + period: Period + language: str + + @property + def key(self) -> str: + """Return the stable human-readable feed key.""" + return f"{self.period.value}/{self.language}" + + +@dataclass(frozen=True, slots=True) +class SnapshotIssue: + """A structured problem with one source ranking item.""" + + code: str + rank: int + field: str + raw_value: str | None = None + + +@dataclass(frozen=True, slots=True) +class RepositoryItem: + """One repository observed in a GitHub Trending ranking.""" + + rank: int + full_name: str + url: str + description: str | None + programming_language: str | None + stars: int | None + forks: int | None + stars_in_period: int | None + + +@dataclass(frozen=True, slots=True) +class Snapshot: + """A successfully parsed GitHub Trending feed.""" + + feed: Feed + source_url: str + observed_at: datetime + status: DocumentStatus + issues: tuple[SnapshotIssue, ...] + items: tuple[RepositoryItem, ...] + + def __post_init__(self) -> None: + if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None: + raise ValueError("observed_at must be timezone-aware") + + +@dataclass(frozen=True, slots=True) +class RollupItem: + """One repository aggregated across observations in a UTC day.""" + + full_name: str + url: str + description: str | None + programming_language: str | None + stars: int | None + forks: int | None + stars_in_period: int | None + best_rank: int + latest_rank: int + first_seen_at: datetime + last_seen_at: datetime + appearances: int + + +@dataclass(frozen=True, slots=True) +class DailyRollup: + """A unique-repository aggregation for one feed and UTC date.""" + + feed: Feed + date: date + source_url: str + updated_at: datetime + latest_status: DocumentStatus + latest_issues: tuple[SnapshotIssue, ...] + items: tuple[RollupItem, ...] + + +def merge_snapshot( + existing: DailyRollup | None, + snapshot: Snapshot, + archive_date: date, +) -> DailyRollup: + """Merge a snapshot into its UTC day's compact rollup.""" + existing_items = () if existing is None else existing.items + items_by_name = {item.full_name.casefold(): item for item in existing_items} + for observed in snapshot.items: + key = observed.full_name.casefold() + previous = items_by_name.get(key) + items_by_name[key] = _merge_item(previous, observed, snapshot.observed_at) + + items = tuple( + sorted( + items_by_name.values(), + key=lambda item: (item.best_rank, item.first_seen_at, item.full_name.casefold()), + ) + ) + return DailyRollup( + feed=snapshot.feed, + date=archive_date, + source_url=snapshot.source_url, + updated_at=snapshot.observed_at, + latest_status=snapshot.status, + latest_issues=snapshot.issues, + items=items, + ) + + +def _merge_item( + previous: RollupItem | None, + observed: RepositoryItem, + observed_at: datetime, +) -> RollupItem: + first_seen_at = observed_at if previous is None else previous.first_seen_at + best_rank = observed.rank if previous is None else min(previous.best_rank, observed.rank) + appearances = 1 if previous is None else previous.appearances + 1 + return RollupItem( + full_name=observed.full_name, + url=observed.url, + description=observed.description, + programming_language=observed.programming_language, + stars=observed.stars, + forks=observed.forks, + stars_in_period=observed.stars_in_period, + best_rank=best_rank, + latest_rank=observed.rank, + first_seen_at=first_seen_at, + last_seen_at=observed_at, + appearances=appearances, + ) + + +@dataclass(frozen=True, slots=True) +class FeedFailure: + """A feed that could not produce a new snapshot.""" + + feed: Feed + category: str + detail: str + + +@dataclass(frozen=True, slots=True) +class FeedDegradation: + """A published feed with one or more malformed source fields.""" + + feed: Feed + issues: tuple[SnapshotIssue, ...] + + +@dataclass(frozen=True, slots=True) +class RunReport: + """Result of attempting every configured feed once.""" + + started_at: datetime + status: RunStatus + published: tuple[Feed, ...] + failures: tuple[FeedFailure, ...] + degraded: tuple[FeedDegradation, ...] = () + + @property + def archive_date(self) -> date: + """Return the UTC date that owns all observations in this run.""" + return self.started_at.astimezone(UTC).date() diff --git a/src/github_trending/formats.py b/src/github_trending/formats.py new file mode 100644 index 0000000..7882001 --- /dev/null +++ b/src/github_trending/formats.py @@ -0,0 +1,316 @@ +import json +from datetime import UTC, date, datetime +from email.utils import format_datetime +from typing import cast +from xml.etree import ElementTree + +from github_trending.domain import ( + SCHEMA_VERSION, + DailyRollup, + DocumentStatus, + Feed, + Period, + RepositoryItem, + RollupItem, + Snapshot, + SnapshotIssue, +) + +GHT_NAMESPACE = "urn:github-trending:v1" +ElementTree.register_namespace("ght", GHT_NAMESPACE) + + +def render_snapshot_json(snapshot: Snapshot) -> str: + """Serialize a current snapshot as stable UTF-8 JSON text.""" + document: dict[str, object] = { + "schemaVersion": SCHEMA_VERSION, + "kind": "snapshot", + "period": snapshot.feed.period.value, + "language": snapshot.feed.language, + "sourceUrl": snapshot.source_url, + "observedAt": _rfc3339(snapshot.observed_at), + "status": snapshot.status.value, + "issues": [_issue_document(issue) for issue in snapshot.issues], + "items": [_snapshot_item_document(item) for item in snapshot.items], + } + return _json_text(document) + + +def render_rollup_json(rollup: DailyRollup) -> str: + """Serialize a daily rollup as stable UTF-8 JSON text.""" + document: dict[str, object] = { + "schemaVersion": SCHEMA_VERSION, + "kind": "daily-rollup", + "date": rollup.date.isoformat(), + "period": rollup.feed.period.value, + "language": rollup.feed.language, + "sourceUrl": rollup.source_url, + "updatedAt": _rfc3339(rollup.updated_at), + "latestStatus": rollup.latest_status.value, + "latestIssues": [_issue_document(issue) for issue in rollup.latest_issues], + "items": [_rollup_item_document(item) for item in rollup.items], + } + return _json_text(document) + + +def decode_rollup_json(payload: str) -> DailyRollup: + """Decode and validate a previously generated daily rollup.""" + raw: object = json.loads(payload) + document = _mapping(raw) + if _integer(document, "schemaVersion") != SCHEMA_VERSION: + raise ValueError("unsupported schemaVersion") + if _string(document, "kind") != "daily-rollup": + raise ValueError("expected daily-rollup document") + feed = Feed(Period(_string(document, "period")), _string(document, "language")) + return DailyRollup( + feed=feed, + date=date.fromisoformat(_string(document, "date")), + source_url=_string(document, "sourceUrl"), + updated_at=_datetime(document, "updatedAt"), + latest_status=DocumentStatus(_string(document, "latestStatus")), + latest_issues=tuple( + _decode_issue(_mapping(value)) for value in _list(document, "latestIssues") + ), + items=tuple(_decode_rollup_item(_mapping(value)) for value in _list(document, "items")), + ) + + +def render_snapshot_xml(snapshot: Snapshot) -> str: + """Serialize a current snapshot as RSS 2.0 with typed extensions.""" + root, channel = _rss_channel(snapshot.feed, snapshot.source_url, snapshot.observed_at) + _extension(channel, "schemaVersion", str(SCHEMA_VERSION)) + _extension(channel, "kind", "snapshot") + _extension(channel, "period", snapshot.feed.period.value) + _extension(channel, "language", snapshot.feed.language) + _extension(channel, "observedAt", _rfc3339(snapshot.observed_at)) + _extension(channel, "status", snapshot.status.value) + _append_issues(channel, snapshot.issues) + for item in snapshot.items: + element = _rss_item(channel, item, snapshot.observed_at) + _extension(element, "rank", str(item.rank)) + _append_repository_extensions(element, item) + return _xml_text(root) + + +def render_rollup_xml(rollup: DailyRollup) -> str: + """Serialize a daily rollup as RSS 2.0 with typed extensions.""" + root, channel = _rss_channel(rollup.feed, rollup.source_url, rollup.updated_at) + _extension(channel, "schemaVersion", str(SCHEMA_VERSION)) + _extension(channel, "kind", "daily-rollup") + _extension(channel, "date", rollup.date.isoformat()) + _extension(channel, "period", rollup.feed.period.value) + _extension(channel, "language", rollup.feed.language) + _extension(channel, "updatedAt", _rfc3339(rollup.updated_at)) + _extension(channel, "latestStatus", rollup.latest_status.value) + _append_issues(channel, rollup.latest_issues, container_name="latestIssues") + for item in rollup.items: + element = _rss_item(channel, item, item.first_seen_at) + _append_repository_extensions(element, item) + _extension(element, "bestRank", str(item.best_rank)) + _extension(element, "latestRank", str(item.latest_rank)) + _extension(element, "firstSeenAt", _rfc3339(item.first_seen_at)) + _extension(element, "lastSeenAt", _rfc3339(item.last_seen_at)) + _extension(element, "appearances", str(item.appearances)) + return _xml_text(root) + + +def _snapshot_item_document(item: RepositoryItem) -> dict[str, object]: + return { + "rank": item.rank, + "fullName": item.full_name, + "url": item.url, + "description": item.description, + "programmingLanguage": item.programming_language, + "stars": item.stars, + "forks": item.forks, + "starsInPeriod": item.stars_in_period, + } + + +def _rollup_item_document(item: RollupItem) -> dict[str, object]: + return { + "fullName": item.full_name, + "url": item.url, + "description": item.description, + "programmingLanguage": item.programming_language, + "stars": item.stars, + "forks": item.forks, + "starsInPeriod": item.stars_in_period, + "bestRank": item.best_rank, + "latestRank": item.latest_rank, + "firstSeenAt": _rfc3339(item.first_seen_at), + "lastSeenAt": _rfc3339(item.last_seen_at), + "appearances": item.appearances, + } + + +def _issue_document(issue: SnapshotIssue) -> dict[str, object]: + return { + "code": issue.code, + "rank": issue.rank, + "field": issue.field, + "rawValue": issue.raw_value, + } + + +def _decode_issue(document: dict[str, object]) -> SnapshotIssue: + return SnapshotIssue( + code=_string(document, "code"), + rank=_integer(document, "rank"), + field=_string(document, "field"), + raw_value=_optional_string(document, "rawValue"), + ) + + +def _decode_rollup_item(document: dict[str, object]) -> RollupItem: + return RollupItem( + full_name=_string(document, "fullName"), + url=_string(document, "url"), + description=_optional_string(document, "description"), + programming_language=_optional_string(document, "programmingLanguage"), + stars=_optional_integer(document, "stars"), + forks=_optional_integer(document, "forks"), + stars_in_period=_optional_integer(document, "starsInPeriod"), + best_rank=_integer(document, "bestRank"), + latest_rank=_integer(document, "latestRank"), + first_seen_at=_datetime(document, "firstSeenAt"), + last_seen_at=_datetime(document, "lastSeenAt"), + appearances=_integer(document, "appearances"), + ) + + +def _rss_channel( + feed: Feed, + source_url: str, + updated_at: datetime, +) -> tuple[ElementTree.Element, ElementTree.Element]: + root = ElementTree.Element("rss", {"version": "2.0"}) + channel = ElementTree.SubElement(root, "channel") + title = f"GitHub Trending — {feed.period.value} — {feed.language}" + ElementTree.SubElement(channel, "title").text = title + ElementTree.SubElement(channel, "link").text = source_url + ElementTree.SubElement(channel, "description").text = title + ElementTree.SubElement(channel, "lastBuildDate").text = _rss_datetime(updated_at) + return root, channel + + +def _rss_item( + channel: ElementTree.Element, + item: RepositoryItem | RollupItem, + published_at: datetime, +) -> ElementTree.Element: + element = ElementTree.SubElement(channel, "item") + ElementTree.SubElement(element, "title").text = item.full_name + ElementTree.SubElement(element, "link").text = item.url + ElementTree.SubElement(element, "description").text = item.description or "" + ElementTree.SubElement(element, "guid", {"isPermaLink": "true"}).text = item.url + ElementTree.SubElement(element, "pubDate").text = _rss_datetime(published_at) + return element + + +def _append_repository_extensions( + element: ElementTree.Element, + item: RepositoryItem | RollupItem, +) -> None: + _extension(element, "programmingLanguage", item.programming_language or "") + _extension(element, "stars", _optional_integer_text(item.stars)) + _extension(element, "forks", _optional_integer_text(item.forks)) + _extension(element, "starsInPeriod", _optional_integer_text(item.stars_in_period)) + + +def _append_issues( + parent: ElementTree.Element, + issues: tuple[SnapshotIssue, ...], + *, + container_name: str = "issues", +) -> None: + container = ElementTree.SubElement(parent, _qualified(container_name)) + for issue in issues: + attributes = {"code": issue.code, "rank": str(issue.rank), "field": issue.field} + if issue.raw_value is not None: + attributes["rawValue"] = issue.raw_value + ElementTree.SubElement( + container, + _qualified("issue"), + attributes, + ) + + +def _extension(parent: ElementTree.Element, name: str, value: str) -> None: + ElementTree.SubElement(parent, _qualified(name)).text = value + + +def _qualified(name: str) -> str: + return f"{{{GHT_NAMESPACE}}}{name}" + + +def _json_text(document: dict[str, object]) -> str: + return json.dumps(document, ensure_ascii=False, indent=2) + "\n" + + +def _xml_text(root: ElementTree.Element) -> str: + ElementTree.indent(root, space=" ") + payload = ElementTree.tostring(root, encoding="unicode", xml_declaration=True) + return f"{payload}\n" + + +def _rfc3339(value: datetime) -> str: + return value.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _rss_datetime(value: datetime) -> str: + return format_datetime(value.astimezone(UTC), usegmt=True) + + +def _optional_integer_text(value: int | None) -> str: + return "" if value is None else str(value) + + +def _mapping(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise TypeError("expected JSON object") + return cast("dict[str, object]", value) + + +def _list(document: dict[str, object], key: str) -> list[object]: + value = document.get(key) + if not isinstance(value, list): + raise TypeError(f"{key} must be an array") + return cast("list[object]", value) + + +def _string(document: dict[str, object], key: str) -> str: + value = document.get(key) + if not isinstance(value, str): + raise TypeError(f"{key} must be a string") + return value + + +def _optional_string(document: dict[str, object], key: str) -> str | None: + value = document.get(key) + if value is not None and not isinstance(value, str): + raise ValueError(f"{key} must be a string or null") + return value + + +def _integer(document: dict[str, object], key: str) -> int: + value = document.get(key) + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f"{key} must be an integer") + return value + + +def _optional_integer(document: dict[str, object], key: str) -> int | None: + value = document.get(key) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f"{key} must be an integer or null") + return value + + +def _datetime(document: dict[str, object], key: str) -> datetime: + value = datetime.fromisoformat(_string(document, key).replace("Z", "+00:00")) + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{key} must include a timezone") + return value diff --git a/src/github_trending/service.py b/src/github_trending/service.py new file mode 100644 index 0000000..6c424eb --- /dev/null +++ b/src/github_trending/service.py @@ -0,0 +1,109 @@ +from datetime import UTC, date, datetime +from typing import Protocol + +from github_trending.domain import ( + DocumentStatus, + Feed, + FeedDegradation, + FeedFailure, + RunReport, + RunStatus, + Snapshot, +) + + +class SourceError(Exception): + """A normalized failure to obtain one feed.""" + + def __init__(self, category: str, detail: str) -> None: + super().__init__(detail) + self.category = category + self.detail = detail + + +class ProgressReporter(Protocol): + """Receive caller-facing progress for one generation run.""" + + def fetching(self, position: int, total: int, feed: Feed) -> None: + """Report that fetching one feed has started.""" + ... + + def published(self, position: int, total: int, snapshot: Snapshot) -> None: + """Report that one feed has been published.""" + ... + + def failed(self, position: int, total: int, feed: Feed, error: SourceError) -> None: + """Report that one feed could not be fetched.""" + ... + + +class FeedSource(Protocol): + """Fetch one configured Trending feed.""" + + def fetch(self, feed: Feed) -> Snapshot: + """Return a parsed snapshot or raise a normalized source error.""" + ... + + +class DatasetPublisher(Protocol): + """Publish one snapshot into current and daily archive outputs.""" + + def publish(self, snapshot: Snapshot, archive_date: date) -> None: + """Persist a snapshot for its owning UTC archive date.""" + ... + + +class GenerationService: + """Coordinate one complete traversal of the configured feeds.""" + + def __init__( + self, + source: FeedSource, + publisher: DatasetPublisher, + feeds: tuple[Feed, ...], + ) -> None: + self._source = source + self._publisher = publisher + self._feeds = feeds + + def run( + self, + started_at: datetime | None = None, + *, + progress: ProgressReporter | None = None, + ) -> RunReport: + """Fetch and publish every feed while isolating feed-level failures.""" + run_started_at = started_at or datetime.now(UTC) + archive_date = run_started_at.astimezone(UTC).date() + published: list[Feed] = [] + failures: list[FeedFailure] = [] + degraded: list[FeedDegradation] = [] + status = RunStatus.COMPLETE + + total = len(self._feeds) + for position, feed in enumerate(self._feeds, start=1): + if progress is not None: + progress.fetching(position, total, feed) + try: + snapshot = self._source.fetch(feed) + except SourceError as exc: + status = RunStatus.DEGRADED + failures.append(FeedFailure(feed, exc.category, exc.detail)) + if progress is not None: + progress.failed(position, total, feed, exc) + continue + self._publisher.publish(snapshot, archive_date) + published.append(feed) + if progress is not None: + progress.published(position, total, snapshot) + if snapshot.status is DocumentStatus.DEGRADED: + status = RunStatus.DEGRADED + degraded.append(FeedDegradation(feed, snapshot.issues)) + + return RunReport( + started_at=run_started_at, + status=status, + published=tuple(published), + failures=tuple(failures), + degraded=tuple(degraded), + ) diff --git a/tests/integration/adapters/test_filesystem_publisher.py b/tests/integration/adapters/test_filesystem_publisher.py new file mode 100644 index 0000000..e61242b --- /dev/null +++ b/tests/integration/adapters/test_filesystem_publisher.py @@ -0,0 +1,259 @@ +import json +from dataclasses import replace +from datetime import UTC, date, datetime +from typing import TYPE_CHECKING +from xml.etree import ElementTree + +from github_trending.adapters.filesystem import FilesystemDatasetPublisher +from github_trending.domain import ( + DocumentStatus, + Feed, + Period, + RepositoryItem, + Snapshot, + SnapshotIssue, +) +from github_trending.formats import decode_rollup_json + +if TYPE_CHECKING: + from pathlib import Path + + +def snapshot( + observed_at: datetime, + *, + rank: int, + stars: int | None, + status: DocumentStatus = DocumentStatus.COMPLETE, + stars_in_period: int | None = 742, +) -> Snapshot: + issues = ( + (SnapshotIssue("invalid_integer", 4, "stars", "unavailable"),) + if status is DocumentStatus.DEGRADED + else () + ) + return Snapshot( + feed=Feed(Period.DAILY, "rust"), + source_url="https://github.com/trending/rust", + observed_at=observed_at, + status=status, + issues=issues, + items=( + RepositoryItem( + rank=rank, + full_name="astral-sh/uv", + url="https://github.com/astral-sh/uv", + description="An extremely fast package manager.", + programming_language="Rust", + stars=stars, + forks=1_289, + stars_in_period=stars_in_period, + ), + ), + ) + + +def read_json(path: Path) -> object: + return json.loads(path.read_text()) + + +def test_publish_writes_current_and_mergeable_daily_json_and_rss(tmp_path: Path) -> None: + archive_date = date(2026, 9, 25) + first_at = datetime(2026, 9, 25, 0, 17, 12, tzinfo=UTC) + second_at = datetime(2026, 9, 25, 6, 17, 13, tzinfo=UTC) + data_root = tmp_path / "data" + + FilesystemDatasetPublisher(data_root).publish( + snapshot(first_at, rank=8, stars=100), archive_date + ) + FilesystemDatasetPublisher(data_root).publish( + snapshot(second_at, rank=3, stars=None, status=DocumentStatus.DEGRADED), + archive_date, + ) + + current_json = data_root / "current/daily/rust.json" + current_xml = data_root / "current/daily/rust.xml" + archive_json = data_root / "archive/2026/09/25/daily/rust.json" + archive_xml = data_root / "archive/2026/09/25/daily/rust.xml" + assert all(path.is_file() for path in (current_json, current_xml, archive_json, archive_xml)) + + assert read_json(current_json) == { + "schemaVersion": 1, + "kind": "snapshot", + "period": "daily", + "language": "rust", + "sourceUrl": "https://github.com/trending/rust", + "observedAt": "2026-09-25T06:17:13Z", + "status": "degraded", + "issues": [ + { + "code": "invalid_integer", + "rank": 4, + "field": "stars", + "rawValue": "unavailable", + } + ], + "items": [ + { + "rank": 3, + "fullName": "astral-sh/uv", + "url": "https://github.com/astral-sh/uv", + "description": "An extremely fast package manager.", + "programmingLanguage": "Rust", + "stars": None, + "forks": 1_289, + "starsInPeriod": 742, + } + ], + } + assert read_json(archive_json) == { + "schemaVersion": 1, + "kind": "daily-rollup", + "date": "2026-09-25", + "period": "daily", + "language": "rust", + "sourceUrl": "https://github.com/trending/rust", + "updatedAt": "2026-09-25T06:17:13Z", + "latestStatus": "degraded", + "latestIssues": [ + { + "code": "invalid_integer", + "rank": 4, + "field": "stars", + "rawValue": "unavailable", + } + ], + "items": [ + { + "fullName": "astral-sh/uv", + "url": "https://github.com/astral-sh/uv", + "description": "An extremely fast package manager.", + "programmingLanguage": "Rust", + "stars": None, + "forks": 1_289, + "starsInPeriod": 742, + "bestRank": 3, + "latestRank": 3, + "firstSeenAt": "2026-09-25T00:17:12Z", + "lastSeenAt": "2026-09-25T06:17:13Z", + "appearances": 2, + } + ], + } + for xml_path in (current_xml, archive_xml): + root = ElementTree.parse(xml_path).getroot() # noqa: S314 - parses trusted generated XML + assert root.tag == "rss" + assert root.attrib == {"version": "2.0"} + title = root.find("channel/item/title") + schema_version = root.find("channel/{urn:github-trending:v1}schemaVersion") + assert title is not None + assert title.text == "astral-sh/uv" + assert schema_version is not None + assert schema_version.text == "1" + stars = root.find("channel/item/{urn:github-trending:v1}stars") + issue = root.find("channel/{urn:github-trending:v1}issues/{urn:github-trending:v1}issue") + if issue is None: + issue = root.find( + "channel/{urn:github-trending:v1}latestIssues/{urn:github-trending:v1}issue" + ) + assert stars is not None + assert stars.text is None + assert issue is not None + assert issue.attrib["rawValue"] == "unavailable" + + decoded = decode_rollup_json(archive_json.read_text()) + assert decoded.items[0].stars is None + assert decoded.latest_issues[0].raw_value == "unavailable" + + legacy_document = read_json(archive_json) + assert isinstance(legacy_document, dict) + legacy_issues = legacy_document["latestIssues"] + assert isinstance(legacy_issues, list) + assert isinstance(legacy_issues[0], dict) + legacy_issues[0].pop("rawValue") + legacy = decode_rollup_json(json.dumps(legacy_document)) + assert legacy.latest_issues[0].raw_value is None + + assert current_json.read_bytes().endswith(b"\n") + assert archive_json.read_bytes().endswith(b"\n") + + +def test_missing_metric_has_json_null_and_no_xml_raw_value(tmp_path: Path) -> None: + data_root = tmp_path / "data" + missing = replace( + snapshot( + datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + rank=1, + stars=None, + status=DocumentStatus.DEGRADED, + ), + issues=(SnapshotIssue("missing_field", 1, "stars"),), + ) + + FilesystemDatasetPublisher(data_root).publish(missing, date(2026, 9, 25)) + + document = read_json(data_root / "current/daily/rust.json") + assert isinstance(document, dict) + issues = document["issues"] + assert isinstance(issues, list) + assert isinstance(issues[0], dict) + assert issues[0]["rawValue"] is None + root = ElementTree.parse( # noqa: S314 - parses trusted generated XML + data_root / "current/daily/rust.xml" + ).getroot() + issue = root.find("channel/{urn:github-trending:v1}issues/{urn:github-trending:v1}issue") + assert issue is not None + assert "rawValue" not in issue.attrib + + +def test_optional_period_metric_is_published_as_null_without_issue(tmp_path: Path) -> None: + data_root = tmp_path / "data" + complete = snapshot( + datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + rank=1, + stars=100, + stars_in_period=None, + ) + + FilesystemDatasetPublisher(data_root).publish(complete, date(2026, 9, 25)) + + document = read_json(data_root / "current/daily/rust.json") + assert isinstance(document, dict) + assert document["status"] == "complete" + assert document["issues"] == [] + items = document["items"] + assert isinstance(items, list) + assert isinstance(items[0], dict) + assert items[0]["starsInPeriod"] is None + root = ElementTree.parse( # noqa: S314 - parses trusted generated XML + data_root / "current/daily/rust.xml" + ).getroot() + period_stars = root.find("channel/item/{urn:github-trending:v1}starsInPeriod") + assert period_stars is not None + assert period_stars.text is None + + +def test_new_utc_date_does_not_rewrite_previous_rollup(tmp_path: Path) -> None: + data_root = tmp_path / "data" + first_date = date(2026, 9, 25) + publisher = FilesystemDatasetPublisher(data_root) + publisher.publish( + snapshot(datetime(2026, 9, 25, 18, 17, tzinfo=UTC), rank=5, stars=100), + first_date, + ) + first_path = data_root / "archive/2026/09/25/daily/rust.json" + original = first_path.read_bytes() + + publisher.publish( + snapshot(datetime(2026, 9, 26, 0, 17, tzinfo=UTC), rank=2, stars=120), + date(2026, 9, 26), + ) + + assert first_path.read_bytes() == original + second = read_json(data_root / "archive/2026/09/26/daily/rust.json") + assert isinstance(second, dict) + assert second["date"] == "2026-09-26" + items = second["items"] + assert isinstance(items, list) + assert isinstance(items[0], dict) + assert items[0]["appearances"] == 1 diff --git a/tests/unit/adapters/test_github_parser.py b/tests/unit/adapters/test_github_parser.py new file mode 100644 index 0000000..b668d2e --- /dev/null +++ b/tests/unit/adapters/test_github_parser.py @@ -0,0 +1,235 @@ +from datetime import UTC, datetime + +import httpx +import pytest + +from github_trending.adapters.github import GitHubTrendingSource, parse_snapshot, source_url +from github_trending.domain import DocumentStatus, Feed, Period +from github_trending.service import SourceError + +OBSERVED_AT = datetime(2026, 9, 25, 6, 17, 12, tzinfo=UTC) +FEED = Feed(Period.DAILY, "rust") +SOURCE_URL = "https://github.com/trending/rust" + + +def test_parse_complete_repository_item() -> None: + html = """ +
+

astral-sh / uv

+

An extremely\n fast & reliable package manager.

+ Rust + 42,137 + 1,289 + Built by contributors + 742 stars today +
+ """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.COMPLETE + assert snapshot.issues == () + assert len(snapshot.items) == 1 + item = snapshot.items[0] + assert item.rank == 1 + assert item.full_name == "astral-sh/uv" + assert item.url == "https://github.com/astral-sh/uv" + assert item.description == "An extremely fast & reliable package manager." + assert item.programming_language == "Rust" + assert item.stars == 42_137 + assert item.forks == 1_289 + assert item.stars_in_period == 742 + + +def test_malformed_metric_is_reported_without_dropping_the_item() -> None: + html = """ + + + """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.DEGRADED + assert [(issue.code, issue.rank, issue.field) for issue in snapshot.issues] == [ + ("invalid_integer", 1, "stars") + ] + assert snapshot.issues[0].raw_value == "not-a-number" + assert [item.rank for item in snapshot.items] == [1, 2] + broken = snapshot.items[0] + assert broken.stars is None + assert broken.forks == 10 + assert broken.stars_in_period == 5 + + +def test_zero_metrics_are_valid() -> None: + html = """ + + """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.COMPLETE + assert snapshot.issues == () + item = snapshot.items[0] + assert (item.stars, item.forks, item.stars_in_period) == (0, 0, 0) + + +def test_missing_period_metric_is_optional() -> None: + html = """ + + """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.COMPLETE + assert snapshot.issues == () + assert snapshot.items[0].stars_in_period is None + + +def test_all_malformed_metrics_are_reported_on_one_retained_item() -> None: + html = """ + + """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.DEGRADED + assert [(issue.code, issue.field, issue.raw_value) for issue in snapshot.issues] == [ + ("missing_field", "stars", None), + ("invalid_integer", "forks", "unknown"), + ("out_of_range", "starsInPeriod", "-1"), + ] + assert len(snapshot.items) == 1 + item = snapshot.items[0] + assert (item.stars, item.forks, item.stars_in_period) == (None, None, None) + + +def test_invalid_repository_identity_is_reported_and_skipped() -> None: + html = """ + + + """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert [ + (issue.code, issue.rank, issue.field, issue.raw_value) for issue in snapshot.issues + ] == [("invalid_repository_link", 1, "fullName", "/owner/too/many")] + assert [item.rank for item in snapshot.items] == [2] + + +def test_explicit_blankslate_is_a_valid_empty_feed() -> None: + html = """ +
+

+ It looks like we don't have any trending repositories for ABNF. +

+
+ """ + + snapshot = parse_snapshot(html, FEED, SOURCE_URL, OBSERVED_AT) + + assert snapshot.status is DocumentStatus.COMPLETE + assert snapshot.items == () + + +def test_unrecognized_empty_page_is_a_parse_failure() -> None: + with pytest.raises(SourceError, match="expected repository rows or empty-state marker") as exc: + parse_snapshot("

GitHub

", FEED, SOURCE_URL, OBSERVED_AT) + + assert exc.value.category == "parse_failed" + + +def test_source_builds_urls_paces_feeds_and_retries_server_errors() -> None: + calls: list[str] = [] + responses = iter([200, 503, 200]) + empty_html = ( + '

It looks like we don\'t have any trending repositories ' + "for Rust.

" + ) + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + return httpx.Response(next(responses), text=empty_html, request=request) + + sleeps: list[float] = [] + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + source = GitHubTrendingSource( + client=client, + clock=lambda: OBSERVED_AT, + sleep=sleeps.append, + jitter=lambda: 0.0, + ) + daily = source.fetch(Feed(Period.DAILY, "all")) + weekly = source.fetch(Feed(Period.WEEKLY, "rust")) + + assert daily.source_url == "https://github.com/trending" + assert weekly.source_url == "https://github.com/trending/rust?since=weekly" + assert calls == [ + "https://github.com/trending", + "https://github.com/trending/rust?since=weekly", + "https://github.com/trending/rust?since=weekly", + ] + assert sleeps == [2.0, 1.0] + + +def test_source_url_omits_daily_query_and_all_path_segment() -> None: + assert source_url(Feed(Period.DAILY, "python")) == "https://github.com/trending/python" + assert source_url(Feed(Period.MONTHLY, "all")) == ("https://github.com/trending?since=monthly") + + +def test_source_honors_numeric_retry_after() -> None: + responses = iter( + [ + httpx.Response(429, headers={"Retry-After": "4.5"}), + httpx.Response( + 200, + text=( + '

It looks like we don\'t have any trending ' + "repositories for Rust.

" + ), + ), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + response = next(responses) + response.request = request + return response + + sleeps: list[float] = [] + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + source = GitHubTrendingSource(client, lambda: OBSERVED_AT, sleeps.append, lambda: 0.0) + source.fetch(FEED) + + assert sleeps == [4.5] diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py new file mode 100644 index 0000000..6f9ae57 --- /dev/null +++ b/tests/unit/cli/test_cli.py @@ -0,0 +1,182 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import pytest + +from github_trending.cli import CliDeps, main +from github_trending.domain import ( + DocumentStatus, + Feed, + FeedDegradation, + FeedFailure, + Period, + RunReport, + RunStatus, + Snapshot, + SnapshotIssue, +) +from github_trending.service import ProgressReporter, SourceError + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass +class StubGenerator: + report: RunReport + calls: int = 0 + received_progress: ProgressReporter | None = None + + def run(self, *, progress: ProgressReporter | None = None) -> RunReport: + self.calls += 1 + self.received_progress = progress + return self.report + + +class FailingGenerator: + def run(self, *, progress: ProgressReporter | None = None) -> RunReport: + del progress + raise ValueError("archive schema is invalid") + + +@dataclass +class ProgressGeneratingStub: + report: RunReport + snapshot: Snapshot + failed_feed: Feed + + def run(self, *, progress: ProgressReporter | None = None) -> RunReport: + assert progress is not None + progress.fetching(1, 2, self.snapshot.feed) + progress.published(1, 2, self.snapshot) + progress.fetching(2, 2, self.failed_feed) + progress.failed(2, 2, self.failed_feed, SourceError("fetch_failed", "HTTP 503")) + return self.report + + +def report(status: RunStatus) -> RunReport: + return RunReport( + started_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=status, + published=(Feed(Period.DAILY, "rust"),), + failures=(), + ) + + +def test_complete_run_prints_summary_and_returns_zero(capsys: pytest.CaptureFixture[str]) -> None: + generator = StubGenerator(report(RunStatus.COMPLETE)) + + exit_code = main([], CliDeps(generator)) + + assert exit_code == 0 + assert generator.calls == 1 + assert generator.received_progress is None + assert "Status: complete" in capsys.readouterr().out + + +@pytest.mark.parametrize("flag", ["-v", "--verbose"]) +def test_verbose_flag_supplies_progress_reporter(flag: str) -> None: + generator = StubGenerator(report(RunStatus.COMPLETE)) + + exit_code = main([flag], CliDeps(generator)) + + assert exit_code == 0 + assert generator.received_progress is not None + + +@pytest.mark.parametrize( + ("status", "issues", "published_line"), + [ + ( + DocumentStatus.COMPLETE, + (), + "[1/2] daily/rust: published repositories=0 status=complete issues=0", + ), + ( + DocumentStatus.DEGRADED, + (SnapshotIssue("missing_field", 4, "starsInPeriod"),), + "[1/2] daily/rust: published repositories=0 status=degraded issues=1", + ), + ], +) +def test_verbose_run_streams_feed_progress( + status: DocumentStatus, + issues: tuple[SnapshotIssue, ...], + published_line: str, + capsys: pytest.CaptureFixture[str], +) -> None: + successful_feed = Feed(Period.DAILY, "rust") + failed_feed = Feed(Period.WEEKLY, "python") + snapshot = Snapshot( + feed=successful_feed, + source_url="https://github.com/trending/rust", + observed_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=status, + issues=issues, + items=(), + ) + generator = ProgressGeneratingStub(report(RunStatus.DEGRADED), snapshot, failed_feed) + + exit_code = main(["--verbose"], CliDeps(generator)) + + assert exit_code == 2 + assert capsys.readouterr().out.startswith( + "[1/2] daily/rust: fetching\n" + f"{published_line}\n" + "[2/2] weekly/python: fetching\n" + "[2/2] weekly/python: failed category=fetch_failed detail=HTTP 503\n" + "# GitHub Trending update\n" + ) + + +def test_degraded_run_writes_actions_summary_and_returns_two( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + summary_path = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + failed = Feed(Period.WEEKLY, "python") + degraded = Feed(Period.DAILY, "rust") + generator = StubGenerator( + RunReport( + started_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=RunStatus.DEGRADED, + published=(degraded,), + failures=(FeedFailure(failed, "fetch_failed", "HTTP 503"),), + degraded=( + FeedDegradation( + degraded, + (SnapshotIssue("invalid_integer", 4, "stars"),), + ), + ), + ) + ) + + exit_code = main([], CliDeps(generator)) + + assert exit_code == 2 + summary = summary_path.read_text() + assert "Status: degraded" in summary + assert "weekly/python" in summary + assert "fetch_failed" in summary + assert "daily/rust" in summary + assert "invalid_integer at rank 4 (stars)" in summary + + +def test_help_does_not_construct_or_run_dependencies(capsys: pytest.CaptureFixture[str]) -> None: + generator = StubGenerator(report(RunStatus.COMPLETE)) + + with pytest.raises(SystemExit) as exc: + main(["--help"], CliDeps(generator)) + + assert exc.value.code == 0 + assert generator.calls == 0 + assert "-v, --verbose" in capsys.readouterr().out + + +def test_fatal_generation_error_returns_one(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = main([], CliDeps(FailingGenerator())) + + assert exit_code == 1 + assert "archive schema is invalid" in capsys.readouterr().err diff --git a/tests/unit/domain/test_rollup.py b/tests/unit/domain/test_rollup.py new file mode 100644 index 0000000..d28ed0c --- /dev/null +++ b/tests/unit/domain/test_rollup.py @@ -0,0 +1,65 @@ +from datetime import UTC, date, datetime + +from github_trending.domain import ( + DocumentStatus, + Feed, + Period, + RepositoryItem, + Snapshot, + SnapshotIssue, + merge_snapshot, +) + + +def item(rank: int, full_name: str, stars: int) -> RepositoryItem: + return RepositoryItem( + rank=rank, + full_name=full_name, + url=f"https://github.com/{full_name}", + description=f"Latest description for {full_name}", + programming_language="Rust", + stars=stars, + forks=100, + stars_in_period=50, + ) + + +def test_merge_keeps_latest_fields_and_daily_rank_extrema() -> None: + feed = Feed(Period.DAILY, "rust") + first_at = datetime(2026, 9, 25, 0, 17, tzinfo=UTC) + latest_at = datetime(2026, 9, 25, 6, 17, tzinfo=UTC) + archive_date = date(2026, 9, 25) + first = Snapshot( + feed=feed, + source_url="https://github.com/trending/rust", + observed_at=first_at, + status=DocumentStatus.COMPLETE, + issues=(), + items=(item(8, "Owner/Alpha", 100),), + ) + existing = merge_snapshot(None, first, archive_date) + issue = SnapshotIssue(code="invalid_integer", rank=4, field="stars") + latest = Snapshot( + feed=feed, + source_url="https://github.com/trending/rust", + observed_at=latest_at, + status=DocumentStatus.DEGRADED, + issues=(issue,), + items=(item(3, "owner/alpha", 120), item(2, "owner/beta", 80)), + ) + + result = merge_snapshot(existing, latest, archive_date) + + assert result.updated_at == latest_at + assert result.latest_status is DocumentStatus.DEGRADED + assert result.latest_issues == (issue,) + assert [entry.full_name for entry in result.items] == ["owner/beta", "owner/alpha"] + beta, alpha = result.items + assert beta.best_rank == 2 + assert beta.appearances == 1 + assert alpha.stars == 120 + assert alpha.best_rank == 3 + assert alpha.latest_rank == 3 + assert alpha.first_seen_at == first_at + assert alpha.last_seen_at == latest_at + assert alpha.appearances == 2 diff --git a/tests/unit/service/test_generation.py b/tests/unit/service/test_generation.py new file mode 100644 index 0000000..c54fdc6 --- /dev/null +++ b/tests/unit/service/test_generation.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass, field +from datetime import UTC, date, datetime + +from github_trending.domain import ( + DocumentStatus, + Feed, + Period, + RepositoryItem, + RunStatus, + Snapshot, + SnapshotIssue, +) +from github_trending.service import GenerationService, SourceError + + +@dataclass +class StubSource: + snapshot: Snapshot + + def fetch(self, feed: Feed) -> Snapshot: + assert feed == self.snapshot.feed + return self.snapshot + + +@dataclass +class SpyPublisher: + published: list[tuple[Snapshot, date]] = field(default_factory=list) + + def publish(self, snapshot: Snapshot, archive_date: date) -> None: + self.published.append((snapshot, archive_date)) + + +@dataclass +class SpyProgressReporter: + events: list[str] = field(default_factory=list) + + def fetching(self, position: int, total: int, feed: Feed) -> None: + self.events.append(f"fetching {position}/{total} {feed.key}") + + def published(self, position: int, total: int, snapshot: Snapshot) -> None: + self.events.append(f"published {position}/{total} {snapshot.feed.key}") + + def failed(self, position: int, total: int, feed: Feed, error: SourceError) -> None: + self.events.append(f"failed {position}/{total} {feed.key} {error.category}") + + +@dataclass +class PartiallyFailingSource: + snapshots: dict[Feed, Snapshot] + failing_feed: Feed + + def fetch(self, feed: Feed) -> Snapshot: + if feed == self.failing_feed: + raise SourceError(category="fetch_failed", detail="HTTP 503") + return self.snapshots[feed] + + +def test_successful_feed_is_published_for_the_run_utc_date() -> None: + feed = Feed(period=Period.DAILY, language="python") + observed_at = datetime(2026, 9, 25, 6, 17, tzinfo=UTC) + snapshot = Snapshot( + feed=feed, + source_url="https://github.com/trending/python", + observed_at=observed_at, + status=DocumentStatus.COMPLETE, + issues=(), + items=( + RepositoryItem( + rank=1, + full_name="astral-sh/uv", + url="https://github.com/astral-sh/uv", + description="An extremely fast Python package manager.", + programming_language="Rust", + stars=42_137, + forks=1_289, + stars_in_period=742, + ), + ), + ) + publisher = SpyPublisher() + started_at = datetime(2026, 9, 25, 23, 59, tzinfo=UTC) + + report = GenerationService(StubSource(snapshot), publisher, (feed,)).run(started_at) + + assert report.status is RunStatus.COMPLETE + assert report.published == (feed,) + assert report.failures == () + assert publisher.published == [(snapshot, date(2026, 9, 25))] + + +def test_failed_feed_stays_stale_while_later_feeds_are_published() -> None: + failed_feed = Feed(period=Period.DAILY, language="python") + successful_feed = Feed(period=Period.DAILY, language="rust") + snapshot = Snapshot( + feed=successful_feed, + source_url="https://github.com/trending/rust", + observed_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=DocumentStatus.COMPLETE, + issues=(), + items=(), + ) + publisher = SpyPublisher() + source = PartiallyFailingSource({successful_feed: snapshot}, failed_feed) + + report = GenerationService(source, publisher, (failed_feed, successful_feed)).run( + datetime(2026, 9, 25, 6, 17, tzinfo=UTC) + ) + + assert report.status is RunStatus.DEGRADED + assert report.published == (successful_feed,) + assert len(report.failures) == 1 + assert report.failures[0].feed == failed_feed + assert report.failures[0].category == "fetch_failed" + assert report.failures[0].detail == "HTTP 503" + assert publisher.published == [(snapshot, date(2026, 9, 25))] + + +def test_progress_reports_each_feed_in_processing_order() -> None: + failed_feed = Feed(period=Period.DAILY, language="python") + successful_feed = Feed(period=Period.DAILY, language="rust") + snapshot = Snapshot( + feed=successful_feed, + source_url="https://github.com/trending/rust", + observed_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=DocumentStatus.COMPLETE, + issues=(), + items=(), + ) + progress = SpyProgressReporter() + source = PartiallyFailingSource({successful_feed: snapshot}, failed_feed) + + GenerationService(source, SpyPublisher(), (failed_feed, successful_feed)).run( + datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + progress=progress, + ) + + assert progress.events == [ + "fetching 1/2 daily/python", + "failed 1/2 daily/python fetch_failed", + "fetching 2/2 daily/rust", + "published 2/2 daily/rust", + ] + + +def test_item_level_issues_are_reported_after_degraded_snapshot_is_published() -> None: + feed = Feed(Period.DAILY, "rust") + issue = SnapshotIssue("invalid_integer", 4, "stars") + snapshot = Snapshot( + feed=feed, + source_url="https://github.com/trending/rust", + observed_at=datetime(2026, 9, 25, 6, 17, tzinfo=UTC), + status=DocumentStatus.DEGRADED, + issues=(issue,), + items=(), + ) + + report = GenerationService(StubSource(snapshot), SpyPublisher(), (feed,)).run( + datetime(2026, 9, 25, 6, 17, tzinfo=UTC) + ) + + assert report.status is RunStatus.DEGRADED + assert report.degraded[0].feed == feed + assert report.degraded[0].issues == (issue,) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6a41f5d --- /dev/null +++ b/uv.lock @@ -0,0 +1,545 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "complexipy" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/00/50bc6acc0cf6d0fb96d2207c30eadc5c9c4ea51535094e2c510ce8c737f3/complexipy-4.2.0.tar.gz", hash = "sha256:88169f130623deab983ab21bc2248bcfd18a8323ccf9dd759bcf78a3fadd49fc", size = 282210, upload-time = "2025-09-21T18:37:34.804Z" } + +[[package]] +name = "deptry" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "requirements-parser" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" }, +] + +[[package]] +name = "github-trending-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "complexipy" }, + { name = "deptry" }, + { name = "import-linter" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-beautifulsoup4" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.13,<5" }, + { name = "httpx", specifier = ">=0.28,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "complexipy", specifier = ">=4,<5" }, + { name = "deptry", specifier = ">=0.23,<1" }, + { name = "import-linter", specifier = ">=2.3,<3" }, + { name = "mypy", specifier = ">=1.18,<2" }, + { name = "pytest", specifier = ">=8.4,<10" }, + { name = "ruff", specifier = ">=0.13,<1" }, + { name = "types-beautifulsoup4", specifier = ">=4.12,<5" }, +] + +[[package]] +name = "grimp" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, + { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, + { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, + { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "import-linter" +version = "2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "requirements-parser" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "types-beautifulsoup4" +version = "4.12.0.20250516" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-html5lib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/d1/32b410f6d65eda94d3dfb0b3d0ca151f12cb1dc4cef731dcf7cbfd8716ff/types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e", size = 16628, upload-time = "2025-05-16T03:09:09.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/79/d84de200a80085b32f12c5820d4fd0addcbe7ba6dce8c1c9d8605e833c8e/types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee", size = 16879, upload-time = "2025-05-16T03:09:09.051Z" }, +] + +[[package]] +name = "types-html5lib" +version = "1.1.11.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/5a/0c708d1b0d35ad48b6a223c77c4a882fd016b40c25becb082a92e02a9c00/types_html5lib-1.1.11.20260518.tar.gz", hash = "sha256:4f33c087cb1119d65c4c80eca4323c2b501f9eaf8af9616b8b732ed4d8eae8fa", size = 18420, upload-time = "2026-05-18T06:07:23.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/d0/b088b9f11eb69637d6826843f06caaff60247156735a25512922d3dc2c13/types_html5lib-1.1.11.20260518-py3-none-any.whl", hash = "sha256:9baa7912224ebb37027c5ccb7e3768e43ea47b1dfdd977e7ddc4b0a4a550584d", size = 24339, upload-time = "2026-05-18T06:07:22.876Z" }, +] + +[[package]] +name = "types-webencodings" +version = "0.6.0.20260907" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/74/b83cf1d523516bc818ffe6fa7c2f5504e0eeee7c5c394ecc1b404f95fe92/types_webencodings-0.6.0.20260907.tar.gz", hash = "sha256:efa85bc5114419ed45aec227ca5051cca63fa3e2bd13fcf79017ee4107603efc", size = 7748, upload-time = "2026-09-07T06:43:22.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/e7/dc1ea506e123c437c4551c35498eaade289f52d7f7ddf3f77cf94f0675dc/types_webencodings-0.6.0.20260907-py3-none-any.whl", hash = "sha256:86dc9b5a14665b24d5d7d061149c8c3f50355243df5ef285bf816c2e2cc093d5", size = 8584, upload-time = "2026-09-07T06:43:21.177Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]