python-devkit 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. python_devkit-0.1.2/.gitattributes +1 -0
  2. {python_devkit-0.1.0 → python_devkit-0.1.2}/.github/workflows/ci.yml +3 -0
  3. {python_devkit-0.1.0 → python_devkit-0.1.2}/.github/workflows/release.yml +1 -1
  4. python_devkit-0.1.2/AGENTS.md +33 -0
  5. python_devkit-0.1.2/PKG-INFO +11 -0
  6. python_devkit-0.1.2/README.md +61 -0
  7. {python_devkit-0.1.0 → python_devkit-0.1.2}/pyproject.toml +9 -4
  8. python_devkit-0.1.2/ruff.base.toml +198 -0
  9. python_devkit-0.1.2/ruff.toml +1 -0
  10. python_devkit-0.1.2/src/python_devkit/lock_python.py +130 -0
  11. python_devkit-0.1.2/src/python_devkit/preflight_runner.py +43 -0
  12. python_devkit-0.1.2/src/python_devkit/ruff.base.toml +198 -0
  13. python_devkit-0.1.2/src/python_devkit/sync_ruff.py +90 -0
  14. python_devkit-0.1.2/uv.lock +319 -0
  15. python_devkit-0.1.0/AGENTS.md +0 -31
  16. python_devkit-0.1.0/PKG-INFO +0 -10
  17. python_devkit-0.1.0/README.md +0 -34
  18. python_devkit-0.1.0/ruff.toml +0 -95
  19. python_devkit-0.1.0/src/python_devkit/ci_step.py +0 -52
  20. python_devkit-0.1.0/src/python_devkit/setup.py +0 -127
  21. python_devkit-0.1.0/src/python_devkit/setup_oras.py +0 -51
  22. python_devkit-0.1.0/src/python_devkit/third-party/README.md +0 -9
  23. python_devkit-0.1.0/src/python_devkit/third-party/dotnet-install.sh +0 -1887
  24. {python_devkit-0.1.0 → python_devkit-0.1.2}/.gitignore +0 -0
  25. {python_devkit-0.1.0 → python_devkit-0.1.2}/.python-version +0 -0
  26. {python_devkit-0.1.0 → python_devkit-0.1.2}/CLAUDE.md +0 -0
  27. {python_devkit-0.1.0 → python_devkit-0.1.2}/LICENSE +0 -0
  28. {python_devkit-0.1.0 → python_devkit-0.1.2}/NOTICE +0 -0
  29. {python_devkit-0.1.0 → python_devkit-0.1.2}/publish-config.json +0 -0
  30. {python_devkit-0.1.0 → python_devkit-0.1.2}/src/python_devkit/__init__.py +0 -0
  31. {python_devkit-0.1.0 → python_devkit-0.1.2}/src/python_devkit/py.typed +0 -0
@@ -0,0 +1 @@
1
+ ruff.base.toml -text
@@ -28,3 +28,6 @@ jobs:
28
28
 
29
29
  - name: Type check
30
30
  run: uv run basedpyright
31
+
32
+ - name: Ruff config drift
33
+ run: uv run sync-ruff --check
@@ -22,4 +22,4 @@ jobs:
22
22
  - uses: astral-sh/setup-uv@v7
23
23
 
24
24
  - name: Publish
25
- run: uvx --from release-devkit==0.1.0 publish-packages --config publish-config.json
25
+ run: uvx --from release-devkit==0.1.1 publish-packages --config publish-config.json
@@ -0,0 +1,33 @@
1
+ # python-devkit
2
+
3
+ The python repo-lifecycle devkit — workspace locking (`lock-python`), the preflight check runner, and the canonical ruff configuration with its sync verb and drift gate, per the tooling-consolidation program. Purely python-domain by charter: the CI runner floor (step wrapper, runner provisioning, git tags, OCI cache) lives in [`ci-devkit`](https://github.com/outernet-foundation/ci-devkit), which this package depends on (`ci-devkit>=0.1.0`).
4
+
5
+ The package is `python_devkit` (src-layout under `src/python_devkit/`); all dependencies resolve from PyPI (`bashrun`, `ci-devkit`, `pydantic`, `typer`; git-source pins only in scratch branches testing unreleased changes).
6
+
7
+ ## Shape
8
+
9
+ Entry point (`[project.scripts]`): `lock-python` → `lock_python.py:app`. The command function doubles as the library API — consumer preflights import `lock_python` and call `lock_python(check=True)` directly.
10
+
11
+ `lock_python.py` regenerates a uv workspace's `uv.lock` plus per-service `pylock.toml` exports. Discovery: members of `[tool.uv.workspace]` in the root `pyproject.toml` that carry a `Dockerfile`; each gets `pylock.toml` (`--no-default-groups`) and one `pylock.<group>.toml` (`--only-group`) per non-`dev` dependency group. Redirect: a group matching a key of `[tool.python-devkit.lock-python] group-export-dirs` (glob → repo-relative directory) exports into that directory instead of the member's, once, deduplicated across members — the generalization of placeframe's neural-networks accelerator groups, which several members declare identically but only a non-member base image consumes. `--check` compares normalized exports against committed files and exits non-zero on any staleness.
12
+
13
+ `preflight_runner.py` is the labeled-check runner consumer preflights thin into: `run_checks(checks)` executes a declarative list of `CommandCheck` (label + shell command under a `ci_step` group) and `GeneratedCheck` (label + generate command + pathspec + fix command — runs the generator, then fails on any `git status --porcelain` dirt under the paths, showing the diff and the fix command). Fail-fast; each check's duration lands in the Actions step summary via `ci_step`.
14
+
15
+ `sync_ruff.py` writes the org-canonical ruff config (`src/python_devkit/ruff.base.toml`, shipped as package data) into a consuming repo's root as `ruff.base.toml`; `--check` is the drift gate (byte compare against the packaged canonical). The verb also validates the consuming repo's `ruff.toml` layer: it must set `extend = "ruff.base.toml"` and use only extend keys — plain `exclude`/`lint.select`/`lint.ignore`/`lint.per-file-ignores` in the layer replace the canonical settings under `extend` and fail the check. This repo consumes its own canonical the same way: root `ruff.base.toml` is a verb-written copy of the packaged one, root `ruff.toml` is the (currently empty) local layer.
16
+
17
+ ## Constraints
18
+
19
+ - **The module surface is cross-repo Python API.** `python_devkit.lock_python.lock_python(check, root)` and `python_devkit.preflight_runner.run_checks(checks)` are contract: placeframe's preflight calls both in-process, the lock-python entry point is on CI's path, and the `[tool.python-devkit.lock-python]` table in consumer root pyprojects is its declarative config surface.
20
+ - **The canonical ruff body is byte-stable contract.** Every consuming repo's `ruff.base.toml` is a verbatim copy compared byte-for-byte (after newline normalization); a cosmetic edit to the canonical ripples into every consumer's diff by design. Repo-local deltas never go here — they go in the consumer's `ruff.toml` extend layer.
21
+ - **`group-export-dirs` is devkit-owned and `extra="forbid"`.** A typo'd key in that table fails validation loudly rather than silently dropping the redirect (the failure mode is per-group pylocks appearing in member directories).
22
+ - **The redirect contract assumes identical group declarations.** Dedup is first-member-wins; members declaring the same redirected group differently get whichever member enumerates first, silently.
23
+ - **Export commands are byte-stability load-bearing.** The exact `uv export` flag order must not churn — committed pylocks are compared byte-for-byte (after newline normalization), so a cosmetic flag change ripples into every consumer's diff.
24
+ - **uv invocations run with `cwd=root`.** `--root` (default: current directory) exists so the verb and the library call work from anywhere; the uv commands themselves must execute inside the workspace root.
25
+
26
+ ## Release flow
27
+
28
+ `release.yml` (workflow_run-gated on CI) publishes via release-devkit uvx-isolated under OIDC trusted publishing (publisher bound to `release.yml`, no environment); the first release is 0.1.0 on the fresh `python-devkit-v*` tag ledger. The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the tags are the version ledger. API-breaking changes ship with a manually bumped version — patch-auto assumes additive changes.
29
+
30
+ ## See also
31
+
32
+ - `README.md` — human-facing overview.
33
+ - [tooling-consolidation.md](https://github.com/outernet-foundation/placeframe/blob/dev/design/tooling-consolidation.md) — the program this repo belongs to.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-devkit
3
+ Version: 0.1.2
4
+ Summary: Python repo-lifecycle tooling: workspace locking, preflight checks, canonical ruff configuration
5
+ License-File: LICENSE
6
+ License-File: NOTICE
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: bashrun>=0.1.0
9
+ Requires-Dist: ci-devkit>=0.1.0
10
+ Requires-Dist: pydantic>=2.11.7
11
+ Requires-Dist: typer>=0.17.4
@@ -0,0 +1,61 @@
1
+ # python-devkit
2
+
3
+ Python repo-lifecycle tooling shared across outernet-foundation repos: workspace locking (`lock-python`), the preflight check runner, and the canonical ruff configuration with its sync verb and drift gate. Sibling of `ci-devkit` (the CI runner floor it will depend on), `unity-devkit`, `docker-devkit`, and `release-devkit`.
4
+
5
+ ## lock-python
6
+
7
+ Regenerates a uv workspace's `uv.lock` and the per-service `pylock.toml` exports consumed by service Dockerfiles:
8
+
9
+ ```bash
10
+ uv run lock-python # write: uv lock + every export
11
+ uv run lock-python --check # validate; exit 1 on any stale lock
12
+ ```
13
+
14
+ For every workspace member that carries a `Dockerfile`, it exports `pylock.toml` (no default groups) plus one `pylock.<group>.toml` per non-`dev` dependency group. Dependency groups whose names match a pattern in the root `pyproject.toml`'s `[tool.python-devkit.lock-python] group-export-dirs` map export into the mapped directory instead, once, deduplicated across members — e.g. placeframe's accelerator groups:
15
+
16
+ ```toml
17
+ [tool.python-devkit.lock-python]
18
+ group-export-dirs = { "neural-networks-*" = "docker/neural-networks-base" }
19
+ ```
20
+
21
+ ## preflight runner
22
+
23
+ Consumer preflights thin into a declarative check list over `run_checks`:
24
+
25
+ ```python
26
+ from python_devkit.preflight_runner import CommandCheck, GeneratedCheck, run_checks
27
+
28
+ run_checks([
29
+ CommandCheck(label="Lint", command="uv run ruff check ."),
30
+ GeneratedCheck(
31
+ label="Check client codegen",
32
+ generate_command="uv run generate-clients",
33
+ paths=[Path("packages/generated/")],
34
+ fix_command="uv run generate-clients",
35
+ ),
36
+ ])
37
+ ```
38
+
39
+ `CommandCheck` wraps a shell command in a labeled CI step group; `GeneratedCheck` additionally guards the checked-in generator output with a git-status staleness check (diff shown, fix command named). Checks run fail-fast with per-step durations in the Actions step summary.
40
+
41
+ ## ruff canonical config
42
+
43
+ `src/python_devkit/ruff.base.toml` is the org-canonical ruff configuration. Consuming repos carry it verbatim as `ruff.base.toml` at their root, with a hand-owned `ruff.toml` beside it layering repo-local deltas on top:
44
+
45
+ ```bash
46
+ uvx --from python-devkit sync-ruff # write/refresh ruff.base.toml
47
+ uvx --from python-devkit sync-ruff --check # drift gate: exit 1 on divergence
48
+ ```
49
+
50
+ The consuming `ruff.toml` must set `extend = "ruff.base.toml"` and may only add via extend keys (`extend-exclude`, `lint.extend-ignore`, `lint.extend-per-file-ignores`) — plain `exclude`, `lint.select`, `lint.ignore`, and `lint.per-file-ignores` replace the canonical settings under `extend`, and `--check` fails on them. To change the canonical: edit it here, release, and run `sync-ruff` in every consuming repo.
51
+
52
+ ## Development
53
+
54
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
55
+
56
+ ```bash
57
+ uv sync
58
+ uv run ruff check .
59
+ uv run ruff format --check .
60
+ uv run basedpyright
61
+ ```
@@ -1,14 +1,19 @@
1
1
  [project]
2
2
  name = "python-devkit"
3
- version = "0.1.0"
4
- description = "Python repo-lifecycle tooling: CI step wrapper, runner provisioning, workspace locking, preflight checks"
3
+ version = "0.1.2"
4
+ description = "Python repo-lifecycle tooling: workspace locking, preflight checks, canonical ruff configuration"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
7
7
  "bashrun>=0.1.0",
8
+ "ci-devkit>=0.1.0",
8
9
  "pydantic>=2.11.7",
9
- "pydantic-settings>=2.9.1",
10
+ "typer>=0.17.4",
10
11
  ]
11
12
 
13
+ [project.scripts]
14
+ lock-python = "python_devkit.lock_python:app"
15
+ sync-ruff = "python_devkit.sync_ruff:app"
16
+
12
17
  [dependency-groups]
13
18
  dev = ["basedpyright>=1.39.10", "ruff>=0.14.11"]
14
19
 
@@ -18,7 +23,7 @@ build-backend = "hatchling.build"
18
23
 
19
24
  [tool.hatch.build.targets.wheel]
20
25
  packages = ["src/python_devkit"]
21
- include = ["src/python_devkit/py.typed", "src/python_devkit/third-party/**"]
26
+ include = ["src/python_devkit/py.typed", "src/python_devkit/ruff.base.toml"]
22
27
 
23
28
  [tool.uv.sources]
24
29
 
@@ -0,0 +1,198 @@
1
+ # Org-canonical ruff configuration, hosted in python-devkit.
2
+ # Consuming repos carry this file verbatim as ruff.base.toml, written by the
3
+ # sync-ruff verb (uvx --from python-devkit sync-ruff) and drift-gated in CI.
4
+ # Never edit a synced copy: change this canonical, release python-devkit, and
5
+ # re-run sync-ruff in each consuming repo.
6
+ target-version = "py313"
7
+ line-length = 120
8
+ preview = true
9
+ required-version = ">=0.14.11"
10
+
11
+ [lint]
12
+ select = ["ALL"]
13
+ ignore = [
14
+ # ── Temporarily ignored (inline noqas exist for these rules but are redundant
15
+ # while the rules are globally ignored — RUF100 suppresses the "unused noqa" warning
16
+ # until we un-ignore each ticket's rules)
17
+ "RUF100",
18
+
19
+ # ── Permanently ignored ──────────────────────────────────────────────
20
+ # Type annotations — basedpyright strict mode handles this
21
+ "ANN",
22
+ # EM101 (string literal in exception) — the msg = ... indirection adds verbosity without benefit
23
+ "EM101",
24
+ # TRY003 (raise vanilla args) — requires custom exception class for every raise site;
25
+ # the real problem (interpolation) is caught by EM102/EM103
26
+ "TRY003",
27
+ # A003 (class attribute shadowing) — class attributes via self.x never conflict with builtins
28
+ "A003",
29
+ # Function complexity counting — C901 already covers structural complexity;
30
+ # these counting rules are largely redundant
31
+ "PLR0911",
32
+ "PLR0912",
33
+ "PLR0913",
34
+ "PLR0914",
35
+ "PLR0915",
36
+ "PLR0917",
37
+
38
+ # ── Temporarily ignored (will enable when conventions are established) ─
39
+ # Docstrings — project policy is "no docstrings" for now
40
+ "D",
41
+ # Copyright notices — not yet established
42
+ "CPY",
43
+ # Docstring formatting — blocked on D
44
+ "DOC",
45
+ # TODO/FIXME comment formatting
46
+ "TD",
47
+ "FIX",
48
+
49
+ # The "Ticket N" blocks below are the transitional cleanup slices tracked
50
+ # by PLE-248 (Enforce the deferred ruff rules):
51
+ # https://linear.app/plerionplatforms/issue/PLE-248
52
+ # Ticket 3 (T201, print → logging) is tracked by PLE-336:
53
+ # https://linear.app/plerionplatforms/issue/PLE-336
54
+ # Lifting a rule = remove it here in python-devkit's canonical config,
55
+ # release, and run sync-ruff in every consuming repo.
56
+
57
+ # ── Ticket 1: Auto-fix formatting & type modernization ───────────────
58
+ # Zero risk. Purely mechanical, all auto-fixable via ruff --fix.
59
+ "COM812", # missing trailing commas
60
+ "I001", # import sorting
61
+ "ISC003", # explicit string concatenation
62
+ "RUF031", # parenthesized tuple in subscript
63
+ "TC001", # move import into TYPE_CHECKING block
64
+ "TC002", # move third-party import into TYPE_CHECKING block
65
+ "TC003", # move stdlib import into TYPE_CHECKING block
66
+ "TC006", # move typing import into TYPE_CHECKING block (PEP 695)
67
+ "TC008", # move import into TYPE_CHECKING block (quoted annotation)
68
+ "UP006", # use X instead of typing.X
69
+ "UP007", # use X | Y instead of Union
70
+ "UP011", # unnecessary parentheses to functools.lru_cache
71
+ "UP015", # unnecessary open mode parameters
72
+ "UP017", # use datetime.UTC alias
73
+ "UP034", # extraneous parentheses
74
+ "UP035", # deprecated import (use collections.abc, etc.)
75
+ "UP040", # use PEP 695 type alias
76
+ "UP042", # use PEP 695 type parameters
77
+ "UP045", # use X | None instead of Optional
78
+
79
+ # ── Ticket 2: Auto-fix code simplifications ──────────────────────────
80
+ # Very low risk. Auto-fixable with trivial semantic changes.
81
+ "C401", # unnecessary generator — use set/dict comprehension
82
+ "C416", # unnecessary comprehension — use list/dict
83
+ "ERA001", # commented-out code
84
+ "FURB101", # read-text can replace open+read
85
+ "FURB103", # write-text can replace open+write
86
+ "FURB110", # use ternary instead of try/except for default
87
+ "FURB113", # use list.extend instead of repeated append
88
+ "FURB118", # use operator module instead of lambda
89
+ "LOG004", # use __name__ as logger name
90
+ "PERF401", # use list comprehension instead of manual append
91
+ "PIE810", # unnecessary spread of single-element starred
92
+ "PLC0207", # use a single __all__ definition
93
+ "PLC1901", # compare to empty string using falsy check
94
+ "PLR1711", # useless return at end of function
95
+ "PLR6104", # use augmented assignment
96
+ "PLR6201", # use a set literal for membership test
97
+ "PLW1510", # subprocess.run without explicit check= argument
98
+ "PLW1514", # open without explicit encoding
99
+ "PT001", # use @pytest.fixture over @pytest.fixture()
100
+ "PT018", # composite assertion — split into multiple asserts
101
+ "PTH101", # os.chmod → Path.chmod
102
+ "PTH111", # os.path.expanduser → Path.expanduser
103
+ "PTH112", # os.path.isdir → Path.is_dir
104
+ "PTH118", # os.path.join → Path /
105
+ "PTH123", # open() → Path.open()
106
+ "PTH201", # pathlib.Path(".")
107
+ "RET501", # unnecessary explicit return None
108
+ "RET504", # unnecessary assignment before return
109
+ "RET505", # unnecessary else after return
110
+ "RET506", # unnecessary else after raise
111
+ "RET507", # unnecessary else after continue
112
+ "RSE102", # unnecessary parentheses on raised exception
113
+ "RUF005", # consider spread operator instead of concatenation
114
+ "RUF010", # use explicit conversion flag in f-string
115
+ "RUF015", # prefer next() over single-element slice
116
+ "RUF029", # function is declared async but has no await
117
+ "RUF052", # local variable is assigned but never used (underscore prefix)
118
+ "RUF056", # unnecessary default value for keyword argument
119
+ "SIM102", # collapsible if statements
120
+ "SIM105", # use contextlib.suppress instead of try/except/pass
121
+ "SIM115", # use context manager for opening files
122
+ "SIM117", # use single with statement for multiple context managers
123
+ "SIM118", # use key in dict instead of key in dict.keys()
124
+ "TID252", # prefer absolute imports over relative
125
+
126
+ # ── Ticket 3: print → logging ────────────────────────────────────────
127
+ # All code should use logging, including CLI scripts.
128
+ "T201", # print found
129
+
130
+ # ── Ticket 4: Exception message interpolation ────────────────────────
131
+ # Remove f-strings from exception messages — use string literals.
132
+ "EM102", # f-string in exception message
133
+
134
+ # ── Ticket 5: Missing __init__.py + shebangs ─────────────────────────
135
+ # Add __init__.py to installable packages; remove vestigial shebangs.
136
+ "INP001", # implicit namespace package — add __init__.py
137
+ "EXE001", # shebang present but file not executable
138
+
139
+ # ── Ticket 6: Type ignore specificity ────────────────────────────────
140
+ # Replace blanket type: ignore with specific error codes.
141
+ "PGH003", # blanket type: ignore
142
+
143
+ # ── Ticket 7: Boolean trap refactoring ───────────────────────────────
144
+ # Change function signatures — callers must update too.
145
+ "FBT001", # boolean positional argument
146
+ "FBT002", # boolean default value in function definition
147
+ "FBT003", # boolean positional value in call
148
+
149
+ # ── Ticket 8: Exception handling patterns ────────────────────────────
150
+ # Behavioral changes to error handling.
151
+ "B020", # loop variable overwritten by except
152
+ "B903", # generator should use yield from
153
+ "B904", # raise without from inside except
154
+ "B905", # zip() without strict= parameter
155
+ "BLE001", # blind except Exception
156
+ "S101", # assert outside tests
157
+ "TRY002", # create custom exception class
158
+ "TRY300", # try/except/return — move return into else
159
+
160
+ # ── Ticket 9: Complexity, naming & refactoring ───────────────────────
161
+ # Structural changes requiring judgment.
162
+ "A001", # builtin shadowing (dir, bytes)
163
+ "ARG001", # unused function argument
164
+ "ARG002", # unused method argument
165
+ "C901", # function too complex
166
+ "E501", # line too long
167
+ "N802", # function name should be lowercase
168
+ "N806", # variable in function should be lowercase
169
+ "N815", # mixed-case variable in class scope
170
+ "N818", # exception name should end in Error
171
+ "PLC0415", # import not at top level
172
+ "PLE0704", # bare raise not inside except handler
173
+ "PLR1702", # too many nested blocks
174
+ "PLR2004", # magic value used in comparison
175
+ "PLR6301", # method could be function or static method
176
+ "PLW0603", # global statement
177
+ "PLW2901", # loop variable overwritten
178
+ "RUF001", # ambiguous unicode — replace with ASCII
179
+ "RUF003", # ambiguous unicode in comment
180
+ "S106", # possible hardcoded password
181
+ "S108", # probable insecure temp file/dir usage
182
+ "S110", # try/except/pass on broad exception
183
+ "S202", # use of tarfile.extractall
184
+ # S404/S603: prerequisite — bash.py needs env, stderr=STDOUT merge, and no-raise-with-capture
185
+ # before context_sha.py, deptry_check.py, test_context_sha.py, list_debug_targets.py can migrate
186
+ "S404", # suspicious subprocess import
187
+ "S603", # subprocess call with non-literal
188
+ "S606", # starting process without shell
189
+ "S607", # starting process with partial path
190
+ ]
191
+
192
+ [lint.per-file-ignores]
193
+ # Tests use assert as the standard pytest mechanism; fixtures take arguments
194
+ # that pytest consumes implicitly; pytest-style test classes need no self use
195
+ "**/tests/**" = ["S101", "ARG001", "ARG002", "PLR6301"]
196
+
197
+ [lint.flake8-builtins]
198
+ ignorelist = ["id", "map"]
@@ -0,0 +1 @@
1
+ extend = "ruff.base.toml"
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from fnmatch import fnmatch
4
+ from pathlib import Path
5
+ from tomllib import load
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from bashrun import bash, bash_check, bash_output
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
13
+
14
+
15
+ class LockPythonConfig(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ group_export_dirs: dict[str, Path] = Field(default_factory=dict, alias="group-export-dirs")
19
+
20
+
21
+ class WorkspaceConfig(BaseModel):
22
+ members: list[str] = Field(default_factory=list)
23
+
24
+
25
+ class ProjectConfig(BaseModel):
26
+ name: str
27
+
28
+
29
+ class PackageConfig(BaseModel):
30
+ project: ProjectConfig
31
+ dependency_groups: dict[str, Any] = Field(default_factory=dict, alias="dependency-groups")
32
+
33
+
34
+ @app.command()
35
+ def lock_python(
36
+ check: Annotated[
37
+ bool, typer.Option("--check", help="Validate lock files without writing; exit non-zero if stale.")
38
+ ] = False,
39
+ root: Annotated[Path, typer.Option(help="uv workspace root; defaults to the current directory.")] = Path(),
40
+ ) -> None:
41
+ root = root.resolve()
42
+ stale = False
43
+
44
+ if check:
45
+ print("Checking uv.lock...")
46
+ if not bash_check("uv lock --check", cwd=root):
47
+ print(" STALE: uv.lock is out of date. Run 'uv run lock-python' to update.")
48
+ stale = True
49
+ else:
50
+ print(" OK")
51
+ else:
52
+ bash("uv lock", cwd=root)
53
+
54
+ with (root / "pyproject.toml").open("rb") as file:
55
+ workspace_toml = load(file)
56
+
57
+ tool = workspace_toml.get("tool", {})
58
+ workspace = WorkspaceConfig.model_validate(tool.get("uv", {}).get("workspace", {}))
59
+ config = LockPythonConfig.model_validate(tool.get("python-devkit", {}).get("lock-python", {}))
60
+
61
+ seen_redirected: set[str] = set()
62
+
63
+ for member in workspace.members:
64
+ member_dir = root / member
65
+
66
+ if not (member_dir / "Dockerfile").exists():
67
+ continue
68
+
69
+ with (member_dir / "pyproject.toml").open("rb") as file:
70
+ package = PackageConfig.model_validate(load(file))
71
+
72
+ stale |= _export_pylock(check, root, member_dir, package.project.name, group=None)
73
+
74
+ for group in package.dependency_groups:
75
+ if group == "dev":
76
+ continue
77
+ redirect = _redirect_dir(group, config, root)
78
+ if redirect is None:
79
+ stale |= _export_pylock(check, root, member_dir, package.project.name, group=group)
80
+ continue
81
+ if group in seen_redirected:
82
+ continue
83
+ seen_redirected.add(group)
84
+ stale |= _export_pylock(check, root, redirect, package.project.name, group=group)
85
+
86
+ if check and stale:
87
+ raise SystemExit(1)
88
+
89
+ if check:
90
+ print("\nAll Python lock files are up to date.")
91
+
92
+
93
+ def _export_pylock(check: bool, root: Path, export_dir: Path, package_name: str, group: str | None) -> bool:
94
+ if group:
95
+ pylock = export_dir / f"pylock.{group}.toml"
96
+ group_flags = f"--only-group {group} "
97
+ else:
98
+ pylock = export_dir / "pylock.toml"
99
+ group_flags = "--no-default-groups "
100
+
101
+ export_command = (
102
+ f"uv export --format pylock.toml --no-header --package {package_name} {group_flags}--no-emit-local --frozen "
103
+ )
104
+
105
+ if check:
106
+ print(f"Checking {pylock}...")
107
+ exported = _normalize_line_endings(bash_output(export_command, cwd=root))
108
+ committed = _normalize_line_endings(pylock.read_text(encoding="utf-8")) if pylock.exists() else ""
109
+ if exported != committed:
110
+ print(f" STALE: {pylock} is out of date.")
111
+ return True
112
+ print(" OK")
113
+ return False
114
+
115
+ bash(export_command + f"--output-file {pylock} ", cwd=root)
116
+ text = pylock.read_text(encoding="utf-8")
117
+ with pylock.open("w", encoding="utf-8", newline="\n") as file:
118
+ file.write(text)
119
+ return False
120
+
121
+
122
+ def _normalize_line_endings(text: str) -> str:
123
+ return text.replace("\r\n", "\n").replace("\r", "\n")
124
+
125
+
126
+ def _redirect_dir(group: str, config: LockPythonConfig, root: Path) -> Path | None:
127
+ for pattern, directory in config.group_export_dirs.items():
128
+ if fnmatch(group, pattern):
129
+ return root / directory
130
+ return None
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+
6
+ from bashrun import bash, bash_output
7
+ from ci_devkit.ci_step import ci_step
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class CommandCheck(BaseModel):
12
+ label: str
13
+ command: str
14
+
15
+
16
+ class GeneratedCheck(BaseModel):
17
+ label: str
18
+ generate_command: str
19
+ paths: list[Path]
20
+ fix_command: str
21
+
22
+
23
+ def run_checks(checks: Sequence[CommandCheck | GeneratedCheck], *, cwd: Path | None = None) -> None:
24
+ for check in checks:
25
+ if isinstance(check, CommandCheck):
26
+ _run_command_check(check, cwd)
27
+ else:
28
+ _run_generated_check(check, cwd)
29
+
30
+
31
+ def _run_command_check(check: CommandCheck, cwd: Path | None) -> None:
32
+ with ci_step(check.label):
33
+ bash(check.command, cwd=cwd)
34
+
35
+
36
+ def _run_generated_check(check: GeneratedCheck, cwd: Path | None) -> None:
37
+ with ci_step(check.label):
38
+ bash(check.generate_command, cwd=cwd)
39
+ pathspec = " ".join(str(path) for path in check.paths)
40
+ staleness_output = bash_output(f"git status --porcelain -- {pathspec}", cwd=cwd)
41
+ if staleness_output.strip():
42
+ bash(f"git diff -- {pathspec}", cwd=cwd)
43
+ raise SystemExit(f"{check.label} output is stale. Run '{check.fix_command}' locally and commit the result.")