diff --git a/.config/mise/config.toml b/.config/mise/config.toml index c3d96926ad7..7ded92ead78 100644 --- a/.config/mise/config.toml +++ b/.config/mise/config.toml @@ -16,7 +16,7 @@ node = { version = "22.21.1", postinstall = "corepack enable" } # Helper to install tools cargo-binstall = "1.19.1" -uv = "0.11.18" +uv = "0.11.28" # Tools to compile the project 'cargo:wasm-opt' = "0.116.1" diff --git a/.config/mise/mise.lock b/.config/mise/mise.lock index a0b941c3084..91919b0877f 100644 --- a/.config/mise/mise.lock +++ b/.config/mise/mise.lock @@ -231,31 +231,31 @@ version = "0.7.1" backend = "ubi:terrastruct/d2" [[tools.uv]] -version = "0.11.18" +version = "0.11.28" backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] -checksum = "sha256:6d895725333680bf7633ad635baff8e49dc45d3b52e00b2b3adf6ced41f2ebe2" -url = "https://github.com/astral-sh/uv/releases/download/0.11.18/uv-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/435541698" +checksum = "sha256:da10cdfa7d92212b7acb62021a0fd61bcf8580c58c3632ec915d10c3a1a7906b" +url = "https://github.com/astral-sh/uv/releases/download/0.11.28/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/469655715" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] -checksum = "sha256:a095a969fc8357f42e35652e0554525a47a29010ddb814bd82650c2ffa7d6d62" -url = "https://github.com/astral-sh/uv/releases/download/0.11.18/uv-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/435541798" +checksum = "sha256:f02146b371c35c287d860f003ece7345c86e358a3fd70a9b63700cd141ee7fb4" +url = "https://github.com/astral-sh/uv/releases/download/0.11.28/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/469655835" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"] -checksum = "sha256:1a7adf8dadae3b55853115d13a8bf564d219597ad13824b93b213706933863e5" -url = "https://github.com/astral-sh/uv/releases/download/0.11.18/uv-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/435541676" +checksum = "sha256:33540eb7c883ab857eff79bd5ac2aa31fe27b595abecb4a9c003a2c998447232" +url = "https://github.com/astral-sh/uv/releases/download/0.11.28/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/469655699" provenance = "github-attestations" [tools.uv."platforms.macos-x64"] -checksum = "sha256:00a61e3db99b53c927a7e6c4ccdccb898aa3253d07928822211e9dc570a25661" -url = "https://github.com/astral-sh/uv/releases/download/0.11.18/uv-x86_64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/435541772" +checksum = "sha256:2ad79983127ffca7d77b77ce6a24278d7e4f7b817a1acf72fea5f8124b4aac5e" +url = "https://github.com/astral-sh/uv/releases/download/0.11.28/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/469655811" provenance = "github-attestations" [[tools.yq]] diff --git a/.config/mise/tasks/fix/python.sh b/.config/mise/tasks/fix/python.sh new file mode 100755 index 00000000000..741bf95a39c --- /dev/null +++ b/.config/mise/tasks/fix/python.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="Fix Python: sync lockfile, apply ruff autofixes and formatting" +set -euo pipefail + +mise exec uv -- uv sync +mise exec uv -- uv run --frozen ruff check --fix . +mise exec uv -- uv run --frozen ruff format . diff --git a/.config/mise/tasks/lint/python.sh b/.config/mise/tasks/lint/python.sh new file mode 100755 index 00000000000..536a891b310 --- /dev/null +++ b/.config/mise/tasks/lint/python.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Lint Python: lock stability, workspace sync, ruff format + lint, ty type checks, tach architecture checks" +set -uo pipefail + +FAILED=0 + +run() { + echo "$ $*" + mise exec uv -- "$@" || FAILED=1 +} + +# `--locked` doubles as the uv.lock stability check: it fails if the lockfile +# is out of sync with the workspace manifests. +run uv sync --locked +# Boundaries: explicit workspace membership, uniform requires-python, shared +# tool pins, and generated package.json turbo wiring. +run uv run --frozen repo-chores sync --check +# Dependency rules: fully bounded version ranges, registry-only sources. +run uv run --frozen repo-chores lint +run uv run --frozen ruff format --check . +run uv run --frozen ruff check . +run uv run --frozen ty check +run uv run --frozen tach check +run uv run --frozen tach check-external + +exit "$FAILED" diff --git a/.config/mise/tasks/sync/python.sh b/.config/mise/tasks/sync/python.sh new file mode 100755 index 00000000000..643d6cc871b --- /dev/null +++ b/.config/mise/tasks/sync/python.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +#MISE description="Synchronize Python workspace membership, version bounds, and generated package.json wiring" +set -euo pipefail + +# Unfrozen on purpose: this is a fix task, so a stale uv.lock is expected +# input. `uv run` refreshes the lock and environment before running the tool. +mise exec uv -- uv run repo-chores sync +# Membership or dependency fixes change the workspace graph; refresh the lock +# and environment again so the result is immediately usable. +mise exec uv -- uv lock +mise exec uv -- uv sync diff --git a/.dockerignore b/.dockerignore index 61a8cd29fc9..a251fe11f19 100644 --- a/.dockerignore +++ b/.dockerignore @@ -136,6 +136,7 @@ blocks/**/.env **/pip-delete-this-directory.txt **/.mypy_cache **/.pytest_cache +**/.ruff_cache **/.hypothesis **/wheels diff --git a/.github/actions/prune-repository/action.yml b/.github/actions/prune-repository/action.yml index a1133a75169..8474d9cf28f 100644 --- a/.github/actions/prune-repository/action.yml +++ b/.github/actions/prune-repository/action.yml @@ -12,12 +12,15 @@ runs: shell: bash env: SCOPE: ${{ inputs.scope }} - run: python3 ${{ github.action_path }}/prune.py "$SCOPE" + # Executed directly: pruning happens before any dependencies are + # installed, so the script relies on the stdlib alone. `repo-chores + # prune` wraps the same logic for use inside the workspace environment. + run: python3 libs/@local/repo-chores/python/repo_chores/prune.py "$SCOPE" - name: Copy required files shell: bash run: | - cp -R Cargo.toml Cargo.lock rust-toolchain.toml oxfmt.config.ts infra/ out/ + cp -R Cargo.toml Cargo.lock rust-toolchain.toml oxfmt.config.ts pyproject.toml uv.lock infra/ out/ # Globs are fun, especially in Bash. Covers all dot-files except `.`, `..`, and `.git`. shopt -s extglob diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8b85c97272..535bd1e1be0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -97,6 +97,10 @@ jobs: | jq '[.tasks[] | select(.task == "lint:clippy" and .command != "")] != []' || echo 'false') echo "clippy=$CLIPPY" | tee -a $GITHUB_OUTPUT + RUFF=$(turbo run lint:ruff --filter '${{ matrix.name }}' --dry-run=json \ + | jq '[.tasks[] | select(.task == "lint:ruff" and .command != "")] != []' || echo 'false') + echo "ruff=$RUFF" | tee -a $GITHUB_OUTPUT + HAS_RUST=$([[ -f "${{ matrix.path }}/Cargo.toml" || ${{ matrix.path }} = "apps/hash-graph" ]] && echo 'true' || echo 'false') echo "has-rust=$HAS_RUST" | tee -a $GITHUB_OUTPUT @@ -129,6 +133,10 @@ jobs: if: always() && steps.lints.outputs.tsc == 'true' run: turbo run lint:tsc --filter "${{ matrix.name }}" + - name: Run ruff + if: always() && steps.lints.outputs.ruff == 'true' + run: turbo run lint:ruff --filter "${{ matrix.name }}" + - name: Run rustfmt if: always() && steps.lints.outputs.has-rust == 'true' working-directory: ${{ matrix.path }} @@ -282,6 +290,20 @@ jobs: exit 1 fi + - name: Run yarn lint:python + if: ${{ success() || failure() }} + run: | + if ! yarn lint:python; then + echo '' + echo '' + echo 'ℹ️ ℹ️ ℹ️' + echo 'Python checks failed (uv.lock stability, ruff, ty, or tach).' + echo 'Try running `yarn fix:python` locally to apply autofixes;' + echo 'ty and tach findings need to be fixed by hand.' + echo 'ℹ️ ℹ️ ℹ️' + exit 1 + fi + - name: Validate Claude Code skills if: ${{ success() || failure() }} run: | diff --git a/.gitignore b/.gitignore index d36c8241fe1..563dd821f72 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,7 @@ blocks/**/.env **/pip-delete-this-directory.txt **/.mypy_cache **/.pytest_cache +**/.ruff_cache **/.hypothesis **/wheels diff --git a/.lefthook.yml b/.lefthook.yml index f5dcdb9b442..6d45320cadb 100644 --- a/.lefthook.yml +++ b/.lefthook.yml @@ -31,6 +31,11 @@ pre-commit: glob: "*.rs" run: rustfmt {staged_files} || true stage_fixed: true + python: + tags: [backend, style] + glob: "*.py" + run: uv run --frozen ruff check --fix {staged_files} && uv run --frozen ruff format {staged_files} || true + stage_fixed: true toml: tags: [backend, style] glob: "*.toml" diff --git a/AGENTS.md b/AGENTS.md index 2575a5db8ff..eaee593d775 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,9 +61,11 @@ The HASH repository is organized into several key directories: - TypeScript type check: `yarn lint:tsc` - ESLint: `yarn lint:eslint` - Formatting check: `yarn lint:format` +- Python (ruff, ty, tach, dependency bounds, uv.lock stability, workspace sync): `mise run lint:python` - Fix ESLint issues: `yarn fix:eslint` - Fix formatting: `yarn fix:format` +- Fix Python (ruff autofixes + formatting, lock refresh): `mise run fix:python` ### For Specific Packages @@ -77,6 +79,10 @@ turbo run --filter '' cargo nextest run --package cargo test --package --doc # For doc tests cargo clippy --all-features --package + +# For Python packages (from the package directory) +uv run --frozen pytest tests +uv run --frozen ruff check . ``` For Rust packages, you can add features as needed with `--all-features`, specific features like `--features=foo,bar`, or use `cargo-hack` with `--feature-powerset` for comprehensive feature testing. @@ -91,6 +97,16 @@ mise run sync:turborepo # sync package.json identity + deps from Cargo.toml m `sync:turborepo` only manages that generated wiring — the `scripts` section is hand-maintained and is used by CI and Turborepo (e.g. `test:unit`, `lint:clippy`, `doc:dependency-diagram`), so add or edit scripts by hand. The task wraps the `repo-chores` CLI; the equivalent direct invocation is `cargo run --package hash-repo-chores --bin repo-chores-cli -- sync-turborepo`. A related task, `mise run fix:package-json`, sorts `package.json` keys consistently. +### Monorepo wiring for Python packages + +Python packages form a single uv workspace rooted at the repository's `pyproject.toml` (shared lockfile, virtual environment, and ruff/ty/tach configuration; Python version bound `>=3.14,<3.15`). After **adding, removing, or renaming a Python package**, or changing its workspace dependencies, re-sync the generated wiring: + +```bash +mise run sync:python # membership, requires-python, dev pins, package.json wiring, uv.lock +``` + +The task wraps the Python `repo-chores` CLI (`libs/@local/repo-chores/python`; direct invocation: `uv run repo-chores sync`). Unlike the Rust wiring, the managed `package.json` includes baseline `scripts` (`lint:ruff`, `fix:ruff`, and `test:unit` when a `tests/` directory exists) — ruff and pytest are requirements, not opt-ins. Extra scripts and other keys are preserved. CI runs `repo-chores sync --check` and fails on any drift, including `pyproject.toml` files that are not workspace members. Import boundaries for new packages are declared in a `tach.domain.toml` next to the package's code; global tach settings live under `[tool.tach]` in the root `pyproject.toml`. + ## Documentation Maintenance ### Petrinaut user-facing docs diff --git a/README.md b/README.md index 19b4d076664..8c8659749de 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,39 @@ This repository's contents is divided across several primary sections: +
+   Python tooling + +### Python tooling + +Python packages are managed as a single [uv](https://docs.astral.sh/uv/) workspace rooted at [`pyproject.toml`](/pyproject.toml), pinned to Python 3.14 (uv downloads the interpreter automatically; `mise install` provides uv itself). All packages share one lockfile (`uv.lock`), one virtual environment (`.venv`), and the workspace-level tool configuration: + +- **[ruff](https://docs.astral.sh/ruff/)** — linting (`ALL` rules + preview, with curated exceptions) and formatting +- **[ty](https://docs.astral.sh/ty/)** — type checking +- **[tach](https://docs.gauge.sh/)** — import-boundary and layering checks, configured under `[tool.tach]` in the root `pyproject.toml` +- **pytest** — tests, run per-package through turbo (`test:unit`) + +Common commands: + +```sh +uv sync # create/refresh .venv from uv.lock +mise run lint:python # everything CI runs: lock stability, sync --check, dependency lint, ruff, ty, tach +mise run fix:python # apply ruff autofixes + formatting, refresh the lock +mise run sync:python # regenerate the boring parts (see below) +``` + +To add a new Python package, create a directory with a `pyproject.toml` (any location; `libs/` or `tools/` are conventional) and run `mise run sync:python`. The sync command — implemented by [`libs/@local/repo-chores/python`](/libs/@local/repo-chores/python), the Python sibling of the Rust `sync-turborepo` tooling — generates and enforces: + +- explicit membership in the root `[tool.uv.workspace]` (no globs; CI fails on unlisted packages), +- a uniform `requires-python` bound across every member (the Python 3.14 boundary), +- shared ruff/pytest pins in each member's dev group, +- each member's `package.json` turbo wiring (`@python/` with managed `lint:ruff`/`fix:ruff`/`test:unit` scripts and `workspace:*` dependencies mirroring `[tool.uv.sources]`), +- coverage by the root `package.json` yarn workspace globs, plus the managed ruff `src` and pytest `testpaths` arrays. + +Module boundaries for new packages are declared in a `tach.domain.toml` next to the package's code (layer assignments; see [`libs/@local/repo-chores/python/repo_chores/tach.domain.toml`](/libs/@local/repo-chores/python/repo_chores/tach.domain.toml)). CI prunes per-package jobs with `turbo prune` (via `repo-chores prune`), which keeps the pruned tree a valid uv workspace by copying the manifests of pruned-out members. + +
+
  Environment variables diff --git a/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md b/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md index 6ecb8d03ac7..29db45e6300 100644 --- a/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md +++ b/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md @@ -28,9 +28,7 @@ The SIR model has two parameters, three uncolored places, and one metric. Uncolored initial states are supplied as token counts. ```python -with PetrinautClient( - model=Path("libs/@hashintel/petrinaut-cli/examples/sir-model.json") -) as client: +with PetrinautClient(model=Path("libs/@hashintel/petrinaut-cli/examples/sir-model.json")) as client: result = client.run( parameters={ "infection_rate": 1.5, @@ -57,9 +55,7 @@ attributes defined by that color: `x`, `y`, `direction`, and `velocity`. ```python with PetrinautClient( - model=Path( - "libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json" - ) + model=Path("libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json") ) as client: result = client.run( parameters={ @@ -92,9 +88,7 @@ This model combines token counts with several colors. `InboundShipments`, ```python with PetrinautClient( - model=Path( - "libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json" - ) + model=Path("libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json") ) as client: result = client.run( parameters={ diff --git a/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md b/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md index 4f0c253ac3c..b8a6ae76c80 100644 --- a/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md +++ b/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md @@ -108,6 +108,7 @@ import optuna client = PetrinautClient(CLI_PATH, MODEL_PATH) + def objective(trial): result = client.run( parameters={ @@ -122,6 +123,7 @@ def objective(trial): ) return result["metrics"]["Infected Fraction"] + study = optuna.create_study(direction="minimize") study.optimize(objective, n_trials=100, n_jobs=1) client.close() @@ -143,7 +145,7 @@ For parallel trials, create one client/process per Optuna worker. different color schemas: ```python - initialState={ + initialState = { "InboundShipments": [ {"eta": 1, "risk_score": 0.2, "source": 1, "cost": 10}, {"eta": 2, "risk_score": 0.4, "source": 2, "cost": 12}, diff --git a/libs/@hashintel/petrinaut-cli/README.md b/libs/@hashintel/petrinaut-cli/README.md index 6e77fa6ccb4..c3d465d3641 100644 --- a/libs/@hashintel/petrinaut-cli/README.md +++ b/libs/@hashintel/petrinaut-cli/README.md @@ -56,6 +56,7 @@ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect("/tmp/petrinaut.sock") stream = sock.makefile("rwb") + def request(payload): stream.write((json.dumps(payload) + "\n").encode()) stream.flush() @@ -64,6 +65,7 @@ def request(payload): raise RuntimeError(response["error"]["message"]) return response["result"] + metadata = request({"id": 1, "method": "metadata"}) result = request({ diff --git a/libs/@local/repo-chores/node/scripts/check-license-in-workspaces.ts b/libs/@local/repo-chores/node/scripts/check-license-in-workspaces.ts index b09aabb2947..398401c49e5 100644 --- a/libs/@local/repo-chores/node/scripts/check-license-in-workspaces.ts +++ b/libs/@local/repo-chores/node/scripts/check-license-in-workspaces.ts @@ -28,6 +28,7 @@ const script = async () => { "**/runner_venv/**", "**/target/**", "**/venv/**", + "**/.venv/**", ], }); diff --git a/libs/@local/repo-chores/python/LICENSE.md b/libs/@local/repo-chores/python/LICENSE.md new file mode 100644 index 00000000000..9a70d795493 --- /dev/null +++ b/libs/@local/repo-chores/python/LICENSE.md @@ -0,0 +1,606 @@ +# GNU Affero General Public License + +_Version 3, 19 November 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: **(1)** assert copyright on the software, and **(2)** offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based +on the Program. + +To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that **(1)** displays an appropriate copyright notice, and **(2)** +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work. + +A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other +than the work as a whole, that **(a)** is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and **(b)** serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +- **a)** The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- **b)** The work must carry prominent notices stating that it is + released under this License and any conditions added under section 7. + This requirement modifies the requirement in section 4 to + “keep intact all notices”. +- **c)** You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- **d)** If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +- **a)** Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- **b)** Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either **(1)** a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or **(2)** access to copy the + Corresponding Source from a network server at no charge. +- **c)** Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- **d)** Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- **e)** Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or **(2)** anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +- **a)** Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- **b)** Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- **c)** Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- **d)** Limiting the use for publicity purposes of names of licensors or + authors of the material; or +- **e)** Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- **f)** Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated **(a)** +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and **(b)** permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either **(1)** cause the Corresponding Source to be so +available, or **(2)** arrange to deprive yourself of the benefit of the +patent license for this particular work, or **(3)** arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license **(a)** in connection with copies of the covered work +conveyed by you (or copies made from those copies), or **(b)** primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/libs/@local/repo-chores/python/package.json b/libs/@local/repo-chores/python/package.json new file mode 100644 index 00000000000..ca1de8a3645 --- /dev/null +++ b/libs/@local/repo-chores/python/package.json @@ -0,0 +1,11 @@ +{ + "name": "@python/hash-repo-chores", + "version": "0.0.0-private", + "private": true, + "license": "AGPL-3", + "scripts": { + "fix:ruff": "uv run --frozen ruff check --fix . && uv run --frozen ruff format .", + "lint:ruff": "uv run --frozen ruff check . && uv run --frozen ruff format --check .", + "test:unit": "uv run --frozen pytest tests" + } +} diff --git a/libs/@local/repo-chores/python/pyproject.toml b/libs/@local/repo-chores/python/pyproject.toml new file mode 100644 index 00000000000..b826267cd04 --- /dev/null +++ b/libs/@local/repo-chores/python/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "hash-repo-chores" +version = "0.0.0" +description = "Keeps the boring parts of the Python workspace correct: membership, version bounds, and turbo wiring" +requires-python = ">=3.14,<3.15" +dependencies = [ + "packaging>=26.2,<27", + "tomlkit>=0.15,<0.16", +] + +[project.scripts] +repo-chores = "repo_chores.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["repo_chores"] + +[dependency-groups] +dev = [ + "pytest>=9.1.1,<10", + "ruff>=0.15.21,<0.16", +] diff --git a/libs/@local/repo-chores/python/repo_chores/__init__.py b/libs/@local/repo-chores/python/repo_chores/__init__.py new file mode 100644 index 00000000000..fa721554570 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/__init__.py @@ -0,0 +1 @@ +"""Workspace chores for the Python side of the monorepo.""" diff --git a/libs/@local/repo-chores/python/repo_chores/cli.py b/libs/@local/repo-chores/python/repo_chores/cli.py new file mode 100644 index 00000000000..7e07c5e17be --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/cli.py @@ -0,0 +1,107 @@ +"""Command-line entry point for the Python repo chores.""" + +import argparse +import sys +from pathlib import Path + +from repo_chores import prune +from repo_chores.lint import lint +from repo_chores.sync import synchronize +from repo_chores.workspace import WorkspaceError, find_workspace_root + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="repo-chores", + description="Keeps the boring parts of the Python workspace correct.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + sync_parser = subparsers.add_parser( + "sync", + help="Synchronize workspace membership, version bounds, and turbo wiring", + ) + sync_parser.add_argument( + "--check", + action="store_true", + help="Report deviations without fixing them (exit 1 if any are found)", + ) + + subparsers.add_parser( + "lint", + help="Lint dependency requirements: upper bounds and registry-only sources", + ) + + prune_parser = subparsers.add_parser( + "prune", + help="Prune the turbo workspace to the packages required for the given scopes", + ) + prune_parser.add_argument( + "scope", + help="Newline or space-separated list of package scopes to include.", + ) + prune_parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without executing turbo prune or creating stubs.", + ) + + return parser.parse_args(argv) + + +def run_sync(*, check: bool) -> None: + try: + root = find_workspace_root(Path.cwd()) + findings = synchronize(root, apply=not check) + except WorkspaceError as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error + + label = "would fix" if check else "fixed" + for finding in findings: + prefix = label if finding.fixable else "cannot fix" + print(f"{prefix}: {finding.path}: {finding.message}") + + if check and findings: + print( + f"\n{len(findings)} deviation(s) found." + " Run `mise run sync:python` (or `uv run repo-chores sync`) to fix.", + file=sys.stderr, + ) + raise SystemExit(1) + + if not check and any(not finding.fixable for finding in findings): + raise SystemExit(1) + + +def run_lint() -> None: + try: + root = find_workspace_root(Path.cwd()) + findings = lint(root) + except WorkspaceError as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error + + for finding in findings: + print(f"{finding.path}: {finding.message}") + + if findings: + raise SystemExit(1) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + + match args.command: + case "sync": + run_sync(check=args.check) + case "lint": + run_lint() + case "prune": + prune.run(args.scope, dry_run=args.dry_run) + case _: # pragma: no cover - argparse enforces the command set + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/libs/@local/repo-chores/python/repo_chores/lint.py b/libs/@local/repo-chores/python/repo_chores/lint.py new file mode 100644 index 00000000000..3b5ed138d62 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/lint.py @@ -0,0 +1,179 @@ +"""Lint the workspace's dependency requirements. + +Two rules, both judged with :mod:`packaging` semantics and reported as +findings (lint never edits): + +- every version range carries a lower and an upper bound (cargo-style, e.g. + `>=1.2.3,<2`): without an upper bound any upstream release can break the + build, and without a lower bound resolution may silently downgrade to any + historic release. A dependency can be exempted by naming it in the + manifest's `[tool.hash]` allowlist; unused allowlist entries are themselves + errors, so the list shrinks when exemptions expire. +- URL requirements are rejected: workspace members are wired through + `[tool.uv.sources]`, and everything else resolves from the registry. + +The entry point is :func:`lint`. +""" + +from collections.abc import Iterable +from pathlib import Path + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name + +from repo_chores.workspace import ( + INSECURE_BOUNDS_ALLOWLIST_KEY, + Finding, + Workspace, + load_workspace, +) + +LOWER_BOUND_OPERATORS = frozenset({">", ">=", "==", "===", "~="}) +"""Specifier operators that impose a lower bound on resolved versions.""" + +UPPER_BOUND_OPERATORS = frozenset({"<", "<=", "==", "===", "~="}) +"""Specifier operators that impose an upper bound on resolved versions.""" + + +def _missing_bounds(requirement: Requirement, /) -> str | None: + """Describe the missing bounds of a requirement, or None when fully bounded.""" + operators = {specifier.operator for specifier in requirement.specifier} + has_lower = bool(operators & LOWER_BOUND_OPERATORS) + has_upper = bool(operators & UPPER_BOUND_OPERATORS) + + match has_lower, has_upper: + case True, True: + return None + case True, False: + return "has no upper bound, so any upstream release can break the build" + case False, True: + return ( + "has no lower bound, so resolution may silently downgrade to any historic release" + ) + case _: + return "has no version bounds at all" + + +def _lint_requirements( + *, + path: str, + requirements: Iterable[str], + allowlist: frozenset[str], + workspace_members: frozenset[str], +) -> tuple[list[Finding], frozenset[str]]: + """Judge one manifest's requirement strings. + + Returns the findings together with the allowlist entries that actually + exempted something, so the caller can flag the stale remainder. + """ + findings: list[Finding] = [] + used_exemptions: set[str] = set() + + for text in requirements: + try: + requirement = Requirement(text) + except InvalidRequirement as error: + findings.append( + Finding(path=path, message=f"invalid requirement: {error}", fixable=False) + ) + continue + + name = canonicalize_name(requirement.name) + + if requirement.url is not None: + findings.append( + Finding( + path=path, + message=f"`{text}` is a URL requirement; workspace members are wired" + " through [tool.uv.sources], and everything else must resolve from" + " the registry", + fixable=False, + ) + ) + continue + + if name in workspace_members: + continue + + problem = _missing_bounds(requirement) + if problem is None: + continue + + if name in allowlist: + used_exemptions.add(name) + continue + + findings.append( + Finding( + path=path, + message=f"`{text}` {problem}; pin a full range (e.g. `>=X.Y.Z, list[Finding]: + return [ + Finding( + path=path, + message=f'"{name}" in `[tool.hash] {INSECURE_BOUNDS_ALLOWLIST_KEY}` exempts' + " nothing; remove it", + fixable=False, + ) + for name in allowlist + if canonicalize_name(name) not in used + ] + + +def _lint_manifest( + *, + path: str, + requirements: tuple[str, ...], + allowlist: tuple[str, ...], + workspace_members: frozenset[str], +) -> list[Finding]: + canonical_allowlist = frozenset(canonicalize_name(name) for name in allowlist) + findings, used = _lint_requirements( + path=path, + requirements=requirements, + allowlist=canonical_allowlist, + workspace_members=workspace_members, + ) + findings.extend(_stale_exemption_findings(path=path, allowlist=allowlist, used=used)) + return findings + + +def lint_workspace(workspace: Workspace, /) -> list[Finding]: + """Judge every manifest's requirements against the dependency rules.""" + members = frozenset( + canonicalize_name(member.name) for member in workspace.members if member.name is not None + ) + + findings = _lint_manifest( + path="pyproject.toml", + requirements=workspace.dev_dependencies, + allowlist=workspace.insecure_bounds_allowlist, + workspace_members=members, + ) + for member in workspace.members: + findings.extend( + _lint_manifest( + path=f"{member.directory}/pyproject.toml", + requirements=member.dependencies + member.dev_dependencies, + allowlist=member.insecure_bounds_allowlist, + workspace_members=members, + ) + ) + + return findings + + +def lint(root: Path, /) -> list[Finding]: + """Load the workspace at `root` and judge its dependency requirements.""" + return lint_workspace(load_workspace(root)) diff --git a/.github/actions/prune-repository/prune.py b/libs/@local/repo-chores/python/repo_chores/prune.py old mode 100644 new mode 100755 similarity index 56% rename from .github/actions/prune-repository/prune.py rename to libs/@local/repo-chores/python/repo_chores/prune.py index e1eca864964..83905603f3a --- a/.github/actions/prune-repository/prune.py +++ b/libs/@local/repo-chores/python/repo_chores/prune.py @@ -1,24 +1,25 @@ #!/usr/bin/env python3 -"""Prunes the turbo workspace to only the packages required for the given scope(s). +"""Prune the turbo workspace to the packages required for the given scope(s). -Handles cyclic and extra dependencies that turbo's dependency graph doesn't track. +Scopes are expanded with dependency rules that turbo's graph leaves out +(cyclic and test-data crates), and the pruned output stays a valid Cargo and +uv workspace: members that turbo removed are stubbed back in. + +This file runs directly (`python3 prune.py`) before any dependencies are +installed, so it stays stdlib-only and self-contained. `repo-chores prune` +exposes the same logic inside the workspace environment. """ import argparse import json import subprocess +import tomllib from collections.abc import Iterable -from glob import glob from pathlib import Path -import tomllib - -# --------------------------------------------------------------------------- -# Extra dependency rules that turbo doesn't track -# --------------------------------------------------------------------------- - -# If any of these packages appear in the dependency closure, add the -# corresponding extra packages to the scope. +# Dependency rules that turbo's graph does not track. When any package whose +# name starts with a key appears in the dependency closure, the listed extra +# packages join the scope. EXTRA_DEPENDENCIES: dict[str, list[str]] = { # Cyclic: hashql crates need compiletest "@rust/hashql-ast": ["@rust/hashql-compiletest"], @@ -56,7 +57,6 @@ def turbo_dependency_map() -> dict[str, frozenset[str]]: """Query turbo for all packages and return a map of package name to its dependencies.""" - result = subprocess.run( ["turbo", "query", TURBO_QUERY], capture_output=True, @@ -78,7 +78,6 @@ def expand_scopes(dependencies: Iterable[str]) -> frozenset[str]: A rule triggers when any dependency name starts with the trigger prefix. """ - extras: set[str] = set() for trigger, additions in EXTRA_DEPENDENCIES.items(): @@ -121,34 +120,26 @@ def turbo_prune(scopes: Iterable[str], *, dry_run: bool = False) -> None: subprocess.run(args, check=True) -def resolve_workspace_members() -> list[Path]: +def resolve_cargo_members(root: Path) -> list[Path]: """Read workspace members from Cargo.toml and resolve globs to Cargo.toml paths.""" - - with open("Cargo.toml", "rb") as fh: + with (root / "Cargo.toml").open("rb") as fh: cargo = tomllib.load(fh) members: list[str] = cargo["workspace"]["members"] - paths: list[Path] = [] - - for member in members: - for match in glob(f"{member}/Cargo.toml"): - paths.append(Path(match)) - return paths + return [match for member in members for match in root.glob(f"{member}/Cargo.toml")] -def stub_missing_members(*, dry_run: bool = False) -> None: +def stub_missing_cargo_members(root: Path, out: Path, *, dry_run: bool = False) -> None: """Create dummy Cargo.toml stubs for workspace members not included by turbo prune.""" - - for cargo_path in resolve_workspace_members(): - directory = cargo_path.parent - out_cargo = Path("out") / directory / "Cargo.toml" + for cargo_path in resolve_cargo_members(root): + out_cargo = out / cargo_path.relative_to(root) if out_cargo.exists(): continue # Read the package name from the real Cargo.toml - with open(cargo_path, "rb") as fh: + with cargo_path.open("rb") as fh: pkg = tomllib.load(fh) name = pkg.get("package", {}).get("name") @@ -160,12 +151,60 @@ def stub_missing_members(*, dry_run: bool = False) -> None: continue # Create the stub - out_dir = out_cargo.parent - src_dir = out_dir / "src" + src_dir = out_cargo.parent / "src" src_dir.mkdir(parents=True, exist_ok=True) - (src_dir / "lib.rs").write_text("\n") - out_cargo.write_text(f'[package]\nname = "{name}"\nedition.workspace = true\n') + (src_dir / "lib.rs").write_text("\n", encoding="utf-8") + stub = f'[package]\nname = "{name}"\nedition.workspace = true\n' + out_cargo.write_text(stub, encoding="utf-8") + + +def resolve_uv_members(root: Path) -> list[Path]: + """Read workspace member globs from pyproject.toml and resolve to pyproject.toml paths.""" + pyproject_path = root / "pyproject.toml" + if not pyproject_path.exists(): + return [] + + with pyproject_path.open("rb") as fh: + pyproject = tomllib.load(fh) + + workspace = pyproject.get("tool", {}).get("uv", {}).get("workspace", {}) + members: list[str] = workspace.get("members", []) + excluded = {match for pattern in workspace.get("exclude", []) for match in root.glob(pattern)} + + return [ + match + for member in members + for match in root.glob(f"{member}/pyproject.toml") + if match.parent not in excluded + ] + + +def stub_missing_uv_members(root: Path, out: Path, *, dry_run: bool = False) -> None: + """Copy manifests for uv workspace members that turbo prune left out. + + uv validates the whole workspace graph against uv.lock even with `--frozen`, + so every member's pyproject.toml must exist in the pruned output. The + manifest alone is enough: members are only installed when requested, so + the pruned-away sources are never built. + """ + for member_pyproject in resolve_uv_members(root): + out_pyproject = out / member_pyproject.relative_to(root) + + if out_pyproject.exists(): + continue + + if dry_run: + print(f"[dry-run] Would stub: {out_pyproject}") + continue + + out_pyproject.parent.mkdir(parents=True, exist_ok=True) + out_pyproject.write_bytes(member_pyproject.read_bytes()) + + +def parse_scopes(scope: str) -> frozenset[str]: + """Split a newline or space-separated scope list into individual scopes.""" + return frozenset(s.strip() for line in scope.splitlines() for s in line.split() if s.strip()) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -184,17 +223,23 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) -def main(argv: list[str] | None = None) -> None: - args = parse_args(argv) - - initial = { - s.strip() for line in args.scope.splitlines() for s in line.split() if s.strip() - } +def run(scope: str, *, dry_run: bool = False) -> None: + """Expand the scopes, run `turbo prune`, and stub pruned-out workspace members.""" + initial = parse_scopes(scope) dependencies = turbo_dependency_map() scopes = fixpoint_expand(initial, dependencies) - turbo_prune(scopes, dry_run=args.dry_run) - stub_missing_members(dry_run=args.dry_run) + turbo_prune(scopes, dry_run=dry_run) + + root = Path.cwd() + out = root / "out" + stub_missing_cargo_members(root, out, dry_run=dry_run) + stub_missing_uv_members(root, out, dry_run=dry_run) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + run(args.scope, dry_run=args.dry_run) if __name__ == "__main__": diff --git a/libs/@local/repo-chores/python/repo_chores/sync.py b/libs/@local/repo-chores/python/repo_chores/sync.py new file mode 100644 index 00000000000..46ad52ba6d3 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/sync.py @@ -0,0 +1,462 @@ +"""Check and fix the workspace invariants. + +Every rule reports deviations as :class:`Finding` records; in apply mode the fixable +are written back to disk: + +- every discovered pyproject.toml is an explicit uv workspace member, +- every member is reachable through the root package.json yarn workspace globs, +- every member declares the workspace's exact requires-python bound, +- every member's dev group carries the shared ruff (and, with tests, pytest) pins, +- every member has a package.json wiring it into turbo (`@python/`), +- the root manifest's ruff `src`, pytest `testpaths`, and tach `source_roots` + cover every member. + +Manifest edits are made in place; comments, ordering, and formatting survive +every fix. The entry point is :func:`synchronize`. +""" + +import json +import shutil +import subprocess +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from functools import cache +from pathlib import Path, PurePosixPath + +import tomlkit +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from tomlkit.items import Array + +from repo_chores.toml_edit import ( + array_to_str, + navigate_to_array, + navigate_to_table, + reconcile_string_array, +) +from repo_chores.workspace import ( + Finding, + Member, + Workspace, + WorkspaceError, + load_workspace, +) + +RUFF_FIX_SCRIPT = "uv run --frozen ruff check --fix . && uv run --frozen ruff format ." +RUFF_LINT_SCRIPT = "uv run --frozen ruff check . && uv run --frozen ruff format --check ." +PYTEST_SCRIPT = "uv run --frozen pytest tests" + +DEFAULT_LICENSE = "AGPL-3" +NPM_SCOPE = "@python" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManifestExpectation: + """A managed string array in the root manifest and its expected contents. + + The expected values are deduplicated and sorted on construction, so every + comparison and reconciliation downstream sees the canonical form. + """ + + table_path: tuple[str, ...] + key: str + expected: tuple[str, ...] + description: str + + def __post_init__(self) -> None: + object.__setattr__(self, "expected", tuple(sorted(set(self.expected)))) + + +def _write(*, path: Path, content: str, apply: bool) -> None: + """Write `content` to `path` in apply mode; check mode leaves disk untouched.""" + if apply: + path.write_text(content, encoding="utf-8") + + +@cache +def _oxfmt_executable() -> str: + """Resolve the oxfmt binary once per run. + + Raises :exc:`WorkspaceError` when oxfmt is not on PATH; `mise install` + provisions it. + """ + executable = shutil.which("oxfmt") + if executable is None: + raise WorkspaceError("oxfmt not found on PATH; run `mise install` to provision it") + + return executable + + +@cache +def _format_package_json(content: str, /, *, root: Path) -> str: + """Format package.json content with the repository formatter. + + oxfmt discovers its configuration from `root`, including package.json + key and script sorting, so generated files match `yarn lint:format` + byte for byte and the canonical ordering has a single owner. Results + are cached: formatting is pure in `content` and the configuration at + `root`. + + Raises :exc:`WorkspaceError` when oxfmt rejects the content. + """ + result = subprocess.run( + [_oxfmt_executable(), "--stdin-filepath=package.json"], + input=content, + capture_output=True, + text=True, + cwd=root, + check=False, + ) + if result.returncode != 0: + raise WorkspaceError(f"oxfmt failed to format package.json: {result.stderr.strip()}") + + return result.stdout + + +def _pins_by_name(specifiers: Iterable[str], /) -> dict[str, str]: + """Index verbatim requirement strings by canonical distribution name. + + Canonicalization follows the PyPA rules, so `Foo.Bar` and `foo-bar` name + the same distribution and compare equal. + + Raises :exc:`InvalidRequirement` when a specifier does not parse. + """ + return {canonicalize_name(Requirement(pin).name): pin for pin in specifiers} + + +def _upsert_requirement(dev_group: Array, /, *, tool: str, specifier: str) -> None: + """Replace the requirement pinning `tool` in place, or append `specifier`. + + In-place replacement keeps the entry's position and trivia; the pinned + tool keeps its spot in the list when only the version spec changes. + """ + # The entries were already parsed when the member's pins were indexed, + # so the Requirement construction here cannot fail. + for index, existing in enumerate(array_to_str(dev_group)): + if existing is not None and canonicalize_name(Requirement(existing).name) == tool: + dev_group[index] = specifier + return + + if not dev_group: + dev_group.multiline(multiline=True) + dev_group.append(specifier) + + +def _named_members(workspace: Workspace) -> list[Member]: + """Select the members that carry a `[project]` name.""" + return [member for member in workspace.members if member.name is not None] + + +def _sync_root_manifest(workspace: Workspace, /, *, apply: bool) -> list[Finding]: + """Reconcile the managed arrays of the root manifest. + + Managed arrays are the uv workspace members, the ruff src roots, the + pytest testpaths, and the tach source roots; each must list exactly the + discovered members (or, for testpaths, the members with a tests + directory). Arrays containing non-string entries are reported as + unfixable and left untouched. + """ + manifest_path = workspace.root / "pyproject.toml" + document = tomlkit.parse(manifest_path.read_text(encoding="utf-8")) + findings: list[Finding] = [] + + member_directories = tuple(member.directory for member in _named_members(workspace)) + test_directories = tuple( + f"{member.directory}/tests" for member in _named_members(workspace) if member.has_tests + ) + + expectations = ( + ManifestExpectation( + table_path=("tool", "uv", "workspace"), + key="members", + expected=member_directories, + description="workspace members", + ), + ManifestExpectation( + table_path=("tool", "ruff"), + key="src", + expected=member_directories, + description="ruff src roots", + ), + ManifestExpectation( + table_path=("tool", "pytest", "ini_options"), + key="testpaths", + expected=tuple(test_directories), + description="pytest testpaths", + ), + ManifestExpectation( + table_path=("tool", "tach"), + key="source_roots", + expected=member_directories, + description="tach source roots", + ), + ) + + for expectation in expectations: + table = navigate_to_table(document, path=expectation.table_path) + current = navigate_to_array(table, path=(expectation.key,)) + current_items = list(array_to_str(current)) + + if any(item is None for item in current_items): + findings.append( + Finding( + path="pyproject.toml", + message=f"{expectation.description} must only contain strings", + fixable=False, + ) + ) + continue + + if tuple(current_items) == expectation.expected: + continue + + findings.append( + Finding( + path="pyproject.toml", + message=f"{expectation.description} must list every member:" + f" {', '.join(expectation.expected)}", + ) + ) + reconcile_string_array(current, expected=expectation.expected) + + if findings: + _write(path=manifest_path, content=tomlkit.dumps(document), apply=apply) + + return findings + + +def _sync_root_package_json(workspace: Workspace, /, *, apply: bool) -> list[Finding]: + """Ensure every member is reachable through the yarn workspace globs. + + turbo discovers packages through the root package.json workspaces list; + a member outside every glob is invisible to it. Coverage is judged with + path-aware glob semantics (`*` stays within one segment, `**` recurses), + matching how yarn reads the patterns. Missing members are added as + explicit entries and the list is kept sorted. + """ + package_json_path = workspace.root / "package.json" + data = json.loads(package_json_path.read_text(encoding="utf-8")) + + patterns = [ + pattern + for pattern in data.get("workspaces", {}).get("packages", []) + if isinstance(pattern, str) and not pattern.startswith("!") + ] + + findings: list[Finding] = [] + for member in _named_members(workspace): + directory = PurePosixPath(member.directory) + if any(directory.full_match(pattern) for pattern in patterns): + continue + + findings.append( + Finding( + path="package.json", + message=( + f"{member.directory} is not covered by workspaces.packages; turbo cannot see it" + ), + ) + ) + + data["workspaces"]["packages"] = sorted({*data["workspaces"]["packages"], member.directory}) + + if findings: + content = _format_package_json(json.dumps(data), root=workspace.root) + _write(path=package_json_path, content=content, apply=apply) + + return findings + + +def _unnamed_member_findings(workspace: Workspace) -> list[Finding]: + """Report manifests that lack a `[project]` name. + + uv rejects nameless workspace members, so these manifests stay out of + the managed arrays and surface as unfixable findings. + """ + return [ + Finding( + path=f"{member.directory}/pyproject.toml", + message=( + "manifest has no [project] name; every pyproject.toml must be a" + " workspace member (move tool-only configuration into the root manifest)" + ), + fixable=False, + ) + for member in workspace.members + if member.name is None + ] + + +def _sync_member_dev_group( + workspace: Workspace, + member: Member, + /, + *, + document: tomlkit.TOMLDocument, +) -> list[Finding]: + """Align a member's dev group with the workspace tool pins. + + Every member needs ruff (its lint scripts run `uv run --frozen ruff` from + the member directory), and members with tests need pytest. Each pin must + match the root dev group's specifier exactly, so the shared lockfile + resolves one version per tool. + """ + try: + root_specifiers = _pins_by_name(workspace.dev_dependencies) + except InvalidRequirement as error: + return [ + Finding( + path="pyproject.toml", message=f"invalid dev requirement: {error}", fixable=False + ) + ] + try: + member_specifiers = _pins_by_name(member.dev_dependencies) + except InvalidRequirement as error: + return [ + Finding( + path=f"{member.directory}/pyproject.toml", + message=f"invalid dev requirement: {error}", + fixable=False, + ) + ] + + required_tools = ["ruff", *(["pytest"] if member.has_tests else [])] + + findings: list[Finding] = [] + for tool in required_tools: + root_specifier = root_specifiers.get(tool) + if root_specifier is None: + findings.append( + Finding( + path="pyproject.toml", + message=f"the root dev group does not pin {tool}, so members cannot inherit it", + fixable=False, + ) + ) + continue + + if member_specifiers.get(tool) == root_specifier: + continue + + findings.append( + Finding( + path=f"{member.directory}/pyproject.toml", + message=f'dev group must pin "{root_specifier}" (matching the workspace root)', + ) + ) + dev_group = navigate_to_array(document, path=("dependency-groups", "dev")) + _upsert_requirement(dev_group, tool=tool, specifier=root_specifier) + + return findings + + +def _sync_member_manifest(workspace: Workspace, member: Member, /, *, apply: bool) -> list[Finding]: + """Align a member manifest with the workspace: requires-python and tool pins.""" + manifest_path = workspace.root / member.directory / "pyproject.toml" + document = tomlkit.parse(manifest_path.read_text(encoding="utf-8")) + findings: list[Finding] = [] + + if member.requires_python != workspace.requires_python: + findings.append( + Finding( + path=f"{member.directory}/pyproject.toml", + message=f'requires-python must be "{workspace.requires_python}"' + f" (found {member.requires_python!r})", + ) + ) + + table = navigate_to_table(document, path=("project",)) + table["requires-python"] = workspace.requires_python + + findings.extend(_sync_member_dev_group(workspace, member, document=document)) + + if findings: + _write(path=manifest_path, content=tomlkit.dumps(document), apply=apply) + + return findings + + +def assemble_package_json( + member: Member, existing: Mapping[str, object] | None, / +) -> dict[str, object]: + """Assemble the package.json contents wiring a member into turbo. + + Managed keys (name, version, private, the ruff and pytest scripts, and + the dependencies mirroring the member's workspace dependencies) are + overwritten; everything else in `existing`, including hand-written + scripts, is preserved. Key and script ordering is left to the repository + formatter, which owns the canonical package.json layout. + """ + data: dict[str, object] = dict(existing or {}) + scripts = data.get("scripts") + scripts = dict(scripts) if isinstance(scripts, dict) else {} + + scripts["fix:ruff"] = RUFF_FIX_SCRIPT + scripts["lint:ruff"] = RUFF_LINT_SCRIPT + if member.has_tests: + scripts["test:unit"] = PYTEST_SCRIPT + elif scripts.get("test:unit") == PYTEST_SCRIPT: + # Managed script only; hand-written test:unit entries survive. + del scripts["test:unit"] + + data["name"] = f"{NPM_SCOPE}/{member.name}" + data["version"] = "0.0.0-private" + data["private"] = True + data.setdefault("license", DEFAULT_LICENSE) + data["scripts"] = scripts + + dependencies = { + f"{NPM_SCOPE}/{dependency}": "workspace:*" for dependency in member.workspace_dependencies + } + if dependencies: + data["dependencies"] = dependencies + else: + data.pop("dependencies", None) + + return data + + +def _sync_member_package_json( + workspace: Workspace, member: Member, /, *, apply: bool +) -> list[Finding]: + """Ensure a member's package.json matches the formatted assembly byte for byte.""" + package_json_path = workspace.root / member.directory / "package.json" + original = ( + package_json_path.read_text(encoding="utf-8") if package_json_path.is_file() else None + ) + existing = json.loads(original) if original is not None else None + + assembled = assemble_package_json(member, existing) + expected = _format_package_json(json.dumps(assembled), root=workspace.root) + if original == expected: + return [] + + _write(path=package_json_path, content=expected, apply=apply) + return [ + Finding( + path=f"{member.directory}/package.json", + message="turbo wiring is missing or out of date" + f" (managed by `repo-chores sync`, name {NPM_SCOPE}/{member.name})", + ) + ] + + +def synchronize(root: Path, /, *, apply: bool) -> list[Finding]: + """Check every workspace invariant, fixing fixable deviations when `apply` is set. + + Check mode never writes; apply mode writes each corrected file once. The + returned findings describe every deviation that was found, fixed or not. + """ + workspace = load_workspace(root) + + findings = [ + *_sync_root_manifest(workspace, apply=apply), + *_sync_root_package_json(workspace, apply=apply), + *_unnamed_member_findings(workspace), + ] + + for member in _named_members(workspace): + findings.extend(_sync_member_manifest(workspace, member, apply=apply)) + findings.extend(_sync_member_package_json(workspace, member, apply=apply)) + + return findings diff --git a/libs/@local/repo-chores/python/repo_chores/tach.domain.toml b/libs/@local/repo-chores/python/repo_chores/tach.domain.toml new file mode 100644 index 00000000000..4fceb8d6b7d --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/tach.domain.toml @@ -0,0 +1,33 @@ +# repo-chores layering: the workspace model (domain) feeds the TOML editing +# helpers (capability) and the sync engine (orchestration), which the CLI +# (application) drives. The package root stays empty. + +[root] +layer = "domain" + +[[modules]] +layer = "domain" +path = "workspace" + +[[modules]] +layer = "capability" +path = "toml_edit" + +[[modules]] +layer = "orchestration" +path = "sync" + +[[modules]] +layer = "orchestration" +path = "lint" + +# prune runs before anything is installed: the bootstrap layer forbids every +# internal import, and the empty external list forbids third-party ones. +[[modules]] +depends_on_external = [] +layer = "bootstrap" +path = "prune" + +[[modules]] +layer = "application" +path = "cli" diff --git a/libs/@local/repo-chores/python/repo_chores/toml_edit.py b/libs/@local/repo-chores/python/repo_chores/toml_edit.py new file mode 100644 index 00000000000..4869c3bbb92 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/toml_edit.py @@ -0,0 +1,107 @@ +"""In-place editing of parsed TOML documents. + +:func:`navigate_to_table` and :func:`navigate_to_array` descend to a node along a +key path, creating missing levels and validating the shape of what they +find. :func:`reconcile_string_array` rewrites a managed array to an expected +value set, and :func:`array_to_str` exposes array elements for string-only +processing. + +Edits are local: everything an edit leaves alone keeps its comments, +ordering, and formatting, so a manifest survives a parse-adjust-dump +round-trip faithfully. +""" + +from collections.abc import Generator, Iterable, MutableMapping, Sequence +from typing import TypeIs + +import tomlkit +from tomlkit.items import Array, String + +from repo_chores.workspace import WorkspaceError + +type TomlTable = MutableMapping[str, object] +"""A live table node inside a parsed tomlkit document.""" + + +def is_table(value: object, /) -> TypeIs[TomlTable]: + """Narrow a tomlkit node to a live table. + + Indexing a parsed document yields :class:`Table` nodes, or + :class:`OutOfOrderTableProxy` nodes for a super-table whose subtables are + split across the file. Both are mutable mappings with string keys, and + both preserve trivia through mutation, so the narrowed type accepts + either. + """ + return isinstance(value, MutableMapping) + + +def navigate_to_table(table: TomlTable, /, *, path: Iterable[str]) -> TomlTable: + """Descend to (creating, if necessary) the table at `path`. + + Raises :exc:`WorkspaceError` when a key on the way holds a non-table value. + """ + for key in path: + if key not in table: + table[key] = tomlkit.table() + + value = table[key] + if not is_table(value): + raise WorkspaceError(f"expected a table at [{key}], found {type(value).__name__}") + + table = value + + return table + + +def navigate_to_array(table: TomlTable, /, *, path: Sequence[str]) -> Array: + """Descend to (creating, if necessary) the array at `path`. + + Raises :exc:`WorkspaceError` when the final key holds a non-array value. + """ + [*prefix, key] = path + table = navigate_to_table(table, path=prefix) + if key not in table: + table[key] = tomlkit.array() + + value = table[key] + if not isinstance(value, Array): + raise WorkspaceError(f"expected an array at `{key}`, found {type(value).__name__}") + + return value + + +def array_to_str(array: Array, /) -> Generator[str | None]: + """Yield each array element as a string, or None for elements of other types.""" + for item in array: + yield item if isinstance(item, String) else None + + +def insert_sorted(array: Array, value: str, /) -> None: + """Insert `value` before the first larger element, keeping a sorted array sorted.""" + for index, item in enumerate(array): + if item > value: + array.insert(index, value) + return + + array.append(value) + + +def reconcile_string_array(array: Array, /, *, expected: Sequence[str]) -> None: + """Mutate a strings-only array to exactly the sorted `expected` values. + + Stale and duplicate entries are removed, missing ones inserted in sorted + position. Surviving entries are untouched, so their comments and + formatting are preserved. + """ + remaining = set(expected) + for index in reversed(range(len(array))): + item = array[index] + if item in remaining: + remaining.discard(item) + else: + del array[index] + + if not array: + array.multiline(multiline=True) + for value in remaining: + insert_sorted(array, value) diff --git a/libs/@local/repo-chores/python/repo_chores/workspace.py b/libs/@local/repo-chores/python/repo_chores/workspace.py new file mode 100644 index 00000000000..c83fe3fde1e --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/workspace.py @@ -0,0 +1,225 @@ +"""Discovery and shared model of the Python workspace. + +The workspace is rooted at the pyproject.toml declaring `[tool.uv.workspace]`. +:func:`load_workspace` parses that manifest, discovers every member manifest in +the repository, and returns an immutable :class:`Workspace` snapshot. + +tomllib parses into untyped dictionaries; every value crossing this module's +boundary is narrowed to a checked type. +""" + +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TypeIs + +PRUNED_DIRECTORIES = frozenset({ + ".git", + ".ruff_cache", + ".turbo", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "out", + "target", + "venv", +}) +"""Directory names that never contain workspace members and are skipped during discovery.""" + + +class WorkspaceError(RuntimeError): + """Raised when the workspace or one of its manifests cannot be interpreted.""" + + +INSECURE_BOUNDS_ALLOWLIST_KEY = ( + "allow_insecure_unbounded_dependency_ranges_that_i_will_personally_fix_when_they_break" +) +"""The `[tool.hash]` key exempting named dependencies from the upper-bound lint. + +The name is deliberately painful: an unbounded range is a standing invitation +for an upstream release to break the build, so opting out should read like +the commitment it is.""" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Finding: + """A single deviation from the expected workspace state. + + A fixable finding can be corrected automatically in apply mode; an + unfixable finding requires a manual edit and fails the run either way. + """ + + path: str + message: str + fixable: bool = True + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Member: + """A Python package manifest discovered in the repository.""" + + directory: str + """Posix path of the package directory, relative to the workspace root.""" + + name: str | None + """The distribution name, or None when the manifest has no `[project]` name.""" + + requires_python: str | None + dependencies: tuple[str, ...] + dev_dependencies: tuple[str, ...] + workspace_dependencies: tuple[str, ...] + insecure_bounds_allowlist: tuple[str, ...] + has_tests: bool + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Workspace: + """The workspace root manifest together with every discovered member.""" + + root: Path + requires_python: str + declared_members: tuple[str, ...] + dev_dependencies: tuple[str, ...] + insecure_bounds_allowlist: tuple[str, ...] + members: tuple[Member, ...] + + +def _load_toml(path: Path, /) -> Mapping[str, object]: + """Parse a TOML file, wrapping parse failures in :exc:`WorkspaceError`.""" + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as error: + raise WorkspaceError(f"{path} is not valid TOML: {error}") from error + + +def _is_table(value: object, /) -> TypeIs[Mapping[str, object]]: + """Narrow a parsed TOML value to a table. + + TOML keys are always strings, so a Mapping instance check is sufficient + to justify the declared key type. + """ + return isinstance(value, Mapping) + + +def _table_at(table: Mapping[str, object], /, *path: str) -> Mapping[str, object]: + """Descend through nested tables, returning an empty mapping where absent.""" + for key in path: + value = table.get(key) + if not _is_table(value): + return {} + table = value + + return table + + +def _str_at(table: Mapping[str, object], key: str, /) -> str | None: + """Read a string value, returning None when absent or of another type.""" + value = table.get(key) + return value if isinstance(value, str) else None + + +def _str_entries(table: Mapping[str, object], key: str, /) -> tuple[str, ...]: + """Collect the string entries of a list value, skipping entries of other types.""" + value = table.get(key) + if not isinstance(value, list): + return () + + return tuple(entry for entry in value if isinstance(entry, str)) + + +def _dev_dependencies(manifest: Mapping[str, object], /) -> tuple[str, ...]: + """Read the dev dependency group of a manifest.""" + return _str_entries(_table_at(manifest, "dependency-groups"), "dev") + + +def _insecure_bounds_allowlist(manifest: Mapping[str, object], /) -> tuple[str, ...]: + """Read the manifest's upper-bound lint exemptions.""" + return _str_entries(_table_at(manifest, "tool", "hash"), INSECURE_BOUNDS_ALLOWLIST_KEY) + + +def find_workspace_root(start: Path, /) -> Path: + """Walk upwards from `start` to the manifest declaring the uv workspace. + + Raises :exc:`WorkspaceError` when no ancestor declares `[tool.uv.workspace]`. + """ + for directory in (start, *start.parents): + manifest_path = directory / "pyproject.toml" + if not manifest_path.is_file(): + continue + + if "workspace" in _table_at(_load_toml(manifest_path), "tool", "uv"): + return directory + + raise WorkspaceError(f"no pyproject.toml with a [tool.uv.workspace] above {start}") + + +def find_member_manifests(root: Path, /) -> list[Path]: + """Find every pyproject.toml under `root` except the workspace root's own. + + Traversal skips :data:`PRUNED_DIRECTORIES` and returns paths in sorted order, + so discovery is deterministic across runs and platforms. + """ + manifests: list[Path] = [] + for directory, directory_names, file_names in root.walk(): + directory_names[:] = sorted(set(directory_names) - PRUNED_DIRECTORIES) + if directory != root and "pyproject.toml" in file_names: + manifests.append(directory / "pyproject.toml") + + return sorted(manifests) + + +def load_member(root: Path, manifest_path: Path, /) -> Member: + """Parse a member manifest into a :class:`Member`. + + Workspace dependencies are the `[tool.uv.sources]` entries marked + `workspace = true`. + """ + manifest = _load_toml(manifest_path) + project = _table_at(manifest, "project") + + workspace_dependencies = tuple( + sorted( + dependency + for dependency, source in _table_at(manifest, "tool", "uv", "sources").items() + if _is_table(source) and source.get("workspace") is True + ) + ) + + return Member( + directory=manifest_path.parent.relative_to(root).as_posix(), + name=_str_at(project, "name"), + requires_python=_str_at(project, "requires-python"), + dependencies=_str_entries(project, "dependencies"), + dev_dependencies=_dev_dependencies(manifest), + workspace_dependencies=workspace_dependencies, + insecure_bounds_allowlist=_insecure_bounds_allowlist(manifest), + has_tests=(manifest_path.parent / "tests").is_dir(), + ) + + +def load_workspace(root: Path, /) -> Workspace: + """Parse the root manifest and discover every member in the repository. + + Raises :exc:`WorkspaceError` when the root manifest declares no + requires-python bound; that bound is the single source of truth for the + workspace's Python version. + """ + manifest = _load_toml(root / "pyproject.toml") + + requires_python = _str_at(_table_at(manifest, "project"), "requires-python") + if requires_python is None: + raise WorkspaceError(f"{root / 'pyproject.toml'} does not declare requires-python") + + return Workspace( + root=root, + requires_python=requires_python, + declared_members=_str_entries(_table_at(manifest, "tool", "uv", "workspace"), "members"), + dev_dependencies=_dev_dependencies(manifest), + insecure_bounds_allowlist=_insecure_bounds_allowlist(manifest), + members=tuple( + load_member(root, manifest_path) for manifest_path in find_member_manifests(root) + ), + ) diff --git a/libs/@local/repo-chores/python/tests/conftest.py b/libs/@local/repo-chores/python/tests/conftest.py new file mode 100644 index 00000000000..4046f496b08 --- /dev/null +++ b/libs/@local/repo-chores/python/tests/conftest.py @@ -0,0 +1,134 @@ +"""Shared fixture: a miniature repository exercising every sync rule.""" + +import json +from pathlib import Path + +import pytest + +REQUIRES_PYTHON = ">=3.14,<3.15" + +ROOT_PYPROJECT = f"""\ +[project] +name = "fixture-root" +version = "0.0.0" +requires-python = "{REQUIRES_PYTHON}" + +[tool.uv] +package = false + +[tool.uv.workspace] +members = [ + "packages/alpha", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.15.21", + "tach>=0.35,<0.36", +] + +[tool.ruff] +src = [] + +[tool.pytest.ini_options] +testpaths = [] + +[tool.tach] +source_roots = [] +""" + +ALPHA_PYPROJECT = """\ +[project] +name = "alpha" +version = "0.1.0" +requires-python = ">=3.13" + +[dependency-groups] +dev = [ + "pytest>=7.0", +] +""" + +BETA_PYPROJECT = """\ +[project] +name = "beta" +version = "0.1.0" +requires-python = ">=3.14,<3.15" +dependencies = [ + "alpha", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.15.21", +] + +[tool.uv.sources] +alpha = { workspace = true } +""" + +GAMMA_PYPROJECT = """\ +[project] +name = "gamma" +version = "0.1.0" +requires-python = ">=3.14,<3.15" + +[dependency-groups] +dev = [ + "ruff>=0.15.21", +] +""" + +TOOL_ONLY_PYPROJECT = """\ +[tool.some-linter] +option = true +""" + + +@pytest.fixture +def fixture_repo(tmp_path: Path) -> Path: + """Build a repository with one compliant member and one deviation per sync rule.""" + (tmp_path / "pyproject.toml").write_text(ROOT_PYPROJECT) + (tmp_path / "package.json").write_text( + json.dumps( + { + "name": "fixture-root", + "private": True, + "workspaces": {"packages": ["!**/node_modules", "packages/**"]}, + }, + indent=2, + ) + + "\n" + ) + + # alpha: declared member, but wrong requires-python, stale dev pin, no package.json. + alpha = tmp_path / "packages" / "alpha" + (alpha / "tests").mkdir(parents=True) + (alpha / "pyproject.toml").write_text(ALPHA_PYPROJECT) + + # beta: undeclared member with a workspace dependency and stale turbo wiring. + beta = tmp_path / "packages" / "beta" + beta.mkdir(parents=True) + (beta / "pyproject.toml").write_text(BETA_PYPROJECT) + (beta / "package.json").write_text( + json.dumps({"name": "@python/wrong-name", "version": "0.0.0-private"}, indent=2) + "\n" + ) + + # gamma: compliant manifest outside the yarn workspace globs. + gamma = tmp_path / "outside" / "lib" + gamma.mkdir(parents=True) + (gamma / "pyproject.toml").write_text(GAMMA_PYPROJECT) + + # A tool-configuration-only manifest: unfixable finding. + tool_only = tmp_path / "packages" / "toolcfg" + tool_only.mkdir(parents=True) + (tool_only / "pyproject.toml").write_text(TOOL_ONLY_PYPROJECT) + + # Manifests under pruned directories must be invisible. + hidden = tmp_path / "node_modules" / "dep" + hidden.mkdir(parents=True) + (hidden / "pyproject.toml").write_text('[project]\nname = "hidden"\n') + + return tmp_path diff --git a/libs/@local/repo-chores/python/tests/test_lint.py b/libs/@local/repo-chores/python/tests/test_lint.py new file mode 100644 index 00000000000..064060cb12e --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_lint.py @@ -0,0 +1,164 @@ +"""Tests for the dependency requirement lint.""" + +from pathlib import Path + +from repo_chores.lint import lint_workspace +from repo_chores.workspace import Member, Workspace + + +def make_workspace( + *, + dev_dependencies: tuple[str, ...] = (), + allowlist: tuple[str, ...] = (), + members: tuple[Member, ...] = (), +) -> Workspace: + return Workspace( + root=Path(), + requires_python=">=3.14,<3.15", + declared_members=tuple(member.directory for member in members), + dev_dependencies=dev_dependencies, + insecure_bounds_allowlist=allowlist, + members=members, + ) + + +def make_member( + *, + name: str = "alpha", + dependencies: tuple[str, ...] = (), + dev_dependencies: tuple[str, ...] = (), + allowlist: tuple[str, ...] = (), +) -> Member: + return Member( + directory=f"packages/{name}", + name=name, + requires_python=">=3.14,<3.15", + dependencies=dependencies, + dev_dependencies=dev_dependencies, + workspace_dependencies=(), + insecure_bounds_allowlist=allowlist, + has_tests=False, + ) + + +# Upper and lower bounds + + +def test_bounded_ranges_pass() -> None: + workspace = make_workspace( + dev_dependencies=( + "ruff>=0.15.21,<0.16", + "tach~=0.35.0", + "exact==1.2.3", + "arbitrary===1.2.3", + ) + ) + + assert lint_workspace(workspace) == [] + + +def test_missing_upper_bound_fails() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("ruff>=0.15.21",))) + + assert len(findings) == 1 + assert "no upper bound" in findings[0].message + assert not findings[0].fixable + + +def test_missing_lower_bound_fails() -> None: + # `<2` alone lets resolution reach arbitrarily far down. + findings = lint_workspace(make_workspace(dev_dependencies=("ruff<2",))) + + assert len(findings) == 1 + assert "no lower bound" in findings[0].message + + +def test_bare_requirement_fails() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("pytest",))) + + assert len(findings) == 1 + assert "no version bounds at all" in findings[0].message + + +def test_exclusion_alone_is_not_a_bound() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("ruff!=0.15.20",))) + + assert len(findings) == 1 + assert "no version bounds at all" in findings[0].message + + +def test_workspace_members_are_exempt() -> None: + member = make_member(name="hash-repo-chores") + workspace = make_workspace(dev_dependencies=("hash-repo-chores",), members=(member,)) + + assert lint_workspace(workspace) == [] + + +def test_member_manifests_are_judged_too() -> None: + member = make_member(dependencies=("requests>=2",), dev_dependencies=("pytest",)) + workspace = make_workspace(members=(member,)) + + findings = lint_workspace(workspace) + + assert len(findings) == 2 + assert all(finding.path == "packages/alpha/pyproject.toml" for finding in findings) + + +# Allowlist + + +def test_allowlisted_dependency_is_exempt() -> None: + workspace = make_workspace(dev_dependencies=("ruff>=0.15.21",), allowlist=("ruff",)) + + assert lint_workspace(workspace) == [] + + +def test_allowlist_names_are_canonicalized() -> None: + member = make_member(dependencies=("legacy.pkg>=1",), allowlist=("Legacy-Pkg",)) + workspace = make_workspace(members=(member,)) + + assert lint_workspace(workspace) == [] + + +def test_stale_allowlist_entry_fails() -> None: + workspace = make_workspace(dev_dependencies=("ruff>=0.15.21,<0.16",), allowlist=("ruff",)) + + findings = lint_workspace(workspace) + + assert len(findings) == 1 + assert "exempts nothing" in findings[0].message + + +def test_allowlist_is_scoped_to_its_manifest() -> None: + # The root allowlist does not cover a member's dependencies. + member = make_member(dependencies=("requests>=2",)) + workspace = make_workspace(members=(member,), allowlist=("requests",)) + + findings = lint_workspace(workspace) + + messages = [finding.message for finding in findings] + assert any("no upper bound" in message for message in messages) + assert any("exempts nothing" in message for message in messages) + + +# URL and invalid requirements + + +def test_url_requirements_fail_even_when_allowlisted() -> None: + workspace = make_workspace( + dev_dependencies=("weird @ https://example.com/weird-1.0.tar.gz",), + allowlist=("weird",), + ) + + findings = lint_workspace(workspace) + + assert any("URL requirement" in finding.message for finding in findings) + + +def test_invalid_requirement_is_reported() -> None: + workspace = make_workspace(dev_dependencies=(">=1.0",)) + + findings = lint_workspace(workspace) + + assert len(findings) == 1 + assert "invalid requirement" in findings[0].message diff --git a/libs/@local/repo-chores/python/tests/test_prune.py b/libs/@local/repo-chores/python/tests/test_prune.py new file mode 100644 index 00000000000..80eecbe6469 --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_prune.py @@ -0,0 +1,191 @@ +"""Tests for the turbo workspace prune module.""" + +from pathlib import Path + +from repo_chores import prune + + +def test_parse_scopes_splits_on_spaces_and_newlines() -> None: + assert prune.parse_scopes("@apps/a @apps/b\n@apps/c") == frozenset({ + "@apps/a", + "@apps/b", + "@apps/c", + }) + + +def test_parse_scopes_ignores_blank_lines_and_extra_whitespace() -> None: + assert prune.parse_scopes("\n @apps/a \n\n\t@apps/b\n") == frozenset({ + "@apps/a", + "@apps/b", + }) + + +def test_expand_scopes_no_rule_matches() -> None: + assert prune.expand_scopes({"@apps/hash-frontend"}) == frozenset() + + +def test_expand_scopes_prefix_trigger() -> None: + # Rules trigger on name prefixes: a child crate pulls in the whole family. + assert "@rust/darwin-kperf-codegen" in prune.expand_scopes({"@rust/darwin-kperf-sys"}) + + +def test_fixpoint_expands_rules_triggered_by_dependencies() -> None: + dependencies = {"@apps/hash-graph": frozenset({"@rust/hashql-ast"})} + scopes = prune.fixpoint_expand({"@apps/hash-graph"}, dependencies) + assert scopes == frozenset({"@apps/hash-graph", "@rust/hashql-compiletest"}) + + +def test_expansion_reaches_fixpoint_through_added_scopes() -> None: + # The scope added by the first rule has dependencies that trigger another rule. + dependencies = { + "@rust/hashql-compiletest": frozenset({"@rust/hash-graph-types"}), + } + scopes = prune.fixpoint_expand({"@rust/hashql-ast"}, dependencies) + assert scopes == frozenset({ + "@rust/hashql-ast", + "@rust/hashql-compiletest", + "@rust/hash-graph-test-data", + }) + + +def test_fixpoint_expand_terminates_on_cycles() -> None: + dependencies = { + "@apps/a": frozenset({"@apps/b"}), + "@apps/b": frozenset({"@apps/a"}), + } + scopes = prune.fixpoint_expand({"@apps/a"}, dependencies) + assert scopes == frozenset({"@apps/a"}) + + +def test_resolves_cargo_globs_and_literal_members(tmp_path: Path) -> None: + (tmp_path / "Cargo.toml").write_text('[workspace]\nmembers = ["crates/*", "standalone"]\n') + for member in ("crates/a", "crates/b", "standalone"): + (tmp_path / member).mkdir(parents=True) + (tmp_path / member / "Cargo.toml").write_text( + f'[package]\nname = "{member.replace("/", "-")}"\n' + ) + # A directory matching the glob but without a manifest is not a member. + (tmp_path / "crates" / "not-a-crate").mkdir() + + members = prune.resolve_cargo_members(tmp_path) + + assert sorted(path.relative_to(tmp_path).as_posix() for path in members) == [ + "crates/a/Cargo.toml", + "crates/b/Cargo.toml", + "standalone/Cargo.toml", + ] + + +def _make_cargo_workspace(root: Path) -> None: + (root / "Cargo.toml").write_text('[workspace]\nmembers = ["crates/*"]\n') + for name in ("kept", "pruned"): + (root / "crates" / name).mkdir(parents=True) + (root / "crates" / name / "Cargo.toml").write_text(f'[package]\nname = "{name}"\n') + + +def test_stubs_only_missing_cargo_members(tmp_path: Path) -> None: + _make_cargo_workspace(tmp_path) + out = tmp_path / "out" + kept_manifest = out / "crates" / "kept" / "Cargo.toml" + kept_manifest.parent.mkdir(parents=True) + kept_manifest.write_text("untouched") + + prune.stub_missing_cargo_members(tmp_path, out) + + assert kept_manifest.read_text() == "untouched" + stub = out / "crates" / "pruned" / "Cargo.toml" + assert 'name = "pruned"' in stub.read_text() + assert (out / "crates" / "pruned" / "src" / "lib.rs").exists() + + +def test_skips_manifests_without_a_package_name(tmp_path: Path) -> None: + (tmp_path / "Cargo.toml").write_text('[workspace]\nmembers = ["virtual"]\n') + (tmp_path / "virtual").mkdir() + (tmp_path / "virtual" / "Cargo.toml").write_text("[workspace]\n") + + prune.stub_missing_cargo_members(tmp_path, tmp_path / "out") + + assert not (tmp_path / "out" / "virtual" / "Cargo.toml").exists() + + +def test_cargo_dry_run_writes_nothing(tmp_path: Path) -> None: + _make_cargo_workspace(tmp_path) + out = tmp_path / "out" + + prune.stub_missing_cargo_members(tmp_path, out, dry_run=True) + + assert not out.exists() + + +def test_missing_root_pyproject(tmp_path: Path) -> None: + assert prune.resolve_uv_members(tmp_path) == [] + + +def test_pyproject_without_workspace(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[project]\nname = "root"\n') + assert prune.resolve_uv_members(tmp_path) == [] + + +def test_resolves_uv_member_globs(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + '[tool.uv.workspace]\nmembers = ["tools/*", "single"]\n' + ) + for member in ("tools/a", "tools/b", "single"): + (tmp_path / member).mkdir(parents=True) + (tmp_path / member / "pyproject.toml").write_text("[project]\n") + (tmp_path / "tools" / "not-a-package").mkdir() + + members = prune.resolve_uv_members(tmp_path) + + assert sorted(path.relative_to(tmp_path).as_posix() for path in members) == [ + "single/pyproject.toml", + "tools/a/pyproject.toml", + "tools/b/pyproject.toml", + ] + + +def test_respects_workspace_excludes(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + '[tool.uv.workspace]\nmembers = ["tools/*"]\nexclude = ["tools/skipped"]\n' + ) + for member in ("tools/a", "tools/skipped"): + (tmp_path / member).mkdir(parents=True) + (tmp_path / member / "pyproject.toml").write_text("[project]\n") + + members = prune.resolve_uv_members(tmp_path) + + assert [path.relative_to(tmp_path).as_posix() for path in members] == ["tools/a/pyproject.toml"] + + +def _make_uv_workspace(root: Path) -> None: + (root / "pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["tools/*"]\n') + for name in ("kept", "pruned"): + (root / "tools" / name).mkdir(parents=True) + (root / "tools" / name / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "1.2.3"\ndependencies = ["numpy>=2"]\n' + ) + + +def test_copies_manifests_of_missing_members(tmp_path: Path) -> None: + _make_uv_workspace(tmp_path) + out = tmp_path / "out" + kept_manifest = out / "tools" / "kept" / "pyproject.toml" + kept_manifest.parent.mkdir(parents=True) + kept_manifest.write_text("untouched") + + prune.stub_missing_uv_members(tmp_path, out) + + assert kept_manifest.read_text() == "untouched" + # The stub is a byte-for-byte manifest copy: uv validates the workspace + # graph (including each member's dependencies) against uv.lock. + stub = out / "tools" / "pruned" / "pyproject.toml" + assert stub.read_bytes() == (tmp_path / "tools" / "pruned" / "pyproject.toml").read_bytes() + + +def test_uv_dry_run_writes_nothing(tmp_path: Path) -> None: + _make_uv_workspace(tmp_path) + out = tmp_path / "out" + + prune.stub_missing_uv_members(tmp_path, out, dry_run=True) + + assert not out.exists() diff --git a/libs/@local/repo-chores/python/tests/test_sync.py b/libs/@local/repo-chores/python/tests/test_sync.py new file mode 100644 index 00000000000..6ff8a6098db --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -0,0 +1,247 @@ +"""Tests for the sync engine: check findings, fixes, and idempotency.""" + +import json +import tomllib +from pathlib import Path + +from repo_chores.sync import PYTEST_SCRIPT, assemble_package_json, synchronize +from repo_chores.workspace import Member + +# Check mode + + +def test_check_reports_every_deviation_without_writing(fixture_repo: Path) -> None: + before = {path: path.read_text() for path in fixture_repo.rglob("*") if path.is_file()} + + findings = synchronize(fixture_repo, apply=False) + messages = "\n".join(f"{finding.path}: {finding.message}" for finding in findings) + + assert "workspace members must list every member" in messages + assert "ruff src roots must list every member" in messages + assert "pytest testpaths must list every member" in messages + assert "tach source roots must list every member" in messages + assert "outside/lib is not covered by workspaces.packages" in messages + assert 'packages/alpha/pyproject.toml: requires-python must be ">=3.14,<3.15"' in messages + assert 'dev group must pin "ruff>=0.15.21"' in messages + assert 'dev group must pin "pytest>=8.0"' in messages + assert "packages/alpha/package.json: turbo wiring is missing" in messages + assert "packages/beta/package.json: turbo wiring is missing" in messages + assert "packages/toolcfg/pyproject.toml: manifest has no [project] name" in messages + + after = {path: path.read_text() for path in fixture_repo.rglob("*") if path.is_file()} + assert before == after, "check mode must not write" + + +def test_single_segment_globs_do_not_cover_nested_members(fixture_repo: Path) -> None: + # fnmatch's `*` crosses path separators and would falsely treat the + # nested member as covered; yarn's `*` stays within one segment. + package_json_path = fixture_repo / "package.json" + data = json.loads(package_json_path.read_text()) + data["workspaces"]["packages"] = ["!**/node_modules", "outside/*", "packages/*"] + package_json_path.write_text(json.dumps(data, indent=2) + "\n") + + nested = fixture_repo / "packages" / "group" / "deep" + nested.mkdir(parents=True) + (nested / "pyproject.toml").write_text( + '[project]\nname = "deep"\nversion = "0.1.0"\nrequires-python = ">=3.14,<3.15"\n' + ) + + findings = synchronize(fixture_repo, apply=False) + + messages = [finding.message for finding in findings] + assert any("packages/group/deep is not covered" in message for message in messages) + assert not any("packages/alpha is not covered" in message for message in messages) + assert not any("outside/lib is not covered" in message for message in messages) + + +def test_unnamed_manifest_is_unfixable(fixture_repo: Path) -> None: + findings = synchronize(fixture_repo, apply=False) + unfixable = [finding for finding in findings if not finding.fixable] + + assert [finding.path for finding in unfixable] == ["packages/toolcfg/pyproject.toml"] + + +def test_invalid_dev_requirement_is_an_unfixable_finding(fixture_repo: Path) -> None: + manifest_path = fixture_repo / "packages" / "alpha" / "pyproject.toml" + manifest_path.write_text( + manifest_path.read_text().replace('"pytest>=7.0"', '">=not-a-requirement"') + ) + + findings = synchronize(fixture_repo, apply=False) + + assert any( + finding.path == "packages/alpha/pyproject.toml" + and "invalid dev requirement" in finding.message + and not finding.fixable + for finding in findings + ) + + +# Fix mode + + +def test_fixes_are_applied_and_idempotent(fixture_repo: Path) -> None: + synchronize(fixture_repo, apply=True) + + root_manifest = tomllib.loads((fixture_repo / "pyproject.toml").read_text()) + expected_members = ["outside/lib", "packages/alpha", "packages/beta"] + assert root_manifest["tool"]["uv"]["workspace"]["members"] == expected_members + assert root_manifest["tool"]["ruff"]["src"] == expected_members + assert root_manifest["tool"]["tach"]["source_roots"] == expected_members + assert root_manifest["tool"]["pytest"]["ini_options"]["testpaths"] == ["packages/alpha/tests"] + + root_package_json = json.loads((fixture_repo / "package.json").read_text()) + assert "outside/lib" in root_package_json["workspaces"]["packages"] + + alpha_manifest = tomllib.loads( + (fixture_repo / "packages" / "alpha" / "pyproject.toml").read_text() + ) + assert alpha_manifest["project"]["requires-python"] == ">=3.14,<3.15" + assert alpha_manifest["dependency-groups"]["dev"] == ["pytest>=8.0", "ruff>=0.15.21"] + + alpha_package_json = json.loads( + (fixture_repo / "packages" / "alpha" / "package.json").read_text() + ) + assert alpha_package_json["name"] == "@python/alpha" + assert alpha_package_json["license"] == "AGPL-3" + assert "test:unit" in alpha_package_json["scripts"] + + beta_package_json = json.loads( + (fixture_repo / "packages" / "beta" / "package.json").read_text() + ) + assert beta_package_json["name"] == "@python/beta" + assert beta_package_json["dependencies"] == {"@python/alpha": "workspace:*"} + assert "test:unit" not in beta_package_json["scripts"] + + # Only the unfixable finding remains; a second apply changes nothing. + remaining = synchronize(fixture_repo, apply=True) + assert [finding.fixable for finding in remaining] == [False] + + +def test_root_manifest_comments_survive_fixes(fixture_repo: Path) -> None: + manifest_path = fixture_repo / "pyproject.toml" + manifest_path.write_text( + manifest_path.read_text().replace( + "[tool.uv.workspace]", "# Managed members list.\n[tool.uv.workspace]" + ) + ) + + synchronize(fixture_repo, apply=True) + + assert "# Managed members list." in manifest_path.read_text() + + +def test_fix_removes_stale_and_duplicate_member_entries(fixture_repo: Path) -> None: + stale = 'members = [\n "packages/ghost",\n "packages/alpha",\n "packages/alpha",\n]' + manifest_path = fixture_repo / "pyproject.toml" + manifest_path.write_text( + manifest_path.read_text().replace('members = [\n "packages/alpha",\n]', stale) + ) + + synchronize(fixture_repo, apply=True) + + root_manifest = tomllib.loads(manifest_path.read_text()) + assert root_manifest["tool"]["uv"]["workspace"]["members"] == [ + "outside/lib", + "packages/alpha", + "packages/beta", + ] + + +def test_non_string_member_entries_are_reported_not_clobbered(fixture_repo: Path) -> None: + manifest_path = fixture_repo / "pyproject.toml" + manifest_path.write_text( + manifest_path.read_text().replace( + 'members = [\n "packages/alpha",\n]', + 'members = [\n "packages/alpha",\n 42,\n]', + ) + ) + + findings = synchronize(fixture_repo, apply=True) + + assert any( + "workspace members must only contain strings" in finding.message and not finding.fixable + for finding in findings + ) + root_manifest = tomllib.loads(manifest_path.read_text()) + assert root_manifest["tool"]["uv"]["workspace"]["members"] == ["packages/alpha", 42] + + +# assemble_package_json + + +def _make_member( + *, + workspace_dependencies: tuple[str, ...] = (), + has_tests: bool = False, +) -> Member: + return Member( + directory="packages/alpha", + name="alpha", + requires_python=">=3.14,<3.15", + dependencies=(), + dev_dependencies=(), + workspace_dependencies=workspace_dependencies, + insecure_bounds_allowlist=(), + has_tests=has_tests, + ) + + +def _scripts_of(assembled: dict[str, object]) -> dict[str, str]: + scripts = assembled["scripts"] + assert isinstance(scripts, dict) + checked = { + key: value + for key, value in scripts.items() + if isinstance(key, str) and isinstance(value, str) + } + assert len(checked) == len(scripts) + return checked + + +def test_assemble_preserves_custom_scripts_and_extra_keys() -> None: + existing = { + "description": "Hand-written description", + "scripts": {"custom:thing": "echo hi"}, + } + + assembled = assemble_package_json(_make_member(), existing) + + assert assembled["description"] == "Hand-written description" + assert _scripts_of(assembled)["custom:thing"] == "echo hi" + assert "lint:ruff" in _scripts_of(assembled) + assert "fix:ruff" in _scripts_of(assembled) + + +def test_assemble_manages_identity_scripts_and_dependencies() -> None: + member = _make_member(workspace_dependencies=("alpha-core",), has_tests=True) + + assembled = assemble_package_json(member, {"name": "@python/stale", "license": "MIT"}) + + assert assembled["name"] == "@python/alpha", "stale names are overwritten" + assert assembled["license"] == "MIT", "hand-set licenses are preserved" + assert "test:unit" in _scripts_of(assembled) + assert assembled["dependencies"] == {"@python/alpha-core": "workspace:*"} + + +def test_assemble_removes_managed_pytest_script_when_tests_are_gone() -> None: + existing = {"scripts": {"test:unit": PYTEST_SCRIPT, "custom:thing": "echo hi"}} + + assembled = assemble_package_json(_make_member(has_tests=False), existing) + + assert "test:unit" not in _scripts_of(assembled) + assert _scripts_of(assembled)["custom:thing"] == "echo hi" + + +def test_assemble_preserves_hand_written_test_script_without_tests_directory() -> None: + existing = {"scripts": {"test:unit": "vitest run"}} + + assembled = assemble_package_json(_make_member(has_tests=False), existing) + + assert _scripts_of(assembled)["test:unit"] == "vitest run" + + +def test_assemble_omits_empty_dependencies() -> None: + assembled = assemble_package_json(_make_member(), {"dependencies": {"x": "1"}}) + + assert "dependencies" not in assembled diff --git a/libs/@local/repo-chores/python/tests/test_workspace.py b/libs/@local/repo-chores/python/tests/test_workspace.py new file mode 100644 index 00000000000..b90b3cca68f --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_workspace.py @@ -0,0 +1,47 @@ +"""Tests for workspace discovery and parsing.""" + +from pathlib import Path + +import pytest + +from repo_chores.workspace import ( + WorkspaceError, + find_member_manifests, + find_workspace_root, + load_workspace, +) + + +def test_find_member_manifests_skips_pruned_and_root(fixture_repo: Path) -> None: + manifests = find_member_manifests(fixture_repo) + directories = [manifest.parent.relative_to(fixture_repo).as_posix() for manifest in manifests] + + assert directories == [ + "outside/lib", + "packages/alpha", + "packages/beta", + "packages/toolcfg", + ] + + +def test_find_workspace_root_walks_up_from_member(fixture_repo: Path) -> None: + assert find_workspace_root(fixture_repo / "packages" / "alpha") == fixture_repo + + +def test_find_workspace_root_errors_when_no_workspace_exists(tmp_path: Path) -> None: + with pytest.raises(WorkspaceError, match=r"tool\.uv\.workspace"): + find_workspace_root(tmp_path) + + +def test_load_workspace_parses_root_and_members(fixture_repo: Path) -> None: + workspace = load_workspace(fixture_repo) + + assert workspace.requires_python == ">=3.14,<3.15" + assert workspace.declared_members == ("packages/alpha",) + + members = {member.directory: member for member in workspace.members} + assert members["packages/alpha"].has_tests + assert members["packages/alpha"].requires_python == ">=3.13" + assert members["packages/beta"].workspace_dependencies == ("alpha",) + assert not members["packages/beta"].has_tests + assert members["packages/toolcfg"].name is None diff --git a/package.json b/package.json index 102d136c87f..8ba5c319ace 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "fix:eslint": "mise run fix:eslint", "fix:format": "oxfmt --write", "fix:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2 --fix", + "fix:python": "mise run fix:python", "fix:taplo": "taplo fmt", "fix:yarn-deduplicate": "yarn dedupe --strategy highest", "generate-ontology-type-ids": "yarn workspace @apps/hash-api generate-ontology-type-ids", @@ -49,6 +50,7 @@ "lint:format": "oxfmt --check", "lint:license-in-workspaces": "yarn workspace @local/repo-chores exe scripts/check-license-in-workspaces.ts", "lint:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2", + "lint:python": "mise run lint:python", "lint:skill": "yarn agents:skill-management validate", "lint:taplo": "taplo fmt --check", "lint:tsc": "mise run lint:tsc", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000000..25844b96f64 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,202 @@ +# Root of the Python workspace. Shared configuration for every Python package +# in the repository lives here; per-package manifests only declare identity and +# dependencies. Generated sections are kept correct by `mise run sync:python` +# (see libs/@local/repo-chores/python) and validated in CI. + +[project] +description = "Workspace-level Python configuration for the HASH monorepo" +name = "hash" +version = "0.0.0" +# The single source of truth for the workspace Python version: every member +# must declare exactly the same bound, enforced by `repo-chores sync --check`. +requires-python = ">=3.14,<3.15" + +[tool.uv] +add-bounds = "major" # require cargo-style bounded ranges (>=1.2.3, <2.0.0) +exclude-newer = "5 days" # mirrors npm release age +package = false + +[tool.uv.workspace] +# Managed by `mise run sync:python` +members = [ + "libs/@local/repo-chores/python", +] + +[dependency-groups] +dev = [ + "hash-repo-chores", # workspace tooling + "pytest>=9.1.1,<10", + "ruff>=0.15.21,<0.16", + "tach>=0.35,<0.36", + "ty>=0.0.58,<0.1", +] + +[tool.uv.sources] +hash-repo-chores = { workspace = true } + +[tool.pytest.ini_options] +addopts = "-q --import-mode=importlib" +# Managed by `mise run sync:python` +testpaths = [ + "libs/@local/repo-chores/python/tests", +] + +[tool.ruff] +# Linter: strictest rule set, exceptions curated below. Formatter: next style epoch. +preview = true +extend-exclude = [ + # Standalone end-user example script: it predates the Python workspace, + # deliberately has no third-party dependencies, and follows its own style. + "libs/@hashintel/petrinaut-cli/examples/python_stdio.py", +] +line-length = 100 +target-version = "py314" + +# Managed by `mise run sync:python` +src = [ + "libs/@local/repo-chores/python", +] + +[tool.ruff.lint] +ignore = [ + # Formatter conflicts (ruff's own recommendation). + "COM812", + "ISC001", + # No copyright-header convention in this repository. + "CPY001", + # Docstring-style pair choices: summary on the first line, no blank + # line before the class docstring. + "D203", + "D213", + # Docstring presence is judged in review, not by rule: a docstring has + # to earn its keep, and mandatory-docstring rules breed filler that + # restates the signature. Module docstrings (D100) stay required. The + # same reasoning covers the pydoclint section rules (DOC): documented + # returns/raises are welcome, mandated ones produce boilerplate. + "D101", + "D102", + "D103", + "D104", + "D105", + "D107", + "DOC201", + "DOC402", + "DOC501", + # Inline f-string messages at the raise site read better than the + # msg-variable indirection these rules force. + "EM101", + "EM102", + "TRY003", + # Keyword-only argument passing is generally preferred to positional arguments. + # Unlike Rust, keyword arguments can be seen as an impromptu configuration struct. + # PLR0917 is still active and limits the max positional arguments allowed. + "PLR0913", + # Post 3.14 typing annotations are evaluated lazily, additionally modern tooling + # like Pydantic relies on the presence of types in value space, even if not used. + # Removing them creates excessive noqa noise, and creates runtime errors. + "TC001", + "TC002", + "TC003", +] +select = ["ALL"] + +[tool.ruff.lint.pylint] +max-positional-args = 3 + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"__future__.annotations".msg = "Python 3.14 evaluates annotations lazily (PEP 649); the future import is redundant and subtly changes annotation semantics." +"typing.Any".msg = "Any is banned: use object for pass-through values, a TypeVar for generic code, or pydantic's JsonValue for opaque JSON." + +[tool.ruff.lint.pydocstyle] +convention = "pep257" + +[tool.ruff.lint.per-file-ignores] +# Tests assert by design, compare against literal expectations, reach into +# private state to pin behavior, and may run subprocesses built from local +# argv. Test trees use importlib import mode instead of __init__.py files. +"**/tests/**" = ["S101", "PLR2004", "SLF001", "D100", "S603", "INP001"] +# Fixture generators are standalone scripts: they print, they live outside +# the package tree, and they assert their own arithmetic. +"**/fixtures/**" = ["T201", "INP001", "S101", "PLR2004", "D100"] +# CLI modules print their results and import heavy dependencies inside +# command bodies on purpose: --help must not pay for the underlying imports. +"**/*_cli.py" = ["PLC0415", "T201"] +"**/cli.py" = ["PLC0415", "T201"] +# The prune module is CI plumbing: it reports progress via print and exists +# to spawn `turbo` (resolved from PATH) with locally-built arguments. +"libs/@local/repo-chores/python/repo_chores/prune.py" = ["T201", "S404", "S603", "S607"] +# The sync engine pipes generated package.json content through the repository +# formatter (oxfmt, resolved via shutil.which); all arguments are owned. +"libs/@local/repo-chores/python/repo_chores/sync.py" = ["S404", "S603"] + +# Editor LSP only; CI runs ty. +[tool.pyright] +venv = ".venv" +venvPath = "." + +[tool.ty.environment] +python = "./.venv" + +[tool.ty.terminal] +error-on-warning = true # fail in CI on warnings (Rust equivalent to -D warnings) + +[tool.ty.rules] +unused-ignore-comment = "error" # see: RUF100 + tach `unused_ignore_directives` +blanket-ignore-comment = "error" # see: PGH004 +missing-type-argument = "error" # bare generics parametrize as Unknown, evading the banned `Any` +missing-override-decorator = "error" # overrides must be made explicit, to show the contract + +# Off by default upstream (false-positive prone); relax per rule when noise appears. +division-by-zero = "error" +possibly-missing-attribute = "error" +possibly-missing-import = "error" +possibly-unresolved-reference = "error" +unsupported-dynamic-base = "error" + + +[tool.ty.src] +exclude = [ + # Kept in sync with [tool.ruff] extend-exclude. + "libs/\\@hashintel/petrinaut-cli/examples/python_stdio.py", +] + +# Global tach settings. Tach if desired (recommended), is configured per module using `tach.domain.toml` +# see: https://docs.gauge.sh/usage/configuration/#tachdomaintoml +[tool.tach] +exact = true +exclude = [ + "**/.venv/**", + "**/__pycache__/**", + "**/node_modules/**", + "**/tests/**", +] +forbid_circular_dependencies = true +ignore_type_checking_imports = false +# Layer ordering alone constrains imports: a module may import from its own +# layer or any lower one, and upward imports fail `tach check`. Declaring a +# module is therefore a two-line layer assignment; `depends_on` stays +# available for packages that want explicitly curated edges. +layers_explicit_depends_on = false +respect_gitignore = true +root_module = "ignore" + +# Managed by `mise run sync:python` +source_roots = [ + "libs/@local/repo-chores/python", +] + +layers = [ + "application", + "orchestration", + "capability", + "domain", + # Self-contained tooling that runs before the environment exists. The + # bottom position is the isolation guarantee: a bootstrap module importing + # anything above it fails `tach check`. + "bootstrap", +] + +[tool.tach.rules] +require_ignore_directive_reasons = "error" +unused_external_dependencies = "error" # on false positives, use `tool.tach.external` +unused_ignore_directives = "error" diff --git a/turbo.json b/turbo.json index 8652ee651b4..497fa7cd49e 100644 --- a/turbo.json +++ b/turbo.json @@ -101,6 +101,7 @@ "env": ["CHECK_TEMPORARILY_DISABLED_RULES"], "dependsOn": ["codegen", "@local/eslint#build"] }, + "lint:ruff": {}, "lint:tsc": { "dependsOn": ["codegen", "build:types"] }, @@ -108,6 +109,9 @@ "dependsOn": ["codegen", "@local/eslint#build"] }, "fix:clippy": {}, + "fix:ruff": { + "cache": false + }, "sentry:sourcemaps": { "dependsOn": ["build"] } diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000000..e539b9d646b --- /dev/null +++ b/uv.lock @@ -0,0 +1,398 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P5D" + +[manifest] +members = [ + "hash", + "hash-repo-chores", +] + +[[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 = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "hash" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "hash-repo-chores" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "tach" }, + { name = "ty" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "hash-repo-chores", editable = "libs/@local/repo-chores/python" }, + { name = "pytest", specifier = ">=9.1.1,<10" }, + { name = "ruff", specifier = ">=0.15.21,<0.16" }, + { name = "tach", specifier = ">=0.35,<0.36" }, + { name = "ty", specifier = ">=0.0.58,<0.1" }, +] + +[[package]] +name = "hash-repo-chores" +version = "0.0.0" +source = { editable = "libs/@local/repo-chores/python" } +dependencies = [ + { name = "packaging" }, + { name = "tomlkit" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "packaging", specifier = ">=26.2,<27" }, + { name = "tomlkit", specifier = ">=0.15,<0.16" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1,<10" }, + { name = "ruff", specifier = ">=0.15.21,<0.16" }, +] + +[[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 = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[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 = "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 = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[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 = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "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/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 = "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.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "tach" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "networkx" }, + { name = "prompt-toolkit" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/54/8bae607899ba9fd7abd9895215f2e5dc789a73529f8afeb0a10d2ee71500/tach-0.35.0.tar.gz", hash = "sha256:db8594820685f8ce316ef45d4dfa1a8ed39ce97dd873aa5006800d2668c3b3bb", size = 731143, upload-time = "2026-05-12T12:48:58.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/65/7ec3ac15167d9a2def25ed5cc6699348a940d7d24408d0c0b3e9bf14f1c3/tach-0.35.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:83b43975bcd23ddca2de388891d9f2cce7de3775682ac0a7857981adc4454787", size = 4042721, upload-time = "2026-05-12T12:48:52.149Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b6/e43617eaf57cd2f8a27935c87256eab63b8505e1c7b7a3e1947f2a612f88/tach-0.35.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:f14a3943783fa3285030468958718a0696d908d50a5c5119e2b42dae37e32114", size = 3910668, upload-time = "2026-05-12T12:48:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/936963142c9af18e7a59909960bf6d4645e82190471e162c28ba80dd2475/tach-0.35.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0109591f1be3d0e323769d495b1e303ee41305bf03e9143328000a40eee7059", size = 4262505, upload-time = "2026-05-12T12:48:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/87/0e/575f119447647e2321fc71bacf3909450b743fcbaf5015f11037bd304fee/tach-0.35.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:272234f3ff9f6cef54dffb5175a83313938461e37617c1fe18bbbaa2cc52179c", size = 4156922, upload-time = "2026-05-12T12:48:47.828Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/d235f6ba6be79c8c4490ee1469a73e2325d1f8f380c29954c4103b6aff09/tach-0.35.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:236d2113e4920e83fa60fd814d09a7fc9cbab87fd60a0a79d5afc9dd60a81102", size = 4576596, upload-time = "2026-05-12T12:49:02.238Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e5/254462c90ec22cd1f2f8f5dc27f4b8bef62940eb3775565366d3fb6cd563/tach-0.35.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c65273272f842c1490c2086279296a250a3eeb40b01c02a252b0c8021c52441", size = 5202902, upload-time = "2026-05-12T12:48:53.869Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/aeb321f3752522198a5e8a2f8b0e88cf36f54c108938ec50a845fe19290c/tach-0.35.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afa96c12355acf2a00dc27d3c39ccbca5516fa954640051d9dab9fd47a373d6d", size = 4284724, upload-time = "2026-05-12T12:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/37/07/65d158d670a3106f3b327e53187fdf812a2894a3286407154f802ade6874/tach-0.35.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162677645f6a28b0fd91d5d33334b1e92ac40c8085540f40bf072fc8ab6a2b0c", size = 4530463, upload-time = "2026-05-12T12:49:04.743Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/7fc5ee5e9521ab6d75c0fa9affad4f324bf70f0554c47251cba599a30b5e/tach-0.35.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e9940ec5f07200e349fb68d85939f72b659b96644c28d2f3d4e97d290ec85f95", size = 4437612, upload-time = "2026-05-12T12:48:46.409Z" }, + { url = "https://files.pythonhosted.org/packages/ea/06/5dd9e47839e9d5b202bf1f9d9d526940020f561d461607498938d2c55a5f/tach-0.35.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4162f07b9ca0364eb4f05530b2b6a66b6c6544fae8752e4378458f604346f800", size = 4432234, upload-time = "2026-05-12T12:48:49.178Z" }, + { url = "https://files.pythonhosted.org/packages/81/87/267e1ea74dc4bacce03d301991b39da58211f636325b1fed7bd83b5e9c40/tach-0.35.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:2c2e9fc654a7a250f3698b26872c89de7b42f649736e3c322cb91edcc749cd55", size = 4587446, upload-time = "2026-05-12T12:49:03.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c3/0859a8f4f85b167174a9173df79bc0f15b93908d69537a737f1c44e2d8b9/tach-0.35.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:e73aebad3af4aa99c20f72d54f270e40eac8681b5fd3bb439333936bfba5c892", size = 5330778, upload-time = "2026-05-12T12:48:55.096Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/e1842c063d0ac7be804797fab569ac782ef8a9fa349092c71125bc628e4d/tach-0.35.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e9275b65ff31e7ff9842b3bd7417a393115dbadd1fa8c3d953ae330cff7fbefb", size = 4565068, upload-time = "2026-05-12T12:49:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/054784261781dbcb8ac663a5d09af6c7996f5bde5230282b4c062dcf1f1f/tach-0.35.0-cp37-abi3-win32.whl", hash = "sha256:72dd6553d9fdd1e72fbd8e5b71892a463e8cd70b3ff6377b4f682aab100937d3", size = 3378975, upload-time = "2026-05-12T12:49:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1d/5dddf88fa9433bf4fcc76afad18e49d8e50e24f1f40c981c286edccd6c2d/tach-0.35.0-cp37-abi3-win_amd64.whl", hash = "sha256:746271a5313c348e00c1b85927260e3e1a75351d86b2da1746e85c5dd77051e6", size = 3671788, upload-time = "2026-05-12T12:48:59.619Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + +[[package]] +name = "ty" +version = "0.0.58" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" }, + { url = "https://files.pythonhosted.org/packages/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" }, + { url = "https://files.pythonhosted.org/packages/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" }, + { url = "https://files.pythonhosted.org/packages/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" }, + { url = "https://files.pythonhosted.org/packages/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" }, + { url = "https://files.pythonhosted.org/packages/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] diff --git a/yarn.lock b/yarn.lock index 3f3909fe6ac..6b4e05e8431 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10902,6 +10902,12 @@ __metadata: languageName: node linkType: hard +"@python/hash-repo-chores@workspace:libs/@local/repo-chores/python": + version: 0.0.0-use.local + resolution: "@python/hash-repo-chores@workspace:libs/@local/repo-chores/python" + languageName: unknown + linkType: soft + "@radix-ui/colors@npm:3.0.0": version: 3.0.0 resolution: "@radix-ui/colors@npm:3.0.0"