nox-python-standards 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,2 @@
1
+ # src/nox_python_standards/__init__.py
2
+ from __future__ import annotations
@@ -0,0 +1,6 @@
1
+ # src/nox_python_standards/__main__.py
2
+ from __future__ import annotations
3
+
4
+ from nox_python_standards.cli import main
5
+
6
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ # src/nox_python_standards/cli.py
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+ import sys
7
+ from typing import cast
8
+
9
+ from nox_python_standards.config import Configuration, release_version
10
+ from nox_python_standards.repository import StandardsError, project_root
11
+ from nox_python_standards.versions import check_versions
12
+
13
+
14
+ def parser() -> argparse.ArgumentParser:
15
+ result = argparse.ArgumentParser(prog="nox-py-standards")
16
+ result.add_argument("--version", action="version", version=release_version())
17
+ result.add_argument(
18
+ "--root", type=Path, help="Consumer Git root (default: current Git root)"
19
+ )
20
+ result.add_argument("command", choices=("check", "sync"))
21
+ return result
22
+
23
+
24
+ def main(arguments: list[str] | None = None) -> int:
25
+ options = parser().parse_args(arguments)
26
+ command = cast(str, options.command)
27
+ try:
28
+ root = project_root(cast(Path | None, options.root))
29
+ configuration = Configuration.read(root)
30
+ if command == "sync":
31
+ configuration.sync()
32
+ else:
33
+ configuration.check()
34
+ check_versions(configuration)
35
+ sys.stdout.write(
36
+ f"nox-python-standards {release_version()}: {command} passed\n"
37
+ )
38
+ return 0
39
+ except (StandardsError, OSError, UnicodeError) as exc:
40
+ sys.stderr.write(f"nox-py-standards: {exc}\n")
41
+ return 1
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
@@ -0,0 +1,286 @@
1
+ # src/nox_python_standards/config.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from importlib import metadata, resources
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import tempfile
10
+ import tomllib
11
+ from typing import cast
12
+
13
+ import tomlkit
14
+ from tomlkit.items import Table
15
+
16
+ from nox_python_standards.repository import (
17
+ StandardsError,
18
+ files,
19
+ in_scope,
20
+ require_regular,
21
+ )
22
+
23
+ SNAPSHOT = Path(".noxala/PYTHON_CONVENTIONS.md")
24
+ _RESOURCE_ROOT = resources.files("nox_python_standards").joinpath("resources")
25
+
26
+
27
+ def resource(name: str) -> str:
28
+ return _RESOURCE_ROOT.joinpath(name).read_text(encoding="utf-8")
29
+
30
+
31
+ def release_version() -> str:
32
+ return metadata.version("nox-python-standards")
33
+
34
+
35
+ def policy() -> dict[str, object]:
36
+ return cast(dict[str, object], json.loads(resource("policy.json")))
37
+
38
+
39
+ def ownership() -> dict[str, str]:
40
+ return cast(dict[str, str], json.loads(resource("ownership.json")))
41
+
42
+
43
+ def table(value: object, name: str) -> dict[str, object]:
44
+ if not isinstance(value, dict):
45
+ raise StandardsError(f"{name}: expected a TOML table")
46
+ return cast(dict[str, object], value)
47
+
48
+
49
+ def strings(value: object) -> bool:
50
+ return isinstance(value, list) and all(
51
+ isinstance(item, str) for item in cast(list[object], value)
52
+ )
53
+
54
+
55
+ def same(left: object, right: object) -> bool:
56
+ if type(left) is not type(right):
57
+ return False
58
+ if isinstance(left, list):
59
+ a, b = cast(list[object], left), cast(list[object], right)
60
+ return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b, strict=True))
61
+ return left == right
62
+
63
+
64
+ def local_valid(value: object, kind: str) -> bool:
65
+ if kind == "string":
66
+ return isinstance(value, str) and bool(value)
67
+ if kind == "strings":
68
+ return strings(value)
69
+ if kind == "private-test-rules":
70
+ return isinstance(value, dict) and all(
71
+ key == "test/**/*.py" and same(rules, ["SLF001"])
72
+ for key, rules in cast(dict[str, object], value).items()
73
+ )
74
+ if kind == "test-environments":
75
+ return isinstance(value, list) and all(
76
+ same(environment, {"root": "test", "reportPrivateUsage": "none"})
77
+ for environment in cast(list[object], value)
78
+ )
79
+ raise StandardsError(f"Unknown installed ownership schema: {kind}")
80
+
81
+
82
+ def validate_boundary(tool: dict[str, object]) -> None:
83
+ shared, local = policy(), ownership()
84
+ keys = set(shared) | set(local)
85
+
86
+ def visit(value: object, prefix: str) -> None:
87
+ if prefix in shared:
88
+ return # Missing/wrong shared values are checked separately and repairable.
89
+ if prefix in local:
90
+ if not local_valid(value, local[prefix]):
91
+ raise StandardsError(
92
+ f"pyproject.toml: tool.{prefix}: invalid consumer value {value!r}; "
93
+ f"expected {local[prefix]} (see installed ownership.json)"
94
+ )
95
+ return
96
+ if not any(key.startswith(prefix + ".") for key in keys):
97
+ raise StandardsError(
98
+ f"pyproject.toml: tool.{prefix}: unclassified override"
99
+ )
100
+ for key, item in sorted(table(value, f"tool.{prefix}").items()):
101
+ visit(item, f"{prefix}.{key}")
102
+
103
+ for name in ("ruff", "pyright"):
104
+ if name in tool:
105
+ visit(tool[name], name)
106
+
107
+
108
+ def shared_diagnostics(tool: dict[str, object]) -> list[str]:
109
+ problems: list[str] = []
110
+ for key, expected in sorted(policy().items()):
111
+ actual: object = tool
112
+ missing = False
113
+ for part in key.split("."):
114
+ current = table(actual, f"tool.{key}")
115
+ if part not in current:
116
+ missing = True
117
+ break
118
+ actual = current[part]
119
+ if missing or not same(actual, expected):
120
+ shown = "<missing>" if missing else repr(actual)
121
+ problems.append(
122
+ f"pyproject.toml: tool.{key}: expected {expected!r}; actual {shown}"
123
+ )
124
+ return problems
125
+
126
+
127
+ @dataclass(frozen=True, slots=True, kw_only=True)
128
+ class Configuration:
129
+ root: Path
130
+ text: str
131
+ data: dict[str, object]
132
+ tool: dict[str, object]
133
+ sources: tuple[Path, ...]
134
+ dependency_group: str
135
+
136
+ @classmethod
137
+ def read(cls, root: Path) -> Configuration:
138
+ path = require_regular(root, Path("pyproject.toml"))
139
+ text = path.read_text(encoding="utf-8")
140
+ try:
141
+ data = cast(dict[str, object], tomllib.loads(text))
142
+ except tomllib.TOMLDecodeError as exc:
143
+ raise StandardsError(f"pyproject.toml: malformed TOML: {exc}") from exc
144
+ tool = table(data.get("tool", {}), "tool")
145
+ settings = table(
146
+ tool.get("nox-python-standards", {}), "tool.nox-python-standards"
147
+ )
148
+ if set(settings) - {"sources", "dependency-group", "ruff"}:
149
+ raise StandardsError("Unclassified tool.nox-python-standards setting")
150
+ source_values = settings.get("sources")
151
+ if not strings(source_values) or not source_values:
152
+ raise StandardsError(
153
+ "tool.nox-python-standards.sources: expected nonempty paths"
154
+ )
155
+ sources: list[Path] = []
156
+ for source in cast(list[str], source_values):
157
+ candidate = Path(source)
158
+ if (
159
+ candidate.is_absolute()
160
+ or ".." in candidate.parts
161
+ or not (root / candidate).exists()
162
+ or not (root / candidate).resolve().is_relative_to(root)
163
+ or (root / candidate).is_symlink()
164
+ ):
165
+ raise StandardsError(f"Invalid consumer source path: {source!r}")
166
+ sources.append(candidate)
167
+ group = settings.get("dependency-group")
168
+ if not isinstance(group, str) or not group:
169
+ raise StandardsError(
170
+ "tool.nox-python-standards.dependency-group is required"
171
+ )
172
+ validate_boundary(tool)
173
+ result = cls(
174
+ root=root,
175
+ text=text,
176
+ data=data,
177
+ tool=tool,
178
+ sources=tuple(sources),
179
+ dependency_group=group,
180
+ )
181
+ result.check_discovery()
182
+ return result
183
+
184
+ def check_discovery(self) -> None:
185
+ for name in ("ruff.toml", ".ruff.toml", "pyrightconfig.json"):
186
+ if (self.root / name).exists() or (self.root / name).is_symlink():
187
+ raise StandardsError(f"Conflicting native configuration: {name}")
188
+ discovered = files(self.root)
189
+ python_files = [
190
+ path
191
+ for path in discovered
192
+ if path.suffix in {".py", ".pyi"} and in_scope(path, self.sources)
193
+ ]
194
+ # Check ancestors too: an ignored config can still shadow a tracked source.
195
+ directories = {parent for path in python_files for parent in path.parents}
196
+ for directory in sorted(directories - {Path(".")}):
197
+ for name in ("ruff.toml", ".ruff.toml", "pyproject.toml"):
198
+ relative = directory / name
199
+ path = self.root / relative
200
+ if not path.exists() and not path.is_symlink():
201
+ continue
202
+ require_regular(self.root, relative)
203
+ if name == "pyproject.toml":
204
+ try:
205
+ nested = cast(
206
+ dict[str, object], tomllib.loads(path.read_text())
207
+ )
208
+ except tomllib.TOMLDecodeError as exc:
209
+ raise StandardsError(
210
+ f"Cannot inspect {relative}: {exc}"
211
+ ) from exc
212
+ if "ruff" not in table(nested.get("tool", {}), str(relative)):
213
+ continue
214
+ raise StandardsError(
215
+ f"Conflicting nested Ruff configuration: {relative}"
216
+ )
217
+
218
+ def check(self) -> None:
219
+ problems = shared_diagnostics(self.tool)
220
+ path = self.root / SNAPSHOT
221
+ if (
222
+ not path.is_file()
223
+ or require_regular(self.root, SNAPSHOT).read_bytes()
224
+ != resource("PYTHON_CONVENTIONS.md").encode()
225
+ ):
226
+ problems.append(
227
+ f"{SNAPSHOT}: expected installed conventions; actual missing/drifted"
228
+ )
229
+ if problems:
230
+ raise StandardsError(
231
+ f"nox-python-standards {release_version()} in {self.root}:\n"
232
+ + "\n".join(problems)
233
+ + "\nReview drift, then explicitly run nox-py-standards sync."
234
+ )
235
+
236
+ def sync(self) -> None:
237
+ document = tomlkit.parse(self.text)
238
+ for key, value in policy().items():
239
+ existing: object = self.tool
240
+ for part in key.split("."):
241
+ existing = table(existing, f"tool.{key}").get(part)
242
+ if existing is None:
243
+ break
244
+ if same(existing, value):
245
+ continue # Keep existing array layout and comments byte-for-byte.
246
+ current = document
247
+ for part in ["tool", *key.split(".")[:-1]]:
248
+ if part not in current:
249
+ current[part] = tomlkit.table()
250
+ child = current[part]
251
+ if not isinstance(child, Table):
252
+ raise StandardsError(f"Cannot repair non-table tool.{key}")
253
+ current = child
254
+ current[key.split(".")[-1]] = value
255
+ rendered = tomlkit.dumps(document)
256
+ proposed = cast(dict[str, object], tomllib.loads(rendered))
257
+ proposed_tool = table(proposed["tool"], "tool")
258
+ validate_boundary(proposed_tool)
259
+ if shared_diagnostics(proposed_tool):
260
+ raise StandardsError("Rendered shared configuration failed validation")
261
+ # Preflight both destinations before writing either file.
262
+ for relative in (Path("pyproject.toml"), SNAPSHOT):
263
+ destination = self.root / relative
264
+ if destination.exists() or destination.is_symlink():
265
+ require_regular(self.root, relative)
266
+ if any(parent.is_symlink() for parent in destination.parents):
267
+ raise StandardsError(f"Refusing symlink path: {relative}")
268
+ atomic_write(self.root / "pyproject.toml", rendered.encode())
269
+ atomic_write(self.root / SNAPSHOT, resource("PYTHON_CONVENTIONS.md").encode())
270
+
271
+
272
+ def atomic_write(path: Path, contents: bytes) -> None:
273
+ if path.is_file() and path.read_bytes() == contents:
274
+ return
275
+ path.parent.mkdir(parents=True, exist_ok=True)
276
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
277
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
278
+ try:
279
+ with os.fdopen(descriptor, "wb") as output:
280
+ output.write(contents)
281
+ output.flush()
282
+ os.fchmod(output.fileno(), mode)
283
+ os.replace(temporary, path)
284
+ finally:
285
+ if os.path.exists(temporary):
286
+ os.unlink(temporary)
File without changes
@@ -0,0 +1,83 @@
1
+ # src/nox_python_standards/repository.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import subprocess
6
+
7
+
8
+ class StandardsError(RuntimeError):
9
+ """An actionable configuration or tool contract failure."""
10
+
11
+
12
+ IGNORED_DIRS = frozenset(
13
+ {
14
+ ".eggs",
15
+ ".git",
16
+ ".mypy_cache",
17
+ ".nox",
18
+ ".pytest_cache",
19
+ ".ruff_cache",
20
+ ".tox",
21
+ ".venv",
22
+ "__pycache__",
23
+ "build",
24
+ "dist",
25
+ "htmlcov",
26
+ "lib",
27
+ "libs",
28
+ "node_modules",
29
+ "site-packages",
30
+ "third_party",
31
+ "venv",
32
+ "vendor",
33
+ }
34
+ )
35
+
36
+
37
+ def git(root: Path, *arguments: str) -> bytes:
38
+ try:
39
+ return subprocess.check_output(
40
+ ["git", "-C", str(root), *arguments], stderr=subprocess.PIPE
41
+ )
42
+ except (OSError, subprocess.CalledProcessError) as exc:
43
+ raise StandardsError(f"Git failed in {root}: {arguments!r}") from exc
44
+
45
+
46
+ def project_root(explicit: Path | None) -> Path:
47
+ location = Path.cwd() if explicit is None else explicit.resolve()
48
+ root = Path(git(location, "rev-parse", "--show-toplevel").decode().strip())
49
+ if explicit is not None and root.resolve() != location:
50
+ raise StandardsError("--root must name the consumer Git root")
51
+ return root.resolve()
52
+
53
+
54
+ def ignored(path: Path) -> bool:
55
+ return any(
56
+ part in IGNORED_DIRS or part.endswith((".egg-info", ".dist-info"))
57
+ for part in path.parts[:-1]
58
+ )
59
+
60
+
61
+ def files(root: Path) -> list[Path]:
62
+ """Git supplies tracked and relevant untracked files without crawling vendors."""
63
+ output = git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
64
+ return sorted(
65
+ {
66
+ Path(p.decode())
67
+ for p in output.split(b"\0")
68
+ if p and not ignored(Path(p.decode()))
69
+ }
70
+ )
71
+
72
+
73
+ def in_scope(path: Path, sources: tuple[Path, ...]) -> bool:
74
+ return any(path == source or source in path.parents for source in sources)
75
+
76
+
77
+ def require_regular(root: Path, relative: Path) -> Path:
78
+ path = root / relative
79
+ if any(parent.is_symlink() for parent in [path, *path.parents] if parent != root):
80
+ raise StandardsError(f"Refusing symlink path: {relative}")
81
+ if not path.is_file():
82
+ raise StandardsError(f"Expected regular file: {relative}")
83
+ return path
@@ -0,0 +1,33 @@
1
+ # Noxala Python conventions
2
+
3
+ Versioned by the installed `nox-python-standards` release. `nox-py-standards check`
4
+ verifies this snapshot; explicit `sync` adopts it. Review and commit changes.
5
+
6
+ ## Tool policy
7
+
8
+ Keep complete Ruff/Pyright settings in local `pyproject.toml`. Shared values and
9
+ exact tool pins match the installed release. Paths, exclusions, import
10
+ classification and approved test exceptions stay local; Tach alone generates
11
+ first-party lists.
12
+
13
+ Ruff version approval is manual; sync, installation and CI never grant it.
14
+ Only PT007 is approved among unsafe fixes. Repositories own tool commands,
15
+ Makefiles, hooks and source-header helpers.
16
+
17
+ ## Python guidance
18
+
19
+ These are review conventions, not an additional AST checker.
20
+
21
+ - Use supported modern syntax, built-in generics and PEP 695.
22
+ - Type fully for strict Pyright, without `Unknown`.
23
+ - Check nullability with `is None` / `is not None`.
24
+ - Prefer small abstractions to broad dependencies.
25
+ - Keep a repository-relative path header in each Python source.
26
+ - Default to `@dataclass(frozen=True, slots=True, kw_only=True)`; put factory
27
+ methods on the dataclass, normally `@classmethod create(...)`.
28
+ - Keep orchestration and I/O with the owning subsystem.
29
+ - Test meaningful success, boundary, invalid-input and conflict behavior.
30
+ Avoid tests that restate the implementation.
31
+ - Use `whitelist`, `blacklist` and `master`.
32
+
33
+ Follow repository instructions for architecture and domain rules.
@@ -0,0 +1,15 @@
1
+ {
2
+ "ruff.target-version": "string",
3
+ "ruff.extend-exclude": "strings",
4
+ "ruff.lint.isort.known-first-party": "strings",
5
+ "ruff.lint.isort.known-third-party": "strings",
6
+ "ruff.lint.per-file-ignores": "private-test-rules",
7
+ "pyright.include": "strings",
8
+ "pyright.exclude": "strings",
9
+ "pyright.extraPaths": "strings",
10
+ "pyright.stubPath": "string",
11
+ "pyright.pythonVersion": "string",
12
+ "pyright.venvPath": "string",
13
+ "pyright.venv": "string",
14
+ "pyright.executionEnvironments": "test-environments"
15
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "ruff.required-version": "==0.15.14",
3
+ "ruff.fix": false,
4
+ "ruff.fix-only": false,
5
+ "ruff.unsafe-fixes": false,
6
+ "ruff.lint.extend-safe-fixes": ["PT007"],
7
+ "ruff.lint.extend-unsafe-fixes": [],
8
+ "ruff.lint.fixable": ["ALL"],
9
+ "ruff.lint.unfixable": [],
10
+ "ruff.line-length": 88,
11
+ "ruff.format.quote-style": "double",
12
+ "ruff.format.indent-style": "space",
13
+ "ruff.format.line-ending": "lf",
14
+ "ruff.format.skip-magic-trailing-comma": false,
15
+ "ruff.format.docstring-code-format": true,
16
+ "ruff.lint.select": ["A", "B", "DTZ", "E", "F", "I", "LOG", "N", "PGH", "PIE", "PT", "RET", "RUF", "SIM", "SLF", "T20", "UP", "W"],
17
+ "ruff.lint.ignore": [],
18
+ "ruff.lint.extend-ignore": ["RET504", "SIM102", "SIM103", "SIM108", "SIM110"],
19
+ "ruff.lint.isort.combine-as-imports": true,
20
+ "ruff.lint.isort.force-sort-within-sections": true,
21
+ "ruff.lint.isort.required-imports": ["from __future__ import annotations"],
22
+ "pyright.typeCheckingMode": "strict",
23
+ "pyright.reportMissingImports": "error",
24
+ "pyright.reportMissingTypeStubs": false
25
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "ruff": "0.15.14",
3
+ "pyright": "1.1.411"
4
+ }
@@ -0,0 +1,134 @@
1
+ # src/nox_python_standards/versions.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from typing import cast
11
+
12
+ from nox_python_standards.config import Configuration, resource, table
13
+ from nox_python_standards.repository import StandardsError
14
+
15
+ _VERSION = r"([0-9]+\.[0-9]+\.[0-9]+)"
16
+
17
+
18
+ def tool_environment() -> dict[str, str]:
19
+ environment = os.environ.copy()
20
+ # Use bundled Pyright; Node availability is checked before launching its probe.
21
+ environment.update(
22
+ {
23
+ "PYRIGHT_PYTHON_FORCE_VERSION": release_pins()["pyright"],
24
+ "PYRIGHT_PYTHON_USE_BUNDLED_PYRIGHT": "1",
25
+ "PYRIGHT_PYTHON_GLOBAL_NODE": "1",
26
+ "PYRIGHT_PYTHON_NODEJS_WHEEL": "0",
27
+ "PYRIGHT_PYTHON_IGNORE_WARNINGS": "1",
28
+ }
29
+ )
30
+ environment.pop("PYRIGHT_PYTHON_PYLANCE_VERSION", None)
31
+ return environment
32
+
33
+
34
+ def group_values(
35
+ groups: dict[str, object],
36
+ name: str,
37
+ ancestors: frozenset[str] = frozenset(),
38
+ ) -> list[object]:
39
+ if name in ancestors:
40
+ raise StandardsError(f"Cyclic dependency-group include: {name}")
41
+ values = groups.get(name)
42
+ if not isinstance(values, list):
43
+ raise StandardsError(f"dependency-groups.{name} must be an array")
44
+ flattened: list[object] = []
45
+ for value in cast(list[object], values):
46
+ if isinstance(value, str):
47
+ flattened.append(value)
48
+ else:
49
+ include = table(value, f"dependency-groups.{name}")
50
+ target = include.get("include-group")
51
+ if set(include) != {"include-group"} or not isinstance(target, str):
52
+ raise StandardsError(f"Invalid dependency-group include in {name}")
53
+ flattened.extend(group_values(groups, target, ancestors | {name}))
54
+ return flattened
55
+
56
+
57
+ def release_pins() -> dict[str, str]:
58
+ """Supported tools are policy data, not package runtime dependencies."""
59
+ return cast(dict[str, str], json.loads(resource("versions.json")))
60
+
61
+
62
+ def consumer_pin(dependencies: list[object], name: str) -> str:
63
+ declarations = [
64
+ dependency
65
+ for dependency in dependencies
66
+ if isinstance(dependency, str)
67
+ and re.match(rf"^\s*{name}(?:\s|[<>=!~@;\[]|$)", dependency, re.IGNORECASE)
68
+ ]
69
+ if len(declarations) != 1:
70
+ raise StandardsError(
71
+ f"Development group must contain exactly one {name} dependency"
72
+ )
73
+ match = re.fullmatch(
74
+ rf"\s*{name}\s*==\s*{_VERSION}\s*", declarations[0], re.IGNORECASE
75
+ )
76
+ if match is None:
77
+ raise StandardsError(
78
+ f"Development dependency must use exact '{name}==X.Y.Z' pin"
79
+ )
80
+ return match.group(1)
81
+
82
+
83
+ def check_versions(configuration: Configuration) -> None:
84
+ groups = table(configuration.data.get("dependency-groups"), "dependency-groups")
85
+ dependencies = group_values(groups, configuration.dependency_group)
86
+ settings = table(configuration.tool["nox-python-standards"], "standards settings")
87
+ approval = table(settings.get("ruff"), "tool.nox-python-standards.ruff")
88
+ authorized = approval.get("authorized_version")
89
+ if (
90
+ set(approval) != {"authorized_version"}
91
+ or not isinstance(authorized, str)
92
+ or re.fullmatch(rf"v{_VERSION}", authorized) is None
93
+ ):
94
+ raise StandardsError("Ruff authorized_version must use the form 'vX.Y.Z'")
95
+ supported = release_pins()
96
+ for name, expected in supported.items():
97
+ declared = consumer_pin(dependencies, name)
98
+ if declared != expected or (name == "ruff" and authorized != f"v{expected}"):
99
+ raise StandardsError(
100
+ f"{name} version mismatch: standards {expected}, "
101
+ f"dependency {declared}, Ruff approval {authorized}. "
102
+ "Update dependencies deliberately; "
103
+ "Ruff approval requires manual review."
104
+ )
105
+ for name, expected in supported.items():
106
+ located = shutil.which(name)
107
+ if located is None:
108
+ raise StandardsError(
109
+ f"Could not find {name!r} on PATH; "
110
+ "install locked development dependencies"
111
+ )
112
+ # GLOBAL_NODE prefers PATH but falls back to installing nodeenv if absent.
113
+ if name == "pyright" and shutil.which("node") is None:
114
+ raise StandardsError(
115
+ "Node.js must be installed on PATH; no downloads are attempted"
116
+ )
117
+ try:
118
+ # Only probe versions. Never format, lint, typecheck or install tools.
119
+ output = subprocess.check_output(
120
+ [str(Path(located).absolute()), "--version"],
121
+ cwd=configuration.root,
122
+ text=True,
123
+ stderr=subprocess.STDOUT,
124
+ env=tool_environment(),
125
+ ).strip()
126
+ except (OSError, subprocess.CalledProcessError) as exc:
127
+ raise StandardsError(f"{name} --version failed: {exc}") from exc
128
+ actual = re.fullmatch(
129
+ rf"{name}\s+{_VERSION}(?:[ \t].*)?", output, re.IGNORECASE
130
+ )
131
+ if actual is None or actual.group(1) != expected:
132
+ raise StandardsError(
133
+ f"{name} executable mismatch: expected {expected}; actual {output!r}"
134
+ )
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: nox-python-standards
3
+ Version: 0.2.1
4
+ Summary: Check and sync complete local copies of Noxala Python standards
5
+ License-Expression: 0BSD
6
+ Project-URL: Repository, https://github.com/noxala/nox-python-standards
7
+ Project-URL: Issues, https://github.com/noxala/nox-python-standards/issues
8
+ Requires-Python: <3.15,>=3.14.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: tomlkit==0.15.0
12
+ Dynamic: license-file
13
+
14
+ # nox-python-standards
15
+
16
+ Shared Noxala Python settings, checked against complete local `pyproject.toml`
17
+ files. Each repository owns its Makefile, hooks, CI and native tool commands.
18
+
19
+ ## Install and adopt
20
+
21
+ Use the Python/uv versions in [pyproject.toml](pyproject.toml), Git, and Node.js
22
+ on PATH for `check`.
23
+
24
+ Add development dependencies with an exact PyPI version and the
25
+ [approved tool versions](src/nox_python_standards/resources/versions.json):
26
+
27
+ ```toml
28
+ [dependency-groups]
29
+ dev = [
30
+ "nox-python-standards==0.2.1",
31
+ "ruff==0.15.14",
32
+ "pyright==1.1.411",
33
+ ]
34
+
35
+ [tool.nox-python-standards]
36
+ sources = ["."]
37
+ dependency-group = "dev"
38
+
39
+ [tool.nox-python-standards.ruff]
40
+ authorized_version = "v0.15.14" # manual approval
41
+ ```
42
+
43
+ Keep local path/exclusion/import settings. Lock and install dependencies, run
44
+ `uv run nox-py-standards sync`, then review and commit the config, lockfile and
45
+ `.noxala/PYTHON_CONVENTIONS.md`. Root agent instructions should require that
46
+ snapshot before Python edits and state repository-specific rules.
47
+
48
+ For upgrades, review the release, update pins/lockfile, install, and explicitly
49
+ sync. Ruff version changes require separate manual approval.
50
+
51
+ ## Check and sync
52
+
53
+ - **`check`** verifies shared settings, ownership, conventions, config discovery,
54
+ exact tool pins, manual Ruff approval and active tool versions. It is read-only;
55
+ tool calls are limited to `--version` probes. Node must exist before probing
56
+ Pyright; the probe selects its bundled pinned release without downloading tools.
57
+ - **`sync`** adopts shared settings and conventions, preserving local settings,
58
+ comments, dependency pins, lockfiles and approval. It needs no Ruff/Pyright
59
+ installation. Invalid TOML, unclassified overrides and competing configs block
60
+ writes. Repeated sync is byte-stable; each changed file is replaced atomically.
61
+
62
+ Run from any consumer subdirectory, or use `nox-py-standards --root /repo check`.
63
+ `python -m nox_python_standards` exposes the same commands. Neither command installs
64
+ dependencies. Repositories control their normal tool execution and environment.
65
+
66
+ From 0.1: `config check/sync` becomes `check/sync`; other commands are removed.
67
+ `check` no longer runs source lint, formatting, headers, typing or tests.
68
+
69
+ ## Native fix policy
70
+
71
+ Checked Ruff settings enforce its exact version and approve PT007 via
72
+ `extend-safe-fixes = ["PT007"]`; other unsafe fixes remain disabled. Keep repair
73
+ recipes local, for example:
74
+
75
+ ```make
76
+ ruff:
77
+ uv run --locked nox-py-standards check
78
+ uv run --locked ruff format .
79
+ uv run --locked ruff check --fix .
80
+ uv run --locked ruff format .
81
+ ```
82
+
83
+ Repeat until stable. CLI flags can override settings; review and test recipes
84
+ to preserve the policy.
85
+
86
+ ## Configuration ownership
87
+
88
+ | Authority | Owns |
89
+ | --- | --- |
90
+ | [policy.json](src/nox_python_standards/resources/policy.json) | Shared settings |
91
+ | [ownership.json](src/nox_python_standards/resources/ownership.json) | Local keys and types |
92
+ | [versions.json](src/nox_python_standards/resources/versions.json) | Exact Ruff/Pyright versions |
93
+
94
+ Shared values must match exactly, even when native defaults agree. Paths,
95
+ exclusions, import classification and approved test exceptions stay local; Tach
96
+ owns generated first-party lists. Unrelated tables are preserved.
97
+
98
+ Unclassified overrides, inheritance, root `ruff.toml`, `.ruff.toml` or
99
+ `pyrightconfig.json`, and nested Ruff configs affecting scoped Git sources fail.
100
+
101
+ Policy originated in [COMO](https://github.com/noxala/como/tree/da28acec147d114270ba4524f51a47a5afb2515e);
102
+ this package has no consumer dependency.
103
+
104
+ ## CI and package development
105
+
106
+ Consumers run `uv run --locked nox-py-standards check` after locked installation,
107
+ before generators/fixes, and first in local pre-commit/pre-push hooks. Require
108
+ the gate unconditionally, then run repository checks. Never auto-sync in CI.
109
+
110
+ For code or packaging changes here:
111
+
112
+ ```sh
113
+ uv sync --locked
114
+ uv run --locked nox-py-standards check
115
+ uv run --locked ruff format --check .
116
+ uv run --locked ruff check .
117
+ uv run --locked pyright --threads 4
118
+ uv run --locked pytest
119
+ uv run --locked python tools/verify_distribution.py
120
+ ```
121
+
122
+ [CI](.github/workflows/ci.yml) runs on Linux/macOS. Distribution verification
123
+ rebuilds from sdist and tests an isolated, non-editable installation, including
124
+ sync without tools and check with missing/present tools.
125
+
126
+ Repository administrators should enable auto-merge and require `quality (Linux)`
127
+ and `quality (macOS)`.
128
+
129
+ ## Publishing
130
+
131
+ Licensed under [0BSD](LICENSE). Releases publish wheels and sdists to
132
+ [PyPI](https://pypi.org/project/nox-python-standards/) through GitHub OIDC;
133
+ no persistent PyPI token is needed.
134
+
135
+ One-time setup: on your [PyPI publishing page](https://pypi.org/manage/account/publishing/),
136
+ add a pending GitHub publisher with project `nox-python-standards`, owner `noxala`,
137
+ repository `nox-python-standards`, workflow `publish.yml`, and environment `pypi`.
138
+ The GitHub repository must have the matching `pypi` environment.
139
+
140
+ For each release, update `project.version` and this installation example, run
141
+ `uv lock`, and merge through the required checks. Publish a GitHub release tagged
142
+ `v<version>` at that merged commit. [Publish](.github/workflows/publish.yml)
143
+ reruns Linux/macOS quality checks, requires the tag to match the package version
144
+ and belong to `master`, builds distributions, then uploads through the `pypi`
145
+ environment. To publish an existing tag, run Publish manually from `master`
146
+ with that tag. A failed upload can be retried before either file is published;
147
+ after a partial upload, inspect PyPI before retrying. Published files cannot be
148
+ overwritten; subsequent changes need a new version.
@@ -0,0 +1,17 @@
1
+ nox_python_standards/__init__.py,sha256=9iE4vnPRRAAdVYaBfPnQRGVYaJ0F77JSnRdeKXzX5Wc,74
2
+ nox_python_standards/__main__.py,sha256=C-XpX_Rcc3kRAgnTa4wEP3wDJdQbxBMjUW7yEpQ9BiQ,143
3
+ nox_python_standards/cli.py,sha256=mb_qv3HZDdWKFl-EJ-kEaSvnXhsS2DY5gcDBFX1u7go,1459
4
+ nox_python_standards/config.py,sha256=GSsst_AupbB7hvLwLDipB1-RDTTMXPya9m6mf-vltD0,11014
5
+ nox_python_standards/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ nox_python_standards/repository.py,sha256=SSRhBzQbZSzkY_40r1Nb0RLsejQRAqzhmfrx4eBTwJw,2344
7
+ nox_python_standards/versions.py,sha256=n485sKtC7JO8gtPsgw7MHZ5n4_PL5QB8GF1BEgwEHAg,5274
8
+ nox_python_standards/resources/PYTHON_CONVENTIONS.md,sha256=zfabwTF6aMniCP4V1u-OgK0DSyjd5LVs_QGaH3Mo8-k,1447
9
+ nox_python_standards/resources/ownership.json,sha256=BS2PhFu8qqew5-cz29k5KEFktyWvejpShtUMIzYEH58,512
10
+ nox_python_standards/resources/policy.json,sha256=_v1ePzg0MHJ8sQ9WwVWGC6gqbCXamHMLUAi5sAWlG94,1043
11
+ nox_python_standards/resources/versions.json,sha256=piyUSGXXkHQxSE8_4M2aNqlS-McNIS3xM_FlNRM2HJM,48
12
+ nox_python_standards-0.2.1.dist-info/licenses/LICENSE,sha256=SbK1hmb8Qbqmih86aEBVlBB6dD8TYfKp1nWa5zh5KXY,656
13
+ nox_python_standards-0.2.1.dist-info/METADATA,sha256=Vu5iW_K_qkKWB9B4Sx9P7tZd_uxEWZdFmZR7zfL3GVc,6107
14
+ nox_python_standards-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
15
+ nox_python_standards-0.2.1.dist-info/entry_points.txt,sha256=CY5ZSUO61H5GtQ1RI2tWRDhHtUIRGslIGLH9RaKm8ec,67
16
+ nox_python_standards-0.2.1.dist-info/top_level.txt,sha256=OjxZIsid7sll0WeVJ4Wa_aeq-uaxBm2tH9w10Wn1KaU,21
17
+ nox_python_standards-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nox-py-standards = nox_python_standards.cli:main
@@ -0,0 +1,14 @@
1
+ Zero-Clause BSD
2
+
3
+ Copyright (c) 2026 Noxala Inc.
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for
6
+ any purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
9
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
10
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
11
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
12
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
13
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
14
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1 @@
1
+ nox_python_standards