From 427ce934885ff41cf78518ad2e4ef46613cdfd2f Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 1 Jun 2026 08:24:24 -0700 Subject: [PATCH 01/16] feat: receive mellea release dispatch and sync versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a workflow that listens for a repository_dispatch event (event_type=mellea-released) and bumps every contribs pyproject.toml's version + mellea>= constraint to the released version. Refreshes every uv.lock and opens a PR. Uses the default GITHUB_TOKEN. The helper script leaves == exact pins alone — subpackages opting into exact pins own their own bumps — and is idempotent. The mellea-side dispatcher follows in a separate PR. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .../scripts/test_update_contribs_versions.py | 169 ++++++++++++++++++ .github/scripts/update_contribs_versions.py | 132 ++++++++++++++ .github/workflows/receive-mellea-release.yml | 101 +++++++++++ 3 files changed, 402 insertions(+) create mode 100644 .github/scripts/test_update_contribs_versions.py create mode 100644 .github/scripts/update_contribs_versions.py create mode 100644 .github/workflows/receive-mellea-release.yml diff --git a/.github/scripts/test_update_contribs_versions.py b/.github/scripts/test_update_contribs_versions.py new file mode 100644 index 0000000..35bf184 --- /dev/null +++ b/.github/scripts/test_update_contribs_versions.py @@ -0,0 +1,169 @@ +"""Tests for update_contribs_versions.py. + +The script walks every pyproject.toml in a contribs-repo checkout and +bumps two things in-place: project.version and the mellea>= dependency +constraint. It is invoked by sync-contribs-version.yml after every +published mellea release. + +Design intent (informs these tests): +- Idempotent. Running twice with the same target produces no edits. +- Touches `>=` and bare-name forms only. Leaves `==` exact pins alone + (subpackages that pin exact versions own their own bumps). +- Skips .venv/, dist/, build/, .git/ when walking. +- Adds a `version = "X.Y.Z"` line to a `[project]` table that has a + name but no version (the root meta-package case after Phase 1 F2). +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from update_contribs_versions import update_repo + + +def write(p: Path, content: str) -> None: + """Write `content` to `p`, dedented and with leading blank line stripped.""" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(content).lstrip()) + + +def test_bumps_version_and_mellea_constraint(tmp_path: Path) -> None: + """Happy path: a typical contribs subpackage gets version + mellea>= bumped.""" + write( + tmp_path / "dspy" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-dspy" + version = "0.5.0" + dependencies = [ + "mellea>=0.5.0", + "mellea-contribs-integration-core", + "dspy>=3.1", + ] + """, + ) + + changed = update_repo(tmp_path, "0.6.0") + + assert (tmp_path / "dspy" / "pyproject.toml") in changed + out = (tmp_path / "dspy" / "pyproject.toml").read_text() + assert 'version = "0.6.0"' in out + assert '"mellea>=0.6.0"' in out + # Cross-deps must NOT be touched. + assert '"mellea-contribs-integration-core"' in out + assert '"dspy>=3.1"' in out + + +def test_preserves_mellea_extras(tmp_path: Path) -> None: + """`mellea[hf]>=X` keeps its extras when bumped.""" + write( + tmp_path / "x" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-x" + version = "0.5.0" + dependencies = ["mellea[hf]>=0.5.0"] + """, + ) + + update_repo(tmp_path, "0.6.0") + out = (tmp_path / "x" / "pyproject.toml").read_text() + assert '"mellea[hf]>=0.6.0"' in out + + +def test_leaves_exact_pins_alone(tmp_path: Path) -> None: + """`==` pins (used by reqlib_package and tools_package) are NOT touched. + + Subpackages pinning to a specific mellea version own their own bumps. + The cookiecutter template (F3) generates `>=` so this only matters + during the legacy migration window. + """ + write( + tmp_path / "reqlib" / "pyproject.toml", + """ + [project] + name = "mellea-reqlib" + version = "0.5.0" + dependencies = [ + "mellea[litellm]==0.3.2", + ] + """, + ) + + changed = update_repo(tmp_path, "0.6.0") + out = (tmp_path / "reqlib" / "pyproject.toml").read_text() + + # The version line gets bumped (it's the project's own version, not a constraint). + assert 'version = "0.6.0"' in out + # The == constraint stays. + assert '"mellea[litellm]==0.3.2"' in out + # File is still in the changed list because the project.version bumped. + assert (tmp_path / "reqlib" / "pyproject.toml") in changed + + +def test_idempotent(tmp_path: Path) -> None: + """Running twice with the same target is a no-op the second time. + + GHA workflow uses this to skip opening empty PRs when contribs is + already on the target version. + """ + write( + tmp_path / "x" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-x" + version = "0.6.0" + dependencies = ["mellea>=0.6.0"] + """, + ) + + first = update_repo(tmp_path, "0.6.0") + second = update_repo(tmp_path, "0.6.0") + assert first == [] + assert second == [] + + +def test_skips_hidden_and_build_dirs(tmp_path: Path) -> None: + """`.venv/`, `dist/`, `build/`, `.git/` are not walked.""" + for sub in [".venv", "dist", "build", ".git"]: + write( + tmp_path / sub / "pyproject.toml", + """ + [project] + name = "ignored" + version = "0.0.0" + """, + ) + write( + tmp_path / "real" / "pyproject.toml", + """ + [project] + name = "real" + version = "0.5.0" + """, + ) + + changed = update_repo(tmp_path, "0.6.0") + assert changed == [tmp_path / "real" / "pyproject.toml"] + + +def test_handles_root_pyproject_with_no_version(tmp_path: Path) -> None: + """Root meta-package after Phase 1 F2 has [project] with name but no version. + + The script must add a `version = "X.Y.Z"` line below `name`, + not skip the file. + """ + write( + tmp_path / "pyproject.toml", + """ + [project] + name = "mellea-contribs" + dependencies = [] + """, + ) + + changed = update_repo(tmp_path, "0.6.0") + out = (tmp_path / "pyproject.toml").read_text() + assert (tmp_path / "pyproject.toml") in changed + assert 'version = "0.6.0"' in out diff --git a/.github/scripts/update_contribs_versions.py b/.github/scripts/update_contribs_versions.py new file mode 100644 index 0000000..2902904 --- /dev/null +++ b/.github/scripts/update_contribs_versions.py @@ -0,0 +1,132 @@ +"""Update version + mellea>= constraints across every pyproject.toml in a contribs checkout. + +Used by .github/workflows/sync-contribs-version.yml after every published mellea release. + +Idempotent: running twice with the same version produces no edits. Leaves `==` +exact pins alone (subpackages that pin exact mellea versions own their own bumps). + +Edits are line-based regex substitutions, not full TOML round-trip, so formatting +and comments are preserved. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +EXCLUDED = {".venv", "dist", "build", ".git", "__pycache__", "node_modules"} + +# Matches a "mellea" or "mellea[extras]" dep line that uses `>=` or `>`. Leaves +# `==`, `<`, `<=`, `~=`, `!=`, and bare name (no operator) alone — `==` because +# subpackages opting into exact pins own their own bumps; the others because +# they're unusual enough that we'd rather surface them via a failing PR than +# silently rewrite. +_DEP_MELLEA_RE = re.compile(r'(\s*)"mellea(\[[^\]]+\])?>=[^"]*"') + + +def update_pyproject(path: Path, target_version: str) -> bool: + """Edit a single pyproject.toml in-place. Return True if the file changed.""" + text = path.read_text() + original = text + + text = _set_project_version(text, target_version) + text = _rewrite_mellea_deps(text, target_version) + + if text == original: + return False + path.write_text(text) + return True + + +def _set_project_version(text: str, target: str) -> str: + """Set `version = "X.Y.Z"` under [project]. + + If [project] has a name but no version, insert one after the name line. + """ + lines = text.splitlines(keepends=True) + out: list[str] = [] + in_project = False + project_has_version = False + project_name_idx = -1 + + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_project = stripped == "[project]" + elif in_project: + if stripped.startswith("name") and "=" in stripped: + project_name_idx = i + if stripped.startswith("version") and "=" in stripped: + project_has_version = True + line = re.sub(r'(\s*version\s*=\s*)"[^"]*"', rf'\1"{target}"', line) + out.append(line) + + text = "".join(out) + + # If [project] has a name but no version, insert one after the name line. + if project_name_idx >= 0 and not project_has_version: + lines = text.splitlines(keepends=True) + name_line = lines[project_name_idx] + indent = name_line[: len(name_line) - len(name_line.lstrip())] + insertion = f'{indent}version = "{target}"\n' + lines.insert(project_name_idx + 1, insertion) + text = "".join(lines) + + return text + + +def _rewrite_mellea_deps(text: str, target: str) -> str: + """Rewrite `"mellea>=X"` and `"mellea[extras]>=X"` to use the target version. + + Leaves `==` pins, other operators, and non-mellea deps untouched. + """ + + def _sub(m: re.Match[str]) -> str: + leading_ws = m.group(1) + extras = m.group(2) or "" + return f'{leading_ws}"mellea{extras}>={target}"' + + return _DEP_MELLEA_RE.sub(_sub, text) + + +def update_repo(root: Path, target_version: str) -> list[Path]: + """Walk the repo and update every relevant pyproject.toml. + + Returns a list of changed paths in deterministic (sorted) order. + """ + changed: list[Path] = [] + for path in sorted(root.rglob("pyproject.toml")): + rel_parts = path.relative_to(root).parts[:-1] + if any(part in EXCLUDED or part.startswith(".") for part in rel_parts): + continue + if update_pyproject(path, target_version): + changed.append(path) + return changed + + +def main() -> int: + """CLI entry point: walk a repo, bump versions, report changed files.""" + parser = argparse.ArgumentParser() + parser.add_argument("repo_root", type=Path) + parser.add_argument("version") + args = parser.parse_args() + + if not args.repo_root.is_dir(): + print(f"error: {args.repo_root} is not a directory", file=sys.stderr) + return 1 + + changed = update_repo(args.repo_root, args.version) + if not changed: + print("No pyproject.toml files needed updating.") + return 0 + + print(f"Updated {len(changed)} file(s):") + for path in changed: + print(f" - {path.relative_to(args.repo_root)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/receive-mellea-release.yml b/.github/workflows/receive-mellea-release.yml new file mode 100644 index 0000000..aeb4e0c --- /dev/null +++ b/.github/workflows/receive-mellea-release.yml @@ -0,0 +1,101 @@ +name: "Receive mellea release" + +# Listens for repository_dispatch from mellea (event_type=mellea-released) +# and bumps every pyproject.toml's version + mellea>= constraint to the +# released version. Refreshes every uv.lock. Opens a PR. +# +# Triggered by mellea/.github/workflows/dispatch-to-contribs.yml. Also +# supports workflow_dispatch for manual recovery. + +on: + repository_dispatch: + types: [mellea-released] + workflow_dispatch: + inputs: + version: + description: "Mellea version to sync (manual / recovery)" + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Resolve version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${{ github.event.client_payload.version }}" + fi + if [ -z "$VERSION" ]; then + echo "ERROR: no version provided (neither workflow_dispatch input nor client_payload.version)" + exit 1 + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Syncing mellea-contribs to mellea v${VERSION}" + + - name: Checkout mellea-contribs + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Update versions across every pyproject.toml + run: | + uv run python .github/scripts/update_contribs_versions.py \ + . "${{ steps.version.outputs.version }}" + + - name: Refresh per-subpackage uv.lock files + run: | + # Walk every uv.lock that has a sibling pyproject.toml; re-lock its + # containing dir with the new mellea version pinned. + while IFS= read -r lockfile; do + dir=$(dirname "$lockfile") + if [ -f "$dir/pyproject.toml" ]; then + echo "Re-locking $dir" + (cd "$dir" && uv lock --upgrade-package mellea) + fi + done < <(find . -name "uv.lock" -not -path "./.venv/*" -not -path "./.git/*") + + - name: Open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + BRANCH_NAME="sync-mellea-${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Defensive: if no changes were produced (contribs is already on this version), + # exit cleanly without opening an empty PR. + if git diff --quiet; then + echo "No changes produced — contribs already at v${VERSION}. Skipping PR." + exit 0 + fi + + git checkout -b "${BRANCH_NAME}" + git add -A + git commit -m "chore: sync mellea version to v${VERSION}" + git push origin "${BRANCH_NAME}" + + gh pr create \ + --title "chore: sync mellea version to v${VERSION}" \ + --body "Automated PR to sync the mellea dependency and bump every contribs subpackage's version after mellea v${VERSION} release. + + **Changes:** + - Updated every \`pyproject.toml\`'s \`project.version\` to \`${VERSION}\`. + - Updated every \`pyproject.toml\`'s \`mellea>=\` dependency to \`mellea>=${VERSION}\`. + - Refreshed every \`uv.lock\`. + + **Testing:** + Please verify CI passes and the new mellea version is compatible with every contribs subpackage." \ + --head "${BRANCH_NAME}" \ + --base main From b40babe8fc39b7c4fff437a88022ccca54ad3b4f Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 11:18:33 -0700 Subject: [PATCH 02/16] feat: bump only project.version; reject bare mellea references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous behavior of bumping both `[project] version` and `mellea>=` lines. The receiver now bumps only `version`; each subpackage owns its `mellea>=` floor and only raises it when CI proves something below it breaks. The script also errors out on `mellea` dependency lines that lack an explicit version constraint — bare `mellea`, `mellea[extras]` without operator, and `mellea @ git+...` are rejected. The receiver cannot reason about those forms and silently skipping them would let incompatibilities through. Acceptable forms remain `mellea>=X.Y.Z` and `mellea==X.Y.Z` (with or without extras). The current contribs repo uses these exclusively; the new check is a forward-looking guard. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .../scripts/test_update_contribs_versions.py | 100 +++++++++++++----- .github/scripts/update_contribs_versions.py | 96 +++++++++++------ 2 files changed, 137 insertions(+), 59 deletions(-) diff --git a/.github/scripts/test_update_contribs_versions.py b/.github/scripts/test_update_contribs_versions.py index 35bf184..58302b5 100644 --- a/.github/scripts/test_update_contribs_versions.py +++ b/.github/scripts/test_update_contribs_versions.py @@ -1,17 +1,20 @@ """Tests for update_contribs_versions.py. The script walks every pyproject.toml in a contribs-repo checkout and -bumps two things in-place: project.version and the mellea>= dependency -constraint. It is invoked by sync-contribs-version.yml after every -published mellea release. +bumps `[project] version` in-place. It is invoked by +receive-mellea-release.yml after every published mellea release. Design intent (informs these tests): +- Bumps `[project] version` only. Does NOT rewrite `mellea>=` constraints. + The `mellea>=` floor is owned by each subpackage (sliding-window model); + it only raises when CI proves something below it breaks. - Idempotent. Running twice with the same target produces no edits. -- Touches `>=` and bare-name forms only. Leaves `==` exact pins alone - (subpackages that pin exact versions own their own bumps). +- Errors loudly when a `mellea` dep line is bare (`"mellea"`, + `"mellea[extras]"`) or a git ref (`"mellea @ git+..."`). Acceptable + forms: `mellea>=X.Y.Z`, `mellea==X.Y.Z`, with or without extras. - Skips .venv/, dist/, build/, .git/ when walking. - Adds a `version = "X.Y.Z"` line to a `[project]` table that has a - name but no version (the root meta-package case after Phase 1 F2). + name but no version (the root meta-package case). """ from __future__ import annotations @@ -19,7 +22,9 @@ import textwrap from pathlib import Path -from update_contribs_versions import update_repo +import pytest + +from update_contribs_versions import UnacceptableMelleaLine, update_repo def write(p: Path, content: str) -> None: @@ -28,8 +33,8 @@ def write(p: Path, content: str) -> None: p.write_text(textwrap.dedent(content).lstrip()) -def test_bumps_version_and_mellea_constraint(tmp_path: Path) -> None: - """Happy path: a typical contribs subpackage gets version + mellea>= bumped.""" +def test_bumps_version_only_not_mellea_constraint(tmp_path: Path) -> None: + """A typical contribs subpackage gets version bumped; mellea>= stays put.""" write( tmp_path / "dspy" / "pyproject.toml", """ @@ -49,14 +54,15 @@ def test_bumps_version_and_mellea_constraint(tmp_path: Path) -> None: assert (tmp_path / "dspy" / "pyproject.toml") in changed out = (tmp_path / "dspy" / "pyproject.toml").read_text() assert 'version = "0.6.0"' in out - assert '"mellea>=0.6.0"' in out + # mellea>= floor is owned by the subpackage; receiver does NOT raise it. + assert '"mellea>=0.5.0"' in out # Cross-deps must NOT be touched. assert '"mellea-contribs-integration-core"' in out assert '"dspy>=3.1"' in out -def test_preserves_mellea_extras(tmp_path: Path) -> None: - """`mellea[hf]>=X` keeps its extras when bumped.""" +def test_preserves_mellea_extras_constraint(tmp_path: Path) -> None: + """`mellea[hf]>=X` is preserved verbatim across a version bump.""" write( tmp_path / "x" / "pyproject.toml", """ @@ -69,16 +75,12 @@ def test_preserves_mellea_extras(tmp_path: Path) -> None: update_repo(tmp_path, "0.6.0") out = (tmp_path / "x" / "pyproject.toml").read_text() - assert '"mellea[hf]>=0.6.0"' in out + assert 'version = "0.6.0"' in out + assert '"mellea[hf]>=0.5.0"' in out def test_leaves_exact_pins_alone(tmp_path: Path) -> None: - """`==` pins (used by reqlib_package and tools_package) are NOT touched. - - Subpackages pinning to a specific mellea version own their own bumps. - The cookiecutter template (F3) generates `>=` so this only matters - during the legacy migration window. - """ + """`==` pins are preserved; only project.version bumps.""" write( tmp_path / "reqlib" / "pyproject.toml", """ @@ -94,20 +96,64 @@ def test_leaves_exact_pins_alone(tmp_path: Path) -> None: changed = update_repo(tmp_path, "0.6.0") out = (tmp_path / "reqlib" / "pyproject.toml").read_text() - # The version line gets bumped (it's the project's own version, not a constraint). + # Project's own version bumps. assert 'version = "0.6.0"' in out # The == constraint stays. assert '"mellea[litellm]==0.3.2"' in out - # File is still in the changed list because the project.version bumped. + # File appears in changed list because project.version bumped. assert (tmp_path / "reqlib" / "pyproject.toml") in changed -def test_idempotent(tmp_path: Path) -> None: - """Running twice with the same target is a no-op the second time. +def test_rejects_bare_mellea_dependency(tmp_path: Path) -> None: + """Bare `mellea` (no version constraint) is rejected — receiver can't reason.""" + write( + tmp_path / "x" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-x" + version = "0.5.0" + dependencies = ["mellea"] + """, + ) - GHA workflow uses this to skip opening empty PRs when contribs is - already on the target version. - """ + with pytest.raises(UnacceptableMelleaLine, match="bare `mellea`"): + update_repo(tmp_path, "0.6.0") + + +def test_rejects_bare_mellea_with_extras(tmp_path: Path) -> None: + """`mellea[extras]` without an operator is rejected.""" + write( + tmp_path / "x" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-x" + version = "0.5.0" + dependencies = ["mellea[hf]"] + """, + ) + + with pytest.raises(UnacceptableMelleaLine, match="bare `mellea`"): + update_repo(tmp_path, "0.6.0") + + +def test_rejects_git_ref(tmp_path: Path) -> None: + """`mellea @ git+...` is rejected — replace before merging.""" + write( + tmp_path / "x" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-x" + version = "0.5.0" + dependencies = ["mellea @ git+https://github.com/example/mellea.git"] + """, + ) + + with pytest.raises(UnacceptableMelleaLine, match="git/url ref"): + update_repo(tmp_path, "0.6.0") + + +def test_idempotent(tmp_path: Path) -> None: + """Running twice with the same target is a no-op the second time.""" write( tmp_path / "x" / "pyproject.toml", """ @@ -149,7 +195,7 @@ def test_skips_hidden_and_build_dirs(tmp_path: Path) -> None: def test_handles_root_pyproject_with_no_version(tmp_path: Path) -> None: - """Root meta-package after Phase 1 F2 has [project] with name but no version. + """Root meta-package has [project] with name but no version. The script must add a `version = "X.Y.Z"` line below `name`, not skip the file. diff --git a/.github/scripts/update_contribs_versions.py b/.github/scripts/update_contribs_versions.py index 2902904..7aae8b4 100644 --- a/.github/scripts/update_contribs_versions.py +++ b/.github/scripts/update_contribs_versions.py @@ -1,12 +1,19 @@ -"""Update version + mellea>= constraints across every pyproject.toml in a contribs checkout. - -Used by .github/workflows/sync-contribs-version.yml after every published mellea release. - -Idempotent: running twice with the same version produces no edits. Leaves `==` -exact pins alone (subpackages that pin exact mellea versions own their own bumps). - -Edits are line-based regex substitutions, not full TOML round-trip, so formatting -and comments are preserved. +"""Update [project] version across every pyproject.toml in a contribs checkout. + +Used by .github/workflows/receive-mellea-release.yml after every published +mellea release. Bumps only ``[project] version`` so contribs's release +version tracks mellea exactly. Does NOT rewrite the ``mellea>=`` constraint +line — that floor is owned by each subpackage and only raised when CI +proves something below it breaks (sliding-window model). + +Idempotent: running twice with the same version produces no edits. Edits +are line-based regex substitutions, not full TOML round-trip, so +formatting and comments are preserved. + +Errors loudly when a subpackage declares ``mellea`` without an explicit +version constraint (bare ``mellea``, ``mellea[extras]``, or ``mellea @ +git+...``). The receiver cannot reason about those forms; the right fix +is to declare an explicit ``>=`` or ``==`` constraint in the source. """ from __future__ import annotations @@ -18,21 +25,54 @@ EXCLUDED = {".venv", "dist", "build", ".git", "__pycache__", "node_modules"} -# Matches a "mellea" or "mellea[extras]" dep line that uses `>=` or `>`. Leaves -# `==`, `<`, `<=`, `~=`, `!=`, and bare name (no operator) alone — `==` because -# subpackages opting into exact pins own their own bumps; the others because -# they're unusual enough that we'd rather surface them via a failing PR than -# silently rewrite. -_DEP_MELLEA_RE = re.compile(r'(\s*)"mellea(\[[^\]]+\])?>=[^"]*"') +# Matches any "mellea..." dep line. Used to inspect each line and decide +# whether it's an acceptable form (>=, ==) or a rejected form (bare, git+, +# or unknown operator). +_MELLEA_LINE_RE = re.compile(r'"mellea(\[[^\]]+\])?(?P[^"]*)"') + + +class UnacceptableMelleaLine(ValueError): + """Raised when a pyproject.toml has a mellea dep line we can't reason about.""" + + +def _validate_mellea_lines(text: str, path: Path) -> None: + """Raise UnacceptableMelleaLine if any `mellea` dep is bare or git-ref'd. + + Acceptable forms: `mellea>=X.Y.Z`, `mellea==X.Y.Z`, + `mellea[extras]>=X.Y.Z`, `mellea[extras]==X.Y.Z`. + + Rejected: bare `mellea`, `mellea[extras]` without operator, `mellea @ git+...`. + """ + for match in _MELLEA_LINE_RE.finditer(text): + spec = match.group("spec").strip() + if not spec: + raise UnacceptableMelleaLine( + f"{path}: bare `mellea` dependency without version constraint. " + "Use `mellea>=X.Y.Z` or `mellea==X.Y.Z`." + ) + if spec.startswith("@"): + raise UnacceptableMelleaLine( + f"{path}: mellea declared as a git/url ref ({spec!r}). " + "Replace with `mellea>=X.Y.Z` or `mellea==X.Y.Z` before merging." + ) + if not (spec.startswith(">=") or spec.startswith("==")): + raise UnacceptableMelleaLine( + f"{path}: mellea constraint uses an operator we won't auto-bump ({spec!r}). " + "Use `mellea>=X.Y.Z` or `mellea==X.Y.Z`." + ) def update_pyproject(path: Path, target_version: str) -> bool: - """Edit a single pyproject.toml in-place. Return True if the file changed.""" + """Edit a single pyproject.toml in-place. Return True if the file changed. + + Validates mellea dep lines first; raises UnacceptableMelleaLine if any + line uses a form the receiver cannot reason about. + """ text = path.read_text() + _validate_mellea_lines(text, path) original = text text = _set_project_version(text, target_version) - text = _rewrite_mellea_deps(text, target_version) if text == original: return False @@ -77,24 +117,11 @@ def _set_project_version(text: str, target: str) -> str: return text -def _rewrite_mellea_deps(text: str, target: str) -> str: - """Rewrite `"mellea>=X"` and `"mellea[extras]>=X"` to use the target version. - - Leaves `==` pins, other operators, and non-mellea deps untouched. - """ - - def _sub(m: re.Match[str]) -> str: - leading_ws = m.group(1) - extras = m.group(2) or "" - return f'{leading_ws}"mellea{extras}>={target}"' - - return _DEP_MELLEA_RE.sub(_sub, text) - - def update_repo(root: Path, target_version: str) -> list[Path]: """Walk the repo and update every relevant pyproject.toml. Returns a list of changed paths in deterministic (sorted) order. + Raises UnacceptableMelleaLine on the first malformed mellea dep line. """ changed: list[Path] = [] for path in sorted(root.rglob("pyproject.toml")): @@ -117,7 +144,12 @@ def main() -> int: print(f"error: {args.repo_root} is not a directory", file=sys.stderr) return 1 - changed = update_repo(args.repo_root, args.version) + try: + changed = update_repo(args.repo_root, args.version) + except UnacceptableMelleaLine as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if not changed: print("No pyproject.toml files needed updating.") return 0 From dfd0f5d611f3a419970d7575a751becf1ae60dac Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 11:19:50 -0700 Subject: [PATCH 03/16] feat: open per-package bump PRs in dependency tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single bump PR with one PR per subpackage so owners can merge independently — slow movers no longer block fast movers, and CI signals compatibility per package. PRs open in dependency tiers: - Tier 1: _integration_core (consumed by frameworks). - Tier 2: dspy, langchain, crewai, tools (depend on tier 1). - Tier 3: legal-reqs, python-imports, grounding-context (leaves). The workflow re-runs when a sync-mellea-* PR merges, advancing to the next tier automatically. Subpackages whose bump PR doesn't merge before the next coordinated contribs release are left at the old version and ship in the following release. open_per_package_bump_prs.py owns the tier detection and PR opening; the workflow itself just wires the trigger events (repository_dispatch, pull_request closed-merged on sync-mellea-* branches, manual dispatch). Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .github/scripts/open_per_package_bump_prs.py | 180 ++++++++++++++++++ .../scripts/test_open_per_package_bump_prs.py | 106 +++++++++++ .github/workflows/receive-mellea-release.yml | 84 +++----- 3 files changed, 317 insertions(+), 53 deletions(-) create mode 100644 .github/scripts/open_per_package_bump_prs.py create mode 100644 .github/scripts/test_open_per_package_bump_prs.py diff --git a/.github/scripts/open_per_package_bump_prs.py b/.github/scripts/open_per_package_bump_prs.py new file mode 100644 index 0000000..6b07ae5 --- /dev/null +++ b/.github/scripts/open_per_package_bump_prs.py @@ -0,0 +1,180 @@ +"""Open one bump PR per subpackage in dependency-tier order. + +Used by ``.github/workflows/receive-mellea-release.yml`` after every +mellea release. Replaces the "one big PR" model with one PR per +subpackage so owners can merge their bump independently — slow movers +don't block fast movers, and per-package CI signals compatibility. + +Tiers (subpackages within a tier are opened concurrently; tiers serialize): + +- Tier 1: ``_integration_core`` (consumed by frameworks). +- Tier 2: framework subpackages — ``dspy``, ``langchain``, ``crewai``, + ``tools``. Opened once Tier 1's PR has merged. +- Tier 3: leaf subpackages with no contribs-internal dependencies — + ``legal-reqs``, ``python-imports``, ``grounding-context``. Opened once + Tier 2 has merged. + +The script runs once per workflow invocation and opens only the PRs for +the *current* tier — i.e., the lowest tier with subpackages that still +need a bump and whose dependencies are already on the target version. +The workflow re-runs (via the bot itself observing tier-N PRs being +merged) to advance to the next tier. + +Subpackages whose ``pyproject.toml`` doesn't exist or doesn't need a +bump (already at target version) are skipped silently. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from update_contribs_versions import UnacceptableMelleaLine, update_pyproject + +# Tier definitions — see module docstring. The tier name is used as a +# stable label only; the receiver looks up subpackages by directory name. +TIERS: list[tuple[str, list[str]]] = [ + ("integration-core", ["_integration_core", "mellea-integration-core"]), + ("frameworks", ["dspy", "langchain", "crewai", "tools"]), + ("leaves", ["legal-reqs", "python-imports", "grounding-context"]), +] + + +def _subpackage_needs_bump(pyproject: Path, target: str) -> bool: + """True iff editing pyproject would actually change [project] version.""" + if not pyproject.exists(): + return False + text = pyproject.read_text() + # Cheap signal: target version string already present in a [project] + # block? Not perfect but avoids reparsing. + needle = f'version = "{target}"' + return needle not in text + + +def _detect_tier(repo: Path, target: str) -> tuple[str, list[Path]] | None: + """Return (tier_name, list_of_subpackage_pyprojects_that_need_bump). + + Returns the lowest tier that has at least one subpackage needing a bump. + If every tier is already on target, returns None. + """ + for tier_name, candidates in TIERS: + needs_bump: list[Path] = [] + for sub in candidates: + pyproject = repo / sub / "pyproject.toml" + if _subpackage_needs_bump(pyproject, target): + needs_bump.append(pyproject) + if needs_bump: + return tier_name, needs_bump + return None + + +def _open_pr(repo: Path, subpackage: Path, target: str) -> None: + """Branch, edit, lock, commit, push, and gh-pr-create for one subpackage. + + Idempotent at the branch level: if the branch already exists upstream, + skip cleanly (the previous run already opened this PR). + """ + sub_dir = subpackage.parent + name = sub_dir.name + branch = f"sync-mellea-{target}-{name}" + + # Skip if branch already exists upstream (PR already opened). + result = subprocess.run( + ["git", "ls-remote", "--exit-code", "origin", f"refs/heads/{branch}"], + cwd=repo, + capture_output=True, + ) + if result.returncode == 0: + print(f" [{name}] branch {branch} already exists upstream — skipping.") + return + + # Bump pyproject.toml. + try: + update_pyproject(subpackage, target) + except UnacceptableMelleaLine as exc: + print(f" [{name}] error: {exc}", file=sys.stderr) + sys.exit(2) + + # Refresh uv.lock. + subprocess.run( + ["uv", "lock", "--upgrade-package", "mellea"], + cwd=sub_dir, + check=True, + ) + + # Branch, commit, push. + subprocess.run(["git", "checkout", "-b", branch], cwd=repo, check=True) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "-m", f"chore({name}): bump version to v{target}"], + cwd=repo, + check=True, + ) + subprocess.run(["git", "push", "origin", branch], cwd=repo, check=True) + + # Open PR. + body = ( + f"Automated bump of `{name}`'s `[project] version` to `{target}` " + f"after the matching mellea release.\n\n" + f"`mellea>=` constraint untouched — owners control that floor.\n\n" + f"Merging this PR is independent of any other tier-{name} subpackage's " + f"bump PR; please verify CI passes." + ) + subprocess.run( + [ + "gh", "pr", "create", + "--title", f"chore({name}): bump version to v{target}", + "--body", body, + "--head", branch, + "--base", "main", + ], + cwd=repo, + check=True, + ) + + # Reset to main for the next iteration. + subprocess.run(["git", "checkout", "main"], cwd=repo, check=True) + subprocess.run(["git", "reset", "--hard", "origin/main"], cwd=repo, check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo_root", type=Path, help="contribs repo root") + parser.add_argument("version", help="target version (matches mellea release)") + parser.add_argument( + "--dry-run", + action="store_true", + help="report which tier and subpackages would be bumped, but don't edit", + ) + args = parser.parse_args() + + if not args.repo_root.is_dir(): + print(f"error: {args.repo_root} is not a directory", file=sys.stderr) + return 1 + + detected = _detect_tier(args.repo_root, args.version) + if detected is None: + print(f"All subpackages already at v{args.version}; nothing to bump.") + return 0 + + tier_name, pyprojects = detected + print(f"Active tier: {tier_name} ({len(pyprojects)} subpackage(s) need bumping)") + for pp in pyprojects: + print(f" - {pp.parent.name}") + + if args.dry_run: + # Emit JSON for the workflow to consume. + print(json.dumps({"tier": tier_name, "subpackages": [pp.parent.name for pp in pyprojects]})) + return 0 + + for pp in pyprojects: + _open_pr(args.repo_root, pp, args.version) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_open_per_package_bump_prs.py b/.github/scripts/test_open_per_package_bump_prs.py new file mode 100644 index 0000000..c13d4ac --- /dev/null +++ b/.github/scripts/test_open_per_package_bump_prs.py @@ -0,0 +1,106 @@ +"""Tests for open_per_package_bump_prs._detect_tier. + +Side-effecting helpers (`_open_pr`, `main`) are not tested here — they +shell out to git/gh and are exercised end-to-end by the workflow. This +test suite covers the tier-detection logic, which is the only piece +with non-trivial branching. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from open_per_package_bump_prs import _detect_tier + + +def write(p: Path, content: str) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(content).lstrip()) + + +def _make_subpackage(repo: Path, name: str, version: str) -> None: + write( + repo / name / "pyproject.toml", + f""" + [project] + name = "mellea-contribs-{name}" + version = "{version}" + dependencies = ["mellea>=0.5.0"] + """, + ) + + +def test_detects_tier1_when_only_integration_core_lags(tmp_path: Path) -> None: + """Tier 1 fires first if `_integration_core` is behind, even if other tiers also lag.""" + _make_subpackage(tmp_path, "_integration_core", "0.5.0") + _make_subpackage(tmp_path, "dspy", "0.5.0") + _make_subpackage(tmp_path, "legal-reqs", "0.5.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is not None + tier_name, pyprojects = detected + assert tier_name == "integration-core" + assert [p.parent.name for p in pyprojects] == ["_integration_core"] + + +def test_detects_tier2_when_tier1_already_on_target(tmp_path: Path) -> None: + """Once tier 1 is on target, tier 2 (frameworks) becomes the active tier.""" + _make_subpackage(tmp_path, "_integration_core", "0.6.0") + _make_subpackage(tmp_path, "dspy", "0.5.0") + _make_subpackage(tmp_path, "langchain", "0.5.0") + _make_subpackage(tmp_path, "legal-reqs", "0.5.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is not None + tier_name, pyprojects = detected + assert tier_name == "frameworks" + assert sorted(p.parent.name for p in pyprojects) == ["dspy", "langchain"] + + +def test_detects_tier3_when_tier1_and_tier2_on_target(tmp_path: Path) -> None: + """Leaves are last.""" + _make_subpackage(tmp_path, "_integration_core", "0.6.0") + _make_subpackage(tmp_path, "dspy", "0.6.0") + _make_subpackage(tmp_path, "legal-reqs", "0.5.0") + _make_subpackage(tmp_path, "python-imports", "0.5.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is not None + tier_name, pyprojects = detected + assert tier_name == "leaves" + assert sorted(p.parent.name for p in pyprojects) == ["legal-reqs", "python-imports"] + + +def test_returns_none_when_all_subpackages_on_target(tmp_path: Path) -> None: + """Steady state: every subpackage is on target, no PRs to open.""" + _make_subpackage(tmp_path, "_integration_core", "0.6.0") + _make_subpackage(tmp_path, "dspy", "0.6.0") + _make_subpackage(tmp_path, "legal-reqs", "0.6.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is None + + +def test_skips_subpackages_that_dont_exist(tmp_path: Path) -> None: + """Tier candidates that aren't present on disk are silently skipped.""" + # Only `dspy` exists; other tier-2 candidates are absent. + _make_subpackage(tmp_path, "dspy", "0.5.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is not None + tier_name, pyprojects = detected + assert tier_name == "frameworks" + assert [p.parent.name for p in pyprojects] == ["dspy"] + + +def test_legacy_mellea_integration_core_name_recognized(tmp_path: Path) -> None: + """During the migration window the directory is `mellea-integration-core`, + not `_integration_core`. Tier 1 must accept either.""" + _make_subpackage(tmp_path, "mellea-integration-core", "0.5.0") + + detected = _detect_tier(tmp_path, "0.6.0") + assert detected is not None + tier_name, pyprojects = detected + assert tier_name == "integration-core" + assert [p.parent.name for p in pyprojects] == ["mellea-integration-core"] diff --git a/.github/workflows/receive-mellea-release.yml b/.github/workflows/receive-mellea-release.yml index aeb4e0c..d20458c 100644 --- a/.github/workflows/receive-mellea-release.yml +++ b/.github/workflows/receive-mellea-release.yml @@ -1,15 +1,20 @@ name: "Receive mellea release" # Listens for repository_dispatch from mellea (event_type=mellea-released) -# and bumps every pyproject.toml's version + mellea>= constraint to the -# released version. Refreshes every uv.lock. Opens a PR. +# and opens one bump PR per subpackage in dependency-tier order. The same +# workflow re-runs when a tier-N bump PR merges, advancing to tier-(N+1). # -# Triggered by mellea/.github/workflows/dispatch-to-contribs.yml. Also -# supports workflow_dispatch for manual recovery. +# Triggered by: +# - mellea/.github/workflows/dispatch-to-contribs.yml (initial event) +# - pull_request closed-merged where head starts with sync-mellea- +# (advances tiers as PRs land) +# - workflow_dispatch (manual recovery) on: repository_dispatch: types: [mellea-released] + pull_request: + types: [closed] workflow_dispatch: inputs: version: @@ -22,18 +27,31 @@ permissions: jobs: sync: + # Only act on tier-advancement PRs that actually merged. Other PR + # close events are ignored. + if: | + github.event_name == 'repository_dispatch' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'sync-mellea-')) runs-on: ubuntu-latest steps: - name: Resolve version id: version run: | - if [ -n "${{ github.event.inputs.version }}" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" - else + elif [ "${{ github.event_name }}" = "repository_dispatch" ]; then VERSION="${{ github.event.client_payload.version }}" + else + # pull_request: extract version from branch name (sync-mellea--) + BRANCH="${{ github.event.pull_request.head.ref }}" + VERSION=$(echo "$BRANCH" | sed -E 's/^sync-mellea-([0-9]+\.[0-9]+\.[0-9]+).*/\1/') fi if [ -z "$VERSION" ]; then - echo "ERROR: no version provided (neither workflow_dispatch input nor client_payload.version)" + echo "ERROR: could not resolve mellea version from event payload." exit 1 fi echo "version=${VERSION}" >> $GITHUB_OUTPUT @@ -43,59 +61,19 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: main - name: Install uv uses: astral-sh/setup-uv@v5 - - name: Update versions across every pyproject.toml - run: | - uv run python .github/scripts/update_contribs_versions.py \ - . "${{ steps.version.outputs.version }}" - - - name: Refresh per-subpackage uv.lock files + - name: Configure git for bot commits run: | - # Walk every uv.lock that has a sibling pyproject.toml; re-lock its - # containing dir with the new mellea version pinned. - while IFS= read -r lockfile; do - dir=$(dirname "$lockfile") - if [ -f "$dir/pyproject.toml" ]; then - echo "Re-locking $dir" - (cd "$dir" && uv lock --upgrade-package mellea) - fi - done < <(find . -name "uv.lock" -not -path "./.venv/*" -not -path "./.git/*") + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Open PR + - name: Open per-package bump PRs for the active tier env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | - BRANCH_NAME="sync-mellea-${VERSION}" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Defensive: if no changes were produced (contribs is already on this version), - # exit cleanly without opening an empty PR. - if git diff --quiet; then - echo "No changes produced — contribs already at v${VERSION}. Skipping PR." - exit 0 - fi - - git checkout -b "${BRANCH_NAME}" - git add -A - git commit -m "chore: sync mellea version to v${VERSION}" - git push origin "${BRANCH_NAME}" - - gh pr create \ - --title "chore: sync mellea version to v${VERSION}" \ - --body "Automated PR to sync the mellea dependency and bump every contribs subpackage's version after mellea v${VERSION} release. - - **Changes:** - - Updated every \`pyproject.toml\`'s \`project.version\` to \`${VERSION}\`. - - Updated every \`pyproject.toml\`'s \`mellea>=\` dependency to \`mellea>=${VERSION}\`. - - Refreshed every \`uv.lock\`. - - **Testing:** - Please verify CI passes and the new mellea version is compatible with every contribs subpackage." \ - --head "${BRANCH_NAME}" \ - --base main + uv run python .github/scripts/open_per_package_bump_prs.py . "$VERSION" From 12ad30a4e2bf38fab02c27c5d4dfff6347cc9b4a Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Wed, 3 Jun 2026 09:23:49 -0700 Subject: [PATCH 04/16] fix: scope the mellea regex to the bare package name The previous pattern `"mellea(\[[^\]]+\])?(?P[^"]*)"` matched sibling distributions like `mellea-contribs-integration-core` and `mellea-tools` because anything after `mellea` was eaten by the optional extras group + spec capture. The receiver then treated those sibling lines as malformed mellea deps and errored out. A negative lookahead `(?![a-zA-Z0-9_-])` after `mellea` rules out the prefix-of-a-longer-name case while still allowing `mellea>=`, `mellea==`, `mellea[extras]>=`, and bare `mellea`. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .github/scripts/update_contribs_versions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/update_contribs_versions.py b/.github/scripts/update_contribs_versions.py index 7aae8b4..6a2a838 100644 --- a/.github/scripts/update_contribs_versions.py +++ b/.github/scripts/update_contribs_versions.py @@ -25,10 +25,11 @@ EXCLUDED = {".venv", "dist", "build", ".git", "__pycache__", "node_modules"} -# Matches any "mellea..." dep line. Used to inspect each line and decide -# whether it's an acceptable form (>=, ==) or a rejected form (bare, git+, -# or unknown operator). -_MELLEA_LINE_RE = re.compile(r'"mellea(\[[^\]]+\])?(?P[^"]*)"') +# Matches a "mellea" or "mellea[extras]" dep line — the bare `mellea` package +# only, not sibling distributions like `mellea-contribs-X` or `mellea-tools`. +# The negative lookahead `(?![a-zA-Z0-9_-])` ensures we don't accidentally +# match the prefix of a longer package name. +_MELLEA_LINE_RE = re.compile(r'"mellea(?![a-zA-Z0-9_-])(\[[^\]]+\])?(?P[^"]*)"') class UnacceptableMelleaLine(ValueError): From 86d47f9871239e58ca548c88bdf01deeb66f888d Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 11:58:34 -0700 Subject: [PATCH 05/16] feat: add root meta-package and dev-only uv.lock Adds an empty meta-package at the repo root with no runtime dependencies and a dev-only [dependency-groups] for the tooling that runs across the whole repo (ruff, mypy, actionlint-py, cookiecutter, pyyaml, pre-commit, pytest). `uv sync` at the root installs only those tools; subpackages stay independent. [project.optional-dependencies] is left empty for now and gets populated incrementally as subpackages migrate. At release time, release.yml rewrites those entries to versioned GitHub Release URLs at build time. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- pyproject.toml | 47 +++ uv.lock | 827 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 874 insertions(+) create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2cdeb1b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mellea-contribs" +version = "0.6.0" +description = "Bleeding-edge contributions to Mellea — framework bridges, sampling algorithms, domain-specific requirements" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +authors = [{ name = "Mellea Contributors" }] +keywords = ["mellea", "contribs", "llm", "generative-programming"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [] # meta-package ships no code + +[project.urls] +Homepage = "https://github.com/generative-computing/mellea" +Repository = "https://github.com/generative-computing/mellea-contribs" + +[project.optional-dependencies] +# Extras populated incrementally as subpackages migrate into the new layout. +# At release time, `release.yml` rewrites these entries to versioned GitHub +# Release URLs at build time. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = [] # meta-package, no code + +[dependency-groups] +dev = [ + "ruff>=0.6", + "mypy>=1.10", + "actionlint-py", + "cookiecutter>=2.6", + "pyyaml", + "pre-commit>=3", + "pytest>=7", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ae26109 --- /dev/null +++ b/uv.lock @@ -0,0 +1,827 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "actionlint-py" +version = "1.7.12.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "binaryornot" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/4755b85101f37707c71526a301c1203e413c715a0016ecb592de3d2dcfff/binaryornot-0.6.0.tar.gz", hash = "sha256:cc8d57cfa71d74ff8c28a7726734d53a851d02fad9e3a5581fb807f989f702f0", size = 478718, upload-time = "2026-03-08T16:26:28.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/0c/31cfaa6b56fe23488ecb993bc9fc526c0d84d89607decdf2a10776426c2e/binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337", size = 14185, upload-time = "2026-03-08T16:26:27.466Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "cookiecutter" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/03/f4c96d8fd4f5e8af0210bf896eb63927f35d3014a8e8f3bf9d2c43ad3332/cookiecutter-2.7.1.tar.gz", hash = "sha256:ca7bb7bc8c6ff441fbf53921b5537668000e38d56e28d763a1b73975c66c6138", size = 142854, upload-time = "2026-03-04T04:06:02.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a9/8c855c14b401dc67d20739345295af5afce5e930a69600ab20f6cfa50b5c/cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9", size = 41317, upload-time = "2026-03-04T04:06:01.221Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[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 = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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 = "mellea-contribs" +version = "0.6.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "actionlint-py" }, + { name = "cookiecutter" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "actionlint-py" }, + { name = "cookiecutter", specifier = ">=2.6" }, + { name = "mypy", specifier = ">=1.10" }, + { name = "pre-commit", specifier = ">=3" }, + { name = "pytest", specifier = ">=7" }, + { name = "pyyaml" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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 = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, +] From cf8407925130ada06a5bad5f13106e79986c1f2e Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 11:59:25 -0700 Subject: [PATCH 06/16] feat: add cookiecutter template for new subpackages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a subpackage scaffold with the namespace package physically on disk: `/mellea_contribs///...`. The wheel layout matches the source layout, no hatch sources remap needed; this is the only shape that works with editable installs (uv sync) when the import path is `mellea_contribs..<...>`. Includes the empty mirror skeleton (stdlib, backends, formatters, helpers, core), a stub module, smoke test, basic example, OWNERS, README, and a pyproject with a [tool.mellea-contribs.ci] block. The pre-gen hook validates snake_case name and rejects unknown core_path values against core_paths.json — a snapshot of valid mellea core paths regenerated by the upstream release sync. The post-gen hook creates the inner directory chain under mellea_contribs//, writes a hello() stub, and runs `uv lock` best-effort. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- cookiecutter/cookiecutter.json | 11 ++++ cookiecutter/core_paths.json | 39 ++++++++++++ cookiecutter/hooks/post_gen_project.py | 53 ++++++++++++++++ cookiecutter/hooks/pre_gen_project.py | 46 ++++++++++++++ cookiecutter/{{cookiecutter.dir_name}}/OWNERS | 1 + .../{{cookiecutter.dir_name}}/README.md | 28 +++++++++ .../examples/basic_usage.py | 11 ++++ .../{{cookiecutter.name}}/__init__.py | 0 .../backends/__init__.py | 0 .../{{cookiecutter.name}}/core/__init__.py | 0 .../formatters/__init__.py | 0 .../{{cookiecutter.name}}/helpers/__init__.py | 0 .../{{cookiecutter.name}}/stdlib/__init__.py | 0 .../{{cookiecutter.dir_name}}/pyproject.toml | 63 +++++++++++++++++++ .../tests/test_smoke.py | 11 ++++ 15 files changed, 263 insertions(+) create mode 100644 cookiecutter/cookiecutter.json create mode 100644 cookiecutter/core_paths.json create mode 100644 cookiecutter/hooks/post_gen_project.py create mode 100644 cookiecutter/hooks/pre_gen_project.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/OWNERS create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/README.md create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/examples/basic_usage.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/backends/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/core/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/formatters/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/helpers/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/stdlib/__init__.py create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/pyproject.toml create mode 100644 cookiecutter/{{cookiecutter.dir_name}}/tests/test_smoke.py diff --git a/cookiecutter/cookiecutter.json b/cookiecutter/cookiecutter.json new file mode 100644 index 0000000..28629e5 --- /dev/null +++ b/cookiecutter/cookiecutter.json @@ -0,0 +1,11 @@ +{ + "name": "my_algo", + "dir_name": "{{ cookiecutter.name | replace('_', '-') }}", + "version": "0.6.0", + "mellea_version": "0.6.0", + "short_description": "A bleeding-edge mellea contribution", + "owner_name": "Mellea Contributor", + "owner_email": "contributor@example.com", + "core_path": "stdlib.sampling_algos", + "_extensions": ["jinja2.ext.do"] +} diff --git a/cookiecutter/core_paths.json b/cookiecutter/core_paths.json new file mode 100644 index 0000000..4366a47 --- /dev/null +++ b/cookiecutter/core_paths.json @@ -0,0 +1,39 @@ +{ + "snapshot_date": "2026-06-02", + "mellea_version": "auto-updated-by-sync", + "paths": [ + "backends", + "backends.adapters", + "backends.aloras", + "backends.aloras.huggingface", + "backends.aloras.openai", + "backends.process_reward_models", + "backends.process_reward_models.huggingface", + "core", + "formatters", + "formatters.granite", + "formatters.granite.base", + "formatters.granite.granite3", + "formatters.granite.granite3.granite32", + "formatters.granite.granite3.granite33", + "formatters.granite.intrinsics", + "formatters.granite.retrievers", + "helpers", + "stdlib", + "stdlib.components", + "stdlib.components.docs", + "stdlib.components.intrinsic", + "stdlib.docs", + "stdlib.frameworks", + "stdlib.intrinsics", + "stdlib.reqlib", + "stdlib.requirements", + "stdlib.requirements.safety", + "stdlib.rewards", + "stdlib.safety", + "stdlib.sampling", + "stdlib.sampling.sampling_algos", + "stdlib.sampling_algos", + "stdlib.tools" + ] +} diff --git a/cookiecutter/hooks/post_gen_project.py b/cookiecutter/hooks/post_gen_project.py new file mode 100644 index 0000000..0a1664b --- /dev/null +++ b/cookiecutter/hooks/post_gen_project.py @@ -0,0 +1,53 @@ +"""Post-generation hook: create the inner mirror chain + run uv lock.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +name = "{{ cookiecutter.name }}" +core_path = "{{ cookiecutter.core_path }}" + +cwd = Path.cwd() # cookiecutter runs hooks in the generated dir + +# Create the chain of directories from core_path under +# mellea_contribs//, e.g. core_path="stdlib.sampling_algos" -> +# mellea_contribs//stdlib/sampling_algos/. +parts = core_path.split(".") +chain = cwd / "mellea_contribs" / name +for part in parts: + chain = chain / part + chain.mkdir(parents=True, exist_ok=True) + init_py = chain / "__init__.py" + if not init_py.exists(): + init_py.write_text("") + +# Create the stub module at the chain's leaf, named after the subpackage. +stub_path = chain / f"{name}.py" +stub_path.write_text( + f'"""Stub module for {name}.\n\n' + f"Replace this with your subpackage's implementation." + f'"""\n\n\n' + f"def hello() -> str:\n" + f' return "Hello from {name}!"\n' +) + +print(f"Generated stub at {stub_path.relative_to(cwd)}") + +# Run uv lock (best effort — don't fail the hook if uv isn't available). +try: + result = subprocess.run( + ["uv", "lock"], cwd=cwd, capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + print(f"warning: uv lock failed: {result.stderr}", file=sys.stderr) + else: + print("Generated uv.lock") +except FileNotFoundError: + print( + "warning: uv not found; skip uv.lock generation. Run `uv lock` manually before committing.", + file=sys.stderr, + ) +except subprocess.TimeoutExpired: + print("warning: uv lock timed out", file=sys.stderr) diff --git a/cookiecutter/hooks/pre_gen_project.py b/cookiecutter/hooks/pre_gen_project.py new file mode 100644 index 0000000..2153e48 --- /dev/null +++ b/cookiecutter/hooks/pre_gen_project.py @@ -0,0 +1,46 @@ +"""Pre-generation hook: validate cookiecutter inputs.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + +name = "{{ cookiecutter.name }}" +core_path = "{{ cookiecutter.core_path }}" +template_dir = "{{ cookiecutter._template }}" + +if not NAME_RE.match(name): + print( + f"ERROR: name '{name}' must be snake_case: lowercase, start with a letter, only [a-z0-9_].", + file=sys.stderr, + ) + sys.exit(1) + +# Cookiecutter sets `cookiecutter._template` to the absolute path of the +# template directory it's rendering from. The core_paths.json snapshot lives +# at the root of that directory. +snapshot_path = Path(template_dir) / "core_paths.json" + +if not snapshot_path.exists(): + print( + f"WARNING: core_paths.json not found at {snapshot_path}; skipping core_path validation.", + file=sys.stderr, + ) +else: + with snapshot_path.open() as f: + snapshot = json.load(f) + valid_paths = set(snapshot["paths"]) + + if core_path not in valid_paths: + print( + f"ERROR: core_path '{core_path}' is not a valid mellea core path.", + file=sys.stderr, + ) + print(f"Valid examples: {sorted(valid_paths)[:10]}", file=sys.stderr) + sys.exit(1) + +print(f"Validated: name={name}, core_path={core_path}") diff --git a/cookiecutter/{{cookiecutter.dir_name}}/OWNERS b/cookiecutter/{{cookiecutter.dir_name}}/OWNERS new file mode 100644 index 0000000..3abbaf0 --- /dev/null +++ b/cookiecutter/{{cookiecutter.dir_name}}/OWNERS @@ -0,0 +1 @@ +@{{ cookiecutter.owner_name | replace(' ', '-') | lower }} diff --git a/cookiecutter/{{cookiecutter.dir_name}}/README.md b/cookiecutter/{{cookiecutter.dir_name}}/README.md new file mode 100644 index 0000000..b1a41e5 --- /dev/null +++ b/cookiecutter/{{cookiecutter.dir_name}}/README.md @@ -0,0 +1,28 @@ +# mellea-contribs-{{ cookiecutter.dir_name }} + +{{ cookiecutter.short_description }} + +## Installation + +```bash +pip install "mellea-contribs[{{ cookiecutter.dir_name }}] @ https://github.com/generative-computing/mellea-contribs/releases/latest/download/mellea_contribs-X.Y.Z-py3-none-any.whl" +``` + +(Update `X.Y.Z` to the latest release version. See the contribs README for details.) + +## Layout convention + +The source is laid out flat (`/...`); the wheel exposes the namespaced path: + +- On disk: `{{ cookiecutter.core_path | replace('.', '/') }}/{{ cookiecutter.name }}.py` +- Imports: `from mellea_contribs.{{ cookiecutter.name }}.{{ cookiecutter.core_path }}.{{ cookiecutter.name }} import …` + +The doubled segment (e.g., `mellea_contribs.{{ cookiecutter.name }}.{{ cookiecutter.core_path }}`) is deliberate — outer = subpackage; inner = identical to core. + +## Development + +```bash +cd {{ cookiecutter.dir_name }} +uv sync --all-extras +uv run pytest -m "not qualitative and not e2e" +``` diff --git a/cookiecutter/{{cookiecutter.dir_name}}/examples/basic_usage.py b/cookiecutter/{{cookiecutter.dir_name}}/examples/basic_usage.py new file mode 100644 index 0000000..2337b19 --- /dev/null +++ b/cookiecutter/{{cookiecutter.dir_name}}/examples/basic_usage.py @@ -0,0 +1,11 @@ +"""Basic usage example for {{ cookiecutter.name }}.""" + +from mellea_contribs.{{ cookiecutter.name }}.{{ cookiecutter.core_path }}.{{ cookiecutter.name }} import hello + + +def main() -> None: + print(hello()) + + +if __name__ == "__main__": + main() diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/backends/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/core/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/formatters/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/formatters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/helpers/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/stdlib/__init__.py b/cookiecutter/{{cookiecutter.dir_name}}/mellea_contribs/{{cookiecutter.name}}/stdlib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookiecutter/{{cookiecutter.dir_name}}/pyproject.toml b/cookiecutter/{{cookiecutter.dir_name}}/pyproject.toml new file mode 100644 index 0000000..172a537 --- /dev/null +++ b/cookiecutter/{{cookiecutter.dir_name}}/pyproject.toml @@ -0,0 +1,63 @@ +[project] +name = "mellea-contribs-{{ cookiecutter.dir_name }}" +version = "{{ cookiecutter.version }}" +description = "{{ cookiecutter.short_description }}" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +authors = [{ name = "{{ cookiecutter.owner_name }}", email = "{{ cookiecutter.owner_email }}" }] +keywords = ["mellea", "contribs", "{{ cookiecutter.name }}"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "mellea>={{ cookiecutter.mellea_version }}", + # Add subpackage-specific dependencies here. +] + +[project.urls] +Homepage = "https://github.com/generative-computing/mellea" +Repository = "https://github.com/generative-computing/mellea-contribs" + +[project.optional-dependencies] +test = ["pytest", "pytest-asyncio"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mellea_contribs"] + +[tool.hatch.metadata] +allow-direct-references = true + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "ruff>=0.6", + "mypy>=1.10", +] + +[tool.mellea-contribs.ci] +skip_ollama = false +timeout_minutes = 30 +python_versions = ["3.12"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "unit: fast, hermetic", + "integration: requires local services", + "e2e: end-to-end against an LLM", + "qualitative: checks LLM output quality", +] diff --git a/cookiecutter/{{cookiecutter.dir_name}}/tests/test_smoke.py b/cookiecutter/{{cookiecutter.dir_name}}/tests/test_smoke.py new file mode 100644 index 0000000..6a6aec9 --- /dev/null +++ b/cookiecutter/{{cookiecutter.dir_name}}/tests/test_smoke.py @@ -0,0 +1,11 @@ +"""Smoke test for {{ cookiecutter.name }}.""" + +import importlib + + +def test_import() -> None: + """The subpackage's main module imports cleanly.""" + module = importlib.import_module( + "mellea_contribs.{{ cookiecutter.name }}.{{ cookiecutter.core_path }}.{{ cookiecutter.name }}" + ) + assert module.hello() == "Hello from {{ cookiecutter.name }}!" From 8347585c8031a44ba3669e2313e3dcf074933d33 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 12:00:14 -0700 Subject: [PATCH 07/16] feat: add validate-structure CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a validator that walks every subpackage at the repo root and asserts the structural contract: required files (pyproject.toml, OWNERS, README.md), required dirs (tests/, examples/), non-empty OWNERS, [tool.hatch.build.targets.wheel].packages = ["mellea_contribs"], the namespace dir mellea_contribs// with __init__.py, only known mirrors under it, and every nested mirror dir resolving to a known dotted path in cookiecutter/core_paths.json. Subpackages whose dependencies list `mellea` without an explicit version constraint (bare `mellea`, `mellea[extras]` without operator, `mellea @ git+...`) are rejected — the receiver workflow can't reason about those forms and silently skipping them would let incompatibilities through. Distribution-name uniqueness is checked across the whole repo. A grandfather list at .github/scripts/grandfather_legacy.json holds the six legacy subpackages under mellea_contribs/ that pre-date the restructure; each migration shrinks the list. The workflow runs on every PR and on push to main. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .github/scripts/grandfather_legacy.json | 12 + .../scripts/test_validate_package_contract.py | 260 ++++++++++++++ .github/scripts/validate_package_contract.py | 322 ++++++++++++++++++ .github/workflows/validate-structure.yml | 15 + 4 files changed, 609 insertions(+) create mode 100644 .github/scripts/grandfather_legacy.json create mode 100644 .github/scripts/test_validate_package_contract.py create mode 100644 .github/scripts/validate_package_contract.py create mode 100644 .github/workflows/validate-structure.yml diff --git a/.github/scripts/grandfather_legacy.json b/.github/scripts/grandfather_legacy.json new file mode 100644 index 0000000..71cda68 --- /dev/null +++ b/.github/scripts/grandfather_legacy.json @@ -0,0 +1,12 @@ +{ + "_comment": "Legacy old-flat subpackages under mellea_contribs/ that pre-date the restructure. Each entry is removed by the corresponding migration PR. After migration is complete, this list is empty and the file is deleted.", + "legacy_paths": [ + "mellea_contribs/__init__.py", + "mellea_contribs/dspy_backend", + "mellea_contribs/crewai_backend", + "mellea_contribs/langchain_backend", + "mellea_contribs/mellea-integration-core", + "mellea_contribs/reqlib_package", + "mellea_contribs/tools_package" + ] +} diff --git a/.github/scripts/test_validate_package_contract.py b/.github/scripts/test_validate_package_contract.py new file mode 100644 index 0000000..3259b4f --- /dev/null +++ b/.github/scripts/test_validate_package_contract.py @@ -0,0 +1,260 @@ +"""Tests for validate_package_contract.py.""" + +from __future__ import annotations + +import json +import shutil +import sys +import textwrap +from pathlib import Path + +# Ensure the script under test is importable when pytest is invoked from the +# repo root (the script lives next to this test file, not on sys.path). +sys.path.insert(0, str(Path(__file__).parent)) + +from validate_package_contract import ( # noqa: E402 + Violation, + validate_repo, +) + + +def write(p: Path, content: str = "") -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(textwrap.dedent(content).lstrip()) + + +def setup_minimal_subpackage( + repo: Path, name: str, core_path: str = "stdlib.sampling_algos" +) -> None: + """Create a minimally-valid subpackage with the nested mellea_contribs layout.""" + sub = repo / name + sub.mkdir(parents=True) + write( + sub / "pyproject.toml", + f""" + [project] + name = "mellea-contribs-{name}" + version = "0.6.0" + dependencies = ["mellea>=0.6.0"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["mellea_contribs"] + """, + ) + write(sub / "OWNERS", "@someone\n") + write(sub / "README.md", f"# {name}\n") + (sub / "tests").mkdir() + write(sub / "tests" / "test_smoke.py", "") + (sub / "examples").mkdir() + # Namespace package: mellea_contribs// is a regular package. + inner_name = name.replace("-", "_") + inner = sub / "mellea_contribs" / inner_name + inner.mkdir(parents=True) + write(inner / "__init__.py", "") + # Core mirror dirs. + for d in ["stdlib", "backends", "formatters", "helpers", "core"]: + (inner / d).mkdir() + write(inner / d / "__init__.py", "") + # Build out the core_path chain under the namespace dir. + parts = core_path.split(".") + p = inner + for part in parts: + p = p / part + if not p.exists(): + p.mkdir() + write(p / "__init__.py", "") + + +def setup_core_paths(repo: Path) -> None: + """Create a minimal cookiecutter/core_paths.json the validator can read.""" + cc = repo / "cookiecutter" + cc.mkdir() + (cc / "core_paths.json").write_text( + json.dumps( + { + "snapshot_date": "2026-05-29", + "mellea_version": "0.6.0", + "paths": [ + "stdlib", + "stdlib.sampling_algos", + "stdlib.frameworks", + "stdlib.frameworks.dspy", + "stdlib.requirements", + "stdlib.tools", + "stdlib.components", + "stdlib.reqlib", + "stdlib.reqlib.legal", + "backends", + "formatters", + "helpers", + "core", + ], + } + ) + ) + + +def setup_grandfather(repo: Path, paths: list[str] | None = None) -> None: + if paths is None: + paths = [] + gh = repo / ".github" / "scripts" + gh.mkdir(parents=True) + (gh / "grandfather_legacy.json").write_text(json.dumps({"legacy_paths": paths})) + + +def test_valid_subpackage_passes(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + violations = validate_repo(tmp_path) + assert violations == [] + + +def test_missing_owners_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + (tmp_path / "demo" / "OWNERS").unlink() + violations = validate_repo(tmp_path) + assert any("OWNERS" in v.message for v in violations) + + +def test_missing_tests_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + shutil.rmtree(tmp_path / "demo" / "tests") + violations = validate_repo(tmp_path) + assert any("tests" in v.message for v in violations) + + +def test_invalid_core_dir_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + # Add a bogus dir under the namespace package; it doesn't match any core_path. + (tmp_path / "demo" / "mellea_contribs" / "demo" / "stdlib" / "totally_bogus").mkdir() + violations = validate_repo(tmp_path) + assert any("totally_bogus" in v.message for v in violations) + + +def test_unexpected_top_level_dir_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + # Add a bogus dir at the subpackage root (not a meta dir, not a known dir). + (tmp_path / "demo" / "totally_bogus").mkdir() + violations = validate_repo(tmp_path) + assert any("totally_bogus" in v.message for v in violations) + + +def test_missing_packages_field_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + # Replace pyproject.toml with one missing the wheel packages field. + write( + tmp_path / "demo" / "pyproject.toml", + """ + [project] + name = "mellea-contribs-demo" + version = "0.6.0" + dependencies = [] + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """, + ) + violations = validate_repo(tmp_path) + assert any("packages" in v.message for v in violations) + + +def test_missing_namespace_init_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + # Remove the namespace package's __init__.py. + (tmp_path / "demo" / "mellea_contribs" / "demo" / "__init__.py").unlink() + violations = validate_repo(tmp_path) + assert any("__init__.py" in v.message for v in violations) + + +def test_bare_mellea_dependency_fails(tmp_path: Path) -> None: + """Bare `mellea` (no version constraint) is rejected — receiver needs an explicit floor.""" + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + pp = tmp_path / "demo" / "pyproject.toml" + pp.write_text(pp.read_text().replace('"mellea>=0.6.0"', '"mellea"')) + violations = validate_repo(tmp_path) + assert any("must declare a version constraint" in v.message for v in violations) + + +def test_mellea_git_ref_fails(tmp_path: Path) -> None: + """`mellea @ git+...` is rejected — replace with explicit version before merging.""" + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + pp = tmp_path / "demo" / "pyproject.toml" + pp.write_text( + pp.read_text().replace( + '"mellea>=0.6.0"', '"mellea @ git+https://github.com/example/mellea.git"' + ) + ) + violations = validate_repo(tmp_path) + assert any("must not be a git/url ref" in v.message for v in violations) + + +def test_mellea_extras_with_explicit_constraint_passes(tmp_path: Path) -> None: + """`mellea[litellm]==0.5.0` (extras + explicit constraint) is accepted.""" + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + pp = tmp_path / "demo" / "pyproject.toml" + pp.write_text(pp.read_text().replace('"mellea>=0.6.0"', '"mellea[litellm]==0.5.0"')) + violations = validate_repo(tmp_path) + assert violations == [] + + +def test_grandfathered_legacy_dir_skipped(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path, paths=["mellea_contribs/legacy_thing"]) + # Set up a legacy dir that would otherwise fail the gate. + legacy = tmp_path / "mellea_contribs" / "legacy_thing" + legacy.mkdir(parents=True) + (legacy / "totally_bogus").mkdir() + # Also set up a valid new-flat subpackage so the test exercises both branches. + setup_minimal_subpackage(tmp_path, "demo") + violations = validate_repo(tmp_path) + assert violations == [] + + +def test_duplicate_distribution_name_fails(tmp_path: Path) -> None: + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + setup_minimal_subpackage(tmp_path, "demo2") + # Force demo2's pyproject to declare the same distribution name as demo. + pp = tmp_path / "demo2" / "pyproject.toml" + pp.write_text(pp.read_text().replace('"mellea-contribs-demo2"', '"mellea-contribs-demo"')) + violations = validate_repo(tmp_path) + assert any("duplicate" in v.message.lower() for v in violations) + + +def test_violation_str_includes_name_and_message() -> None: + v = Violation("demo", "missing required file: OWNERS") + assert "demo" in str(v) + assert "missing required file: OWNERS" in str(v) + + +def test_real_repo_passes() -> None: + """The current contribs repo must pass the gate (legacy paths grandfathered).""" + repo = Path(__file__).resolve().parents[2] + # Sanity: we are pointing at the contribs repo root. + assert (repo / "cookiecutter" / "core_paths.json").exists() + violations = validate_repo(repo) + assert violations == [], "unexpected violations:\n" + "\n".join(str(v) for v in violations) diff --git a/.github/scripts/validate_package_contract.py b/.github/scripts/validate_package_contract.py new file mode 100644 index 0000000..1ff32ba --- /dev/null +++ b/.github/scripts/validate_package_contract.py @@ -0,0 +1,322 @@ +"""Validate the structural contract for mellea-contribs subpackages. + +Used by ``.github/workflows/validate-structure.yml`` on every PR. Walks every +subpackage at the repo root and asserts: + +- Required files: ``pyproject.toml``, ``OWNERS``, ``README.md`` +- Required dirs: ``tests/``, ``examples/``, ``mellea_contribs//`` +- ``OWNERS`` is non-empty +- ``pyproject.toml`` has ``[tool.hatch.build.targets.wheel] packages = + ["mellea_contribs"]`` so the wheel ships the namespace package +- The subpackage's namespace dir ``mellea_contribs//`` exists and + contains an ``__init__.py`` +- Every top-level directory under ``mellea_contribs//`` is one of the + known core mirror dirs (stdlib, backends, formatters, helpers, core) +- Every dir under a mirror dir corresponds to a known dotted path in + ``cookiecutter/core_paths.json`` +- No two subpackages declare the same distribution name + +Legacy old-flat subpackages listed in ``.github/scripts/grandfather_legacy.json`` +are skipped. The list shrinks as each legacy subpackage is migrated. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +REQUIRED_FILES = ["pyproject.toml", "OWNERS", "README.md"] +REQUIRED_DIRS = ["tests", "examples"] +KNOWN_MIRROR_DIRS = {"stdlib", "backends", "formatters", "helpers", "core"} +NAMESPACE_PKG = "mellea_contribs" +REQUIRED_HATCH_PACKAGES = [NAMESPACE_PKG] + +META_DIRS = { + "tests", + "examples", + "dist", + "build", + ".venv", + "__pycache__", + ".pytest_cache", + "docs", + NAMESPACE_PKG, +} + + +@dataclass(frozen=True) +class Violation: + """A single contract violation discovered during validation.""" + + subpackage: str + message: str + + def __str__(self) -> str: + return f" - [{self.subpackage}] {self.message}" + + +def _load_core_paths(repo: Path) -> set[str]: + snapshot = repo / "cookiecutter" / "core_paths.json" + if not snapshot.exists(): + raise FileNotFoundError(f"core_paths.json not found at {snapshot}") + with snapshot.open() as f: + return set(json.load(f)["paths"]) + + +def _load_grandfather(repo: Path) -> set[Path]: + gh = repo / ".github" / "scripts" / "grandfather_legacy.json" + if not gh.exists(): + return set() + with gh.open() as f: + return {(repo / p).resolve() for p in json.load(f).get("legacy_paths", [])} + + +def _is_subpackage_dir(d: Path) -> bool: + """A directory at repo root is a subpackage if it has a pyproject.toml.""" + return d.is_dir() and (d / "pyproject.toml").exists() + + +def _bad_mellea_constraint(dep: str) -> str | None: + """Return a human-readable error if dep is a `mellea` line lacking a constraint. + + Acceptable: ``mellea>=X.Y.Z``, ``mellea==X.Y.Z``, ``mellea[extras]>=...``, + ``mellea[extras]==...``. Any other form involving the bare ``mellea`` + package name (no operator, git/url ref, or unsupported operator) is + rejected so the receiver workflow can reason about the line. + + Returns None if dep is not a mellea line or is acceptable. + """ + s = dep.strip() + # Strip optional extras: "mellea[hf]>=0.5.0" -> "mellea>=0.5.0". + if s.startswith("mellea["): + close = s.find("]") + if close == -1: + return None # Malformed; let toml/pep508 parser complain elsewhere. + spec = s[close + 1 :].strip() + head = "mellea[...]" + elif s.startswith("mellea"): + spec = s[len("mellea") :].strip() + head = "mellea" + else: + return None # Not a mellea line. + + # Reject bare names ("mellea" or "mellea[extras]") and git/url refs. + if not spec: + return f"`{head}` dependency must declare a version constraint (e.g. `>=X.Y.Z` or `==X.Y.Z`)" + if spec.startswith("@"): + return f"`{head}` dependency must not be a git/url ref; use `>=X.Y.Z` or `==X.Y.Z`" + if not (spec.startswith(">=") or spec.startswith("==")): + return f"`{head}` dependency must use `>=X.Y.Z` or `==X.Y.Z`" + return None + + +def _check_mirror_paths( + name: str, + namespace_dir: Path, + mirror_dir: Path, + valid_core_paths: set[str], + violations: list[Violation], +) -> None: + """Walk mirror_dir; every dir must match a known dotted core_path. + + Paths are computed relative to the namespace dir + (``mellea_contribs//``) so the resulting dotted form lines up with + entries in ``cookiecutter/core_paths.json``. + """ + for path in mirror_dir.rglob("*"): + if not path.is_dir() or path.name == "__pycache__": + continue + rel = path.relative_to(namespace_dir) + dotted = ".".join(rel.parts) + if dotted not in valid_core_paths: + violations.append( + Violation(name, f"directory {rel} does not match any valid core_path") + ) + + +def _validate_subpackage( + name: str, sub: Path, valid_core_paths: set[str] +) -> list[Violation]: + violations: list[Violation] = [] + + # 1. Required files and dirs at the subpackage root. + for f in REQUIRED_FILES: + if not (sub / f).exists(): + violations.append(Violation(name, f"missing required file: {f}")) + for d in REQUIRED_DIRS: + if not (sub / d).is_dir(): + violations.append(Violation(name, f"missing required directory: {d}/")) + + # 2. OWNERS not empty. + owners = sub / "OWNERS" + if owners.exists() and not owners.read_text().strip(): + violations.append(Violation(name, "OWNERS file is empty")) + + # 3. pyproject.toml [tool.hatch.build.targets.wheel] packages = ["mellea_contribs"]. + # 3b. Every `mellea` dep line must use an explicit `>=` or `==` constraint. + pp = sub / "pyproject.toml" + if pp.exists(): + try: + data = tomllib.loads(pp.read_text()) + wheel = ( + data.get("tool", {}) + .get("hatch", {}) + .get("build", {}) + .get("targets", {}) + .get("wheel", {}) + ) + packages = wheel.get("packages") + if packages != REQUIRED_HATCH_PACKAGES: + violations.append( + Violation( + name, + f"[tool.hatch.build.targets.wheel].packages must be {REQUIRED_HATCH_PACKAGES} (got {packages!r})", + ) + ) + for dep in data.get("project", {}).get("dependencies", []): + msg = _bad_mellea_constraint(dep) + if msg: + violations.append(Violation(name, f"{msg} (got {dep!r})")) + except tomllib.TOMLDecodeError as exc: + violations.append(Violation(name, f"pyproject.toml is invalid TOML: {exc}")) + + # 4. Subpackage root must contain mellea_contribs// with __init__.py. + namespace_dir = sub / NAMESPACE_PKG + inner_dir = namespace_dir / name.replace("-", "_") + if not namespace_dir.is_dir(): + violations.append( + Violation(name, f"missing required directory: {NAMESPACE_PKG}/") + ) + elif not inner_dir.is_dir(): + violations.append( + Violation( + name, f"missing required namespace directory: {NAMESPACE_PKG}/{inner_dir.name}/" + ) + ) + elif not (inner_dir / "__init__.py").exists(): + violations.append( + Violation( + name, + f"missing __init__.py at {NAMESPACE_PKG}/{inner_dir.name}/ (regular-package boundary)", + ) + ) + else: + # Walk the namespace dir's children: each must be a known mirror dir. + for child in inner_dir.iterdir(): + if not child.is_dir(): + continue + if child.name == "__pycache__": + continue + if child.name not in KNOWN_MIRROR_DIRS: + violations.append( + Violation( + name, + f"unexpected directory under {NAMESPACE_PKG}/{inner_dir.name}/: {child.name}/ (not a core mirror)", + ) + ) + else: + _check_mirror_paths( + name, inner_dir, child, valid_core_paths, violations + ) + + # 5. No unexpected top-level dirs at the subpackage root. + for child in sub.iterdir(): + if not child.is_dir(): + continue + if child.name in META_DIRS or child.name.startswith("."): + continue + if child.name in REQUIRED_DIRS: + continue + violations.append( + Violation( + name, + f"unexpected top-level directory: {child.name}/", + ) + ) + + return violations + + +def validate_repo(repo: Path) -> list[Violation]: + """Validate every new-flat subpackage at the repo root. + + Args: + repo: Path to the contribs repo root. + + Returns: + List of violations; empty if the repo conforms. + """ + valid_core_paths = _load_core_paths(repo) + grandfathered = _load_grandfather(repo) + violations: list[Violation] = [] + + distribution_names: dict[str, str] = {} + + for child in sorted(repo.iterdir()): + if not _is_subpackage_dir(child): + continue + # Skip cookiecutter (it has a templated subdir but is not a subpackage). + if child.name == "cookiecutter": + continue + # Skip grandfathered legacy paths (or any subpackage living under one). + resolved = child.resolve() + if resolved in grandfathered or any(p in grandfathered for p in resolved.parents): + continue + + name = child.name + violations.extend(_validate_subpackage(name, child, valid_core_paths)) + + # Distribution-name uniqueness. + try: + data = tomllib.loads((child / "pyproject.toml").read_text()) + dist = data.get("project", {}).get("name") + if dist: + if dist in distribution_names: + violations.append( + Violation( + name, + f"duplicate distribution name '{dist}' (also in {distribution_names[dist]})", + ) + ) + else: + distribution_names[dist] = name + except (tomllib.TOMLDecodeError, FileNotFoundError): + pass # Already reported above. + + # Also walk legacy subpackages nested under grandfathered roots so we still + # check distribution-name uniqueness across the migration window? Not yet — + # legacy packages are explicitly excluded until they migrate. + + return violations + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate the structural contract for mellea-contribs subpackages." + ) + parser.add_argument( + "repo_root", + type=Path, + nargs="?", + default=Path.cwd(), + help="Path to the contribs repo root (defaults to CWD).", + ) + args = parser.parse_args() + + violations = validate_repo(args.repo_root) + if not violations: + print("validate-structure: PASS - all subpackages conform.") + return 0 + + print(f"validate-structure: FAIL ({len(violations)} violation(s)):") + for v in violations: + print(v) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/validate-structure.yml b/.github/workflows/validate-structure.yml new file mode 100644 index 0000000..d47049c --- /dev/null +++ b/.github/workflows/validate-structure.yml @@ -0,0 +1,15 @@ +name: validate-structure + +on: + pull_request: + push: + branches: [main] + +jobs: + validate-structure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Run validate-structure + run: uv run python .github/scripts/validate_package_contract.py From b747bf9467cd7a32d927110e9399e08d2f5fa552 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Tue, 2 Jun 2026 12:01:58 -0700 Subject: [PATCH 08/16] feat: add ci.yml and package-ci.yml with PR-scoped matrix Replaces the old ci.yml with a scoped-discovery + matrix-dispatch pair. The new ci.yml walks the diff against base, classifies the PR (docs-only, cookiecutter-only, root-pyproject, workflow, subpackage, union, or stacked-PR), and dispatches package-ci.yml per touched subpackage. Stacked PRs (base_ref != main) promote to all packages so downstream branches see the cumulative effect of their parent. package-ci.yml is a workflow_call template that reads each subpackage's [tool.mellea-contribs.ci] table for skip_ollama, timeout_minutes, and python_versions, replacing the hardcoded path-string special-cases the previous ci.yml had to carry. The previous ci.yml is preserved as legacy-ci.yml, scoped to mellea_contribs/** so it stops firing once those paths are removed. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .github/scripts/discover_subpackages.py | 198 +++++++++++++++++++ .github/scripts/test_discover_subpackages.py | 95 +++++++++ .github/workflows/ci.yml | 115 +++++------ .github/workflows/legacy-ci.yml | 81 ++++++++ .github/workflows/package-ci.yml | 68 +++++++ 5 files changed, 496 insertions(+), 61 deletions(-) create mode 100644 .github/scripts/discover_subpackages.py create mode 100644 .github/scripts/test_discover_subpackages.py create mode 100644 .github/workflows/legacy-ci.yml create mode 100644 .github/workflows/package-ci.yml diff --git a/.github/scripts/discover_subpackages.py b/.github/scripts/discover_subpackages.py new file mode 100644 index 0000000..e32c910 --- /dev/null +++ b/.github/scripts/discover_subpackages.py @@ -0,0 +1,198 @@ +"""Discover which subpackages a PR touches; output a CI matrix. + +Implements the PR-scoping rules and the stacked-PR override (when +``base_ref`` is not ``main``, the matrix is promoted to all packages so +the stacked branch sees the full effect of its parent's changes). + +Used by ``.github/workflows/ci.yml``'s ``discover`` job. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path + +DOCS_PREFIXES = ("docs/", "RFCs/") +DOCS_FILE_SUFFIX = (".md",) +COOKIECUTTER_PREFIX = "cookiecutter/" +ROOT_TRIGGER_FILES = {"pyproject.toml", "uv.lock"} +WORKFLOWS_PREFIX = ".github/workflows/" +SHARED_SCRIPTS_PREFIX = ".github/scripts/" +LEGACY_PREFIX = "mellea_contribs/" + + +@dataclass +class DiscoverInputs: + """Inputs to :func:`discover`.""" + + changed_files: list[str] + base_ref: str + repo_root: Path + + +@dataclass +class DiscoverResult: + """Result of :func:`discover`.""" + + matrix: list[str] = field(default_factory=list) + run_template_smoke: bool = False + reason: str = "" + + +def _is_docs_only(files: list[str]) -> bool: + return ( + all(f.startswith(DOCS_PREFIXES) or f.endswith(DOCS_FILE_SUFFIX) for f in files) + and len(files) > 0 + ) + + +def _has_cookiecutter_change(files: list[str]) -> bool: + return any(f.startswith(COOKIECUTTER_PREFIX) for f in files) + + +def _has_root_trigger(files: list[str]) -> bool: + return any( + f in ROOT_TRIGGER_FILES + or f.startswith(WORKFLOWS_PREFIX) + or f.startswith(SHARED_SCRIPTS_PREFIX) + for f in files + ) + + +def _changed_subpackages(files: list[str], all_subpackages: list[str]) -> list[str]: + """Among the new-flat subpackages, which ones did this PR touch?""" + touched: set[str] = set() + sub_set = set(all_subpackages) + for f in files: + first_segment = f.split("/", 1)[0] if "/" in f else f + if first_segment in sub_set: + touched.add(first_segment) + return sorted(touched) + + +def discover( + inputs: DiscoverInputs, all_subpackages: list[str] | None = None +) -> DiscoverResult: + """Compute which subpackages need CI for the given changed-files set.""" + if all_subpackages is None: + all_subpackages = [] + files = inputs.changed_files + + # Stacked-PR override: when base_ref isn't main, run all packages so the + # stacked branch sees the full effect of its parent's changes. + if inputs.base_ref and inputs.base_ref != "main": + return DiscoverResult( + matrix=list(all_subpackages), + run_template_smoke=False, + reason="stacked-PR (base_ref != main -> all-packages)", + ) + + if not files: + return DiscoverResult(matrix=[], run_template_smoke=False, reason="no-changes") + + # Docs-only short-circuit. + if _is_docs_only(files): + return DiscoverResult(matrix=[], run_template_smoke=False, reason="docs-only") + + template_smoke = _has_cookiecutter_change(files) + + # Root-level / workflow / shared-script trigger: run everything. + if _has_root_trigger(files): + return DiscoverResult( + matrix=list(all_subpackages), + run_template_smoke=template_smoke, + reason="root-or-workflow-change (all-packages)", + ) + + # Subpackage-scoped changes. + touched = _changed_subpackages(files, all_subpackages) + + # If only legacy changes hit, the new ci.yml runs nothing (legacy-ci.yml + # picks up these PRs). + legacy_only = ( + all( + f.startswith(LEGACY_PREFIX) or f.startswith(COOKIECUTTER_PREFIX) + for f in files + ) + and not touched + ) + if legacy_only and not template_smoke: + return DiscoverResult( + matrix=[], run_template_smoke=False, reason="legacy-only-no-new-ci" + ) + + if touched: + reason = "subpackage-scoped" + elif template_smoke: + reason = "cookiecutter-only" + else: + reason = "no-trigger" + + return DiscoverResult( + matrix=touched, run_template_smoke=template_smoke, reason=reason + ) + + +def _load_subpackages_from_repo(repo_root: Path) -> list[str]: + """Find new-flat subpackages: top-level dirs containing a pyproject.toml.""" + subs: list[str] = [] + exclude = {"cookiecutter", "mellea_contribs", ".github", ".venv", "RFCs", "docs"} + for child in sorted(repo_root.iterdir()): + if not child.is_dir() or child.name in exclude or child.name.startswith("."): + continue + if (child / "pyproject.toml").exists(): + subs.append(child.name) + return subs + + +def main() -> int: + """CLI entrypoint.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--changed-files", + required=True, + help="Newline-separated list of changed files", + ) + parser.add_argument("--base-ref", required=True) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + args = parser.parse_args() + + files = [f.strip() for f in args.changed_files.splitlines() if f.strip()] + inputs = DiscoverInputs( + changed_files=files, + base_ref=args.base_ref, + repo_root=args.repo_root, + ) + all_subs = _load_subpackages_from_repo(args.repo_root) + result = discover(inputs, all_subpackages=all_subs) + + print( + f"Discover: matrix={result.matrix}, " + f"template_smoke={result.run_template_smoke}, reason={result.reason}", + file=sys.stderr, + ) + + output = { + "matrix": result.matrix, + "run_template_smoke": result.run_template_smoke, + "reason": result.reason, + } + gh_out = os.environ.get("GITHUB_OUTPUT") + if gh_out: + with open(gh_out, "a") as f: + f.write(f"matrix={json.dumps(result.matrix)}\n") + f.write( + "run_template_smoke=" + f"{'true' if result.run_template_smoke else 'false'}\n" + ) + f.write(f"reason={result.reason}\n") + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_discover_subpackages.py b/.github/scripts/test_discover_subpackages.py new file mode 100644 index 0000000..322156d --- /dev/null +++ b/.github/scripts/test_discover_subpackages.py @@ -0,0 +1,95 @@ +"""Tests for discover_subpackages.py.""" + +from __future__ import annotations + +from pathlib import Path + +from discover_subpackages import DiscoverInputs, discover + + +def test_docs_only_pr_runs_nothing() -> None: + inputs = DiscoverInputs( + changed_files=["README.md", "RFCs/foo.md", "docs/bar.md"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs) + assert result.matrix == [] + assert result.run_template_smoke is False + assert result.reason == "docs-only" + + +def test_cookiecutter_only_runs_template_smoke() -> None: + inputs = DiscoverInputs( + changed_files=["cookiecutter/cookiecutter.json"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs) + assert result.matrix == [] + assert result.run_template_smoke is True + + +def test_root_pyproject_runs_all_packages() -> None: + inputs = DiscoverInputs( + changed_files=["pyproject.toml"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert sorted(result.matrix) == ["dspy", "tools"] + + +def test_workflow_change_runs_all_packages() -> None: + inputs = DiscoverInputs( + changed_files=[".github/workflows/ci.yml"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert sorted(result.matrix) == ["dspy", "tools"] + + +def test_subpackage_change_runs_only_that_subpackage() -> None: + inputs = DiscoverInputs( + changed_files=["dspy/stdlib/frameworks/dspy/integration.py"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert result.matrix == ["dspy"] + + +def test_union_of_categories(tmp_path: Path) -> None: + inputs = DiscoverInputs( + changed_files=["cookiecutter/foo.py", "dspy/stdlib/frameworks/dspy/x.py"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert result.matrix == ["dspy"] + assert result.run_template_smoke is True + + +def test_stacked_pr_promotes_to_all_packages() -> None: + """When base_ref != 'main', promote to all-packages.""" + inputs = DiscoverInputs( + changed_files=["dspy/stdlib/frameworks/dspy/x.py"], + base_ref="feat/restructure-validate-structure", # stacked PR + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert sorted(result.matrix) == ["dspy", "tools"] + assert "stacked" in result.reason.lower() + + +def test_legacy_subpackage_change_does_not_trigger_new_ci() -> None: + """Changes under mellea_contribs// are routed to legacy-ci.yml.""" + inputs = DiscoverInputs( + changed_files=["mellea_contribs/dspy_backend/src/foo.py"], + base_ref="main", + repo_root=Path("/dummy"), + ) + result = discover(inputs, all_subpackages=["dspy", "tools"]) + assert result.matrix == [] + assert result.reason == "legacy-only-no-new-ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65dc123..ddd13bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,84 +1,77 @@ -name: CI - All Subpackages +name: CI (new-flat subpackages) on: pull_request: - # No paths filter — must always run so the required check always reports. merge_group: push: - branches: - - main - paths: - - 'mellea_contribs/**' - - '.github/workflows/ci.yml' - - '.github/workflows/quality-generic.yml' - - '.github/workflows/build-package.yml' - - '.github/workflows/release-package.yml' + branches: [main] jobs: - discover-subpackages: + discover: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix: ${{ steps.discover.outputs.matrix }} + run_template_smoke: ${{ steps.discover.outputs.run_template_smoke }} + reason: ${{ steps.discover.outputs.reason }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - uses: astral-sh/setup-uv@v5 - name: Discover changed subpackages - id: set-matrix + id: discover run: | - # Determine the base for comparison - if [ "${{ github.event_name }}" == "pull_request" ]; then - BASE_REF="${{ github.event.pull_request.base.sha }}" - elif [ "${{ github.event_name }}" == "merge_group" ]; then - BASE_REF="${{ github.event.merge_group.base_sha }}" + # Resolve a base SHA we can diff against. Each event type carries + # the right base under a different payload key. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + BASE_REF="${{ github.base_ref }}" + elif [ "${{ github.event_name }}" = "merge_group" ]; then + BASE_SHA="${{ github.event.merge_group.base_sha }}" + BASE_REF="main" else - # Use github.event.before for push events (more reliable than HEAD~1) - BASE_REF="${{ github.event.before }}" + BASE_SHA="${{ github.event.before }}" + BASE_REF="main" fi - - # Fallback to HEAD~1 if the event base is not available or is all zeros (initial commit) - if [ -z "$BASE_REF" ] || [ "$BASE_REF" == "0000000000000000000000000000000000000000" ]; then - BASE_REF="HEAD~1" - fi - - # Validate that BASE_REF exists before proceeding - if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then - echo "Warning: BASE_REF '$BASE_REF' does not exist or is invalid" - echo "Falling back to testing all subpackages for safety" - CHANGED_SUBPACKAGES=$(find mellea_contribs -maxdepth 1 -mindepth 1 -type d ! -name '__pycache__' ! -name '.pytest_cache' | sort) - else - # Get list of changed files (excluding deleted files) using status instead - CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTU $BASE_REF HEAD | grep "^mellea_contribs/" || true) - - # Extract unique subpackage directories that changed - CHANGED_SUBPACKAGES=$(echo "$CHANGED_FILES" | sed 's|^\(mellea_contribs/[^/]*\).*|\1|' | sort -u || true) - - if [ -z "$CHANGED_SUBPACKAGES" ]; then - # If no subpackages changed, check if workflow files changed - WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(ci\.yml|quality-generic\.yml)" || true) - if [ -n "$WORKFLOW_CHANGED" ]; then - # Test all subpackages if workflow changed - CHANGED_SUBPACKAGES=$(find mellea_contribs -maxdepth 1 -mindepth 1 -type d ! -name '__pycache__' ! -name '.pytest_cache' | sort) - fi - fi + # Fallback to HEAD~1 if the event base is not available or zeroed. + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + BASE_SHA="HEAD~1" fi + CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD || git ls-files) + uv run python .github/scripts/discover_subpackages.py \ + --changed-files "${CHANGED}" \ + --base-ref "${BASE_REF}" \ + --repo-root "${{ github.workspace }}" - # Map subpackages to their configurations - MATRIX=$(echo "$CHANGED_SUBPACKAGES" | jq -R -s -c 'split("\n")[:-1] | unique | map(select(length > 0)) | map({ - subpackage: ., - skip_ollama: (if test("mellea-integration-core") or test("reqlib_package") then true else false end), - timeout_minutes: (if test("crewai_backend") then 90 else 30 end) - })') - - echo "matrix={\"include\":$(echo "$MATRIX" | jq -c '.[]' | jq -s -c '.')}" >> $GITHUB_OUTPUT + template-smoke: + needs: discover + if: needs.discover.outputs.run_template_smoke == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Generate demo subpackage + run: | + mkdir -p /tmp/template-smoke + cd /tmp/template-smoke + uv run --with cookiecutter cookiecutter \ + "${{ github.workspace }}/cookiecutter" \ + --no-input \ + name=tplsmoke \ + core_path=stdlib.sampling_algos + - name: Test demo subpackage + run: | + cd /tmp/template-smoke/tplsmoke + uv sync --all-extras + uv run pytest -v -m "not qualitative and not e2e" test: - needs: discover-subpackages + needs: discover + if: fromJson(needs.discover.outputs.matrix)[0] != null strategy: - matrix: ${{ fromJson(needs.discover-subpackages.outputs.matrix) }} - max-parallel: 6 - uses: ./.github/workflows/quality-generic.yml + fail-fast: false + matrix: + package: ${{ fromJson(needs.discover.outputs.matrix) }} + uses: ./.github/workflows/package-ci.yml with: - subpackage: ${{ matrix.subpackage }} - skip_ollama: ${{ matrix.skip_ollama }} - timeout_minutes: ${{ matrix.timeout_minutes }} + package: ${{ matrix.package }} diff --git a/.github/workflows/legacy-ci.yml b/.github/workflows/legacy-ci.yml new file mode 100644 index 0000000..91cd5de --- /dev/null +++ b/.github/workflows/legacy-ci.yml @@ -0,0 +1,81 @@ +name: CI - Legacy Subpackages + +on: + pull_request: + paths: + - 'mellea_contribs/**' + - '.github/workflows/legacy-ci.yml' + - '.github/workflows/quality-generic.yml' + push: + branches: + - main + paths: + - 'mellea_contribs/**' + - '.github/workflows/legacy-ci.yml' + - '.github/workflows/quality-generic.yml' + +jobs: + discover-subpackages: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Discover changed subpackages + id: set-matrix + run: | + # Determine the base for comparison + if [ "${{ github.event_name }}" == "pull_request" ]; then + BASE_REF="${{ github.event.pull_request.base.sha }}" + else + # Use github.event.before for push events (more reliable than HEAD~1) + BASE_REF="${{ github.event.before }}" + # Fallback to HEAD~1 if github.event.before is not available or is all zeros (initial commit) + if [ -z "$BASE_REF" ] || [ "$BASE_REF" == "0000000000000000000000000000000000000000" ]; then + BASE_REF="HEAD~1" + fi + fi + + # Validate that BASE_REF exists before proceeding + if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + echo "Warning: BASE_REF '$BASE_REF' does not exist or is invalid" + echo "Falling back to testing all subpackages for safety" + CHANGED_SUBPACKAGES=$(find mellea_contribs -maxdepth 1 -mindepth 1 -type d ! -name '__pycache__' ! -name '.pytest_cache' | sort) + else + # Get list of changed files (excluding deleted files) using status instead + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTU $BASE_REF HEAD | grep "^mellea_contribs/" || true) + + # Extract unique subpackage directories that changed + CHANGED_SUBPACKAGES=$(echo "$CHANGED_FILES" | sed 's|^\(mellea_contribs/[^/]*\).*|\1|' | sort -u || true) + + if [ -z "$CHANGED_SUBPACKAGES" ]; then + # If no subpackages changed, check if workflow files changed + WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(ci\.yml|quality-generic\.yml)" || true) + if [ -n "$WORKFLOW_CHANGED" ]; then + # Test all subpackages if workflow changed + CHANGED_SUBPACKAGES=$(find mellea_contribs -maxdepth 1 -mindepth 1 -type d ! -name '__pycache__' ! -name '.pytest_cache' | sort) + fi + fi + fi + + # Map subpackages to their configurations + MATRIX=$(echo "$CHANGED_SUBPACKAGES" | jq -R -s -c 'split("\n")[:-1] | unique | map(select(length > 0)) | map({ + subpackage: ., + skip_ollama: (if test("mellea-integration-core") or test("reqlib_package") then true else false end), + timeout_minutes: (if test("crewai_backend") then 90 else 30 end) + })') + + echo "matrix={\"include\":$(echo "$MATRIX" | jq -c '.[]' | jq -s -c '.')}" >> $GITHUB_OUTPUT + + test: + needs: discover-subpackages + strategy: + matrix: ${{ fromJson(needs.discover-subpackages.outputs.matrix) }} + max-parallel: 6 + uses: ./.github/workflows/quality-generic.yml + with: + subpackage: ${{ matrix.subpackage }} + skip_ollama: ${{ matrix.skip_ollama }} + timeout_minutes: ${{ matrix.timeout_minutes }} diff --git a/.github/workflows/package-ci.yml b/.github/workflows/package-ci.yml new file mode 100644 index 0000000..5bfa59e --- /dev/null +++ b/.github/workflows/package-ci.yml @@ -0,0 +1,68 @@ +name: Package CI (per-subpackage) + +on: + workflow_call: + inputs: + package: + required: true + type: string + +jobs: + package-ci: + runs-on: ubuntu-latest + timeout-minutes: 30 # Default; subpackages override via [tool.mellea-contribs.ci] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + + - name: Read [tool.mellea-contribs.ci] flags + id: ci-flags + working-directory: ${{ inputs.package }} + run: | + uv run --with tomli_w python -c " + import tomllib, json, os + data = tomllib.loads(open('pyproject.toml').read()) + ci = data.get('tool', {}).get('mellea-contribs', {}).get('ci', {}) + out = { + 'skip_ollama': bool(ci.get('skip_ollama', False)), + 'timeout_minutes': int(ci.get('timeout_minutes', 30)), + 'python_versions': ci.get('python_versions', ['3.12']), + } + for k, v in out.items(): + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + if k == 'python_versions': + f.write(f'{k}={json.dumps(v)}\n') + else: + f.write(f'{k}={str(v).lower() if isinstance(v, bool) else v}\n') + " + + # NOTE: Ollama installation lives outside the matrix (single-version test for v1). + - name: Install Ollama + if: steps.ci-flags.outputs.skip_ollama != 'true' + run: curl -fsSL https://ollama.com/install.sh | sh + + - name: Start Ollama + if: steps.ci-flags.outputs.skip_ollama != 'true' + run: nohup ollama serve & + + - name: Pull granite4:micro + if: steps.ci-flags.outputs.skip_ollama != 'true' + run: ollama pull granite4:micro + + - name: Sync dependencies + working-directory: ${{ inputs.package }} + run: uv sync --all-extras + + - name: Lint + working-directory: ${{ inputs.package }} + run: | + uv run ruff format --check . + uv run ruff check . + + - name: Type-check + working-directory: ${{ inputs.package }} + run: uv run mypy . + + - name: Test + working-directory: ${{ inputs.package }} + run: uv run pytest -m "not qualitative and not e2e" From f11697c5b3c7bd659918df83b2a429aea3ebca63 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Wed, 3 Jun 2026 09:50:20 -0700 Subject: [PATCH 09/16] feat: add smoke-against-mellea-main and auto-issue bot Adds a daily smoke workflow that runs each opted-in subpackage's tests against mellea@main, plus an auto-issue bot that opens a tracking issue after two consecutive reds, comments on recovery, and applies a 21-day archival timeline driven by the contribs-broken label. The smoke matrix in .github/smoke-matrix.json starts empty so the smoke job is skipped until the first subpackage opts in. The bot ships in two modes: an in-memory fake used by the unit tests (14 tests covering the failure threshold, recovery, and the day-7 / 14 / 21 milestones) and a PyGithub-backed real mode that persists state on a bot-managed branch. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .github/scripts/auto_issue_bot.py | 564 ++++++++++++++++++ .github/scripts/test_auto_issue_bot.py | 241 ++++++++ .github/smoke-matrix.json | 4 + .github/workflows/auto-issue-archival.yml | 28 + .../workflows/smoke-against-mellea-main.yml | 78 +++ pyproject.toml | 1 + uv.lock | 205 +++++++ 7 files changed, 1121 insertions(+) create mode 100644 .github/scripts/auto_issue_bot.py create mode 100644 .github/scripts/test_auto_issue_bot.py create mode 100644 .github/smoke-matrix.json create mode 100644 .github/workflows/auto-issue-archival.yml create mode 100644 .github/workflows/smoke-against-mellea-main.yml diff --git a/.github/scripts/auto_issue_bot.py b/.github/scripts/auto_issue_bot.py new file mode 100644 index 0000000..ae7a445 --- /dev/null +++ b/.github/scripts/auto_issue_bot.py @@ -0,0 +1,564 @@ +"""Auto-issue bot for the daily smoke job against ``mellea@main``. + +Lifecycle (per the contribs maintainer playbook): + +* The smoke workflow records a failure for a subpackage by invoking + ``auto_issue_bot.py --action record-failure``. The first red is silent; + on the second consecutive red the bot opens a tracking issue labelled + ``contribs-broken`` and assigns the package's OWNERS. Subsequent reds + add a comment to the existing issue. +* The smoke workflow records a recovery by invoking + ``--action record-recovery``. The bot adds a "smoke green again on + , fixed in " comment, removes the ``contribs-broken`` label, + and resets the consecutive-failure counter and the label-applied-days + counter. The issue itself stays open until a human closes it. +* A separate daily workflow invokes ``--action apply-archival``. The bot + walks each tracked package: if the issue still carries the + ``contribs-broken`` label it bumps ``label_applied_days`` and posts the + appropriate milestone comment (day 7 reminder, day 14 release-notes + mention, day 21 archival label). + +The bot exposes two execution modes: + +* **Fake mode** (the default in tests, and the fallback when no + ``GITHUB_TOKEN`` is set in the environment) keeps state in memory via + :class:`FakeGitHub`. The unit tests drive the bot end to end through + this fake. +* **Real mode** uses :class:`RealGitHub`, a thin wrapper over PyGithub. + Persistent state is stored as JSON on a bot-managed branch in the + repository (see :func:`_load_persistent_state` / + :func:`_save_persistent_state`). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol + + +# Threshold for opening an issue: at least this many consecutive failed runs. +FAILURE_THRESHOLD = 2 + +# Milestones (in days of contribs-broken label persistence). +DAY_7_REMINDER = 7 +DAY_14_RELEASE_NOTES_MENTION = 14 +DAY_21_ARCHIVAL = 21 + +CONTRIBS_BROKEN_LABEL = "contribs-broken" +ARCHIVED_LABEL = "archived" + +# Path inside the bot-managed branch where persistent state lives. +STATE_FILE_PATH = ".github/bot-state/auto_issue_bot.json" + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + + +@dataclass +class BotState: + """Persistent state tracked across bot invocations. + + Attributes: + consecutive_failures: Per-package count of consecutive failed + smoke runs since the last green. + open_issue_numbers: Per-package issue number for the current + tracking issue (``None`` if the bot has never opened one). + label_applied_days: Per-package days the ``contribs-broken`` + label has continuously been present on the tracking issue. + Reset to 0 on recovery and bumped by ``apply-archival``. + last_failure_run_url: Per-package URL of the most recent failed + workflow run (used in issue bodies and follow-up comments). + milestones_posted: Per-package list of milestone names already + commented on the tracking issue (e.g. ``["day-7"]``); used + to keep ``apply-archival`` idempotent. + """ + + consecutive_failures: dict[str, int] = field(default_factory=dict) + open_issue_numbers: dict[str, int] = field(default_factory=dict) + label_applied_days: dict[str, int] = field(default_factory=dict) + last_failure_run_url: dict[str, str] = field(default_factory=dict) + milestones_posted: dict[str, list[str]] = field(default_factory=dict) + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2, sort_keys=True) + + @classmethod + def from_json(cls, text: str) -> "BotState": + data = json.loads(text) + return cls( + consecutive_failures=dict(data.get("consecutive_failures", {})), + open_issue_numbers=dict(data.get("open_issue_numbers", {})), + label_applied_days=dict(data.get("label_applied_days", {})), + last_failure_run_url=dict(data.get("last_failure_run_url", {})), + milestones_posted={ + k: list(v) for k, v in data.get("milestones_posted", {}).items() + }, + ) + + +# --------------------------------------------------------------------------- +# GitHub client interface +# --------------------------------------------------------------------------- + + +class GitHubClient(Protocol): + """Narrow interface the bot needs from GitHub.""" + + def open_issue( + self, + *, + title: str, + body: str, + labels: list[str], + assignees: list[str], + ) -> int: ... + + def add_comment(self, issue_number: int, body: str) -> None: ... + + def add_label(self, issue_number: int, label: str) -> None: ... + + def remove_label(self, issue_number: int, label: str) -> None: ... + + def get_issue_labels(self, issue_number: int) -> list[str]: ... + + +@dataclass +class FakeGitHub: + """In-memory ``GitHubClient`` used by tests and dry runs.""" + + issues_opened: list[dict[str, Any]] = field(default_factory=list) + comments_added: list[str] = field(default_factory=list) + next_issue_number: int = 1 + + def open_issue( + self, + *, + title: str, + body: str, + labels: list[str], + assignees: list[str], + ) -> int: + n = self.next_issue_number + self.next_issue_number += 1 + self.issues_opened.append( + { + "number": n, + "title": title, + "body": body, + "labels": list(labels), + "assignees": list(assignees), + "state": "open", + } + ) + return n + + def add_comment(self, issue_number: int, body: str) -> None: + self.comments_added.append(body) + for issue in self.issues_opened: + if issue["number"] == issue_number: + issue.setdefault("comments", []).append(body) + + def add_label(self, issue_number: int, label: str) -> None: + for issue in self.issues_opened: + if issue["number"] == issue_number and label not in issue["labels"]: + issue["labels"].append(label) + + def remove_label(self, issue_number: int, label: str) -> None: + for issue in self.issues_opened: + if issue["number"] == issue_number and label in issue["labels"]: + issue["labels"].remove(label) + + def get_issue_labels(self, issue_number: int) -> list[str]: + for issue in self.issues_opened: + if issue["number"] == issue_number: + return list(issue["labels"]) + return [] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _today_iso() -> str: + return datetime.now(timezone.utc).date().isoformat() + + +def _load_owners(package_dir: Path) -> list[str]: + """Read the OWNERS file for a subpackage; return ``@handle`` lines. + + A missing or empty OWNERS file results in an empty list (the issue + is opened unassigned). + """ + owners_file = package_dir / "OWNERS" + if not owners_file.exists(): + return [] + handles: list[str] = [] + for raw in owners_file.read_text().splitlines(): + line = raw.strip() + if line.startswith("@"): + handles.append(line) + return handles + + +def _issue_title(package: str) -> str: + return f"[{CONTRIBS_BROKEN_LABEL}] {package}: smoke red since {_today_iso()}" + + +def _issue_body(package: str, run_url: str, owners: list[str]) -> str: + owners_line = " ".join(owners) if owners else "(no OWNERS file found)" + return ( + f"The daily smoke job has failed against `mellea@main` for the\n" + f"`{package}` subpackage on {FAILURE_THRESHOLD} consecutive runs.\n\n" + f"Latest failing run: {run_url}\n\n" + f"OWNERS: {owners_line}\n\n" + f"This issue is tracked by the auto-issue bot. While the\n" + f"`{CONTRIBS_BROKEN_LABEL}` label is present the archival timeline\n" + f"runs: a reminder is posted on day {DAY_7_REMINDER}, the\n" + f"subpackage is mentioned in the next release notes on\n" + f"day {DAY_14_RELEASE_NOTES_MENTION}, and the\n" + f"`{ARCHIVED_LABEL}` label is applied on day {DAY_21_ARCHIVAL}.\n" + f"Removing the `{CONTRIBS_BROKEN_LABEL}` label resets that clock.\n" + ) + + +# --------------------------------------------------------------------------- +# Core actions +# --------------------------------------------------------------------------- + + +def record_failure( + gh: GitHubClient, + state: BotState, + *, + package: str, + run_url: str, +) -> None: + """Record a failed smoke run for ``package``. + + Below the failure threshold this only bumps the in-memory counter. + At the threshold it opens a fresh tracking issue. Above the + threshold it appends a "still red" comment to the existing issue. + """ + state.consecutive_failures[package] = ( + state.consecutive_failures.get(package, 0) + 1 + ) + state.last_failure_run_url[package] = run_url + n = state.consecutive_failures[package] + + if n < FAILURE_THRESHOLD: + return + + if n == FAILURE_THRESHOLD and package not in state.open_issue_numbers: + owners = _load_owners(Path(package)) + issue_n = gh.open_issue( + title=_issue_title(package), + body=_issue_body(package, run_url, owners), + labels=[CONTRIBS_BROKEN_LABEL], + assignees=[o.lstrip("@") for o in owners], + ) + state.open_issue_numbers[package] = issue_n + state.label_applied_days[package] = 0 + state.milestones_posted[package] = [] + return + + issue_n = state.open_issue_numbers.get(package) + if issue_n is not None: + gh.add_comment( + issue_n, + f"Still red after run {run_url} (consecutive failure #{n}).", + ) + + +def record_recovery( + gh: GitHubClient, + state: BotState, + *, + package: str, + commit_sha: str, +) -> None: + """Record a green smoke run for ``package``. + + Resets the consecutive-failure counter and, if a tracking issue is + open, posts a recovery comment, removes the ``contribs-broken`` + label, and resets the archival clock. The issue itself is left + open for a human to close. + """ + had_failures = state.consecutive_failures.get(package, 0) > 0 + state.consecutive_failures[package] = 0 + + issue_n = state.open_issue_numbers.get(package) + if issue_n is None or not had_failures: + return + + gh.add_comment( + issue_n, + f"smoke green again on {_today_iso()}, fixed in {commit_sha}", + ) + gh.remove_label(issue_n, CONTRIBS_BROKEN_LABEL) + state.label_applied_days[package] = 0 + state.milestones_posted[package] = [] + + +def apply_archival_timeline( + gh: GitHubClient, + state: BotState, + *, + today_iso: str | None = None, +) -> None: + """Walk tracked packages and post the day-7 / 14 / 21 milestones. + + For each package with an open tracking issue still carrying the + ``contribs-broken`` label, this function bumps + ``label_applied_days`` by one and, when a milestone is hit for the + first time, posts the corresponding comment (and applies the + ``archived`` label on day 21). Packages whose label has been + cleared are skipped (the recovery path zeroed the counter). + """ + today = today_iso or _today_iso() + + for package in list(state.open_issue_numbers): + issue_n = state.open_issue_numbers[package] + labels = gh.get_issue_labels(issue_n) + if CONTRIBS_BROKEN_LABEL not in labels: + # Label was cleared — recovery already reset the clock. + continue + + days = state.label_applied_days.get(package, 0) + 1 + state.label_applied_days[package] = days + posted = state.milestones_posted.setdefault(package, []) + + if days >= DAY_7_REMINDER and "day-7" not in posted: + gh.add_comment( + issue_n, + f"Day {DAY_7_REMINDER} reminder ({today}): the smoke job " + f"for `{package}` has been red for one week. Please " + f"investigate or escalate to the contribs maintainers.", + ) + posted.append("day-7") + + if ( + days >= DAY_14_RELEASE_NOTES_MENTION + and "day-14" not in posted + ): + gh.add_comment( + issue_n, + f"Day {DAY_14_RELEASE_NOTES_MENTION} notice ({today}): " + f"`{package}` will be called out as broken in the next " + f"contribs release notes.", + ) + posted.append("day-14") + + if days >= DAY_21_ARCHIVAL and "day-21" not in posted: + gh.add_label(issue_n, ARCHIVED_LABEL) + gh.add_comment( + issue_n, + f"Archival triggered on {today} after {days} days of " + f"`{CONTRIBS_BROKEN_LABEL}` label persistence. The " + f"`{ARCHIVED_LABEL}` label has been applied; contribs " + f"maintainers will move the subpackage to the archived " + f"layout in a follow-up PR.", + ) + posted.append("day-21") + + +# --------------------------------------------------------------------------- +# Real-mode (PyGithub) — wired but optional +# --------------------------------------------------------------------------- + + +@dataclass +class RealGitHub: + """Thin wrapper over PyGithub implementing :class:`GitHubClient`. + + Constructed only inside :func:`_real_main` so that fake-mode runs + (and the test suite) do not require ``PyGithub`` to be installed. + """ + + repo: Any # github.Repository.Repository (kept loose to avoid import-time dep) + + def open_issue( + self, + *, + title: str, + body: str, + labels: list[str], + assignees: list[str], + ) -> int: + issue = self.repo.create_issue( + title=title, + body=body, + labels=labels, + assignees=assignees or [], + ) + return int(issue.number) + + def add_comment(self, issue_number: int, body: str) -> None: + self.repo.get_issue(issue_number).create_comment(body) + + def add_label(self, issue_number: int, label: str) -> None: + self.repo.get_issue(issue_number).add_to_labels(label) + + def remove_label(self, issue_number: int, label: str) -> None: + self.repo.get_issue(issue_number).remove_from_labels(label) + + def get_issue_labels(self, issue_number: int) -> list[str]: + return [lbl.name for lbl in self.repo.get_issue(issue_number).labels] + + +def _load_persistent_state(repo: Any, branch: str) -> BotState: + """Fetch the bot-state JSON from a managed branch (real-mode only). + + Returns a fresh :class:`BotState` if the file (or the branch) does + not exist yet. + """ + try: + contents = repo.get_contents(STATE_FILE_PATH, ref=branch) + except Exception: # pragma: no cover - first run / missing branch + return BotState() + decoded = contents.decoded_content.decode("utf-8") + return BotState.from_json(decoded) + + +def _save_persistent_state( + repo: Any, + branch: str, + state: BotState, + *, + commit_message: str, +) -> None: + """Write the bot-state JSON back to the managed branch.""" + payload = state.to_json() + try: + existing = repo.get_contents(STATE_FILE_PATH, ref=branch) + repo.update_file( + path=STATE_FILE_PATH, + message=commit_message, + content=payload, + sha=existing.sha, + branch=branch, + ) + except Exception: # pragma: no cover - first commit on the branch + repo.create_file( + path=STATE_FILE_PATH, + message=commit_message, + content=payload, + branch=branch, + ) + + +def _real_main(args: argparse.Namespace) -> int: # pragma: no cover + """Run the bot against a real GitHub repository via PyGithub.""" + try: + from github import Github # type: ignore[import-not-found] + except ImportError: + print( + "PyGithub is required for real-mode operation. " + "Install it with `uv sync` (it lives in the dev group), " + "or rerun with --fake to use the in-memory client.", + file=sys.stderr, + ) + return 2 + + token = os.environ["GITHUB_TOKEN"] + repo_full_name = os.environ["GITHUB_REPOSITORY"] + branch = os.environ.get("BOT_STATE_BRANCH", "bot-state/auto-issue-bot") + + gh_api = Github(token) + repo = gh_api.get_repo(repo_full_name) + gh = RealGitHub(repo=repo) + + state = _load_persistent_state(repo, branch) + + if args.action == "record-failure": + record_failure(gh, state, package=args.package, run_url=args.run_url) + msg = f"bot: record failure for {args.package}" + elif args.action == "record-recovery": + record_recovery( + gh, state, package=args.package, commit_sha=args.commit_sha + ) + msg = f"bot: record recovery for {args.package}" + elif args.action == "apply-archival": + apply_archival_timeline(gh, state) + msg = "bot: apply archival timeline" + else: + print(f"Unknown action: {args.action}", file=sys.stderr) + return 2 + + _save_persistent_state(repo, branch, state, commit_message=msg) + return 0 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--action", + required=True, + choices=["record-failure", "record-recovery", "apply-archival"], + ) + parser.add_argument("--package", default=None) + parser.add_argument("--run-url", default="") + parser.add_argument("--commit-sha", default="") + parser.add_argument( + "--fake", + action="store_true", + help="Force fake (in-memory) mode even if GITHUB_TOKEN is set.", + ) + return parser + + +def _fake_main(args: argparse.Namespace) -> int: + """Run the bot against an in-memory ``FakeGitHub`` (no API calls).""" + gh = FakeGitHub() + state = BotState() + if args.action == "record-failure": + if not args.package: + print("--package is required for record-failure", file=sys.stderr) + return 2 + record_failure(gh, state, package=args.package, run_url=args.run_url) + elif args.action == "record-recovery": + if not args.package: + print("--package is required for record-recovery", file=sys.stderr) + return 2 + record_recovery( + gh, state, package=args.package, commit_sha=args.commit_sha + ) + elif args.action == "apply-archival": + apply_archival_timeline(gh, state) + + print( + json.dumps( + { + "issues_opened": gh.issues_opened, + "comments_added": gh.comments_added, + "state": asdict(state), + }, + indent=2, + ) + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if args.fake or not os.environ.get("GITHUB_TOKEN"): + return _fake_main(args) + return _real_main(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_auto_issue_bot.py b/.github/scripts/test_auto_issue_bot.py new file mode 100644 index 0000000..b043840 --- /dev/null +++ b/.github/scripts/test_auto_issue_bot.py @@ -0,0 +1,241 @@ +"""Tests for ``auto_issue_bot.py`` — drives the bot via the in-memory fake.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Make the sibling module importable when pytest is invoked from the +# repo root (``uv run pytest .github/scripts/test_auto_issue_bot.py``). +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from auto_issue_bot import ( # noqa: E402 + ARCHIVED_LABEL, + CONTRIBS_BROKEN_LABEL, + DAY_7_REMINDER, + DAY_14_RELEASE_NOTES_MENTION, + DAY_21_ARCHIVAL, + BotState, + FakeGitHub, + apply_archival_timeline, + record_failure, + record_recovery, +) + + +# --------------------------------------------------------------------------- +# record_failure +# --------------------------------------------------------------------------- + + +def test_first_failure_does_not_open_issue() -> None: + """A single red is silent — issues only open on the second consecutive red.""" + gh = FakeGitHub() + state = BotState() + record_failure(gh, state, package="dspy", run_url="https://example/run/1") + assert gh.issues_opened == [] + assert state.consecutive_failures["dspy"] == 1 + assert "dspy" not in state.open_issue_numbers + + +def test_second_consecutive_failure_opens_issue() -> None: + gh = FakeGitHub() + state = BotState() + record_failure(gh, state, package="dspy", run_url="https://example/run/1") + record_failure(gh, state, package="dspy", run_url="https://example/run/2") + assert len(gh.issues_opened) == 1 + issue = gh.issues_opened[0] + assert issue["title"].startswith(f"[{CONTRIBS_BROKEN_LABEL}] dspy") + assert CONTRIBS_BROKEN_LABEL in issue["labels"] + assert "https://example/run/2" in issue["body"] + assert state.open_issue_numbers["dspy"] == issue["number"] + assert state.label_applied_days["dspy"] == 0 + + +def test_third_failure_comments_on_existing_issue() -> None: + gh = FakeGitHub() + state = BotState() + for i in range(3): + record_failure( + gh, state, package="dspy", run_url=f"https://example/run/{i}" + ) + assert len(gh.issues_opened) == 1 + assert len(gh.comments_added) == 1 + assert "Still red" in gh.comments_added[0] + + +def test_failures_are_per_package() -> None: + gh = FakeGitHub() + state = BotState() + record_failure(gh, state, package="dspy", run_url="r1") + record_failure(gh, state, package="langchain", run_url="r1") + # Each package needs its own second failure to open an issue. + assert gh.issues_opened == [] + record_failure(gh, state, package="dspy", run_url="r2") + assert len(gh.issues_opened) == 1 + assert "dspy" in gh.issues_opened[0]["title"] + + +# --------------------------------------------------------------------------- +# record_recovery +# --------------------------------------------------------------------------- + + +def test_recovery_clears_label_but_leaves_issue_open() -> None: + """On recovery the bot comments + removes the label; the issue stays open.""" + gh = FakeGitHub() + state = BotState() + record_failure(gh, state, package="dspy", run_url="r1") + record_failure(gh, state, package="dspy", run_url="r2") + record_recovery(gh, state, package="dspy", commit_sha="abc1234") + + assert state.consecutive_failures["dspy"] == 0 + issue = gh.issues_opened[0] + assert CONTRIBS_BROKEN_LABEL not in issue["labels"] + assert issue["state"] == "open" + assert any("smoke green again" in c for c in gh.comments_added) + assert any("abc1234" in c for c in gh.comments_added) + + +def test_recovery_with_no_open_issue_is_a_noop() -> None: + gh = FakeGitHub() + state = BotState() + record_recovery(gh, state, package="dspy", commit_sha="abc") + assert gh.issues_opened == [] + assert gh.comments_added == [] + + +def test_recovery_after_single_failure_does_not_comment() -> None: + """A single red never opened an issue, so recovery should be silent.""" + gh = FakeGitHub() + state = BotState() + record_failure(gh, state, package="dspy", run_url="r1") + record_recovery(gh, state, package="dspy", commit_sha="abc") + assert gh.comments_added == [] + assert state.consecutive_failures["dspy"] == 0 + + +# --------------------------------------------------------------------------- +# apply_archival_timeline — milestones based on label persistence +# --------------------------------------------------------------------------- + + +def _open_issue(gh: FakeGitHub, state: BotState, package: str) -> int: + record_failure(gh, state, package=package, run_url="r1") + record_failure(gh, state, package=package, run_url="r2") + return state.open_issue_numbers[package] + + +def test_day_7_reminder_posted_once() -> None: + gh = FakeGitHub() + state = BotState() + issue_n = _open_issue(gh, state, "dspy") + + # Tick days 1..7 — only the day-7 tick should produce a milestone comment. + for _ in range(DAY_7_REMINDER): + apply_archival_timeline(gh, state, today_iso="2026-06-08") + + day_7_comments = [c for c in gh.comments_added if "Day 7 reminder" in c] + assert len(day_7_comments) == 1 + assert state.label_applied_days["dspy"] == DAY_7_REMINDER + assert "day-7" in state.milestones_posted["dspy"] + assert ARCHIVED_LABEL not in gh.get_issue_labels(issue_n) + + +def test_day_14_release_notes_mention() -> None: + gh = FakeGitHub() + state = BotState() + _open_issue(gh, state, "dspy") + state.label_applied_days["dspy"] = DAY_14_RELEASE_NOTES_MENTION - 1 + state.milestones_posted["dspy"] = ["day-7"] + + apply_archival_timeline(gh, state, today_iso="2026-06-15") + assert any("Day 14 notice" in c for c in gh.comments_added) + assert "day-14" in state.milestones_posted["dspy"] + + +def test_archival_after_21_days_of_label_persistence() -> None: + """21 days of contribs-broken label persistence triggers archival.""" + gh = FakeGitHub() + state = BotState() + issue_n = _open_issue(gh, state, "dspy") + state.label_applied_days["dspy"] = DAY_21_ARCHIVAL - 1 + state.milestones_posted["dspy"] = ["day-7", "day-14"] + + apply_archival_timeline(gh, state, today_iso="2026-06-22") + assert ARCHIVED_LABEL in gh.get_issue_labels(issue_n) + assert any("Archival triggered" in c for c in gh.comments_added) + + +def test_recovery_resets_archival_clock() -> None: + """A recovery clears the label so subsequent ticks do not advance.""" + gh = FakeGitHub() + state = BotState() + issue_n = _open_issue(gh, state, "dspy") + record_recovery(gh, state, package="dspy", commit_sha="abc") + + # 30 ticks pass — but the label is gone, so nothing should change. + for _ in range(30): + apply_archival_timeline(gh, state, today_iso="2026-06-29") + + assert ARCHIVED_LABEL not in gh.get_issue_labels(issue_n) + # The counter never advanced past 0 once the label was cleared. + assert state.label_applied_days["dspy"] == 0 + + +def test_apply_archival_skips_packages_with_label_cleared() -> None: + """Manually removing the label out-of-band should also skip the timeline.""" + gh = FakeGitHub() + state = BotState() + issue_n = _open_issue(gh, state, "dspy") + gh.remove_label(issue_n, CONTRIBS_BROKEN_LABEL) + + apply_archival_timeline(gh, state, today_iso="2026-06-22") + assert state.label_applied_days["dspy"] == 0 + assert ARCHIVED_LABEL not in gh.get_issue_labels(issue_n) + + +def test_apply_archival_is_idempotent() -> None: + """Re-running on the same day after day-21 should not re-add comments.""" + gh = FakeGitHub() + state = BotState() + _open_issue(gh, state, "dspy") + state.label_applied_days["dspy"] = DAY_21_ARCHIVAL - 1 + state.milestones_posted["dspy"] = ["day-7", "day-14"] + + apply_archival_timeline(gh, state, today_iso="2026-06-22") + archival_comments_first = [ + c for c in gh.comments_added if "Archival triggered" in c + ] + assert len(archival_comments_first) == 1 + + # A second tick on the same day adds no new archival comment because + # day-21 has already been recorded in milestones_posted. + apply_archival_timeline(gh, state, today_iso="2026-06-23") + archival_comments_second = [ + c for c in gh.comments_added if "Archival triggered" in c + ] + assert len(archival_comments_second) == 1 + + +# --------------------------------------------------------------------------- +# BotState round-trip +# --------------------------------------------------------------------------- + + +def test_bot_state_json_round_trip() -> None: + state = BotState( + consecutive_failures={"dspy": 3}, + open_issue_numbers={"dspy": 42}, + label_applied_days={"dspy": 5}, + last_failure_run_url={"dspy": "https://example/run/3"}, + milestones_posted={"dspy": ["day-7"]}, + ) + restored = BotState.from_json(state.to_json()) + assert restored.consecutive_failures == state.consecutive_failures + assert restored.open_issue_numbers == state.open_issue_numbers + assert restored.label_applied_days == state.label_applied_days + assert restored.last_failure_run_url == state.last_failure_run_url + assert restored.milestones_posted == state.milestones_posted diff --git a/.github/smoke-matrix.json b/.github/smoke-matrix.json new file mode 100644 index 0000000..27f89be --- /dev/null +++ b/.github/smoke-matrix.json @@ -0,0 +1,4 @@ +{ + "_comment": "Daily smoke matrix gating list. Each migration PR appends its subpackage path (relative to repo root). The smoke job is skipped while this list is empty.", + "subpackages": [] +} diff --git a/.github/workflows/auto-issue-archival.yml b/.github/workflows/auto-issue-archival.yml new file mode 100644 index 0000000..6aed131 --- /dev/null +++ b/.github/workflows/auto-issue-archival.yml @@ -0,0 +1,28 @@ +name: auto-issue-archival + +# Daily archival pass. Walks the bot's persistent state, advances the +# label-applied-days counter for each subpackage that still carries the +# `contribs-broken` label, and applies the day-7 reminder, day-14 +# release-notes mention, and day-21 archival label. + +"on": + schedule: + - cron: "0 8 * * *" # 08:00 UTC daily, ~45 minutes after smoke + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + archival: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Apply archival timeline + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + uv run python .github/scripts/auto_issue_bot.py --action apply-archival diff --git a/.github/workflows/smoke-against-mellea-main.yml b/.github/workflows/smoke-against-mellea-main.yml new file mode 100644 index 0000000..d1534f5 --- /dev/null +++ b/.github/workflows/smoke-against-mellea-main.yml @@ -0,0 +1,78 @@ +name: smoke-against-mellea-main + +# Daily smoke against mellea@main per subpackage. The matrix is read from +# .github/smoke-matrix.json so that subpackages opt in explicitly. While the +# list is empty the smoke job is skipped (load-matrix still runs and reports). + +"on": + schedule: + - cron: "17 7 * * *" # 07:17 UTC daily + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + load-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.load.outputs.matrix }} + empty: ${{ steps.load.outputs.empty }} + steps: + - uses: actions/checkout@v4 + - id: load + run: | + set -euo pipefail + MATRIX=$(jq -c '.subpackages' .github/smoke-matrix.json) + echo "matrix=${MATRIX}" >> "$GITHUB_OUTPUT" + if [ "${MATRIX}" = "[]" ]; then + echo "empty=true" >> "$GITHUB_OUTPUT" + echo "Smoke matrix is empty (no subpackages opted in yet); the smoke job will be skipped." + else + echo "empty=false" >> "$GITHUB_OUTPUT" + fi + + smoke: + needs: load-matrix + if: needs.load-matrix.outputs.empty == 'false' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.load-matrix.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Install package against mellea@main + working-directory: ${{ matrix.package }} + run: | + set -euo pipefail + uv sync --all-extras + uv pip install "mellea @ git+https://github.com/generative-computing/mellea.git@main" + - name: Run subpackage tests + id: pytest + working-directory: ${{ matrix.package }} + run: uv run pytest -m "not qualitative and not e2e" + - name: Record failure with auto-issue bot + if: failure() && steps.pytest.outcome == 'failure' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + uv run python .github/scripts/auto_issue_bot.py \ + --action record-failure \ + --package "${{ matrix.package }}" \ + --run-url "$RUN_URL" + - name: Record recovery with auto-issue bot + if: success() && steps.pytest.outcome == 'success' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + run: | + uv run python .github/scripts/auto_issue_bot.py \ + --action record-recovery \ + --package "${{ matrix.package }}" \ + --commit-sha "$COMMIT_SHA" diff --git a/pyproject.toml b/pyproject.toml index 2cdeb1b..7c9ece1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,4 +44,5 @@ dev = [ "pyyaml", "pre-commit>=3", "pytest>=7", + "PyGithub>=2.1", ] diff --git a/uv.lock b/uv.lock index ae26109..31e82c3 100644 --- a/uv.lock +++ b/uv.lock @@ -83,6 +83,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -221,6 +291,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/a9/8c855c14b401dc67d20739345295af5afce5e930a69600ab20f6cfa50b5c/cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9", size = 41317, upload-time = "2026-03-04T04:06:01.221Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + [[package]] name = "distlib" version = "0.4.1" @@ -457,6 +586,7 @@ dev = [ { name = "cookiecutter" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pygithub" }, { name = "pytest" }, { name = "pyyaml" }, { name = "ruff" }, @@ -470,6 +600,7 @@ dev = [ { name = "cookiecutter", specifier = ">=2.6" }, { name = "mypy", specifier = ">=1.10" }, { name = "pre-commit", specifier = ">=3" }, + { name = "pygithub", specifier = ">=2.1" }, { name = "pytest", specifier = ">=7" }, { name = "pyyaml" }, { name = "ruff", specifier = ">=0.6" }, @@ -596,6 +727,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -605,6 +761,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.0.3" From 7acab1ed2f171b226c18f37a9042b7d64e2e4fb6 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Wed, 3 Jun 2026 10:17:31 -0700 Subject: [PATCH 10/16] docs: document the smoke + auto-issue bot lifecycle in RELEASING.md Adds a section explaining the daily smoke job's gating (.github/smoke-matrix.json), the auto-issue bot's two-consecutive-reds threshold, the contribs-broken label as the source of truth for the 21-day archival timeline, and the day-7 / 14 / 21 milestones. Also shows how to run the bot locally against the in-memory fake. The rest of RELEASING.md still describes the per-subpackage tag release flow that's slated for replacement when the coordinated release pipeline lands; this commit only documents the smoke-bot lifecycle that the foundation work just introduced. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- RELEASING.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/RELEASING.md b/RELEASING.md index ba69777..52d0beb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -264,6 +264,54 @@ This means **no workflow changes are needed when adding new packages** - just en 6. **Coordinate Releases**: If multiple packages depend on each other, release them in dependency order +## Smoke + auto-issue bot + +A daily smoke job (`.github/workflows/smoke-against-mellea-main.yml`) +runs each opted-in subpackage's tests against `mellea @ main` at +07:17 UTC. The matrix is read from `.github/smoke-matrix.json`; while +that list is empty the smoke job is skipped. Each subpackage opts in +by appending its path to the `subpackages` array as part of its +migration PR. + +When a subpackage's smoke job goes red, the auto-issue bot +(`.github/scripts/auto_issue_bot.py`) tracks consecutive failures and +opens a tracking issue once the second consecutive red lands. Issues +are labelled `contribs-broken` and assigned to the package's OWNERS. + +A second daily workflow (`.github/workflows/auto-issue-archival.yml`) +runs at 08:00 UTC and applies the archival timeline based on how long +the `contribs-broken` label has continuously been present on each +tracking issue: + +- **Day 7** — a reminder comment is posted to escalate the failure. +- **Day 14** — the bot posts a notice that the subpackage will be + called out as broken in the next contribs release notes. When you + cut a release while a tracking issue is at or past day 14, mention + the affected subpackages explicitly in the release notes. +- **Day 21** — the bot applies the `archived` label and posts a final + comment. Contribs maintainers move the subpackage to the archived + layout in a follow-up PR. + +Recovery (a green smoke run) clears the `contribs-broken` label, +posts a "smoke green again on ``, fixed in ``" comment, +resets the archival clock, and leaves the issue open for a human to +close. Removing the `contribs-broken` label by hand has the same +effect — the timeline is driven by the label, not the issue's open +state. + +The bot's persistent state lives at +`.github/bot-state/auto_issue_bot.json` on a bot-managed branch and +is updated by each invocation. To run the bot locally against the +in-memory fake (no GitHub API calls): + +```bash +uv run python .github/scripts/auto_issue_bot.py \ + --action record-failure \ + --package mellea_contribs/dspy_backend \ + --run-url https://example/run/1 \ + --fake +``` + ## Support For issues with the release process: From 98fbf911b5f095afb27546149620a4ecbea65ad5 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Thu, 4 Jun 2026 10:00:19 -0700 Subject: [PATCH 11/16] fix: don't flag sibling contribs dists in mellea-constraint check; allow config/ The validate-structure rule that requires explicit mellea>= or mellea== constraints was matching sibling distribution names like mellea-contribs-integration-core and mellea-tools because they start with "mellea". Same prefix-matching bug class as the receiver script's regex; fix is the analogous disambiguation: a hyphen, underscore, letter, or digit immediately after `mellea` means we're looking at a sibling dist, not the upstream package. Also adds `config` to META_DIRS so subpackages that ship YAML configs alongside their code don't trip the unexpected-top-level-directory check. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan --- .../scripts/test_validate_package_contract.py | 25 +++++++++++++++++++ .github/scripts/validate_package_contract.py | 14 ++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_validate_package_contract.py b/.github/scripts/test_validate_package_contract.py index 3259b4f..999c62e 100644 --- a/.github/scripts/test_validate_package_contract.py +++ b/.github/scripts/test_validate_package_contract.py @@ -220,6 +220,31 @@ def test_mellea_extras_with_explicit_constraint_passes(tmp_path: Path) -> None: assert violations == [] +def test_sibling_distribution_names_not_flagged(tmp_path: Path) -> None: + """`mellea-contribs-X` and `mellea-tools` are sibling dists, not the upstream package. + + They start with `mellea` but should NOT trigger the explicit-constraint + rule — they're contribs subpackages depending on each other, not the + upstream mellea package. + """ + setup_core_paths(tmp_path) + setup_grandfather(tmp_path) + setup_minimal_subpackage(tmp_path, "demo") + pp = tmp_path / "demo" / "pyproject.toml" + # Add a sibling-dependency dep alongside the explicit `mellea>=` line. + pp.write_text( + pp.read_text().replace( + '"mellea>=0.6.0"', + '"mellea>=0.6.0",\n "mellea-contribs-integration-core",\n "mellea-tools"', + ) + ) + violations = validate_repo(tmp_path) + assert violations == [], ( + "sibling distribution names should not trigger the mellea-constraint rule; " + f"got: {[str(v) for v in violations]}" + ) + + def test_grandfathered_legacy_dir_skipped(tmp_path: Path) -> None: setup_core_paths(tmp_path) setup_grandfather(tmp_path, paths=["mellea_contribs/legacy_thing"]) diff --git a/.github/scripts/validate_package_contract.py b/.github/scripts/validate_package_contract.py index 1ff32ba..2c5d16d 100644 --- a/.github/scripts/validate_package_contract.py +++ b/.github/scripts/validate_package_contract.py @@ -44,6 +44,7 @@ "__pycache__", ".pytest_cache", "docs", + "config", NAMESPACE_PKG, } @@ -88,6 +89,10 @@ def _bad_mellea_constraint(dep: str) -> str | None: package name (no operator, git/url ref, or unsupported operator) is rejected so the receiver workflow can reason about the line. + Sibling distribution names like ``mellea-contribs-integration-core`` and + ``mellea-tools`` start with ``mellea`` but are NOT the upstream mellea + package — they're skipped here. + Returns None if dep is not a mellea line or is acceptable. """ s = dep.strip() @@ -99,7 +104,14 @@ def _bad_mellea_constraint(dep: str) -> str | None: spec = s[close + 1 :].strip() head = "mellea[...]" elif s.startswith("mellea"): - spec = s[len("mellea") :].strip() + # Disambiguate from sibling distribution names (mellea-X, mellea_X). + # The bare `mellea` package is followed by an operator, whitespace, + # `@` (git ref), or end-of-string — never by a hyphen, underscore, + # letter, or digit. + rest = s[len("mellea") :] + if rest and rest[0] in "-_" or (rest and rest[0].isalnum()): + return None # Sibling distribution name, not the upstream mellea package. + spec = rest.strip() head = "mellea" else: return None # Not a mellea line. From 62a4080d45b5a9cc3886a54ec3478878b243b6ca Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 8 Jun 2026 10:00:42 -0700 Subject: [PATCH 12/16] fix(bot): store auto-issue state per-package to eliminate write race Concurrent smoke legs previously raced on a single bot-state JSON file's blob SHA: two legs would load the same SHA, the first update_file call would win, the second would 409, fall through to create_file and 422 unhandled, losing the counter update. With contents:read also missing on smoke, write attempts were silently 403'd inside a broad except, making fresh BotState() reload look like a successful save. Switch storage to one JSON file per subpackage at .github/bot-state/.json. record-failure / record-recovery touch only their own package's path, so legs running in parallel write disjoint paths and never share a SHA. apply-archival lists the dir, loads each file, runs the timeline, and saves each back. Exception handling is narrowed to GithubException with explicit status checks (404 = missing -> create; anything else propagates) instead of swallowing every error. All 14 existing tests pass unchanged. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan --- .github/scripts/auto_issue_bot.py | 226 ++++++++++++++++++++++++------ 1 file changed, 185 insertions(+), 41 deletions(-) diff --git a/.github/scripts/auto_issue_bot.py b/.github/scripts/auto_issue_bot.py index ae7a445..405e09f 100644 --- a/.github/scripts/auto_issue_bot.py +++ b/.github/scripts/auto_issue_bot.py @@ -25,9 +25,11 @@ :class:`FakeGitHub`. The unit tests drive the bot end to end through this fake. * **Real mode** uses :class:`RealGitHub`, a thin wrapper over PyGithub. - Persistent state is stored as JSON on a bot-managed branch in the - repository (see :func:`_load_persistent_state` / - :func:`_save_persistent_state`). + Persistent state is stored as one JSON file per subpackage on a + bot-managed branch (``.github/bot-state/.json``). Each file + holds only that package's counters, so concurrent smoke legs touch + disjoint paths and never race on the GitHub Contents API's blob-SHA + CAS check. See :func:`_load_package_state` / :func:`_save_package_state`. """ from __future__ import annotations @@ -53,8 +55,12 @@ CONTRIBS_BROKEN_LABEL = "contribs-broken" ARCHIVED_LABEL = "archived" -# Path inside the bot-managed branch where persistent state lives. -STATE_FILE_PATH = ".github/bot-state/auto_issue_bot.json" +# Directory inside the bot-managed branch where per-package state files live. +# Each subpackage gets its own ``/.json`` so that concurrent +# smoke legs only ever read-modify-write disjoint paths and cannot race on +# the GitHub Contents API's blob-SHA CAS check. See ``_package_state_path`` +# for how subpackage names are mapped to filenames. +STATE_DIR_PATH = ".github/bot-state" # --------------------------------------------------------------------------- @@ -103,6 +109,56 @@ def from_json(cls, text: str) -> "BotState": }, ) + def to_per_package_json(self, package: str) -> str: + """Serialize just one package's slots to a flat JSON document. + + Per-package state lives at ``.github/bot-state/.json`` on + the bot-state branch; storing only the requested package's keys + means concurrent smoke legs touch disjoint paths and never share + a blob SHA. + """ + payload: dict[str, Any] = { + "consecutive_failures": self.consecutive_failures.get(package, 0), + "last_failure_run_url": self.last_failure_run_url.get(package, ""), + "label_applied_days": self.label_applied_days.get(package, 0), + "milestones_posted": list(self.milestones_posted.get(package, [])), + } + if package in self.open_issue_numbers: + payload["open_issue_number"] = self.open_issue_numbers[package] + return json.dumps(payload, indent=2, sort_keys=True) + + @classmethod + def from_per_package_json(cls, package: str, text: str) -> "BotState": + """Hydrate a :class:`BotState` from one package's flat document. + + The returned state is populated only for ``package``; other + packages have no entries. ``apply-archival`` merges multiple + single-package states by reading each file in turn. + """ + data = json.loads(text) + state = cls() + state.consecutive_failures[package] = int(data.get("consecutive_failures", 0)) + state.last_failure_run_url[package] = str(data.get("last_failure_run_url", "")) + state.label_applied_days[package] = int(data.get("label_applied_days", 0)) + state.milestones_posted[package] = list(data.get("milestones_posted", [])) + issue_n = data.get("open_issue_number") + if issue_n is not None: + state.open_issue_numbers[package] = int(issue_n) + return state + + def merge_from(self, other: "BotState") -> None: + """Copy ``other``'s package slots into ``self`` (last-write-wins). + + Used by ``apply-archival`` to build a single in-memory + :class:`BotState` from N per-package files. + """ + self.consecutive_failures.update(other.consecutive_failures) + self.open_issue_numbers.update(other.open_issue_numbers) + self.label_applied_days.update(other.label_applied_days) + self.last_failure_run_url.update(other.last_failure_run_url) + for k, v in other.milestones_posted.items(): + self.milestones_posted[k] = list(v) + # --------------------------------------------------------------------------- # GitHub client interface @@ -414,45 +470,107 @@ def get_issue_labels(self, issue_number: int) -> list[str]: return [lbl.name for lbl in self.repo.get_issue(issue_number).labels] -def _load_persistent_state(repo: Any, branch: str) -> BotState: - """Fetch the bot-state JSON from a managed branch (real-mode only). +def _package_state_path(package: str) -> str: + """Return the bot-state file path for a given subpackage. - Returns a fresh :class:`BotState` if the file (or the branch) does - not exist yet. + The package name is the directory name in the contribs repo (e.g. + ``dspy``, ``_integration_core``). We slash-replace defensively even + though current names are flat — subpackages may be nested under a + namespace one day. """ + safe = package.replace("/", "__") + return f"{STATE_DIR_PATH}/{safe}.json" + + +def _load_package_state(repo: Any, branch: str, package: str) -> BotState: + """Fetch the per-package state file from the managed branch. + + Returns a fresh :class:`BotState` populated only with the requested + package's slots if the file (or the branch) does not exist yet — + this is the first-failure path. + + Raises :class:`github.GithubException` for anything other than 404, + so authentication and permission problems surface at the call site + instead of being swallowed into an empty state. + """ + from github import GithubException # type: ignore[import-not-found] + + path = _package_state_path(package) try: - contents = repo.get_contents(STATE_FILE_PATH, ref=branch) - except Exception: # pragma: no cover - first run / missing branch + contents = repo.get_contents(path, ref=branch) + except GithubException as e: + if e.status != 404: + raise return BotState() decoded = contents.decoded_content.decode("utf-8") - return BotState.from_json(decoded) + return BotState.from_per_package_json(package, decoded) -def _save_persistent_state( +def _save_package_state( repo: Any, branch: str, + package: str, state: BotState, *, commit_message: str, ) -> None: - """Write the bot-state JSON back to the managed branch.""" - payload = state.to_json() + """Write the per-package state slice back to the managed branch. + + Concurrent smoke legs target different paths, so the GitHub Contents + API's blob-SHA CAS check never conflicts across legs. Within a single + leg the get_contents → update_file pair still races against any + workflow_dispatch invocation that targets the same package, so 404 + is the only swallowed status; everything else is raised. + """ + from github import GithubException # type: ignore[import-not-found] + + path = _package_state_path(package) + payload = state.to_per_package_json(package) try: - existing = repo.get_contents(STATE_FILE_PATH, ref=branch) - repo.update_file( - path=STATE_FILE_PATH, - message=commit_message, - content=payload, - sha=existing.sha, - branch=branch, - ) - except Exception: # pragma: no cover - first commit on the branch + existing = repo.get_contents(path, ref=branch) + except GithubException as e: + if e.status != 404: + raise repo.create_file( - path=STATE_FILE_PATH, + path=path, message=commit_message, content=payload, branch=branch, ) + return + repo.update_file( + path=path, + message=commit_message, + content=payload, + sha=existing.sha, + branch=branch, + ) + + +def _list_tracked_packages(repo: Any, branch: str) -> list[str]: + """Enumerate every subpackage with a state file on the managed branch. + + Used by ``apply-archival`` to walk all tracked packages without + relying on a single combined state document. Returns an empty list + if the directory (or branch) does not exist yet. + """ + from github import GithubException # type: ignore[import-not-found] + + try: + entries = repo.get_contents(STATE_DIR_PATH, ref=branch) + except GithubException as e: + if e.status != 404: + raise + return [] + if not isinstance(entries, list): + entries = [entries] + packages: list[str] = [] + for entry in entries: + name = getattr(entry, "name", "") + if not name.endswith(".json"): + continue + packages.append(name[:-len(".json")].replace("__", "/")) + return packages def _real_main(args: argparse.Namespace) -> int: # pragma: no cover @@ -476,25 +594,51 @@ def _real_main(args: argparse.Namespace) -> int: # pragma: no cover repo = gh_api.get_repo(repo_full_name) gh = RealGitHub(repo=repo) - state = _load_persistent_state(repo, branch) + if args.action in ("record-failure", "record-recovery"): + if not args.package: + print(f"--package is required for {args.action}", file=sys.stderr) + return 2 - if args.action == "record-failure": - record_failure(gh, state, package=args.package, run_url=args.run_url) - msg = f"bot: record failure for {args.package}" - elif args.action == "record-recovery": - record_recovery( - gh, state, package=args.package, commit_sha=args.commit_sha + state = _load_package_state(repo, branch, args.package) + + if args.action == "record-failure": + record_failure(gh, state, package=args.package, run_url=args.run_url) + msg = f"bot: record failure for {args.package}" + else: + record_recovery( + gh, state, package=args.package, commit_sha=args.commit_sha + ) + msg = f"bot: record recovery for {args.package}" + + _save_package_state( + repo, branch, args.package, state, commit_message=msg ) - msg = f"bot: record recovery for {args.package}" - elif args.action == "apply-archival": - apply_archival_timeline(gh, state) - msg = "bot: apply archival timeline" - else: - print(f"Unknown action: {args.action}", file=sys.stderr) - return 2 + return 0 + + if args.action == "apply-archival": + # Archival walks every tracked package. Load each per-package + # file separately, run the timeline against the merged state, + # then write each package's slot back to its own file. Archival + # runs from a single workflow job (no matrix) so the + # read-then-walk-then-write order is safe — no peer is editing + # state mid-walk. + packages = _list_tracked_packages(repo, branch) + merged = BotState() + for pkg in packages: + merged.merge_from(_load_package_state(repo, branch, pkg)) + apply_archival_timeline(gh, merged) + for pkg in packages: + _save_package_state( + repo, + branch, + pkg, + merged, + commit_message=f"bot: apply archival timeline for {pkg}", + ) + return 0 - _save_persistent_state(repo, branch, state, commit_message=msg) - return 0 + print(f"Unknown action: {args.action}", file=sys.stderr) + return 2 # --------------------------------------------------------------------------- From 71d04134d97d45939b8b7787f2c092c4e833bf7d Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 8 Jun 2026 10:01:41 -0700 Subject: [PATCH 13/16] fix(ci): grant contents:write to bot workflows so state persists Both smoke-against-mellea-main and auto-issue-archival call auto_issue_bot.py, which now commits per-package state files to .github/bot-state/ on the default branch. With contents:read these writes 403 silently (the bot's wrapper used to swallow the exception and re-create a fresh BotState, masking the failure). After commit 1 the wrapper raises on permission errors, so without this elevation both workflows would start failing loudly. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan --- .github/workflows/auto-issue-archival.yml | 2 +- .github/workflows/smoke-against-mellea-main.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-issue-archival.yml b/.github/workflows/auto-issue-archival.yml index 6aed131..8f7b4b9 100644 --- a/.github/workflows/auto-issue-archival.yml +++ b/.github/workflows/auto-issue-archival.yml @@ -11,7 +11,7 @@ name: auto-issue-archival workflow_dispatch: permissions: - contents: read + contents: write # auto_issue_bot.py writes per-package state under .github/bot-state/ issues: write jobs: diff --git a/.github/workflows/smoke-against-mellea-main.yml b/.github/workflows/smoke-against-mellea-main.yml index d1534f5..86fd5f7 100644 --- a/.github/workflows/smoke-against-mellea-main.yml +++ b/.github/workflows/smoke-against-mellea-main.yml @@ -10,7 +10,7 @@ name: smoke-against-mellea-main workflow_dispatch: permissions: - contents: read + contents: write # auto_issue_bot.py writes per-package state under .github/bot-state/ issues: write jobs: From 5d0a226d287b9ab0f8cde9cf8e0a4dfe87c883f3 Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 8 Jun 2026 10:02:19 -0700 Subject: [PATCH 14/16] fix(ci): record bot failure on install breaks, not only pytest fails The smoke job's failure-recording step gated on `steps.pytest.outcome == 'failure'`, but that output is unset when an earlier step (e.g. `uv sync` or the mellea@main pip install) crashed before pytest ran. Those breakages would surface only as red workflow runs with no auto-issue created. Drop the pytest-only gate; rely on `if: failure()` so the bot fires for any failure in the leg. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan --- .github/workflows/smoke-against-mellea-main.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-against-mellea-main.yml b/.github/workflows/smoke-against-mellea-main.yml index 86fd5f7..dbd85fc 100644 --- a/.github/workflows/smoke-against-mellea-main.yml +++ b/.github/workflows/smoke-against-mellea-main.yml @@ -55,7 +55,10 @@ jobs: working-directory: ${{ matrix.package }} run: uv run pytest -m "not qualitative and not e2e" - name: Record failure with auto-issue bot - if: failure() && steps.pytest.outcome == 'failure' + # Catch install/sync failures too, not just pytest failures — + # `steps.pytest.outcome` is unset when an earlier step (e.g. uv sync) + # broke before pytest ran, and we want those reported. + if: failure() env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} From a977a69da08dd8d002ebf55efcda8c76ad09fe3c Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 8 Jun 2026 10:04:48 -0700 Subject: [PATCH 15/16] fix(ci): tighten change-detection in ci.yml + legacy-ci.yml ci.yml: drop the `|| git ls-files` fallback on the base-ref diff. If $BASE_SHA is wrong or unfetched, the fallback silently runs the full matrix on every PR, hiding the underlying issue. Let the step fail loudly instead. legacy-ci.yml: the workflow-touched fallback grepped for `ci.yml`, which is now a separate file under the new structure. Update the pattern to `legacy-ci.yml` so changes to this workflow correctly trigger the full subpackage matrix. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan --- .github/workflows/ci.yml | 5 ++++- .github/workflows/legacy-ci.yml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd13bb..1a2fb24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,10 @@ jobs: if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="HEAD~1" fi - CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD || git ls-files) + # Fail loud rather than fall through to `git ls-files` — the latter + # silently runs the full matrix on every PR if the base ref is bad, + # which masks the real problem (missing fetch depth, wrong base sha). + CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD) uv run python .github/scripts/discover_subpackages.py \ --changed-files "${CHANGED}" \ --base-ref "${BASE_REF}" \ diff --git a/.github/workflows/legacy-ci.yml b/.github/workflows/legacy-ci.yml index 91cd5de..a02e204 100644 --- a/.github/workflows/legacy-ci.yml +++ b/.github/workflows/legacy-ci.yml @@ -52,7 +52,7 @@ jobs: if [ -z "$CHANGED_SUBPACKAGES" ]; then # If no subpackages changed, check if workflow files changed - WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(ci\.yml|quality-generic\.yml)" || true) + WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(legacy-ci\.yml|quality-generic\.yml)" || true) if [ -n "$WORKFLOW_CHANGED" ]; then # Test all subpackages if workflow changed CHANGED_SUBPACKAGES=$(find mellea_contribs -maxdepth 1 -mindepth 1 -type d ! -name '__pycache__' ! -name '.pytest_cache' | sort) From dd837cbbdf8b018bdf291e507d8a0c9c2d93b36d Mon Sep 17 00:00:00 2001 From: Avinash Balakrishnan Date: Mon, 8 Jun 2026 10:08:09 -0700 Subject: [PATCH 16/16] fix(ci): wire per-package timeout + pin Ollama install in package-ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `timeout-minutes` was hardcoded to 30 with a comment claiming subpackages override via [tool.mellea-contribs.ci.timeout_minutes], but the toml read ran inside the same job — `timeout-minutes` is resolved when the job starts, so the step output never reached it. Hoist the toml read into a prerequisite `read-config` job and let `package-ci` consume its outputs. Also drop `curl https://ollama.com/install.sh | sh` in favour of a pinned release tarball (currently v0.5.7). Piping the upstream script directly to sh is the supply-chain shape we want to avoid in CI; pinning the release artifact gives us a deterministic install with a versionable upgrade path. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan --- .github/workflows/package-ci.yml | 39 ++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/package-ci.yml b/.github/workflows/package-ci.yml index 5bfa59e..1a82197 100644 --- a/.github/workflows/package-ci.yml +++ b/.github/workflows/package-ci.yml @@ -8,13 +8,18 @@ on: type: string jobs: - package-ci: + read-config: + # Hoisted into its own job so its output can drive `timeout-minutes` on + # the matrix job below — `timeout-minutes` is resolved when a job starts, + # so it cannot reference a step output produced inside the same job. runs-on: ubuntu-latest - timeout-minutes: 30 # Default; subpackages override via [tool.mellea-contribs.ci] + outputs: + skip_ollama: ${{ steps.ci-flags.outputs.skip_ollama }} + timeout_minutes: ${{ steps.ci-flags.outputs.timeout_minutes }} + python_versions: ${{ steps.ci-flags.outputs.python_versions }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - - name: Read [tool.mellea-contribs.ci] flags id: ci-flags working-directory: ${{ inputs.package }} @@ -36,17 +41,37 @@ jobs: f.write(f'{k}={str(v).lower() if isinstance(v, bool) else v}\n') " + package-ci: + needs: read-config + runs-on: ubuntu-latest + timeout-minutes: ${{ fromJson(needs.read-config.outputs.timeout_minutes) }} + env: + SKIP_OLLAMA: ${{ needs.read-config.outputs.skip_ollama }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + # NOTE: Ollama installation lives outside the matrix (single-version test for v1). + # Pinned to a known good release commit instead of fetching a fresh install + # script on every run — the upstream script is mutable and arbitrary code + # piped to sh is exactly the supply-chain shape we don't want in CI. - name: Install Ollama - if: steps.ci-flags.outputs.skip_ollama != 'true' - run: curl -fsSL https://ollama.com/install.sh | sh + if: env.SKIP_OLLAMA != 'true' + env: + OLLAMA_VERSION: "v0.5.7" + run: | + set -euo pipefail + curl -fsSL -o /tmp/ollama.tgz \ + "https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz" + sudo tar -C /usr -xzf /tmp/ollama.tgz + ollama --version - name: Start Ollama - if: steps.ci-flags.outputs.skip_ollama != 'true' + if: env.SKIP_OLLAMA != 'true' run: nohup ollama serve & - name: Pull granite4:micro - if: steps.ci-flags.outputs.skip_ollama != 'true' + if: env.SKIP_OLLAMA != 'true' run: ollama pull granite4:micro - name: Sync dependencies