python-constricter 0.2.2__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.
constricter/project.py ADDED
@@ -0,0 +1,229 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Cross-module `--fix`: the return types of the functions other checked files define.
3
+
4
+ `index` reads every file once for its module name, its top-level functions' declared return types
5
+ (as `annotations.returns` picks them), and what each top-level name refers to. `calls` then gives a
6
+ file the return type of each function it imports (`from m import f`, `import m as a` then `a.f()`),
7
+ but only where every name in that type means the same thing in the file as where it was written:
8
+ otherwise the fix would name something undefined, or something else.
9
+ """
10
+
11
+ import ast
12
+ import builtins
13
+ import itertools
14
+ from collections.abc import Iterator, Mapping, Sequence
15
+ from pathlib import Path
16
+ from typing import Final, NamedTuple, TypeAlias
17
+
18
+ from constricter.annotations import returns
19
+
20
+ _BUILTINS: Final = frozenset(dir(builtins))
21
+ _PACKAGE: Final = "__init__"
22
+ _SUFFIX: Final = ".py"
23
+ _HOPS: Final = 5 # how many re-exports (`from .util import f` in an `__init__`) to follow
24
+ # What a name refers to: a module and an attribute of it (`None`: the module itself).
25
+ Origin: TypeAlias = tuple[str, str | None]
26
+
27
+
28
+ class Module(NamedTuple):
29
+ """What one file offers and uses: its name, functions' return types, and names' origins."""
30
+
31
+ name: str
32
+ returns: dict[str, str]
33
+ names: dict[str, Origin]
34
+
35
+
36
+ def module_name(path: Path) -> str:
37
+ """Name `path`'s module: its package folders (those with an `__init__.py`), then it.
38
+
39
+ Returns:
40
+ The dotted module name.
41
+
42
+ """
43
+ packages: list[Path] = list(
44
+ itertools.takewhile(
45
+ lambda folder: (folder / f"{_PACKAGE}{_SUFFIX}").is_file(),
46
+ path.resolve().parents,
47
+ ),
48
+ )
49
+ return ".".join(
50
+ [*(folder.name for folder in reversed(packages)), *([] if path.stem == _PACKAGE else [path.stem])],
51
+ )
52
+
53
+
54
+ def _absolute(name: str, module: str | None, level: int, *, is_package: bool) -> str:
55
+ """Resolve `from <.level><module> import ...` in module `name`.
56
+
57
+ Returns:
58
+ The absolute module name.
59
+
60
+ """
61
+ if not level:
62
+ return module or ""
63
+ package: list[str] = name.split(".") if is_package else name.split(".")[:-1]
64
+ base: list[str] = package[: len(package) - (level - 1)] if level > 1 else package
65
+ return ".".join([*base, *([module] if module else [])])
66
+
67
+
68
+ def _names(tree: ast.Module, name: str, *, is_package: bool) -> dict[str, Origin]:
69
+ """Map module `name`'s top-level names (the last binding wins).
70
+
71
+ Returns:
72
+ What each refers to.
73
+
74
+ """
75
+ names: dict[str, Origin] = {}
76
+ stmt: ast.stmt
77
+ alias: ast.alias
78
+ module: str | None
79
+ level: int
80
+ for stmt in tree.body:
81
+ match stmt:
82
+ case ast.Import():
83
+ for alias in stmt.names:
84
+ if alias.asname:
85
+ names[alias.asname] = (alias.name, None)
86
+ else: # `import a.b` binds `a`
87
+ names[alias.name.split(".")[0]] = (alias.name.split(".")[0], None)
88
+ case ast.ImportFrom(module=module, level=level):
89
+ for alias in stmt.names:
90
+ names[alias.asname or alias.name] = (
91
+ _absolute(name, module, level, is_package=is_package),
92
+ alias.name,
93
+ )
94
+ case _:
95
+ names.update((bound, (name, bound)) for bound in _bound(stmt))
96
+ return names
97
+
98
+
99
+ def _bound(stmt: ast.stmt) -> Iterator[str]:
100
+ """Walk a top-level statement other than an import.
101
+
102
+ Yields:
103
+ Each name it binds.
104
+
105
+ """
106
+ node: ast.AST
107
+ match stmt:
108
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
109
+ yield stmt.name
110
+ case ast.Assign() | ast.AnnAssign() | ast.AugAssign():
111
+ targets: list[ast.expr] = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
112
+ for node in (n for target in targets for n in ast.walk(target)):
113
+ if isinstance(node, ast.Name):
114
+ yield node.id
115
+ case _:
116
+ pass
117
+
118
+
119
+ def index(paths: Sequence[Path]) -> dict[str, Module]:
120
+ """Read each `.py` file in `paths` (one that can't be read or parsed is left out).
121
+
122
+ Returns:
123
+ Each module's name, mapped to what it offers and uses.
124
+
125
+ """
126
+ modules: dict[str, Module] = {}
127
+ path: Path
128
+ for path in paths:
129
+ if path.suffix != _SUFFIX or not path.is_file():
130
+ continue
131
+ try:
132
+ tree: ast.Module = ast.parse(path.read_bytes(), str(path))
133
+ except (OSError, SyntaxError, ValueError):
134
+ continue
135
+ name: str = module_name(path)
136
+ modules[name] = Module(name, returns(tree), _names(tree, name, is_package=path.stem == _PACKAGE))
137
+ return modules
138
+
139
+
140
+ def _origin(module: Module, name: str) -> Origin | None:
141
+ if name in module.names:
142
+ return module.names[name]
143
+ return ("builtins", name) if name in _BUILTINS else None
144
+
145
+
146
+ def _roots(annotation: str) -> set[str]:
147
+ """Find the names an annotation (maybe a string one) starts from.
148
+
149
+ Returns:
150
+ The names: `m.Row` gives `m`.
151
+
152
+ """
153
+ tree: ast.expr = ast.parse(annotation, mode="eval").body
154
+ if isinstance(tree, ast.Constant) and isinstance(tree.value, str):
155
+ tree = ast.parse(tree.value, mode="eval").body
156
+ return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
157
+
158
+
159
+ def _function(modules: Mapping[str, Module], origin: Origin, hops: int = _HOPS) -> tuple[Module, str] | None:
160
+ """Follow `origin` (through re-exports) to the module that defines it.
161
+
162
+ Returns:
163
+ That module and the function's name, or `None`.
164
+
165
+ """
166
+ module: Module | None = modules.get(origin[0])
167
+ attribute: str | None = origin[1]
168
+ if module is None or attribute is None or not hops:
169
+ return None
170
+ if attribute in module.returns:
171
+ return module, attribute
172
+ onward: Origin | None = module.names.get(attribute)
173
+ return _function(modules, onward, hops - 1) if onward and onward[0] != module.name else None
174
+
175
+
176
+ def calls(modules: Mapping[str, Module], path: Path) -> dict[str, str]:
177
+ """Return, for the file at `path`, the return type of each function it imports whose type it can name.
178
+
179
+ Returns:
180
+ Each call's name as written (`helper`, `u.helper`, `pkg.util.helper`), mapped to its type; nothing
181
+ for a file `modules` doesn't have (a notebook, standard input).
182
+
183
+ """
184
+ name: str = module_name(path)
185
+ target: Module | None
186
+ if path.suffix != _SUFFIX or (target := modules.get(name)) is None:
187
+ return {}
188
+ found: dict[str, str] = {}
189
+ local: str
190
+ origin: Origin
191
+ for local, origin in target.names.items():
192
+ if origin[1] is not None and origin[0] != name:
193
+ _add(found, modules, target, local, origin)
194
+ elif origin[1] is None: # a module: `u.f()`, or `pkg.util.f()` after `import pkg.util`
195
+ other: Module
196
+ for other in modules.values():
197
+ if other.name == origin[0] or other.name.startswith(f"{origin[0]}."):
198
+ prefix: str = local + other.name.removeprefix(origin[0])
199
+ function: str
200
+ for function in other.returns:
201
+ _add(found, modules, target, f"{prefix}.{function}", (other.name, function))
202
+ return found
203
+
204
+
205
+ def _add(
206
+ found: dict[str, str],
207
+ modules: Mapping[str, Module],
208
+ target: Module,
209
+ key: str,
210
+ origin: Origin,
211
+ ) -> None:
212
+ """Record `key`'s return type in `found` if every name in it means the same in `target`."""
213
+ defined: tuple[Module, str] | None
214
+ if (defined := _function(modules, origin)) is None:
215
+ return
216
+ annotation: str = defined[0].returns[defined[1]]
217
+ if all(_same(target, defined[0], root) for root in _roots(annotation)):
218
+ found[key] = annotation
219
+
220
+
221
+ def _same(target: Module, defined: Module, name: str) -> bool:
222
+ """Compare what `name` refers to in both modules.
223
+
224
+ Returns:
225
+ Whether it's something, and the same thing.
226
+
227
+ """
228
+ origin: Origin | None = _origin(target, name)
229
+ return origin is not None and origin == _origin(defined, name)
constricter/py.typed ADDED
File without changes
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The rules as a pylint plugin (C9101-C9106); reports the codes the level makes errors."""
3
+
4
+ from typing import IO, TYPE_CHECKING, NamedTuple, cast, final
5
+
6
+ from astroid import nodes
7
+ from pylint.checkers import BaseRawFileChecker
8
+ from pylint.lint import PyLinter
9
+ from pylint.typing import Options
10
+
11
+ from constricter.checker import (
12
+ COMMENT_TYPED_TARGET,
13
+ LEVELS,
14
+ MESSAGES,
15
+ NESTED_TYPE,
16
+ NESTING,
17
+ UNANNOTATED,
18
+ UNANNOTATED_MEMBER,
19
+ UNTYPED_TARGET,
20
+ VAGUE_TYPE,
21
+ Checks,
22
+ Level,
23
+ Offence,
24
+ check_source,
25
+ )
26
+ from constricter.noqa import lines, unsuppressed
27
+
28
+ if TYPE_CHECKING:
29
+ from typing_extensions import override # `typing.override` is 3.12+
30
+ else:
31
+
32
+ def override(func: object) -> object:
33
+ """Mark an override (for type checkers only).
34
+
35
+ Returns:
36
+ `func`, unchanged.
37
+
38
+ """
39
+ return func
40
+
41
+
42
+ class Message(NamedTuple):
43
+ """A code's pylint message id and symbol."""
44
+
45
+ msg_id: str
46
+ symbol: str
47
+
48
+
49
+ SYMBOLS: dict[str, Message] = {
50
+ UNANNOTATED: Message("C9101", "unannotated-local-variable"),
51
+ UNTYPED_TARGET: Message("C9102", "untyped-for-or-match-variable"),
52
+ COMMENT_TYPED_TARGET: Message("C9103", "comment-typed-for-variable"),
53
+ UNANNOTATED_MEMBER: Message("C9104", "unannotated-module-or-class-variable"),
54
+ VAGUE_TYPE: Message("C9105", "vague-annotation"),
55
+ NESTED_TYPE: Message("C9106", "deeply-nested-annotation"),
56
+ }
57
+
58
+
59
+ @final
60
+ class ConstricterChecker(BaseRawFileChecker):
61
+ """pylint's checker, run once per module on its raw source."""
62
+
63
+ name: str = "constricter"
64
+ options: Options = (
65
+ (
66
+ "constricter-level",
67
+ {
68
+ "default": "strict",
69
+ "type": "choice",
70
+ "choices": list(LEVELS),
71
+ "metavar": "<level>",
72
+ "help": "Which codes are reported.",
73
+ },
74
+ ),
75
+ (
76
+ "constricter-type-comments",
77
+ {"default": False, "type": "yn", "metavar": "<y or n>", "help": "Count `# type:` comments."},
78
+ ),
79
+ (
80
+ "constricter-all-scopes",
81
+ {"default": False, "type": "yn", "metavar": "<y or n>", "help": "Check module and class bodies."},
82
+ ),
83
+ (
84
+ "constricter-nesting",
85
+ {
86
+ "default": NESTING,
87
+ "type": "int",
88
+ "metavar": "<n>",
89
+ "help": "Report an annotation nested this deep.",
90
+ },
91
+ ),
92
+ )
93
+
94
+ def __init__(self, linter: PyLinter) -> None:
95
+ """Register the messages with `linter`."""
96
+ super().__init__(linter)
97
+ self.msgs = {
98
+ message.msg_id: (MESSAGES[code].format(name="%r"), message.symbol, f"See constricter's {code}.")
99
+ for code, message in SYMBOLS.items()
100
+ }
101
+
102
+ @override
103
+ def process_module(self, node: nodes.Module) -> None:
104
+ """Report the module's error-level offences."""
105
+ opened: IO[bytes] | None
106
+ if (opened := node.stream()) is None: # no file behind the module
107
+ return
108
+ stream: IO[bytes]
109
+ with opened as stream:
110
+ source: bytes = stream.read()
111
+ level: Level = LEVELS[cast("str", self.linter.config.constricter_level)]
112
+ checks: Checks = Checks(
113
+ type_comments=cast("bool", self.linter.config.constricter_type_comments),
114
+ all_scopes=cast("bool", self.linter.config.constricter_all_scopes),
115
+ nesting=cast("int", self.linter.config.constricter_nesting),
116
+ )
117
+ text: str = source.decode("utf-8")
118
+ offences: list[Offence] = check_source(text, node.file or "<unknown>", checks)
119
+ o: Offence
120
+ for o in unsuppressed(offences, lines(text)): # suppression comments, as the CLI reads them
121
+ if o.is_error(level):
122
+ self.add_message(SYMBOLS[o.code].symbol, line=o.line, col_offset=o.col, args=(o.name,))
123
+
124
+
125
+ def register(linter: PyLinter) -> None:
126
+ """Register the checker (pylint's plugin hook)."""
127
+ linter.register_checker(ConstricterChecker(linter))
constricter/report.py ADDED
@@ -0,0 +1,252 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The command's output: text, JSON, GitHub workflow commands, SARIF, and statistics."""
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import Counter
7
+ from collections.abc import Callable, Iterator, Sequence
8
+ from enum import StrEnum
9
+ from html import escape
10
+ from pathlib import Path
11
+ from typing import Final, NamedTuple, TypeAlias
12
+
13
+ from constricter import __version__
14
+ from constricter.checker import MESSAGES, Level, Offence
15
+
16
+ _URL: Final = "https://github.com/ivylikethevine/python-constricter"
17
+ _Json: TypeAlias = "str | int | bool | list[_Json] | dict[str, _Json]"
18
+
19
+
20
+ class Format(StrEnum):
21
+ """The `--format` choices."""
22
+
23
+ TEXT = "text"
24
+ JSON = "json"
25
+ GITHUB = "github"
26
+ SARIF = "sarif"
27
+ GITLAB = "gitlab"
28
+ JUNIT = "junit"
29
+ RDJSON = "rdjson"
30
+
31
+
32
+ class Result(NamedTuple):
33
+ """One reported offence, in its file, at the level that applies to that file."""
34
+
35
+ path: Path
36
+ offence: Offence
37
+ level: Level
38
+
39
+ @property
40
+ def severity(self) -> str:
41
+ """`error` or `warning`, at the result's level."""
42
+ return "error" if self.offence.is_error(self.level) else "warning"
43
+
44
+ @property
45
+ def message(self) -> str:
46
+ """The offence's message; in a notebook, with its cell and line first."""
47
+ o: Offence = self.offence
48
+ return o.message if o.cell is None else f"cell {o.cell}, line {o.line}: {o.message}"
49
+
50
+
51
+ def _github_escape(text: str, *, prop: bool = False) -> str:
52
+ text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
53
+ return text.replace(":", "%3A").replace(",", "%2C") if prop else text
54
+
55
+
56
+ def _where(result: Result) -> dict[str, _Json]:
57
+ """Locate `result` for SARIF.
58
+
59
+ Returns:
60
+ Its physical location; a notebook's is the file, as its cells have no file lines.
61
+
62
+ """
63
+ where: dict[str, _Json] = {"artifactLocation": {"uri": result.path.as_posix()}}
64
+ if result.offence.cell is None:
65
+ where["region"] = {"startLine": result.offence.line, "startColumn": result.offence.col + 1}
66
+ return where
67
+
68
+
69
+ def _sarif(results: Sequence[Result]) -> dict[str, _Json]:
70
+ rules: list[_Json] = [
71
+ {"id": code, "shortDescription": {"text": message.format(name="`name`")}}
72
+ for code, message in MESSAGES.items()
73
+ ]
74
+ findings: list[_Json] = [
75
+ {
76
+ "ruleId": r.offence.code,
77
+ "level": r.severity,
78
+ "message": {"text": r.message},
79
+ "locations": [{"physicalLocation": _where(r)}],
80
+ }
81
+ for r in results
82
+ ]
83
+ driver: dict[str, _Json] = {
84
+ "name": "constricter",
85
+ "version": __version__,
86
+ "informationUri": _URL,
87
+ "rules": rules,
88
+ }
89
+ return {
90
+ "version": "2.1.0",
91
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
92
+ "runs": [{"tool": {"driver": driver}, "results": findings}],
93
+ }
94
+
95
+
96
+ def _text(results: Sequence[Result]) -> Iterator[str]:
97
+ r: Result
98
+ for r in results:
99
+ cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
100
+ where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
101
+ yield f"{where}: {r.severity}: {r.offence.code} {r.offence.message}"
102
+
103
+
104
+ def _json(results: Sequence[Result]) -> Iterator[str]:
105
+ yield json.dumps(
106
+ [
107
+ {
108
+ "path": str(r.path),
109
+ "line": r.offence.line,
110
+ "column": r.offence.col + 1,
111
+ "code": r.offence.code,
112
+ "severity": r.severity,
113
+ "message": r.offence.message,
114
+ "cell": r.offence.cell,
115
+ }
116
+ for r in results
117
+ ],
118
+ indent=2,
119
+ )
120
+
121
+
122
+ def _github(results: Sequence[Result]) -> Iterator[str]:
123
+ r: Result
124
+ for r in results:
125
+ location: str = f"file={_github_escape(str(r.path), prop=True)}"
126
+ if r.offence.cell is None: # a notebook's cells have no file lines to point at
127
+ location += f",line={r.offence.line},col={r.offence.col + 1}"
128
+ yield f"::{r.severity} {location},title={r.offence.code}::{_github_escape(r.message)}"
129
+
130
+
131
+ def _gitlab(results: Sequence[Result]) -> Iterator[str]:
132
+ """Render GitLab's Code Quality report (Code Climate JSON).
133
+
134
+ Yields:
135
+ Its lines; a fingerprint survives lines moving.
136
+
137
+ """
138
+ seen: Counter[tuple[Path, str, str]] = Counter()
139
+ issues: list[_Json] = []
140
+ r: Result
141
+ for r in results:
142
+ key: tuple[Path, str, str] = (r.path, r.offence.code, r.offence.name)
143
+ seen[key] += 1
144
+ fingerprint: str = hashlib.sha256(
145
+ f"{r.path.as_posix()}:{key[1]}:{key[2]}:{seen[key]}".encode(),
146
+ ).hexdigest()
147
+ issues.append(
148
+ {
149
+ "description": f"{r.offence.code} {r.message}",
150
+ "check_name": r.offence.code,
151
+ "fingerprint": fingerprint,
152
+ "severity": "major" if r.offence.is_error(r.level) else "minor",
153
+ "location": {"path": r.path.as_posix(), "lines": {"begin": r.offence.line}},
154
+ },
155
+ )
156
+ yield json.dumps(issues, indent=2)
157
+
158
+
159
+ def _junit(results: Sequence[Result]) -> Iterator[str]:
160
+ """Render a JUnit XML report.
161
+
162
+ Yields:
163
+ Its lines: a test suite per file with offences, a failed test case per offence.
164
+
165
+ """
166
+ by_file: dict[Path, list[Result]] = {}
167
+ r: Result
168
+ for r in results:
169
+ by_file.setdefault(r.path, []).append(r)
170
+ yield "<?xml version='1.0' encoding='utf-8'?>"
171
+ yield f'<testsuites name="constricter" tests="{len(results)}" failures="{len(results)}">'
172
+ path: Path
173
+ found: list[Result]
174
+ for path, found in by_file.items():
175
+ yield f' <testsuite name={_quoted(str(path))} tests="{len(found)}" failures="{len(found)}">'
176
+ for r in found:
177
+ name: str = _quoted(f"{r.offence.code} {r.offence.name}")
178
+ where: str = escape(f"{path}:{r.offence.line}:{r.offence.col + 1}")
179
+ yield f" <testcase name={name} classname={_quoted(str(path))}>"
180
+ yield f" <failure message={_quoted(r.message)} type={_quoted(r.severity)}>{where}</failure>"
181
+ yield " </testcase>"
182
+ yield " </testsuite>"
183
+ yield "</testsuites>"
184
+
185
+
186
+ def _quoted(text: str) -> str:
187
+ """Quote `text` for XML.
188
+
189
+ Returns:
190
+ It as a double-quoted attribute value.
191
+
192
+ """
193
+ return f'"{escape(text, quote=True)}"'
194
+
195
+
196
+ def _rdjson(results: Sequence[Result]) -> Iterator[str]:
197
+ """Render reviewdog's Diagnostic JSON; a certain fix is a suggestion.
198
+
199
+ Yields:
200
+ Its lines (columns count UTF-8 bytes).
201
+
202
+ """
203
+ diagnostics: list[_Json] = []
204
+ r: Result
205
+ for r in results:
206
+ o: Offence = r.offence
207
+ start: dict[str, _Json] = {"line": o.line, "column": o.col + 1}
208
+ diagnostic: dict[str, _Json] = {
209
+ "message": r.message,
210
+ "location": {"path": r.path.as_posix(), "range": {"start": start}},
211
+ "severity": r.severity.upper(),
212
+ "code": {"value": o.code},
213
+ }
214
+ if o.fix and not o.unsafe and o.cell is None:
215
+ end: dict[str, _Json] = {"line": o.line, "column": o.col + len(o.name.encode()) + 1}
216
+ diagnostic["suggestions"] = [{"range": {"start": end, "end": end}, "text": f": {o.fix}"}]
217
+ diagnostics.append(diagnostic)
218
+ yield json.dumps({"source": {"name": "constricter", "url": _URL}, "diagnostics": diagnostics}, indent=2)
219
+
220
+
221
+ _RENDERERS: dict[Format, Callable[[Sequence[Result]], Iterator[str]]] = {
222
+ Format.TEXT: _text,
223
+ Format.JSON: _json,
224
+ Format.GITHUB: _github,
225
+ Format.SARIF: lambda results: iter([json.dumps(_sarif(results), indent=2)]),
226
+ Format.GITLAB: _gitlab,
227
+ Format.JUNIT: _junit,
228
+ Format.RDJSON: _rdjson,
229
+ }
230
+
231
+
232
+ def render(fmt: Format, results: Sequence[Result]) -> Iterator[str]:
233
+ """Render `results` in format `fmt`.
234
+
235
+ Returns:
236
+ The output lines.
237
+
238
+ """
239
+ return _RENDERERS[fmt](results)
240
+
241
+
242
+ def statistics(results: Sequence[Result]) -> Iterator[str]:
243
+ """Count the results per code and severity.
244
+
245
+ Yields:
246
+ A line each: how many, the code, and the severity.
247
+
248
+ """
249
+ key: tuple[str, str]
250
+ count: int
251
+ for key, count in Counter((r.offence.code, r.severity) for r in results).most_common():
252
+ yield f"{count:>5} {key[0]} {key[1]}"