pyrigor 0.1.1__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.
- pyrigor-0.1.1/LICENSE +21 -0
- pyrigor-0.1.1/PKG-INFO +107 -0
- pyrigor-0.1.1/README.md +83 -0
- pyrigor-0.1.1/pyproject.toml +75 -0
- pyrigor-0.1.1/pyrigor/__init__.py +1 -0
- pyrigor-0.1.1/pyrigor/checkers/__init__.py +6 -0
- pyrigor-0.1.1/pyrigor/checkers/cli.py +40 -0
- pyrigor-0.1.1/pyrigor/checkers/pyr402_keyword_only_arguments.py +68 -0
- pyrigor-0.1.1/pyrigor/py.typed +0 -0
- pyrigor-0.1.1/pyrigor/rules.py +15 -0
- pyrigor-0.1.1/pyrigor/suppression.py +108 -0
- pyrigor-0.1.1/pyrigor.egg-info/PKG-INFO +107 -0
- pyrigor-0.1.1/pyrigor.egg-info/SOURCES.txt +18 -0
- pyrigor-0.1.1/pyrigor.egg-info/dependency_links.txt +1 -0
- pyrigor-0.1.1/pyrigor.egg-info/entry_points.txt +2 -0
- pyrigor-0.1.1/pyrigor.egg-info/requires.txt +9 -0
- pyrigor-0.1.1/pyrigor.egg-info/top_level.txt +1 -0
- pyrigor-0.1.1/setup.cfg +4 -0
- pyrigor-0.1.1/tests/test_rules.py +9 -0
- pyrigor-0.1.1/tests/test_suppression.py +120 -0
pyrigor-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarl Hoyem (@jarl-hoyem)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyrigor-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrigor
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Python coding discipline guideline collection and (eventually) linter.
|
|
5
|
+
Author: Jarl Hoyem (@jarl-hoyem)
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
19
|
+
Requires-Dist: pyright>=1.1.411; extra == "dev"
|
|
20
|
+
Requires-Dist: ty>=0.0.69; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
22
|
+
Requires-Dist: mutmut>=3; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# pyrigor
|
|
26
|
+
|
|
27
|
+
Disciplined Python patterns for catching bugs that type checkers and
|
|
28
|
+
standard linters miss — inspired by safety-critical coding guidelines from
|
|
29
|
+
other languages, adapted for a language and ecosystem they were not written
|
|
30
|
+
for.
|
|
31
|
+
|
|
32
|
+
## What this is
|
|
33
|
+
|
|
34
|
+
Python’s failure modes are often silent: implicit type coercion,
|
|
35
|
+
positional-argument swaps between same-typed parameters, mutable default
|
|
36
|
+
arguments, float equality checks, and tuple-unpacking that "type-checks"
|
|
37
|
+
while being semantically wrong are all real, tool-catchable classes of
|
|
38
|
+
bugs that slip past mypy, pylint, and ruff’s default rule sets.
|
|
39
|
+
|
|
40
|
+
`pyrigor` collects a set of guidelines — and, over time, tooling to enforce
|
|
41
|
+
them — aimed at closing those gaps.
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
Early stage. As of 2026-08, a set of documented guidelines. AST-based checks are
|
|
46
|
+
in progress. Plugin integration (pylint, possibly ruff) is a future goal,
|
|
47
|
+
not a current feature.
|
|
48
|
+
|
|
49
|
+
- [x] Guideline documentation
|
|
50
|
+
- [x] Standalone AST-based checkers (pre-commit local hooks) — PYR402 implemented.
|
|
51
|
+
PYR201, PYR202, PYR401, PYR403 documented but not yet enforced.
|
|
52
|
+
- [ ] pylint plugin
|
|
53
|
+
- [ ] ruff plugin (stretch goal — contingent on learning Rust)
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pyrigor
|
|
59
|
+
pyrigor path/to/file.py [path/to/another.py ...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Only PYR402 is enforced today. A violation exits non-zero and prints
|
|
63
|
+
`path:line:col: PYR402 message (keyword-only-arguments)`.
|
|
64
|
+
|
|
65
|
+
To suppress a specific violation, add a same-line comment with a
|
|
66
|
+
reason:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
def f(weight, bias): # pyrigor: PYR402 # matches a fixed external API
|
|
70
|
+
...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Codes may be given as the full code (`PYR402`), the bare number
|
|
74
|
+
(`402`), or the rule’s symbolic name (`keyword-only-arguments`).
|
|
75
|
+
Multiple codes: `# pyrigor: 402,403 # reason`. A suppression comment
|
|
76
|
+
without a reason is ignored, and a warning is printed.
|
|
77
|
+
|
|
78
|
+
## Guidelines
|
|
79
|
+
|
|
80
|
+
See [`guidelines/`](./guidelines) for the full list. Each guideline has a
|
|
81
|
+
rule ID, rationale, example, and — once implemented — a link to its
|
|
82
|
+
enforcing check.
|
|
83
|
+
|
|
84
|
+
Guidelines documented so far:
|
|
85
|
+
|
|
86
|
+
| ID | Rule | Enforced by |
|
|
87
|
+
|--------|-----------------------------------------------------------------------|---------------------------------|
|
|
88
|
+
| PYR401 | Use `NamedTuple` for any function returning more than one value | Not yet implemented |
|
|
89
|
+
| PYR201 | Use `NewType` for same-typed values at risk of being swapped | Not yet implemented |
|
|
90
|
+
| PYR202 | Use `Enum` instead of magic strings, ints, or bools for closed states | Not yet implemented |
|
|
91
|
+
| PYR402 | Force keyword-only arguments for 2+ function parameters (bare `*`) | `pyrigor` CLI (pre-commit hook) |
|
|
92
|
+
| PYR403 | Force keyword-only arguments for single-parameter functions | Not yet implemented |
|
|
93
|
+
|
|
94
|
+
## Philosophy
|
|
95
|
+
|
|
96
|
+
Prefer explicit over implicit. Make illegal states unrepresentable. Do not
|
|
97
|
+
rely on convention or code review where a tool can enforce correctness
|
|
98
|
+
instead.
|
|
99
|
+
|
|
100
|
+
## Contributing
|
|
101
|
+
|
|
102
|
+
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the setup and workflow.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
pyrigor-0.1.1/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# pyrigor
|
|
2
|
+
|
|
3
|
+
Disciplined Python patterns for catching bugs that type checkers and
|
|
4
|
+
standard linters miss — inspired by safety-critical coding guidelines from
|
|
5
|
+
other languages, adapted for a language and ecosystem they were not written
|
|
6
|
+
for.
|
|
7
|
+
|
|
8
|
+
## What this is
|
|
9
|
+
|
|
10
|
+
Python’s failure modes are often silent: implicit type coercion,
|
|
11
|
+
positional-argument swaps between same-typed parameters, mutable default
|
|
12
|
+
arguments, float equality checks, and tuple-unpacking that "type-checks"
|
|
13
|
+
while being semantically wrong are all real, tool-catchable classes of
|
|
14
|
+
bugs that slip past mypy, pylint, and ruff’s default rule sets.
|
|
15
|
+
|
|
16
|
+
`pyrigor` collects a set of guidelines — and, over time, tooling to enforce
|
|
17
|
+
them — aimed at closing those gaps.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
Early stage. As of 2026-08, a set of documented guidelines. AST-based checks are
|
|
22
|
+
in progress. Plugin integration (pylint, possibly ruff) is a future goal,
|
|
23
|
+
not a current feature.
|
|
24
|
+
|
|
25
|
+
- [x] Guideline documentation
|
|
26
|
+
- [x] Standalone AST-based checkers (pre-commit local hooks) — PYR402 implemented.
|
|
27
|
+
PYR201, PYR202, PYR401, PYR403 documented but not yet enforced.
|
|
28
|
+
- [ ] pylint plugin
|
|
29
|
+
- [ ] ruff plugin (stretch goal — contingent on learning Rust)
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pyrigor
|
|
35
|
+
pyrigor path/to/file.py [path/to/another.py ...]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Only PYR402 is enforced today. A violation exits non-zero and prints
|
|
39
|
+
`path:line:col: PYR402 message (keyword-only-arguments)`.
|
|
40
|
+
|
|
41
|
+
To suppress a specific violation, add a same-line comment with a
|
|
42
|
+
reason:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
def f(weight, bias): # pyrigor: PYR402 # matches a fixed external API
|
|
46
|
+
...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Codes may be given as the full code (`PYR402`), the bare number
|
|
50
|
+
(`402`), or the rule’s symbolic name (`keyword-only-arguments`).
|
|
51
|
+
Multiple codes: `# pyrigor: 402,403 # reason`. A suppression comment
|
|
52
|
+
without a reason is ignored, and a warning is printed.
|
|
53
|
+
|
|
54
|
+
## Guidelines
|
|
55
|
+
|
|
56
|
+
See [`guidelines/`](./guidelines) for the full list. Each guideline has a
|
|
57
|
+
rule ID, rationale, example, and — once implemented — a link to its
|
|
58
|
+
enforcing check.
|
|
59
|
+
|
|
60
|
+
Guidelines documented so far:
|
|
61
|
+
|
|
62
|
+
| ID | Rule | Enforced by |
|
|
63
|
+
|--------|-----------------------------------------------------------------------|---------------------------------|
|
|
64
|
+
| PYR401 | Use `NamedTuple` for any function returning more than one value | Not yet implemented |
|
|
65
|
+
| PYR201 | Use `NewType` for same-typed values at risk of being swapped | Not yet implemented |
|
|
66
|
+
| PYR202 | Use `Enum` instead of magic strings, ints, or bools for closed states | Not yet implemented |
|
|
67
|
+
| PYR402 | Force keyword-only arguments for 2+ function parameters (bare `*`) | `pyrigor` CLI (pre-commit hook) |
|
|
68
|
+
| PYR403 | Force keyword-only arguments for single-parameter functions | Not yet implemented |
|
|
69
|
+
|
|
70
|
+
## Philosophy
|
|
71
|
+
|
|
72
|
+
Prefer explicit over implicit. Make illegal states unrepresentable. Do not
|
|
73
|
+
rely on convention or code review where a tool can enforce correctness
|
|
74
|
+
instead.
|
|
75
|
+
|
|
76
|
+
## Contributing
|
|
77
|
+
|
|
78
|
+
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the setup and workflow.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyrigor"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A Python coding discipline guideline collection and (eventually) linter."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jarl Hoyem (@jarl-hoyem)" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
pyrigor = "pyrigor.checkers.cli:run"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-cov>=5",
|
|
31
|
+
"mypy>=1.10",
|
|
32
|
+
"pyright>=1.1.411",
|
|
33
|
+
"ty>=0.0.69",
|
|
34
|
+
"ruff>=0.6",
|
|
35
|
+
"mutmut>=3",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["pyrigor*"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.11"
|
|
44
|
+
strict = true
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
target-version = "py311"
|
|
48
|
+
line-length = 120
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
markers = [
|
|
52
|
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
53
|
+
]
|
|
54
|
+
addopts = [
|
|
55
|
+
"-m", "not slow",
|
|
56
|
+
"--cov=pyrigor",
|
|
57
|
+
"--cov-branch",
|
|
58
|
+
"--cov-report=html",
|
|
59
|
+
"--cov-report=term-missing",
|
|
60
|
+
"--cov-fail-under=100",
|
|
61
|
+
"--strict-markers",
|
|
62
|
+
"-W", "error",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.coverage.run]
|
|
66
|
+
source = ["pyrigor"]
|
|
67
|
+
|
|
68
|
+
[tool.pydocstyle]
|
|
69
|
+
convention = "google"
|
|
70
|
+
|
|
71
|
+
[tool.mutmut]
|
|
72
|
+
source_paths = "pyrigor/"
|
|
73
|
+
|
|
74
|
+
[tool.pylint.format]
|
|
75
|
+
max-line-length = 120
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pyrigor: a Python coding-discipline guideline collection (and eventually a linter)."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command-line entry point for pyrigor's checkers."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pyrigor.checkers import find_pyr402_violations
|
|
7
|
+
from pyrigor.suppression import filter_suppressed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(paths: list[str]) -> int:
|
|
11
|
+
"""Run all checkers against the given file paths.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
paths: File paths to check.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
0 if no violations were found, 1 otherwise.
|
|
18
|
+
"""
|
|
19
|
+
exit_code = 0
|
|
20
|
+
|
|
21
|
+
for path in paths:
|
|
22
|
+
source = Path(path).read_text(encoding="utf-8")
|
|
23
|
+
violations = find_pyr402_violations(source)
|
|
24
|
+
violations = filter_suppressed(violations=violations, source=source)
|
|
25
|
+
|
|
26
|
+
for violation in violations:
|
|
27
|
+
location = f"{path}:{violation.line}:{violation.column}"
|
|
28
|
+
print(f"{location}: {violation.rule.name} {violation.message} ({violation.rule.value})")
|
|
29
|
+
exit_code = 1
|
|
30
|
+
|
|
31
|
+
return exit_code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run() -> None:
|
|
35
|
+
"""Console-script entry point: parse argv and run main()."""
|
|
36
|
+
sys.exit(main(paths=sys.argv[1:]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__": # pragma: no cover
|
|
40
|
+
run()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""PYR402 checker: flag functions with parameters before a bare `*`."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
from pyrigor.rules import Rule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Violation(NamedTuple):
|
|
10
|
+
"""A single rule violation found by one of pyrigor's checkers."""
|
|
11
|
+
|
|
12
|
+
line: int
|
|
13
|
+
column: int
|
|
14
|
+
function_name: str
|
|
15
|
+
rule: Rule
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _has_violation(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
20
|
+
"""Check whether a function definition violates PYR402.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
node: The function definition to check.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if the function has two or more parameters with at least
|
|
27
|
+
one positional (beyond an optional leading `self`/`cls`).
|
|
28
|
+
Single-parameter functions are exempt — see PYR403.
|
|
29
|
+
"""
|
|
30
|
+
positional_args = list(node.args.posonlyargs) + list(node.args.args)
|
|
31
|
+
if positional_args and positional_args[0].arg in ("self", "cls"):
|
|
32
|
+
positional_args = positional_args[1:]
|
|
33
|
+
|
|
34
|
+
total_params = len(positional_args) + len(node.args.kwonlyargs)
|
|
35
|
+
if total_params < 2:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return bool(positional_args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_violations(source: str) -> list[Violation]:
|
|
42
|
+
"""Find PYR402 violations in a source string.
|
|
43
|
+
|
|
44
|
+
PYR402: all parameters should be keyword-only.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
source: Python source code to check.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A list of violations found, one per offending function.
|
|
51
|
+
"""
|
|
52
|
+
tree = ast.parse(source)
|
|
53
|
+
violations = []
|
|
54
|
+
|
|
55
|
+
for node in ast.walk(tree):
|
|
56
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_violation(node):
|
|
57
|
+
violations.append(
|
|
58
|
+
Violation(
|
|
59
|
+
line=node.lineno,
|
|
60
|
+
column=node.col_offset + 1,
|
|
61
|
+
function_name=node.name,
|
|
62
|
+
rule=Rule.PYR402,
|
|
63
|
+
message=f"Function '{node.name}' has positional parameters; "
|
|
64
|
+
f"all parameters should be keyword-only (PYR402).",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return violations
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Registry of all pyrigor rules.
|
|
2
|
+
|
|
3
|
+
Each Rule member's name is the rule's code (for example, PYR402), and its
|
|
4
|
+
value is the rule's symbolic name (for example, "keyword-only-arguments") —
|
|
5
|
+
matching the pylint-style dual-identifier convention pyrigor's own
|
|
6
|
+
output and suppression comments use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Rule(Enum):
|
|
13
|
+
"""All pyrigor rules implemented or planned."""
|
|
14
|
+
|
|
15
|
+
PYR402 = "keyword-only-arguments"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Suppression-comment mechanism for pyrigor's checkers.
|
|
2
|
+
|
|
3
|
+
Recognizes `# pyrigor: CODE[, CODE...]` comments on the same line as a
|
|
4
|
+
violation, where CODE may be a rule's full code ("PYR402"), its
|
|
5
|
+
numeric shorthand ("402"), or its symbolic name
|
|
6
|
+
("keyword-only-arguments"). Whitespace around the colon and commas is
|
|
7
|
+
tolerated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import NamedTuple
|
|
13
|
+
|
|
14
|
+
from pyrigor.checkers.pyr402_keyword_only_arguments import Violation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _SuppressionInfo(NamedTuple):
|
|
18
|
+
"""Parsed contents of a `# pyrigor:` suppression comment."""
|
|
19
|
+
|
|
20
|
+
tokens: set[str]
|
|
21
|
+
reason: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SUPPRESSION_PATTERN = re.compile(r"#\s*pyrigor\s*:\s*(?P<tokens>.+)$")
|
|
25
|
+
_NEAR_MISS_PATTERN = re.compile(r"#.*pyrigor", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _suppressed_tokens(*, line: str) -> _SuppressionInfo:
|
|
29
|
+
"""Get the suppression tokens and optional reason from a source line.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
line: One line of source code.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The suppression information is found on this line, or an empty
|
|
36
|
+
_SuppressionInfo if there is no suppression comment. If a
|
|
37
|
+
comment mentions "pyrigor" but doesn't match the expected
|
|
38
|
+
pattern, a warning is printed.
|
|
39
|
+
"""
|
|
40
|
+
match = _SUPPRESSION_PATTERN.search(line)
|
|
41
|
+
if match is None:
|
|
42
|
+
if _NEAR_MISS_PATTERN.search(line):
|
|
43
|
+
print(
|
|
44
|
+
f"Warning: comment mentions 'pyrigor' but doesn't match "
|
|
45
|
+
f"'# pyrigor: CODE[,CODE] # reason' -- ignoring: {line.strip()}",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
return _SuppressionInfo(tokens=set(), reason=None)
|
|
49
|
+
|
|
50
|
+
body = match.group("tokens")
|
|
51
|
+
codes_part, _, reason_part = body.partition("#")
|
|
52
|
+
|
|
53
|
+
tokens = {token.strip() for token in codes_part.split(",")}
|
|
54
|
+
reason = reason_part.strip() or None
|
|
55
|
+
|
|
56
|
+
return _SuppressionInfo(tokens=tokens, reason=reason)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _matches_suppression(*, violation: Violation, suppression: _SuppressionInfo) -> bool:
|
|
60
|
+
"""Check whether a violation is suppressed by the given suppression information.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
violation: The violation to check.
|
|
64
|
+
suppression: Suppression information parsed from the violation's line.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if the violation's rule code, numeric shorthand, or
|
|
68
|
+
symbolic name is present in suppression tokens, and a reason
|
|
69
|
+
is present. Suppression with codes but no reason does not
|
|
70
|
+
suppress. Instead, a warning is printed.
|
|
71
|
+
"""
|
|
72
|
+
code = violation.rule.name
|
|
73
|
+
shorthand = code.removeprefix("PYR")
|
|
74
|
+
name = violation.rule.value
|
|
75
|
+
|
|
76
|
+
code_matches = bool(suppression.tokens & {code, shorthand, name})
|
|
77
|
+
|
|
78
|
+
if code_matches and suppression.reason is None:
|
|
79
|
+
print(
|
|
80
|
+
f"Warning: suppression on line {violation.line} for {code} is missing required reason, ignoring.",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
return code_matches
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def filter_suppressed(*, violations: list[Violation], source: str) -> list[Violation]:
|
|
89
|
+
"""Remove violations suppressed by a same-line `# pyrigor:` comment.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
violations: Violations to filter.
|
|
93
|
+
source: The source code the violations were found in.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Violations that are not suppressed.
|
|
97
|
+
"""
|
|
98
|
+
lines = source.splitlines()
|
|
99
|
+
|
|
100
|
+
result = []
|
|
101
|
+
for violation in violations:
|
|
102
|
+
line_text = lines[violation.line - 1] if 0 < violation.line <= len(lines) else ""
|
|
103
|
+
suppression = _suppressed_tokens(line=line_text)
|
|
104
|
+
|
|
105
|
+
if not _matches_suppression(violation=violation, suppression=suppression):
|
|
106
|
+
result.append(violation)
|
|
107
|
+
|
|
108
|
+
return result
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrigor
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Python coding discipline guideline collection and (eventually) linter.
|
|
5
|
+
Author: Jarl Hoyem (@jarl-hoyem)
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
19
|
+
Requires-Dist: pyright>=1.1.411; extra == "dev"
|
|
20
|
+
Requires-Dist: ty>=0.0.69; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
22
|
+
Requires-Dist: mutmut>=3; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# pyrigor
|
|
26
|
+
|
|
27
|
+
Disciplined Python patterns for catching bugs that type checkers and
|
|
28
|
+
standard linters miss — inspired by safety-critical coding guidelines from
|
|
29
|
+
other languages, adapted for a language and ecosystem they were not written
|
|
30
|
+
for.
|
|
31
|
+
|
|
32
|
+
## What this is
|
|
33
|
+
|
|
34
|
+
Python’s failure modes are often silent: implicit type coercion,
|
|
35
|
+
positional-argument swaps between same-typed parameters, mutable default
|
|
36
|
+
arguments, float equality checks, and tuple-unpacking that "type-checks"
|
|
37
|
+
while being semantically wrong are all real, tool-catchable classes of
|
|
38
|
+
bugs that slip past mypy, pylint, and ruff’s default rule sets.
|
|
39
|
+
|
|
40
|
+
`pyrigor` collects a set of guidelines — and, over time, tooling to enforce
|
|
41
|
+
them — aimed at closing those gaps.
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
Early stage. As of 2026-08, a set of documented guidelines. AST-based checks are
|
|
46
|
+
in progress. Plugin integration (pylint, possibly ruff) is a future goal,
|
|
47
|
+
not a current feature.
|
|
48
|
+
|
|
49
|
+
- [x] Guideline documentation
|
|
50
|
+
- [x] Standalone AST-based checkers (pre-commit local hooks) — PYR402 implemented.
|
|
51
|
+
PYR201, PYR202, PYR401, PYR403 documented but not yet enforced.
|
|
52
|
+
- [ ] pylint plugin
|
|
53
|
+
- [ ] ruff plugin (stretch goal — contingent on learning Rust)
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pyrigor
|
|
59
|
+
pyrigor path/to/file.py [path/to/another.py ...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Only PYR402 is enforced today. A violation exits non-zero and prints
|
|
63
|
+
`path:line:col: PYR402 message (keyword-only-arguments)`.
|
|
64
|
+
|
|
65
|
+
To suppress a specific violation, add a same-line comment with a
|
|
66
|
+
reason:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
def f(weight, bias): # pyrigor: PYR402 # matches a fixed external API
|
|
70
|
+
...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Codes may be given as the full code (`PYR402`), the bare number
|
|
74
|
+
(`402`), or the rule’s symbolic name (`keyword-only-arguments`).
|
|
75
|
+
Multiple codes: `# pyrigor: 402,403 # reason`. A suppression comment
|
|
76
|
+
without a reason is ignored, and a warning is printed.
|
|
77
|
+
|
|
78
|
+
## Guidelines
|
|
79
|
+
|
|
80
|
+
See [`guidelines/`](./guidelines) for the full list. Each guideline has a
|
|
81
|
+
rule ID, rationale, example, and — once implemented — a link to its
|
|
82
|
+
enforcing check.
|
|
83
|
+
|
|
84
|
+
Guidelines documented so far:
|
|
85
|
+
|
|
86
|
+
| ID | Rule | Enforced by |
|
|
87
|
+
|--------|-----------------------------------------------------------------------|---------------------------------|
|
|
88
|
+
| PYR401 | Use `NamedTuple` for any function returning more than one value | Not yet implemented |
|
|
89
|
+
| PYR201 | Use `NewType` for same-typed values at risk of being swapped | Not yet implemented |
|
|
90
|
+
| PYR202 | Use `Enum` instead of magic strings, ints, or bools for closed states | Not yet implemented |
|
|
91
|
+
| PYR402 | Force keyword-only arguments for 2+ function parameters (bare `*`) | `pyrigor` CLI (pre-commit hook) |
|
|
92
|
+
| PYR403 | Force keyword-only arguments for single-parameter functions | Not yet implemented |
|
|
93
|
+
|
|
94
|
+
## Philosophy
|
|
95
|
+
|
|
96
|
+
Prefer explicit over implicit. Make illegal states unrepresentable. Do not
|
|
97
|
+
rely on convention or code review where a tool can enforce correctness
|
|
98
|
+
instead.
|
|
99
|
+
|
|
100
|
+
## Contributing
|
|
101
|
+
|
|
102
|
+
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the setup and workflow.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pyrigor/__init__.py
|
|
5
|
+
pyrigor/py.typed
|
|
6
|
+
pyrigor/rules.py
|
|
7
|
+
pyrigor/suppression.py
|
|
8
|
+
pyrigor.egg-info/PKG-INFO
|
|
9
|
+
pyrigor.egg-info/SOURCES.txt
|
|
10
|
+
pyrigor.egg-info/dependency_links.txt
|
|
11
|
+
pyrigor.egg-info/entry_points.txt
|
|
12
|
+
pyrigor.egg-info/requires.txt
|
|
13
|
+
pyrigor.egg-info/top_level.txt
|
|
14
|
+
pyrigor/checkers/__init__.py
|
|
15
|
+
pyrigor/checkers/cli.py
|
|
16
|
+
pyrigor/checkers/pyr402_keyword_only_arguments.py
|
|
17
|
+
tests/test_rules.py
|
|
18
|
+
tests/test_suppression.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyrigor
|
pyrigor-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Tests for pyrigor's Rule registry."""
|
|
2
|
+
|
|
3
|
+
from pyrigor.rules import Rule
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_pyr402_rule_has_correct_code_and_name() -> None:
|
|
7
|
+
"""Rule PYR402's enum name/value should match the expected code and symbolic name."""
|
|
8
|
+
assert Rule.PYR402.name == "PYR402"
|
|
9
|
+
assert Rule.PYR402.value == "keyword-only-arguments"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tests for pyrigor's suppression-comment mechanism."""
|
|
2
|
+
|
|
3
|
+
from pytest import CaptureFixture
|
|
4
|
+
|
|
5
|
+
from pyrigor.checkers.pyr402_keyword_only_arguments import Violation
|
|
6
|
+
from pyrigor.rules import Rule
|
|
7
|
+
|
|
8
|
+
# noinspection PyProtectedMember
|
|
9
|
+
from pyrigor.suppression import (
|
|
10
|
+
_suppressed_tokens, # pylint: disable=protected-access
|
|
11
|
+
filter_suppressed,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_suppressed_violation_is_filtered_out() -> None:
|
|
16
|
+
"""A violation on a line with a matching # pyrigor: CODE # reason comment should be removed."""
|
|
17
|
+
source = "def apply_correction(weight, bias): # pyrigor: PYR402 # positional swap risk\n ...\n"
|
|
18
|
+
violations = [Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="...")]
|
|
19
|
+
|
|
20
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
21
|
+
|
|
22
|
+
assert not result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_unsuppressed_violation_is_kept() -> None:
|
|
26
|
+
"""A violation on a line with no suppression comment should be kept."""
|
|
27
|
+
source = "def apply_correction(weight, bias):\n ...\n"
|
|
28
|
+
violations = [Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="...")]
|
|
29
|
+
|
|
30
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
31
|
+
|
|
32
|
+
assert result == violations
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_multiple_suppressed_codes_on_one_line() -> None:
|
|
36
|
+
"""Multiple codes in one # pyrigor: comment should each suppress their matching violation."""
|
|
37
|
+
source = "def apply_correction(weight, bias): # pyrigor: 402,201 # some reason\n ...\n"
|
|
38
|
+
violations = [
|
|
39
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
43
|
+
|
|
44
|
+
assert not result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_symbolic_name_suppresses_violation() -> None:
|
|
48
|
+
"""A suppression comment using the symbolic name should work, as well as the code."""
|
|
49
|
+
source = "def apply_correction(weight, bias): # pyrigor: keyword-only-arguments # some reason\n ...\n"
|
|
50
|
+
violations = [
|
|
51
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
55
|
+
|
|
56
|
+
assert not result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_whitespace_around_colon_and_commas_is_tolerated() -> None:
|
|
60
|
+
"""Irregular spacing around the colon and commas should still parse correctly."""
|
|
61
|
+
source = "def apply_correction(weight, bias): #pyrigor: 402 , 201 # some reason\n ...\n"
|
|
62
|
+
violations = [
|
|
63
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
67
|
+
|
|
68
|
+
assert not result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_suppression_comment_with_reason_is_parsed() -> None:
|
|
72
|
+
"""A # pyrigor: CODE # reason comment should suppress and capture the reason text."""
|
|
73
|
+
source = (
|
|
74
|
+
"def apply_correction(weight, bias): # pyrigor: 402 # pytest fixture injection is positional-only\n ...\n"
|
|
75
|
+
)
|
|
76
|
+
violations = [
|
|
77
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
81
|
+
|
|
82
|
+
assert not result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_suppression_comment_reason_is_parsed_correctly() -> None:
|
|
86
|
+
"""The free-text reason after the second # should be captured verbatim."""
|
|
87
|
+
line = "def apply_correction(weight, bias): # pyrigor: 402 # positional injection required by pytest"
|
|
88
|
+
|
|
89
|
+
info = _suppressed_tokens(line=line)
|
|
90
|
+
|
|
91
|
+
assert info.tokens == {"402"}
|
|
92
|
+
assert info.reason == "positional injection required by pytest"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_suppression_without_reason_does_not_suppress(capsys: CaptureFixture[str]) -> None:
|
|
96
|
+
"""A suppression comment with no reason should not suppress and should warn."""
|
|
97
|
+
source = "def apply_correction(weight, bias): # pyrigor: 402\n ...\n"
|
|
98
|
+
violations = [
|
|
99
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
103
|
+
|
|
104
|
+
captured = capsys.readouterr()
|
|
105
|
+
assert result == violations
|
|
106
|
+
assert "missing required reason" in captured.err
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_near_miss_comment_warns(capsys: CaptureFixture[str]) -> None:
|
|
110
|
+
"""A comment mentioning 'pyrigor' that doesn't match the suppression pattern should warn."""
|
|
111
|
+
source = "def apply_correction(weight, bias): # pyrigor 402 missing colon\n ...\n"
|
|
112
|
+
violations = [
|
|
113
|
+
Violation(line=1, column=1, function_name="apply_correction", rule=Rule.PYR402, message="..."),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
result = filter_suppressed(violations=violations, source=source)
|
|
117
|
+
|
|
118
|
+
captured = capsys.readouterr()
|
|
119
|
+
assert result == violations
|
|
120
|
+
assert "doesn't match" in captured.err
|