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.
- nox_python_standards/__init__.py +2 -0
- nox_python_standards/__main__.py +6 -0
- nox_python_standards/cli.py +45 -0
- nox_python_standards/config.py +286 -0
- nox_python_standards/py.typed +0 -0
- nox_python_standards/repository.py +83 -0
- nox_python_standards/resources/PYTHON_CONVENTIONS.md +33 -0
- nox_python_standards/resources/ownership.json +15 -0
- nox_python_standards/resources/policy.json +25 -0
- nox_python_standards/resources/versions.json +4 -0
- nox_python_standards/versions.py +134 -0
- nox_python_standards-0.2.1.dist-info/METADATA +148 -0
- nox_python_standards-0.2.1.dist-info/RECORD +17 -0
- nox_python_standards-0.2.1.dist-info/WHEEL +5 -0
- nox_python_standards-0.2.1.dist-info/entry_points.txt +2 -0
- nox_python_standards-0.2.1.dist-info/licenses/LICENSE +14 -0
- nox_python_standards-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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,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,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
|