From a9650b9e49008250bbffc8cfdfe2a4576f7329fa Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:14:57 +0200 Subject: [PATCH 1/8] feat: initialize python monorepo structure --- .../@local/repo-chores/python/repo_chores}/prune.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {.github/actions/prune-repository => libs/@local/repo-chores/python/repo_chores}/prune.py (100%) diff --git a/.github/actions/prune-repository/prune.py b/libs/@local/repo-chores/python/repo_chores/prune.py similarity index 100% rename from .github/actions/prune-repository/prune.py rename to libs/@local/repo-chores/python/repo_chores/prune.py From 1d2308522a7f243b4ad749b7a1d18274c3311f5d Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:43:10 +0200 Subject: [PATCH 2/8] feat: initialize repo chores --- .config/mise/config.toml | 2 +- .config/mise/mise.lock | 26 +- .config/mise/tasks/fix/python.sh | 7 + .config/mise/tasks/lint/python.sh | 24 + .config/mise/tasks/sync/python.sh | 11 + .dockerignore | 1 + .github/actions/prune-repository/action.yml | 7 +- .github/workflows/lint.yml | 22 + .gitignore | 1 + .lefthook.yml | 5 + AGENTS.md | 16 + README.md | 33 + libs/@local/repo-chores/python/LICENSE.md | 606 ++++++++++++++++++ libs/@local/repo-chores/python/package.json | 11 + libs/@local/repo-chores/python/pyproject.toml | 25 + .../python/repo_chores/__init__.py | 1 + .../repo-chores/python/repo_chores/cli.py | 84 +++ .../repo-chores/python/repo_chores/prune.py | 125 ++-- .../repo-chores/python/repo_chores/sync.py | 469 ++++++++++++++ .../python/repo_chores/tach.domain.toml | 29 + .../python/repo_chores/toml_edit.py | 107 ++++ .../python/repo_chores/workspace.py | 193 ++++++ .../repo-chores/python/tests/conftest.py | 134 ++++ .../repo-chores/python/tests/test_prune.py | 189 ++++++ .../repo-chores/python/tests/test_sync.py | 198 ++++++ .../python/tests/test_workspace.py | 51 ++ package.json | 2 + pyproject.toml | 199 ++++++ turbo.json | 4 + uv.lock | 394 ++++++++++++ yarn.lock | 6 + 31 files changed, 2926 insertions(+), 56 deletions(-) create mode 100755 .config/mise/tasks/fix/python.sh create mode 100755 .config/mise/tasks/lint/python.sh create mode 100755 .config/mise/tasks/sync/python.sh create mode 100644 libs/@local/repo-chores/python/LICENSE.md create mode 100644 libs/@local/repo-chores/python/package.json create mode 100644 libs/@local/repo-chores/python/pyproject.toml create mode 100644 libs/@local/repo-chores/python/repo_chores/__init__.py create mode 100644 libs/@local/repo-chores/python/repo_chores/cli.py mode change 100644 => 100755 libs/@local/repo-chores/python/repo_chores/prune.py create mode 100644 libs/@local/repo-chores/python/repo_chores/sync.py create mode 100644 libs/@local/repo-chores/python/repo_chores/tach.domain.toml create mode 100644 libs/@local/repo-chores/python/repo_chores/toml_edit.py create mode 100644 libs/@local/repo-chores/python/repo_chores/workspace.py create mode 100644 libs/@local/repo-chores/python/tests/conftest.py create mode 100644 libs/@local/repo-chores/python/tests/test_prune.py create mode 100644 libs/@local/repo-chores/python/tests/test_sync.py create mode 100644 libs/@local/repo-chores/python/tests/test_workspace.py create mode 100644 pyproject.toml create mode 100644 uv.lock 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..68b3a08e39b --- /dev/null +++ b/.config/mise/tasks/lint/python.sh @@ -0,0 +1,24 @@ +#!/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 +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..a550cf04302 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, 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..b5ff396eee1 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, 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/@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..4c94fe96647 --- /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>=25", + "tomlkit>=0.13", +] + +[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>=8.0", + "ruff>=0.15.21", +] 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..d907f625057 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/cli.py @@ -0,0 +1,84 @@ +"""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.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)", + ) + + 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 main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + + match args.command: + case "sync": + run_sync(check=args.check) + 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/prune.py b/libs/@local/repo-chores/python/repo_chores/prune.py old mode 100644 new mode 100755 index e1eca864964..83905603f3a --- a/libs/@local/repo-chores/python/repo_chores/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..334d2cafe06 --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/sync.py @@ -0,0 +1,469 @@ +"""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 fnmatch import fnmatch +from functools import cache +from pathlib import Path + +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 ( + 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 Finding: + """A single deviation from the expected workspace state. + + A fixable finding is corrected in apply mode; an unfixable finding + requires a manual edit and fails the run in either mode. + """ + + path: str + message: str + fixable: bool = True + + +@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. 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): + if any(fnmatch(member.directory, 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 + + 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..eb5db251d4f --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/tach.domain.toml @@ -0,0 +1,29 @@ +# 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" + +# 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..f47cb2a8c6e --- /dev/null +++ b/libs/@local/repo-chores/python/repo_chores/workspace.py @@ -0,0 +1,193 @@ +"""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.""" + + +@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 + dev_dependencies: tuple[str, ...] + workspace_dependencies: 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, ...] + 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 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"), + dev_dependencies=_dev_dependencies(manifest), + workspace_dependencies=workspace_dependencies, + 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), + 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_prune.py b/libs/@local/repo-chores/python/tests/test_prune.py new file mode 100644 index 00000000000..116837dba0d --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_prune.py @@ -0,0 +1,189 @@ +"""Tests for the turbo workspace prune module.""" + +from pathlib import Path + +from repo_chores import prune + + +class TestParseScopes: + def test_splits_on_spaces_and_newlines(self) -> None: + assert prune.parse_scopes("@apps/a @apps/b\n@apps/c") == frozenset( + {"@apps/a", "@apps/b", "@apps/c"} + ) + + def test_ignores_blank_lines_and_extra_whitespace(self) -> None: + assert prune.parse_scopes("\n @apps/a \n\n\t@apps/b\n") == frozenset( + {"@apps/a", "@apps/b"} + ) + + +class TestExpandScopes: + def test_no_rule_matches(self) -> None: + assert prune.expand_scopes({"@apps/hash-frontend"}) == frozenset() + + def test_prefix_trigger(self) -> 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"}) + + +class TestFixpointExpand: + def test_expands_rules_triggered_by_dependencies(self) -> 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(self) -> 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_terminates_on_cyclic_dependency_maps(self) -> None: + dependencies = { + "@apps/a": frozenset({"@apps/b"}), + "@apps/b": frozenset({"@apps/a"}), + } + scopes = prune.fixpoint_expand({"@apps/a"}, dependencies) + assert scopes == frozenset({"@apps/a"}) + + +class TestResolveCargoMembers: + def test_resolves_globs_and_literal_members(self, 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", + ] + + +class TestStubMissingCargoMembers: + @staticmethod + def make_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_members(self, tmp_path: Path) -> None: + self.make_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(self, 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_dry_run_writes_nothing(self, tmp_path: Path) -> None: + self.make_workspace(tmp_path) + out = tmp_path / "out" + + prune.stub_missing_cargo_members(tmp_path, out, dry_run=True) + + assert not out.exists() + + +class TestResolveUvMembers: + def test_missing_root_pyproject(self, tmp_path: Path) -> None: + assert prune.resolve_uv_members(tmp_path) == [] + + def test_pyproject_without_workspace(self, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[project]\nname = "root"\n') + assert prune.resolve_uv_members(tmp_path) == [] + + def test_resolves_member_globs(self, 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(self, 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" + ] + + +class TestStubMissingUvMembers: + @staticmethod + def make_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(self, tmp_path: Path) -> None: + self.make_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_dry_run_writes_nothing(self, tmp_path: Path) -> None: + self.make_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..63efa387242 --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -0,0 +1,198 @@ +"""Tests for the sync engine: check findings, fixes, and idempotency.""" + +import json +import tomllib +from pathlib import Path + +from repo_chores.sync import assemble_package_json, synchronize +from repo_chores.workspace import Member + + +class TestCheck: + def test_reports_every_deviation_without_writing(self, 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_unnamed_manifest_is_unfixable(self, 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(self, 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 + ) + + +class TestFix: + def test_fixes_are_applied_and_idempotent(self, 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(self, 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_removes_stale_and_duplicate_member_entries(self, 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(self, 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] + + +class TestAssemblePackageJson: + @staticmethod + 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", + dev_dependencies=(), + workspace_dependencies=workspace_dependencies, + has_tests=has_tests, + ) + + @staticmethod + 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_preserves_custom_scripts_and_extra_keys(self) -> None: + existing = { + "description": "Hand-written description", + "scripts": {"custom:thing": "echo hi"}, + } + + assembled = assemble_package_json(self.make_member(), existing) + + assert assembled["description"] == "Hand-written description" + assert self.scripts_of(assembled)["custom:thing"] == "echo hi" + assert "lint:ruff" in self.scripts_of(assembled) + assert "fix:ruff" in self.scripts_of(assembled) + + def test_manages_identity_scripts_and_dependencies(self) -> None: + member = self.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 self.scripts_of(assembled) + assert assembled["dependencies"] == {"@python/alpha-core": "workspace:*"} + + def test_omits_empty_dependencies(self) -> None: + assembled = assemble_package_json(self.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..bd358592aca --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_workspace.py @@ -0,0 +1,51 @@ +"""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, +) + + +class TestFindMemberManifests: + def test_skips_pruned_directories_and_root_manifest(self, 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", + ] + + +class TestFindWorkspaceRoot: + def test_walks_up_from_a_member_directory(self, fixture_repo: Path) -> None: + assert find_workspace_root(fixture_repo / "packages" / "alpha") == fixture_repo + + def test_errors_when_no_workspace_exists(self, tmp_path: Path) -> None: + with pytest.raises(WorkspaceError, match=r"tool\.uv\.workspace"): + find_workspace_root(tmp_path) + + +class TestLoadWorkspace: + def test_parses_root_and_members(self, 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..8eb920984f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,199 @@ +# 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] +name = "hash" +version = "0.0.0" +description = "Workspace-level Python configuration for the HASH monorepo" +# 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] +package = false + +# Managed by `mise run sync:python`: every pyproject.toml in the repository +# must be listed here explicitly. CI fails if one is missing. +[tool.uv.workspace] +members = [ + "libs/@local/repo-chores/python", +] + +[dependency-groups] +dev = [ + # Workspace tooling: `uv run repo-chores sync [--check]` and + # `uv run repo-chores prune `. + "hash-repo-chores", + "pytest>=8.0", + "ruff>=0.15.21", + "tach>=0.35,<0.36", + "ty>=0.0.59", +] + +[tool.uv.sources] +hash-repo-chores = { workspace = true } + +[tool.pytest.ini_options] +addopts = "-q --import-mode=importlib" +# Managed by `mise run sync:python`: one entry per member tests directory. +testpaths = [ + "libs/@local/repo-chores/python/tests", +] + +[tool.ruff] +target-version = "py314" +line-length = 100 +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", +] + +# Managed by `mise run sync:python`: one entry per workspace member, so ruff +# classifies member-local imports as first-party. +src = [ + "libs/@local/repo-chores/python", +] + +[tool.ruff.lint] +# Preview rules are enabled deliberately: the strictest available rule set, +# with exceptions curated below. +preview = true +select = ["ALL"] +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 the house style; the *total* + # argument-count limit fights it without adding safety. PLR0917 keeps + # policing the positional count via `max-positional-args` below. + "PLR0913", + # Typing-only import blocks are a hazard for runtime-evaluated + # annotations: pydantic models and TypeAdapters build their schemas from + # annotations at runtime, so imports moved under TYPE_CHECKING break + # model building. + "TC001", + "TC002", + "TC003", +] + +[tool.ruff.lint.pylint] +# Positional arguments are capped tightly (PLR0917): beyond this, callers +# must pass keywords. +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, +# and grouping tests in classes (PLR6301) is the house pytest idiom. +"**/tests/**" = ["S101", "PLR2004", "SLF001", "D100", "S603", "INP001", "PLR6301"] +# 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"] + +[tool.pyright] +venvPath = "." +venv = ".venv" + +[tool.ty.environment] +python = "./.venv" + +[tool.ty.rules] +# Suppressions must pull their weight, mirroring tach's +# `unused_ignore_directives = "error"` and ruff's RUF100. +unused-ignore-comment = "error" + +[tool.ty.src] +exclude = [ + # Kept in sync with [tool.ruff] extend-exclude. + "libs/\\@hashintel/petrinaut-cli/examples/python_stdio.py", +] + +# Global tach settings. Modules and interfaces live next to the code they +# describe, in each package's tach.domain.toml. +[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`: one entry per workspace member. +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" +# Accounting is scoped to each package's [project.dependencies]; a dependency +# that stops being imported fails `tach check-external`. Dependencies that are +# legitimately unimported belong in a `[tool.tach.external]` exclude list. +unused_external_dependencies = "error" +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..71a29da0537 --- /dev/null +++ b/uv.lock @@ -0,0 +1,394 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[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.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/30/a8a0c15f9480dc91b5b7f11ebd26105e5f80898d7ff02da197fef35d8395/gitpython-3.1.51.tar.gz", hash = "sha256:22c9c94bb6b0b9f3c7157c684fece45a414cea204586b600beae6cd4570dcd6d", size = 223519, upload-time = "2026-07-12T13:40:07.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/11/1232bb1ba52a230f20ff08e1b3df7cd9d43a71b61cc0a1c37941064fe4c7/gitpython-3.1.51-py3-none-any.whl", hash = "sha256:d5b29685708f3c80a56db040b665bfd69492c8e150b5848532af8013b686c5a4", size = 215246, upload-time = "2026-07-12T13:40:06.43Z" }, +] + +[[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 = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.21" }, + { name = "tach", specifier = ">=0.35,<0.36" }, + { name = "ty", specifier = ">=0.0.59" }, +] + +[[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 = ">=25" }, + { name = "tomlkit", specifier = ">=0.13" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.21" }, +] + +[[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.59" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/84ae7b3bf6e3e9f57eb9635eeff5a80b36e57aa089f40be0fb5c384fa176/ty-0.0.59.tar.gz", hash = "sha256:53e53ffeed78ad59cd237fa8ea1316d2b94e13efdea9a945698acab549e005aa", size = 6145435, upload-time = "2026-07-12T20:22:02.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/e8/650b42fbef4d48e6ca682b0b6e9b68fa8fcf55cbb0a6892ab89990018b6f/ty-0.0.59-py3-none-linux_armv6l.whl", hash = "sha256:f8fb08a767ef8f11ea3c537b9d77860726cc2bc39e6f77ad13c02d5b289f20a7", size = 11700328, upload-time = "2026-07-12T20:21:26.046Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/0ca3a89d5f59ae5f308e5e83428cac5f9143200767743e052fba90b4b81e/ty-0.0.59-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c7f4d5630836c8a0ba13dd4ac7bdae080a7d6ebe965b817ff642dc961bcf2a53", size = 11494310, upload-time = "2026-07-12T20:21:28.491Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/5076de6001cefbccd8e6dc8472262697e43308ff66b0e87c72abba136357/ty-0.0.59-py3-none-macosx_11_0_arm64.whl", hash = "sha256:872f6fb02c6db5553c4d5fb283b3d50f0985fb9a29a910e4fda4793a775c1926", size = 11026797, upload-time = "2026-07-12T20:21:30.879Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/fca28481b6a138e2b798ad9fdc98a095475f9104948ba242fce4b477782b/ty-0.0.59-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af8eefbfe806337770eec12c0c819c5f1b8f5b85f8369cb1cc9fa25234a2208", size = 11475304, upload-time = "2026-07-12T20:21:33.041Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/1fed8b81b389ef4bbc0400f19e05fc16496b162577779dc0e5fc65ac216c/ty-0.0.59-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0acf8b76a1c9a7ddef460b42475f6c76193164426ab080783af1c3175b4b999b", size = 11533131, upload-time = "2026-07-12T20:21:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fc/04eec35e05a10e0fea1c6503a290ccc3935efda9c845aff64e83282c1af7/ty-0.0.59-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:043c2e00eb1d7475f928af7dedd71f69b64e69bfca55e36f4c968479e1373fc4", size = 12205932, upload-time = "2026-07-12T20:21:37.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dd/a61de859659fa11b55917ad38340a8f2c61f5ae17d1874929f29084c6990/ty-0.0.59-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0d688d857441df57f48fca66c029d85cf737c510e7be1d01144cdad1e58d968", size = 12758406, upload-time = "2026-07-12T20:21:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e8/fa66f05997eab8ca75fc4f17320140e25467849e0cc75597f898cc22099c/ty-0.0.59-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a96c9f88394a3b42c737e2125b2330543f0d90a43b49761f377d96f8c3ee0d62", size = 12288176, upload-time = "2026-07-12T20:21:41.784Z" }, + { url = "https://files.pythonhosted.org/packages/15/68/0fca59963bd5123f42d5f7da50667e7a52e8e9615e3a16d8c2c0d3b2d143/ty-0.0.59-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08dbcb268edcafcb152e59475b5b495ce28d0b340a395c09943557678f4d5a6", size = 12028471, upload-time = "2026-07-12T20:21:43.82Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/cd7dabbbab392578f11179919da5c25d8c3322e5388a688f539ea0539603/ty-0.0.59-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8812764b9a40fdc98df1272826e73a298ef56b06681135e643bcf90aad1896f7", size = 12297646, upload-time = "2026-07-12T20:21:45.76Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/2e9c94f0b383d8cbe1a35517ab470b7810bc9d7501603ab532bcd5be5e90/ty-0.0.59-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd53b8581641d8dad7bfac6d5ea589e91a883d6837e0b9a286fdae30722b7c69", size = 11432519, upload-time = "2026-07-12T20:21:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0a/af93e9785200f11ac416cc20235fc2464c9bd978e791190684ea0e458795/ty-0.0.59-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:86da5872124a41877d95058bc17d33ddcff034b587eb5f1e2917ab88ba227dac", size = 11554993, upload-time = "2026-07-12T20:21:49.671Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dd/651bf87e20d00376c81b19124756491cffaf20eb8bec05a8794e5a8cf641/ty-0.0.59-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a233eef5f2fd4d894881e4a0aec83c9f172bfae1d787d6596ee1939fcc7723e", size = 11818230, upload-time = "2026-07-12T20:21:51.659Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/c947c4155fea751d135b19affdf734bbce72a94e446b866cf0c62f8bed69/ty-0.0.59-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ff678c18b5f1e3128b75a35e50dee7908dea55155baa31cd790619d5014cbf5", size = 12135194, upload-time = "2026-07-12T20:21:53.796Z" }, + { url = "https://files.pythonhosted.org/packages/b1/13/e5feb138888de1e95037c843571bbbd4ac21bf0a190507468098599a321f/ty-0.0.59-py3-none-win32.whl", hash = "sha256:cf8abb4b8095c5fe39102b8127f5886db308c8d4600909ddbc905512ce9c8163", size = 11179249, upload-time = "2026-07-12T20:21:55.752Z" }, + { url = "https://files.pythonhosted.org/packages/76/dd/52914dcbeeba92c207de40ef7109a58dcb5527aeb21c8f8feb7402aa9e29/ty-0.0.59-py3-none-win_amd64.whl", hash = "sha256:1dde20a82243d24407869e5a608c2f15efddd5cefc662aef461a5af84bfb3f8b", size = 12251079, upload-time = "2026-07-12T20:21:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, +] + +[[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" From 402d27dc5462502eae16ff3e6c3f80591569f96c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:14:44 +0200 Subject: [PATCH 3/8] feat: add upper bound lint --- .config/mise/tasks/lint/python.sh | 2 + AGENTS.md | 2 +- README.md | 2 +- libs/@local/repo-chores/python/pyproject.toml | 14 +- .../repo-chores/python/repo_chores/cli.py | 23 +++ .../repo-chores/python/repo_chores/lint.py | 179 ++++++++++++++++++ .../repo-chores/python/repo_chores/sync.py | 14 +- .../python/repo_chores/tach.domain.toml | 4 + .../python/repo_chores/workspace.py | 34 ++++ .../repo-chores/python/tests/test_lint.py | 148 +++++++++++++++ .../repo-chores/python/tests/test_sync.py | 2 + pyproject.toml | 15 +- uv.lock | 62 +++--- 13 files changed, 446 insertions(+), 55 deletions(-) create mode 100644 libs/@local/repo-chores/python/repo_chores/lint.py create mode 100644 libs/@local/repo-chores/python/tests/test_lint.py diff --git a/.config/mise/tasks/lint/python.sh b/.config/mise/tasks/lint/python.sh index 68b3a08e39b..536a891b310 100755 --- a/.config/mise/tasks/lint/python.sh +++ b/.config/mise/tasks/lint/python.sh @@ -15,6 +15,8 @@ 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 diff --git a/AGENTS.md b/AGENTS.md index a550cf04302..eaee593d775 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ 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, uv.lock stability, workspace sync): `mise run lint:python` +- 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` diff --git a/README.md b/README.md index b5ff396eee1..8c8659749de 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ Common commands: ```sh uv sync # create/refresh .venv from uv.lock -mise run lint:python # everything CI runs: lock stability, sync --check, ruff, ty, tach +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) ``` diff --git a/libs/@local/repo-chores/python/pyproject.toml b/libs/@local/repo-chores/python/pyproject.toml index 4c94fe96647..b826267cd04 100644 --- a/libs/@local/repo-chores/python/pyproject.toml +++ b/libs/@local/repo-chores/python/pyproject.toml @@ -1,11 +1,11 @@ [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" +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>=25", - "tomlkit>=0.13", + "packaging>=26.2,<27", + "tomlkit>=0.15,<0.16", ] [project.scripts] @@ -20,6 +20,6 @@ packages = ["repo_chores"] [dependency-groups] dev = [ - "pytest>=8.0", - "ruff>=0.15.21", + "pytest>=9.1.1,<10", + "ruff>=0.15.21,<0.16", ] diff --git a/libs/@local/repo-chores/python/repo_chores/cli.py b/libs/@local/repo-chores/python/repo_chores/cli.py index d907f625057..7e07c5e17be 100644 --- a/libs/@local/repo-chores/python/repo_chores/cli.py +++ b/libs/@local/repo-chores/python/repo_chores/cli.py @@ -5,6 +5,7 @@ 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 @@ -26,6 +27,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 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", @@ -68,12 +74,29 @@ def run_sync(*, check: bool) -> None: 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 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/libs/@local/repo-chores/python/repo_chores/sync.py b/libs/@local/repo-chores/python/repo_chores/sync.py index 334d2cafe06..9a8e380c3a0 100644 --- a/libs/@local/repo-chores/python/repo_chores/sync.py +++ b/libs/@local/repo-chores/python/repo_chores/sync.py @@ -36,6 +36,7 @@ reconcile_string_array, ) from repo_chores.workspace import ( + Finding, Member, Workspace, WorkspaceError, @@ -50,19 +51,6 @@ NPM_SCOPE = "@python" -@dataclass(frozen=True, kw_only=True, slots=True) -class Finding: - """A single deviation from the expected workspace state. - - A fixable finding is corrected in apply mode; an unfixable finding - requires a manual edit and fails the run in either mode. - """ - - path: str - message: str - fixable: bool = True - - @dataclass(frozen=True, kw_only=True, slots=True) class ManifestExpectation: """A managed string array in the root manifest and its expected contents. diff --git a/libs/@local/repo-chores/python/repo_chores/tach.domain.toml b/libs/@local/repo-chores/python/repo_chores/tach.domain.toml index eb5db251d4f..4fceb8d6b7d 100644 --- a/libs/@local/repo-chores/python/repo_chores/tach.domain.toml +++ b/libs/@local/repo-chores/python/repo_chores/tach.domain.toml @@ -17,6 +17,10 @@ path = "toml_edit" 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]] diff --git a/libs/@local/repo-chores/python/repo_chores/workspace.py b/libs/@local/repo-chores/python/repo_chores/workspace.py index f47cb2a8c6e..6896b1dbda2 100644 --- a/libs/@local/repo-chores/python/repo_chores/workspace.py +++ b/libs/@local/repo-chores/python/repo_chores/workspace.py @@ -36,6 +36,29 @@ 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.""" @@ -47,8 +70,10 @@ class Member: """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 @@ -60,6 +85,7 @@ class Workspace: requires_python: str declared_members: tuple[str, ...] dev_dependencies: tuple[str, ...] + insecure_bounds_allowlist: tuple[str, ...] members: tuple[Member, ...] @@ -111,6 +137,11 @@ def _dev_dependencies(manifest: Mapping[str, object], /) -> tuple[str, ...]: 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. @@ -163,8 +194,10 @@ def load_member(root: Path, manifest_path: Path, /) -> 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(), ) @@ -187,6 +220,7 @@ def load_workspace(root: Path, /) -> Workspace: 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/test_lint.py b/libs/@local/repo-chores/python/tests/test_lint.py new file mode 100644 index 00000000000..74b92b90a67 --- /dev/null +++ b/libs/@local/repo-chores/python/tests/test_lint.py @@ -0,0 +1,148 @@ +"""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, + ) + + +class TestUpperBounds: + def test_bounded_ranges_pass(self) -> 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(self) -> 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(self) -> 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(self) -> 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(self) -> 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(self) -> 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(self) -> 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) + + +class TestAllowlist: + def test_allowlisted_dependency_is_exempt(self) -> None: + workspace = make_workspace(dev_dependencies=("ruff>=0.15.21",), allowlist=("ruff",)) + + assert lint_workspace(workspace) == [] + + def test_allowlist_names_are_canonicalized(self) -> 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(self) -> 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(self) -> 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) + + +class TestUrlRequirements: + def test_url_requirements_fail_even_when_allowlisted(self) -> 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(self) -> 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_sync.py b/libs/@local/repo-chores/python/tests/test_sync.py index 63efa387242..f51d4c1ec50 100644 --- a/libs/@local/repo-chores/python/tests/test_sync.py +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -152,8 +152,10 @@ def make_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, ) diff --git a/pyproject.toml b/pyproject.toml index 8eb920984f0..d0a5cbe648b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,14 @@ description = "Workspace-level Python configuration for the HASH monorepo" requires-python = ">=3.14,<3.15" [tool.uv] -package = false +# `uv add` writes cargo-style bounded ranges (>=1.2.3, <2.0.0) by default. +# `repo-chores lint` rejects requirements without a lower and upper bound. +add-bounds = "major" +# Dependency cooldown: newly published releases are held back before +# resolution considers them, matching the yarn (`npmMinimalAgeGate`) and mise +# (`minimum_release_age`) posture of 5 days. +exclude-newer = "5 days" +package = false # Managed by `mise run sync:python`: every pyproject.toml in the repository # must be listed here explicitly. CI fails if one is missing. @@ -26,10 +33,10 @@ dev = [ # Workspace tooling: `uv run repo-chores sync [--check]` and # `uv run repo-chores prune `. "hash-repo-chores", - "pytest>=8.0", - "ruff>=0.15.21", + "pytest>=9.1.1,<10", + "ruff>=0.15.21,<0.16", "tach>=0.35,<0.36", - "ty>=0.0.59", + "ty>=0.0.58,<0.1", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index 71a29da0537..e539b9d646b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,10 @@ 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", @@ -31,14 +35,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.51" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/30/a8a0c15f9480dc91b5b7f11ebd26105e5f80898d7ff02da197fef35d8395/gitpython-3.1.51.tar.gz", hash = "sha256:22c9c94bb6b0b9f3c7157c684fece45a414cea204586b600beae6cd4570dcd6d", size = 223519, upload-time = "2026-07-12T13:40:07.922Z" } +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/f8/11/1232bb1ba52a230f20ff08e1b3df7cd9d43a71b61cc0a1c37941064fe4c7/gitpython-3.1.51-py3-none-any.whl", hash = "sha256:d5b29685708f3c80a56db040b665bfd69492c8e150b5848532af8013b686c5a4", size = 215246, upload-time = "2026-07-12T13:40:06.43Z" }, + { 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]] @@ -60,10 +64,10 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "hash-repo-chores", editable = "libs/@local/repo-chores/python" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = ">=0.15.21" }, + { 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.59" }, + { name = "ty", specifier = ">=0.0.58,<0.1" }, ] [[package]] @@ -83,14 +87,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "packaging", specifier = ">=25" }, - { name = "tomlkit", specifier = ">=0.13" }, + { name = "packaging", specifier = ">=26.2,<27" }, + { name = "tomlkit", specifier = ">=0.15,<0.16" }, ] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = ">=0.15.21" }, + { name = "pytest", specifier = ">=9.1.1,<10" }, + { name = "ruff", specifier = ">=0.15.21,<0.16" }, ] [[package]] @@ -361,27 +365,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.59" +version = "0.0.58" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/b0/84ae7b3bf6e3e9f57eb9635eeff5a80b36e57aa089f40be0fb5c384fa176/ty-0.0.59.tar.gz", hash = "sha256:53e53ffeed78ad59cd237fa8ea1316d2b94e13efdea9a945698acab549e005aa", size = 6145435, upload-time = "2026-07-12T20:22:02.781Z" } +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/57/e8/650b42fbef4d48e6ca682b0b6e9b68fa8fcf55cbb0a6892ab89990018b6f/ty-0.0.59-py3-none-linux_armv6l.whl", hash = "sha256:f8fb08a767ef8f11ea3c537b9d77860726cc2bc39e6f77ad13c02d5b289f20a7", size = 11700328, upload-time = "2026-07-12T20:21:26.046Z" }, - { url = "https://files.pythonhosted.org/packages/22/ac/0ca3a89d5f59ae5f308e5e83428cac5f9143200767743e052fba90b4b81e/ty-0.0.59-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c7f4d5630836c8a0ba13dd4ac7bdae080a7d6ebe965b817ff642dc961bcf2a53", size = 11494310, upload-time = "2026-07-12T20:21:28.491Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5076de6001cefbccd8e6dc8472262697e43308ff66b0e87c72abba136357/ty-0.0.59-py3-none-macosx_11_0_arm64.whl", hash = "sha256:872f6fb02c6db5553c4d5fb283b3d50f0985fb9a29a910e4fda4793a775c1926", size = 11026797, upload-time = "2026-07-12T20:21:30.879Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0f/fca28481b6a138e2b798ad9fdc98a095475f9104948ba242fce4b477782b/ty-0.0.59-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af8eefbfe806337770eec12c0c819c5f1b8f5b85f8369cb1cc9fa25234a2208", size = 11475304, upload-time = "2026-07-12T20:21:33.041Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/1fed8b81b389ef4bbc0400f19e05fc16496b162577779dc0e5fc65ac216c/ty-0.0.59-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0acf8b76a1c9a7ddef460b42475f6c76193164426ab080783af1c3175b4b999b", size = 11533131, upload-time = "2026-07-12T20:21:35.189Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fc/04eec35e05a10e0fea1c6503a290ccc3935efda9c845aff64e83282c1af7/ty-0.0.59-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:043c2e00eb1d7475f928af7dedd71f69b64e69bfca55e36f4c968479e1373fc4", size = 12205932, upload-time = "2026-07-12T20:21:37.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/dd/a61de859659fa11b55917ad38340a8f2c61f5ae17d1874929f29084c6990/ty-0.0.59-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0d688d857441df57f48fca66c029d85cf737c510e7be1d01144cdad1e58d968", size = 12758406, upload-time = "2026-07-12T20:21:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e8/fa66f05997eab8ca75fc4f17320140e25467849e0cc75597f898cc22099c/ty-0.0.59-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a96c9f88394a3b42c737e2125b2330543f0d90a43b49761f377d96f8c3ee0d62", size = 12288176, upload-time = "2026-07-12T20:21:41.784Z" }, - { url = "https://files.pythonhosted.org/packages/15/68/0fca59963bd5123f42d5f7da50667e7a52e8e9615e3a16d8c2c0d3b2d143/ty-0.0.59-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08dbcb268edcafcb152e59475b5b495ce28d0b340a395c09943557678f4d5a6", size = 12028471, upload-time = "2026-07-12T20:21:43.82Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5b/cd7dabbbab392578f11179919da5c25d8c3322e5388a688f539ea0539603/ty-0.0.59-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8812764b9a40fdc98df1272826e73a298ef56b06681135e643bcf90aad1896f7", size = 12297646, upload-time = "2026-07-12T20:21:45.76Z" }, - { url = "https://files.pythonhosted.org/packages/1d/37/2e9c94f0b383d8cbe1a35517ab470b7810bc9d7501603ab532bcd5be5e90/ty-0.0.59-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd53b8581641d8dad7bfac6d5ea589e91a883d6837e0b9a286fdae30722b7c69", size = 11432519, upload-time = "2026-07-12T20:21:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0a/af93e9785200f11ac416cc20235fc2464c9bd978e791190684ea0e458795/ty-0.0.59-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:86da5872124a41877d95058bc17d33ddcff034b587eb5f1e2917ab88ba227dac", size = 11554993, upload-time = "2026-07-12T20:21:49.671Z" }, - { url = "https://files.pythonhosted.org/packages/4b/dd/651bf87e20d00376c81b19124756491cffaf20eb8bec05a8794e5a8cf641/ty-0.0.59-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a233eef5f2fd4d894881e4a0aec83c9f172bfae1d787d6596ee1939fcc7723e", size = 11818230, upload-time = "2026-07-12T20:21:51.659Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/c947c4155fea751d135b19affdf734bbce72a94e446b866cf0c62f8bed69/ty-0.0.59-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ff678c18b5f1e3128b75a35e50dee7908dea55155baa31cd790619d5014cbf5", size = 12135194, upload-time = "2026-07-12T20:21:53.796Z" }, - { url = "https://files.pythonhosted.org/packages/b1/13/e5feb138888de1e95037c843571bbbd4ac21bf0a190507468098599a321f/ty-0.0.59-py3-none-win32.whl", hash = "sha256:cf8abb4b8095c5fe39102b8127f5886db308c8d4600909ddbc905512ce9c8163", size = 11179249, upload-time = "2026-07-12T20:21:55.752Z" }, - { url = "https://files.pythonhosted.org/packages/76/dd/52914dcbeeba92c207de40ef7109a58dcb5527aeb21c8f8feb7402aa9e29/ty-0.0.59-py3-none-win_amd64.whl", hash = "sha256:1dde20a82243d24407869e5a608c2f15efddd5cefc662aef461a5af84bfb3f8b", size = 12251079, upload-time = "2026-07-12T20:21:58.1Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, + { 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]] From dec90500adc4ab14255b438846230f17ad16564c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:31:18 +0200 Subject: [PATCH 4/8] fix: remove fnmatch --- .../repo-chores/python/repo_chores/sync.py | 12 ++++++----- .../repo-chores/python/tests/test_sync.py | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/libs/@local/repo-chores/python/repo_chores/sync.py b/libs/@local/repo-chores/python/repo_chores/sync.py index 9a8e380c3a0..cd5ff515b25 100644 --- a/libs/@local/repo-chores/python/repo_chores/sync.py +++ b/libs/@local/repo-chores/python/repo_chores/sync.py @@ -20,9 +20,8 @@ import subprocess from collections.abc import Iterable, Mapping from dataclasses import dataclass -from fnmatch import fnmatch from functools import cache -from pathlib import Path +from pathlib import Path, PurePosixPath import tomlkit from packaging.requirements import InvalidRequirement, Requirement @@ -230,8 +229,10 @@ def _sync_root_package_json(workspace: Workspace, /, *, apply: bool) -> list[Fin """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. Missing members are added - as explicit entries and the list is kept sorted. + 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")) @@ -244,7 +245,8 @@ def _sync_root_package_json(workspace: Workspace, /, *, apply: bool) -> list[Fin findings: list[Finding] = [] for member in _named_members(workspace): - if any(fnmatch(member.directory, pattern) for pattern in patterns): + directory = PurePosixPath(member.directory) + if any(directory.full_match(pattern) for pattern in patterns): continue findings.append( diff --git a/libs/@local/repo-chores/python/tests/test_sync.py b/libs/@local/repo-chores/python/tests/test_sync.py index f51d4c1ec50..7ce5ec629dc 100644 --- a/libs/@local/repo-chores/python/tests/test_sync.py +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -30,6 +30,27 @@ def test_reports_every_deviation_without_writing(self, fixture_repo: Path) -> No 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(self, 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(self, fixture_repo: Path) -> None: findings = synchronize(fixture_repo, apply=False) unfixable = [finding for finding in findings if not finding.fixable] From 8c66fa97c317bacc71cbd60ae47b4c604f1847e7 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:09:28 +0200 Subject: [PATCH 5/8] fix: make ty strict --- .../petrinaut-cli/MODEL_EXAMPLES.md | 12 ++----- .../petrinaut-cli/PYTHON_INTEGRATION.md | 4 ++- libs/@hashintel/petrinaut-cli/README.md | 2 ++ .../repo-chores/python/repo_chores/sync.py | 3 ++ .../python/repo_chores/workspace.py | 28 +++++++-------- .../repo-chores/python/tests/test_prune.py | 27 +++++++------- .../repo-chores/python/tests/test_sync.py | 17 ++++++++- pyproject.toml | 35 ++++++++++++++----- 8 files changed, 80 insertions(+), 48 deletions(-) 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/python/repo_chores/sync.py b/libs/@local/repo-chores/python/repo_chores/sync.py index cd5ff515b25..46ad52ba6d3 100644 --- a/libs/@local/repo-chores/python/repo_chores/sync.py +++ b/libs/@local/repo-chores/python/repo_chores/sync.py @@ -395,6 +395,9 @@ def assemble_package_json( 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" diff --git a/libs/@local/repo-chores/python/repo_chores/workspace.py b/libs/@local/repo-chores/python/repo_chores/workspace.py index 6896b1dbda2..c83fe3fde1e 100644 --- a/libs/@local/repo-chores/python/repo_chores/workspace.py +++ b/libs/@local/repo-chores/python/repo_chores/workspace.py @@ -14,21 +14,19 @@ from pathlib import Path from typing import TypeIs -PRUNED_DIRECTORIES = frozenset( - { - ".git", - ".ruff_cache", - ".turbo", - ".venv", - "__pycache__", - "build", - "dist", - "node_modules", - "out", - "target", - "venv", - } -) +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.""" diff --git a/libs/@local/repo-chores/python/tests/test_prune.py b/libs/@local/repo-chores/python/tests/test_prune.py index 116837dba0d..0353b1169b8 100644 --- a/libs/@local/repo-chores/python/tests/test_prune.py +++ b/libs/@local/repo-chores/python/tests/test_prune.py @@ -7,14 +7,17 @@ class TestParseScopes: def test_splits_on_spaces_and_newlines(self) -> None: - assert prune.parse_scopes("@apps/a @apps/b\n@apps/c") == frozenset( - {"@apps/a", "@apps/b", "@apps/c"} - ) + assert prune.parse_scopes("@apps/a @apps/b\n@apps/c") == frozenset({ + "@apps/a", + "@apps/b", + "@apps/c", + }) def test_ignores_blank_lines_and_extra_whitespace(self) -> None: - assert prune.parse_scopes("\n @apps/a \n\n\t@apps/b\n") == frozenset( - {"@apps/a", "@apps/b"} - ) + assert prune.parse_scopes("\n @apps/a \n\n\t@apps/b\n") == frozenset({ + "@apps/a", + "@apps/b", + }) class TestExpandScopes: @@ -38,13 +41,11 @@ def test_expansion_reaches_fixpoint_through_added_scopes(self) -> None: "@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", - } - ) + assert scopes == frozenset({ + "@rust/hashql-ast", + "@rust/hashql-compiletest", + "@rust/hash-graph-test-data", + }) def test_terminates_on_cyclic_dependency_maps(self) -> None: dependencies = { diff --git a/libs/@local/repo-chores/python/tests/test_sync.py b/libs/@local/repo-chores/python/tests/test_sync.py index 7ce5ec629dc..354c33319b1 100644 --- a/libs/@local/repo-chores/python/tests/test_sync.py +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -4,7 +4,7 @@ import tomllib from pathlib import Path -from repo_chores.sync import assemble_package_json, synchronize +from repo_chores.sync import PYTEST_SCRIPT, assemble_package_json, synchronize from repo_chores.workspace import Member @@ -215,6 +215,21 @@ def test_manages_identity_scripts_and_dependencies(self) -> None: assert "test:unit" in self.scripts_of(assembled) assert assembled["dependencies"] == {"@python/alpha-core": "workspace:*"} + def test_removes_managed_pytest_script_when_tests_are_gone(self) -> None: + existing = {"scripts": {"test:unit": PYTEST_SCRIPT, "custom:thing": "echo hi"}} + + assembled = assemble_package_json(self.make_member(has_tests=False), existing) + + assert "test:unit" not in self.scripts_of(assembled) + assert self.scripts_of(assembled)["custom:thing"] == "echo hi" + + def test_preserves_hand_written_test_script_without_tests_directory(self) -> None: + existing = {"scripts": {"test:unit": "vitest run"}} + + assembled = assemble_package_json(self.make_member(has_tests=False), existing) + + assert self.scripts_of(assembled)["test:unit"] == "vitest run" + def test_omits_empty_dependencies(self) -> None: assembled = assemble_package_json(self.make_member(), {"dependencies": {"x": "1"}}) diff --git a/pyproject.toml b/pyproject.toml index d0a5cbe648b..ed61850b904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ # (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" -description = "Workspace-level Python configuration for the HASH monorepo" # 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" @@ -50,13 +50,15 @@ testpaths = [ ] [tool.ruff] -target-version = "py314" -line-length = 100 +# 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`: one entry per workspace member, so ruff # classifies member-local imports as first-party. @@ -65,10 +67,6 @@ src = [ ] [tool.ruff.lint] -# Preview rules are enabled deliberately: the strictest available rule set, -# with exceptions curated below. -preview = true -select = ["ALL"] ignore = [ # Formatter conflicts (ruff's own recommendation). "COM812", @@ -110,6 +108,7 @@ ignore = [ "TC002", "TC003", ] +select = ["ALL"] [tool.ruff.lint.pylint] # Positional arguments are capped tightly (PLR0917): beyond this, callers @@ -134,8 +133,8 @@ convention = "pep257" "**/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"] +"**/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"] @@ -143,17 +142,35 @@ convention = "pep257" # 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] -venvPath = "." venv = ".venv" +venvPath = "." [tool.ty.environment] python = "./.venv" +[tool.ty.terminal] +# Warn-level diagnostics fail CI. +error-on-warning = true + [tool.ty.rules] # Suppressions must pull their weight, mirroring tach's # `unused_ignore_directives = "error"` and ruff's RUF100. unused-ignore-comment = "error" +# Rule codes required, mirroring the bare-noqa ban (PGH004). +blanket-ignore-comment = "error" +# Bare generics parameterize as Unknown, the same loophole the Any ban closes. +missing-type-argument = "error" +# Every override is marked; pairs with invalid-explicit-override. +missing-override-decorator = "error" +# 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 = [ From 86cb483816790ee6694f10d3b8f712b6679e62a5 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:32:20 +0200 Subject: [PATCH 6/8] fix: discourage class based tests --- .../repo-chores/python/tests/test_lint.py | 160 +++---- .../repo-chores/python/tests/test_prune.py | 345 ++++++++-------- .../repo-chores/python/tests/test_sync.py | 389 +++++++++--------- .../python/tests/test_workspace.py | 70 ++-- pyproject.toml | 71 ++-- 5 files changed, 519 insertions(+), 516 deletions(-) diff --git a/libs/@local/repo-chores/python/tests/test_lint.py b/libs/@local/repo-chores/python/tests/test_lint.py index 74b92b90a67..064060cb12e 100644 --- a/libs/@local/repo-chores/python/tests/test_lint.py +++ b/libs/@local/repo-chores/python/tests/test_lint.py @@ -41,108 +41,124 @@ def make_member( ) -class TestUpperBounds: - def test_bounded_ranges_pass(self) -> None: - workspace = make_workspace( - dev_dependencies=( - "ruff>=0.15.21,<0.16", - "tach~=0.35.0", - "exact==1.2.3", - "arbitrary===1.2.3", - ) +# 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) == [] + assert lint_workspace(workspace) == [] - def test_missing_upper_bound_fails(self) -> 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_upper_bound_fails() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("ruff>=0.15.21",))) - def test_missing_lower_bound_fails(self) -> None: - # `<2` alone lets resolution reach arbitrarily far down. - findings = lint_workspace(make_workspace(dev_dependencies=("ruff<2",))) + assert len(findings) == 1 + assert "no upper bound" in findings[0].message + assert not findings[0].fixable - assert len(findings) == 1 - assert "no lower bound" in findings[0].message - def test_bare_requirement_fails(self) -> None: - findings = lint_workspace(make_workspace(dev_dependencies=("pytest",))) +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 version bounds at all" in findings[0].message + assert len(findings) == 1 + assert "no lower bound" in findings[0].message - def test_exclusion_alone_is_not_a_bound(self) -> 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_bare_requirement_fails() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("pytest",))) - def test_workspace_members_are_exempt(self) -> None: - member = make_member(name="hash-repo-chores") - workspace = make_workspace(dev_dependencies=("hash-repo-chores",), members=(member,)) + assert len(findings) == 1 + assert "no version bounds at all" in findings[0].message - assert lint_workspace(workspace) == [] - def test_member_manifests_are_judged_too(self) -> None: - member = make_member(dependencies=("requests>=2",), dev_dependencies=("pytest",)) - workspace = make_workspace(members=(member,)) +def test_exclusion_alone_is_not_a_bound() -> None: + findings = lint_workspace(make_workspace(dev_dependencies=("ruff!=0.15.20",))) - findings = lint_workspace(workspace) + assert len(findings) == 1 + assert "no version bounds at all" in findings[0].message - assert len(findings) == 2 - assert all(finding.path == "packages/alpha/pyproject.toml" for finding in findings) +def test_workspace_members_are_exempt() -> None: + member = make_member(name="hash-repo-chores") + workspace = make_workspace(dev_dependencies=("hash-repo-chores",), members=(member,)) -class TestAllowlist: - def test_allowlisted_dependency_is_exempt(self) -> None: - workspace = make_workspace(dev_dependencies=("ruff>=0.15.21",), allowlist=("ruff",)) + assert lint_workspace(workspace) == [] - assert lint_workspace(workspace) == [] - def test_allowlist_names_are_canonicalized(self) -> None: - member = make_member(dependencies=("legacy.pkg>=1",), allowlist=("Legacy-Pkg",)) - workspace = make_workspace(members=(member,)) +def test_member_manifests_are_judged_too() -> None: + member = make_member(dependencies=("requests>=2",), dev_dependencies=("pytest",)) + workspace = make_workspace(members=(member,)) - assert lint_workspace(workspace) == [] + findings = lint_workspace(workspace) - def test_stale_allowlist_entry_fails(self) -> None: - workspace = make_workspace(dev_dependencies=("ruff>=0.15.21,<0.16",), allowlist=("ruff",)) + assert len(findings) == 2 + assert all(finding.path == "packages/alpha/pyproject.toml" for finding in findings) - findings = lint_workspace(workspace) - assert len(findings) == 1 - assert "exempts nothing" in findings[0].message +# Allowlist - def test_allowlist_is_scoped_to_its_manifest(self) -> 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) +def test_allowlisted_dependency_is_exempt() -> None: + workspace = make_workspace(dev_dependencies=("ruff>=0.15.21",), allowlist=("ruff",)) - 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) + assert lint_workspace(workspace) == [] -class TestUrlRequirements: - def test_url_requirements_fail_even_when_allowlisted(self) -> None: - workspace = make_workspace( - dev_dependencies=("weird @ https://example.com/weird-1.0.tar.gz",), - allowlist=("weird",), - ) +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) - findings = lint_workspace(workspace) + assert any("URL requirement" in finding.message for finding in findings) - assert any("URL requirement" in finding.message for finding in findings) - def test_invalid_requirement_is_reported(self) -> None: - workspace = make_workspace(dev_dependencies=(">=1.0",)) +def test_invalid_requirement_is_reported() -> None: + workspace = make_workspace(dev_dependencies=(">=1.0",)) - findings = lint_workspace(workspace) + findings = lint_workspace(workspace) - assert len(findings) == 1 - assert "invalid requirement" in findings[0].message + 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 index 0353b1169b8..80eecbe6469 100644 --- a/libs/@local/repo-chores/python/tests/test_prune.py +++ b/libs/@local/repo-chores/python/tests/test_prune.py @@ -5,186 +5,187 @@ from repo_chores import prune -class TestParseScopes: - def test_splits_on_spaces_and_newlines(self) -> None: - assert prune.parse_scopes("@apps/a @apps/b\n@apps/c") == frozenset({ - "@apps/a", - "@apps/b", - "@apps/c", - }) - - def test_ignores_blank_lines_and_extra_whitespace(self) -> None: - assert prune.parse_scopes("\n @apps/a \n\n\t@apps/b\n") == frozenset({ - "@apps/a", - "@apps/b", - }) - - -class TestExpandScopes: - def test_no_rule_matches(self) -> None: - assert prune.expand_scopes({"@apps/hash-frontend"}) == frozenset() - - def test_prefix_trigger(self) -> 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"}) - - -class TestFixpointExpand: - def test_expands_rules_triggered_by_dependencies(self) -> 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(self) -> 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_terminates_on_cyclic_dependency_maps(self) -> None: - dependencies = { - "@apps/a": frozenset({"@apps/b"}), - "@apps/b": frozenset({"@apps/a"}), - } - scopes = prune.fixpoint_expand({"@apps/a"}, dependencies) - assert scopes == frozenset({"@apps/a"}) - - -class TestResolveCargoMembers: - def test_resolves_globs_and_literal_members(self, 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", - ] - - -class TestStubMissingCargoMembers: - @staticmethod - def make_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_members(self, tmp_path: Path) -> None: - self.make_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(self, 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_dry_run_writes_nothing(self, tmp_path: Path) -> None: - self.make_workspace(tmp_path) - out = tmp_path / "out" - - prune.stub_missing_cargo_members(tmp_path, out, dry_run=True) - - assert not out.exists() - - -class TestResolveUvMembers: - def test_missing_root_pyproject(self, tmp_path: Path) -> None: - assert prune.resolve_uv_members(tmp_path) == [] - - def test_pyproject_without_workspace(self, tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[project]\nname = "root"\n') - assert prune.resolve_uv_members(tmp_path) == [] - - def test_resolves_member_globs(self, 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(self, tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - '[tool.uv.workspace]\nmembers = ["tools/*"]\nexclude = ["tools/skipped"]\n' +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' ) - for member in ("tools/a", "tools/skipped"): - (tmp_path / member).mkdir(parents=True) - (tmp_path / member / "pyproject.toml").write_text("[project]\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) - members = prune.resolve_uv_members(tmp_path) + 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() - assert [path.relative_to(tmp_path).as_posix() for path in members] == [ - "tools/a/pyproject.toml" - ] + +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' + ) -class TestStubMissingUvMembers: - @staticmethod - def make_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") - def test_copies_manifests_of_missing_members(self, tmp_path: Path) -> None: - self.make_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) - 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() - 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_dry_run_writes_nothing(self, tmp_path: Path) -> None: - self.make_workspace(tmp_path) - out = tmp_path / "out" +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) + prune.stub_missing_uv_members(tmp_path, out, dry_run=True) - assert not out.exists() + 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 index 354c33319b1..6ff8a6098db 100644 --- a/libs/@local/repo-chores/python/tests/test_sync.py +++ b/libs/@local/repo-chores/python/tests/test_sync.py @@ -7,230 +7,241 @@ from repo_chores.sync import PYTEST_SCRIPT, assemble_package_json, synchronize from repo_chores.workspace import Member +# Check mode -class TestCheck: - def test_reports_every_deviation_without_writing(self, 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(self, 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) +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()} - 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) + findings = synchronize(fixture_repo, apply=False) + messages = "\n".join(f"{finding.path}: {finding.message}" for finding in findings) - def test_unnamed_manifest_is_unfixable(self, fixture_repo: Path) -> None: - findings = synchronize(fixture_repo, apply=False) - unfixable = [finding for finding in findings if not finding.fixable] + 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 - assert [finding.path for finding in unfixable] == ["packages/toolcfg/pyproject.toml"] + 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_invalid_dev_requirement_is_an_unfixable_finding(self, 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) +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") - assert any( - finding.path == "packages/alpha/pyproject.toml" - and "invalid dev requirement" in finding.message - and not finding.fixable - for finding in findings - ) + 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) -class TestFix: - def test_fixes_are_applied_and_idempotent(self, fixture_repo: Path) -> None: - synchronize(fixture_repo, apply=True) + 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) - 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"] +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] - 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"] + assert [finding.path for finding in unfixable] == ["packages/toolcfg/pyproject.toml"] - 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(self, 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]" - ) - ) +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"') + ) - synchronize(fixture_repo, apply=True) + findings = synchronize(fixture_repo, apply=False) - assert "# Managed members list." in manifest_path.read_text() + assert any( + finding.path == "packages/alpha/pyproject.toml" + and "invalid dev requirement" in finding.message + and not finding.fixable + for finding in findings + ) - def test_removes_stale_and_duplicate_member_entries(self, 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(self, 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]', - ) - ) +# 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"] - findings = synchronize(fixture_repo, apply=True) + 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"] - assert any( - "workspace members must only contain strings" in finding.message and not finding.fixable - for finding in findings + 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]" ) - root_manifest = tomllib.loads(manifest_path.read_text()) - assert root_manifest["tool"]["uv"]["workspace"]["members"] == ["packages/alpha", 42] - - -class TestAssemblePackageJson: - @staticmethod - 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, + ) + + 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) - @staticmethod - 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_preserves_custom_scripts_and_extra_keys(self) -> None: - existing = { - "description": "Hand-written description", - "scripts": {"custom:thing": "echo hi"}, - } +def test_assemble_manages_identity_scripts_and_dependencies() -> None: + member = _make_member(workspace_dependencies=("alpha-core",), has_tests=True) - assembled = assemble_package_json(self.make_member(), existing) + assembled = assemble_package_json(member, {"name": "@python/stale", "license": "MIT"}) - assert assembled["description"] == "Hand-written description" - assert self.scripts_of(assembled)["custom:thing"] == "echo hi" - assert "lint:ruff" in self.scripts_of(assembled) - assert "fix:ruff" in self.scripts_of(assembled) + 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_manages_identity_scripts_and_dependencies(self) -> None: - member = self.make_member(workspace_dependencies=("alpha-core",), has_tests=True) - assembled = assemble_package_json(member, {"name": "@python/stale", "license": "MIT"}) +def test_assemble_removes_managed_pytest_script_when_tests_are_gone() -> None: + existing = {"scripts": {"test:unit": PYTEST_SCRIPT, "custom:thing": "echo hi"}} - assert assembled["name"] == "@python/alpha", "stale names are overwritten" - assert assembled["license"] == "MIT", "hand-set licenses are preserved" - assert "test:unit" in self.scripts_of(assembled) - assert assembled["dependencies"] == {"@python/alpha-core": "workspace:*"} + assembled = assemble_package_json(_make_member(has_tests=False), existing) - def test_removes_managed_pytest_script_when_tests_are_gone(self) -> None: - existing = {"scripts": {"test:unit": PYTEST_SCRIPT, "custom:thing": "echo hi"}} + assert "test:unit" not in _scripts_of(assembled) + assert _scripts_of(assembled)["custom:thing"] == "echo hi" - assembled = assemble_package_json(self.make_member(has_tests=False), existing) - assert "test:unit" not in self.scripts_of(assembled) - assert self.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"}} - def test_preserves_hand_written_test_script_without_tests_directory(self) -> None: - existing = {"scripts": {"test:unit": "vitest run"}} + assembled = assemble_package_json(_make_member(has_tests=False), existing) - assembled = assemble_package_json(self.make_member(has_tests=False), existing) + assert _scripts_of(assembled)["test:unit"] == "vitest run" - assert self.scripts_of(assembled)["test:unit"] == "vitest run" - def test_omits_empty_dependencies(self) -> None: - assembled = assemble_package_json(self.make_member(), {"dependencies": {"x": "1"}}) +def test_assemble_omits_empty_dependencies() -> None: + assembled = assemble_package_json(_make_member(), {"dependencies": {"x": "1"}}) - assert "dependencies" not in assembled + 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 index bd358592aca..b90b3cca68f 100644 --- a/libs/@local/repo-chores/python/tests/test_workspace.py +++ b/libs/@local/repo-chores/python/tests/test_workspace.py @@ -12,40 +12,36 @@ ) -class TestFindMemberManifests: - def test_skips_pruned_directories_and_root_manifest(self, 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", - ] - - -class TestFindWorkspaceRoot: - def test_walks_up_from_a_member_directory(self, fixture_repo: Path) -> None: - assert find_workspace_root(fixture_repo / "packages" / "alpha") == fixture_repo - - def test_errors_when_no_workspace_exists(self, tmp_path: Path) -> None: - with pytest.raises(WorkspaceError, match=r"tool\.uv\.workspace"): - find_workspace_root(tmp_path) - - -class TestLoadWorkspace: - def test_parses_root_and_members(self, 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 +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/pyproject.toml b/pyproject.toml index ed61850b904..25844b96f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,27 +12,19 @@ version = "0.0.0" requires-python = ">=3.14,<3.15" [tool.uv] -# `uv add` writes cargo-style bounded ranges (>=1.2.3, <2.0.0) by default. -# `repo-chores lint` rejects requirements without a lower and upper bound. -add-bounds = "major" -# Dependency cooldown: newly published releases are held back before -# resolution considers them, matching the yarn (`npmMinimalAgeGate`) and mise -# (`minimum_release_age`) posture of 5 days. -exclude-newer = "5 days" +add-bounds = "major" # require cargo-style bounded ranges (>=1.2.3, <2.0.0) +exclude-newer = "5 days" # mirrors npm release age package = false -# Managed by `mise run sync:python`: every pyproject.toml in the repository -# must be listed here explicitly. CI fails if one is missing. [tool.uv.workspace] +# Managed by `mise run sync:python` members = [ "libs/@local/repo-chores/python", ] [dependency-groups] dev = [ - # Workspace tooling: `uv run repo-chores sync [--check]` and - # `uv run repo-chores prune `. - "hash-repo-chores", + "hash-repo-chores", # workspace tooling "pytest>=9.1.1,<10", "ruff>=0.15.21,<0.16", "tach>=0.35,<0.36", @@ -44,7 +36,7 @@ hash-repo-chores = { workspace = true } [tool.pytest.ini_options] addopts = "-q --import-mode=importlib" -# Managed by `mise run sync:python`: one entry per member tests directory. +# Managed by `mise run sync:python` testpaths = [ "libs/@local/repo-chores/python/tests", ] @@ -60,8 +52,7 @@ extend-exclude = [ line-length = 100 target-version = "py314" -# Managed by `mise run sync:python`: one entry per workspace member, so ruff -# classifies member-local imports as first-party. +# Managed by `mise run sync:python` src = [ "libs/@local/repo-chores/python", ] @@ -96,14 +87,13 @@ ignore = [ "EM101", "EM102", "TRY003", - # Keyword-only argument passing is the house style; the *total* - # argument-count limit fights it without adding safety. PLR0917 keeps - # policing the positional count via `max-positional-args` below. + # 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", - # Typing-only import blocks are a hazard for runtime-evaluated - # annotations: pydantic models and TypeAdapters build their schemas from - # annotations at runtime, so imports moved under TYPE_CHECKING break - # model building. + # 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", @@ -111,8 +101,6 @@ ignore = [ select = ["ALL"] [tool.ruff.lint.pylint] -# Positional arguments are capped tightly (PLR0917): beyond this, callers -# must pass keywords. max-positional-args = 3 [tool.ruff.lint.flake8-tidy-imports.banned-api] @@ -125,9 +113,8 @@ 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, -# and grouping tests in classes (PLR6301) is the house pytest idiom. -"**/tests/**" = ["S101", "PLR2004", "SLF001", "D100", "S603", "INP001", "PLR6301"] +# 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"] @@ -151,19 +138,14 @@ venvPath = "." python = "./.venv" [tool.ty.terminal] -# Warn-level diagnostics fail CI. -error-on-warning = true +error-on-warning = true # fail in CI on warnings (Rust equivalent to -D warnings) [tool.ty.rules] -# Suppressions must pull their weight, mirroring tach's -# `unused_ignore_directives = "error"` and ruff's RUF100. -unused-ignore-comment = "error" -# Rule codes required, mirroring the bare-noqa ban (PGH004). -blanket-ignore-comment = "error" -# Bare generics parameterize as Unknown, the same loophole the Any ban closes. -missing-type-argument = "error" -# Every override is marked; pairs with invalid-explicit-override. -missing-override-decorator = "error" +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" @@ -178,8 +160,8 @@ exclude = [ "libs/\\@hashintel/petrinaut-cli/examples/python_stdio.py", ] -# Global tach settings. Modules and interfaces live next to the code they -# describe, in each package's tach.domain.toml. +# 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 = [ @@ -198,7 +180,7 @@ layers_explicit_depends_on = false respect_gitignore = true root_module = "ignore" -# Managed by `mise run sync:python`: one entry per workspace member. +# Managed by `mise run sync:python` source_roots = [ "libs/@local/repo-chores/python", ] @@ -216,8 +198,5 @@ layers = [ [tool.tach.rules] require_ignore_directive_reasons = "error" -# Accounting is scoped to each package's [project.dependencies]; a dependency -# that stops being imported fails `tach check-external`. Dependencies that are -# legitimately unimported belong in a `[tool.tach.external]` exclude list. -unused_external_dependencies = "error" -unused_ignore_directives = "error" +unused_external_dependencies = "error" # on false positives, use `tool.tach.external` +unused_ignore_directives = "error" From 2e7ec3e92c73c5eb5dced4d73183b5f6267ade4f Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:13:47 +0200 Subject: [PATCH 7/8] fix: added `.venv` to license ignore --- .../scripts/check-license-in-workspaces.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) 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..43326743814 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 @@ -13,7 +13,7 @@ const script = async () => { const yarnWorkspaceDirPaths = [ monorepoRootDirPath, ...Object.entries(yarnWorkspaceInfoLookup).map(([, yarnWorkspaceInfo]) => - path.resolve(monorepoRootDirPath, yarnWorkspaceInfo.location), + path.resolve(monorepoRootDirPath, yarnWorkspaceInfo.location) ), ]; @@ -28,6 +28,7 @@ const script = async () => { "**/runner_venv/**", "**/target/**", "**/venv/**", + "**/.venv/**", ], }); @@ -47,7 +48,7 @@ const script = async () => { for (const yarnWorkspaceDirPath of yarnWorkspaceDirPaths) { const canonicalLicenseFilePath = path.resolve( yarnWorkspaceDirPath, - "LICENSE.md", + "LICENSE.md" ); const currentLicenseFilePaths = licenseFilePaths.filter( @@ -55,7 +56,7 @@ const script = async () => { licenseFilePath.startsWith(yarnWorkspaceDirPath) && !licenseFilePath .slice(yarnWorkspaceDirPath.length + 1) - .includes(path.sep), + .includes(path.sep) ); let canonicalLicenseFilePathIsPresent = false; @@ -70,12 +71,12 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[MISSING]"), - path.relative(monorepoRootDirPath, canonicalLicenseFilePath), + path.relative(monorepoRootDirPath, canonicalLicenseFilePath) ); } else { console.log( chalk.green("[FOUND] "), - path.relative(monorepoRootDirPath, canonicalLicenseFilePath), + path.relative(monorepoRootDirPath, canonicalLicenseFilePath) ); } @@ -88,19 +89,19 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[NAMING] "), - path.relative(monorepoRootDirPath, licenseFilePath), + path.relative(monorepoRootDirPath, licenseFilePath) ); } else { console.log( chalk.yellow(" "), - path.relative(monorepoRootDirPath, licenseFilePath), + path.relative(monorepoRootDirPath, licenseFilePath) ); } } } const unusedLicenseFilePaths = licenseFilePaths.filter( - (licenseFilePath) => !usedLicenseFileSet.has(licenseFilePath), + (licenseFilePath) => !usedLicenseFileSet.has(licenseFilePath) ); let extraLicenseFilesArePresent = false; @@ -110,13 +111,13 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[NAMING] "), - path.relative(monorepoRootDirPath, licenseFilePath), + path.relative(monorepoRootDirPath, licenseFilePath) ); } else { extraLicenseFilesArePresent = true; console.log( chalk.yellow("[EXTRA] "), - path.relative(monorepoRootDirPath, licenseFilePath), + path.relative(monorepoRootDirPath, licenseFilePath) ); } } @@ -129,16 +130,16 @@ const script = async () => { if (checkFailed) { console.log( chalk.red( - "Please make sure that each Yarn workspace has a LICENSE.md file. Additional license files need to be named as LICENSE-*.md (all caps)", - ), + "Please make sure that each Yarn workspace has a LICENSE.md file. Additional license files need to be named as LICENSE-*.md (all caps)" + ) ); } if (extraLicenseFilesArePresent) { console.log( chalk.yellow( - "You may want to delete or relocate extra license files which are not located in Yarn workspaces", - ), + "You may want to delete or relocate extra license files which are not located in Yarn workspaces" + ) ); } From 8a662f158e170677aee598b0b2e57bd200b0a3db Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:12:25 +0200 Subject: [PATCH 8/8] fix: formaet --- .../scripts/check-license-in-workspaces.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 43326743814..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 @@ -13,7 +13,7 @@ const script = async () => { const yarnWorkspaceDirPaths = [ monorepoRootDirPath, ...Object.entries(yarnWorkspaceInfoLookup).map(([, yarnWorkspaceInfo]) => - path.resolve(monorepoRootDirPath, yarnWorkspaceInfo.location) + path.resolve(monorepoRootDirPath, yarnWorkspaceInfo.location), ), ]; @@ -48,7 +48,7 @@ const script = async () => { for (const yarnWorkspaceDirPath of yarnWorkspaceDirPaths) { const canonicalLicenseFilePath = path.resolve( yarnWorkspaceDirPath, - "LICENSE.md" + "LICENSE.md", ); const currentLicenseFilePaths = licenseFilePaths.filter( @@ -56,7 +56,7 @@ const script = async () => { licenseFilePath.startsWith(yarnWorkspaceDirPath) && !licenseFilePath .slice(yarnWorkspaceDirPath.length + 1) - .includes(path.sep) + .includes(path.sep), ); let canonicalLicenseFilePathIsPresent = false; @@ -71,12 +71,12 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[MISSING]"), - path.relative(monorepoRootDirPath, canonicalLicenseFilePath) + path.relative(monorepoRootDirPath, canonicalLicenseFilePath), ); } else { console.log( chalk.green("[FOUND] "), - path.relative(monorepoRootDirPath, canonicalLicenseFilePath) + path.relative(monorepoRootDirPath, canonicalLicenseFilePath), ); } @@ -89,19 +89,19 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[NAMING] "), - path.relative(monorepoRootDirPath, licenseFilePath) + path.relative(monorepoRootDirPath, licenseFilePath), ); } else { console.log( chalk.yellow(" "), - path.relative(monorepoRootDirPath, licenseFilePath) + path.relative(monorepoRootDirPath, licenseFilePath), ); } } } const unusedLicenseFilePaths = licenseFilePaths.filter( - (licenseFilePath) => !usedLicenseFileSet.has(licenseFilePath) + (licenseFilePath) => !usedLicenseFileSet.has(licenseFilePath), ); let extraLicenseFilesArePresent = false; @@ -111,13 +111,13 @@ const script = async () => { checkFailed = true; console.log( chalk.red("[NAMING] "), - path.relative(monorepoRootDirPath, licenseFilePath) + path.relative(monorepoRootDirPath, licenseFilePath), ); } else { extraLicenseFilesArePresent = true; console.log( chalk.yellow("[EXTRA] "), - path.relative(monorepoRootDirPath, licenseFilePath) + path.relative(monorepoRootDirPath, licenseFilePath), ); } } @@ -130,16 +130,16 @@ const script = async () => { if (checkFailed) { console.log( chalk.red( - "Please make sure that each Yarn workspace has a LICENSE.md file. Additional license files need to be named as LICENSE-*.md (all caps)" - ) + "Please make sure that each Yarn workspace has a LICENSE.md file. Additional license files need to be named as LICENSE-*.md (all caps)", + ), ); } if (extraLicenseFilesArePresent) { console.log( chalk.yellow( - "You may want to delete or relocate extra license files which are not located in Yarn workspaces" - ) + "You may want to delete or relocate extra license files which are not located in Yarn workspaces", + ), ); }