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/config.py ADDED
@@ -0,0 +1,177 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The command's defaults, from the nearest `pyproject.toml`'s `[tool.constricter]` table."""
3
+
4
+ import tomllib
5
+ from collections.abc import Callable, Sequence
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Final, TypeAlias
9
+
10
+ from constricter.checker import LEVELS, MESSAGES
11
+
12
+ if TYPE_CHECKING:
13
+ from datetime import date, datetime, time
14
+ from io import BufferedReader
15
+
16
+ _Toml: TypeAlias = "str | int | float | bool | datetime | date | time | list[_Toml] | dict[str, _Toml]"
17
+ Default: TypeAlias = str | int | bool | list[str] | dict[str, str] | dict[str, list[str]]
18
+
19
+
20
+ def unknown_codes(codes: Sequence[str]) -> list[str]:
21
+ """Check `codes` (or code prefixes) against the known codes.
22
+
23
+ Returns:
24
+ Those that match none.
25
+
26
+ """
27
+ return [code for code in codes if not any(known.startswith(code.upper()) for known in MESSAGES)]
28
+
29
+
30
+ DEFAULT_BASELINE: Final = "constricter-baseline.json"
31
+
32
+
33
+ def project_root(start: Path) -> Path:
34
+ """Find the project's root.
35
+
36
+ Returns:
37
+ The directory of the nearest `pyproject.toml`, or `start` if there's none.
38
+
39
+ """
40
+ path: Path | None = _pyproject(start)
41
+ return start if path is None else path.parent
42
+
43
+
44
+ def _pyproject(start: Path) -> Path | None:
45
+ """Find the nearest `pyproject.toml` in `start` or above it.
46
+
47
+ Returns:
48
+ Its path, or `None`.
49
+
50
+ """
51
+ directory: Path
52
+ path: Path
53
+ for directory in (start, *start.parents):
54
+ if (path := directory / "pyproject.toml").is_file():
55
+ return path
56
+ return None
57
+
58
+
59
+ def _table(path: Path) -> dict[str, _Toml]:
60
+ """Return `path`'s `[tool.constricter]` table, or an empty one.
61
+
62
+ Returns:
63
+ The table's keys and values, as TOML parsed them.
64
+
65
+ Raises:
66
+ ValueError: The file isn't TOML, or `tool.constricter` isn't a table.
67
+
68
+ """
69
+ file: BufferedReader
70
+ document: dict[str, _Toml]
71
+ message: str
72
+ with path.open("rb") as file:
73
+ try:
74
+ document = tomllib.load(file)
75
+ except tomllib.TOMLDecodeError as error:
76
+ message = f"{path}: {error}"
77
+ raise ValueError(message) from error
78
+ table: dict[str, _Toml]
79
+ match document.get("tool"):
80
+ case {"constricter": dict() as table}:
81
+ return table
82
+ case {"constricter": _}:
83
+ message = f"{path}: [tool.constricter] isn't a table"
84
+ raise ValueError(message)
85
+ case _:
86
+ return {}
87
+
88
+
89
+ def _level(value: _Toml) -> str | None:
90
+ """Read a level's name or number as the options take it.
91
+
92
+ Returns:
93
+ The level's name, or `None` if it isn't one.
94
+
95
+ """
96
+ text: str = str(value).lower()
97
+ return text if isinstance(value, str | int) and not isinstance(value, bool) and text in LEVELS else None
98
+
99
+
100
+ def _whole(value: _Toml, minimum: int) -> int | None:
101
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= minimum else None
102
+
103
+
104
+ def _strings(value: _Toml) -> list[str] | None:
105
+ return (
106
+ [str(item) for item in value]
107
+ if isinstance(value, list) and all(isinstance(item, str) for item in value)
108
+ else None
109
+ )
110
+
111
+
112
+ def _codes(value: _Toml) -> list[str] | None:
113
+ codes: list[str] | None = _strings(value)
114
+ return None if codes is None or unknown_codes(codes) else [code.upper() for code in codes]
115
+
116
+
117
+ def _flag(value: _Toml) -> bool | None:
118
+ return value if isinstance(value, bool) else None
119
+
120
+
121
+ def _levels(value: _Toml) -> dict[str, str] | None:
122
+ if not isinstance(value, dict):
123
+ return None
124
+ levels: dict[str, str | None] = {glob: _level(level) for glob, level in value.items()}
125
+ return None if None in levels.values() else {glob: str(level) for glob, level in levels.items()}
126
+
127
+
128
+ _BASELINE: Final = "baseline"
129
+
130
+
131
+ def _ignores(value: _Toml) -> dict[str, list[str]] | None:
132
+ if not isinstance(value, dict):
133
+ return None
134
+ ignores: dict[str, list[str] | None] = {glob: _codes(codes) for glob, codes in value.items()}
135
+ return None if None in ignores.values() else {glob: list(codes or []) for glob, codes in ignores.items()}
136
+
137
+
138
+ # Each key's reader: its option default, or `None` for a wrong value.
139
+ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
140
+ "level": _level,
141
+ "nesting": partial(_whole, minimum=1),
142
+ "jobs": partial(_whole, minimum=0),
143
+ "exclude": _strings,
144
+ "select": _codes,
145
+ "ignore": _codes,
146
+ "type-comments": _flag,
147
+ "all-scopes": _flag,
148
+ "per-path-levels": _levels,
149
+ "per-file-ignores": _ignores,
150
+ _BASELINE: lambda value: value if isinstance(value, str) and value else None,
151
+ }
152
+
153
+
154
+ def config_defaults(start: Path) -> dict[str, Default]:
155
+ """Return the option defaults in the nearest `pyproject.toml`'s `[tool.constricter]`.
156
+
157
+ Returns:
158
+ Each option's default, keyed by its `argparse` name (`type_comments`, not `type-comments`).
159
+
160
+ Raises:
161
+ ValueError: The file isn't TOML, or the table has an unknown key or a wrong value.
162
+
163
+ """
164
+ path: Path | None
165
+ if (path := _pyproject(start)) is None:
166
+ return {}
167
+ defaults: dict[str, Default] = {}
168
+ key: str
169
+ value: _Toml
170
+ for key, value in _table(path).items():
171
+ default: Default | None
172
+ if key not in _READERS or (default := _READERS[key](value)) is None:
173
+ message: str = f"{path}: [tool.constricter] has an invalid {key} = {value!r}"
174
+ raise ValueError(message)
175
+ # A baseline path is relative to the pyproject.toml that names it.
176
+ defaults[key.replace("-", "_")] = str(path.parent / str(default)) if key == _BASELINE else default
177
+ return defaults
constricter/explain.py ADDED
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Each code's rationale and fix, for `constricter --explain`."""
3
+
4
+ from typing import Final
5
+
6
+ from constricter.checker import (
7
+ COMMENT_TYPED_TARGET,
8
+ MESSAGES,
9
+ NESTED_TYPE,
10
+ UNANNOTATED,
11
+ UNANNOTATED_MEMBER,
12
+ UNTYPED_TARGET,
13
+ VAGUE_TYPE,
14
+ Level,
15
+ Offence,
16
+ )
17
+
18
+ _WHY: Final = {
19
+ UNANNOTATED: (
20
+ "A local's type should be written where it's first bound, not left to inference.\n"
21
+ "Annotate the binding (`count: int = 0`), or declare it first (`first: int`) when unpacking,\n"
22
+ "`:=` or `with ... as` binds it. `--fix` adds the annotation when the value decides it."
23
+ ),
24
+ UNTYPED_TARGET: (
25
+ "A `for` target or `match` capture binds a local with no annotation. Declare it before the\n"
26
+ "statement (`item: str`, then `for item in items:`)."
27
+ ),
28
+ COMMENT_TYPED_TARGET: (
29
+ "A `# type:` comment is the old, Python 2 form of an annotation, which some tools ignore.\n"
30
+ "Declare the variable before the loop instead."
31
+ ),
32
+ UNANNOTATED_MEMBER: (
33
+ "With `all-scopes`, module and class variables need annotating too (`LIMIT: int = 3`). In a\n"
34
+ "dataclass, use `ClassVar[T]`: a plain annotation there makes a field. Dunder names and enum\n"
35
+ "members are exempt."
36
+ ),
37
+ VAGUE_TYPE: (
38
+ "`Any`, `object` and generics without their parameters (`list`, `dict`) say almost nothing\n"
39
+ "about the value. Name the real type: `list[str]`, a `TypedDict`, a union."
40
+ ),
41
+ NESTED_TYPE: (
42
+ "An annotation nested `nesting` deep (5 by default) is hard to read and to change. Name a part\n"
43
+ "of it with an alias (`Row: TypeAlias = tuple[int, set[str]]`, then `dict[str, list[Row]]`)."
44
+ ),
45
+ }
46
+
47
+
48
+ def explain(code: str) -> str:
49
+ """Explain `code`.
50
+
51
+ Returns:
52
+ Its message, rationale and the levels that report it.
53
+
54
+ """
55
+ offence: Offence = Offence(1, 0, "name", code)
56
+ levels: list[str] = [
57
+ f"{level.name.lower()}: {'error' if offence.is_error(level) else 'warning'}"
58
+ for level in Level
59
+ if offence.is_reported(level)
60
+ ]
61
+ return f"{code}: {MESSAGES[code].format(name='`name`')}\n\n{_WHY[code]}\n\n{', '.join(levels)}\n"
constricter/fixes.py ADDED
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--fix`: add the annotations offences offer, to a file's lines or a notebook cell's."""
3
+
4
+ from collections.abc import Sequence
5
+
6
+ from constricter.checker import Offence
7
+
8
+
9
+ def apply(lines: Sequence[str], offences: Sequence[Offence]) -> list[str]:
10
+ """Return `lines` with each fixable offence's annotation added after its name.
11
+
12
+ Returns:
13
+ New lines; `lines` is left as it was. An offence's `line` indexes `lines` from 1.
14
+
15
+ """
16
+ text: list[str] = list(lines)
17
+ o: Offence
18
+ for o in sorted((o for o in offences if o.fix), key=lambda o: (o.line, o.col), reverse=True):
19
+ raw: bytes = text[o.line - 1].encode()
20
+ end: int = o.col + len(o.name.encode()) # `col` counts bytes, as `ast` does
21
+ text[o.line - 1] = (raw[:end] + f": {o.fix}".encode() + raw[end:]).decode()
22
+ return text
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The rules as a flake8 plugin (`LVA` prefix); reports the codes the level makes errors."""
3
+
4
+ import argparse
5
+ import ast
6
+ from collections.abc import Iterator, Sequence
7
+ from typing import TYPE_CHECKING, ClassVar, Final, cast, final
8
+
9
+ from constricter import __version__
10
+ from constricter.checker import LEVELS, NESTING, Checks, Level, Offence, check_source, check_tree
11
+
12
+ if TYPE_CHECKING:
13
+ from flake8.options.manager import OptionManager
14
+
15
+ _TYPE_COMMENT: Final = "type:"
16
+
17
+
18
+ @final
19
+ class ConstricterChecker:
20
+ """flake8's checker: one instance per file."""
21
+
22
+ name: str = "constricter"
23
+ version: str = __version__
24
+ level: ClassVar[Level] = Level.STRICT
25
+ type_comments: ClassVar[bool] = False
26
+ all_scopes: ClassVar[bool] = False
27
+ nesting: ClassVar[int] = NESTING
28
+
29
+ def __init__(self, tree: ast.Module, lines: Sequence[str]) -> None:
30
+ """Take the file flake8 parsed, and its lines."""
31
+ self.tree: ast.Module = tree
32
+ self.lines: Sequence[str] = lines
33
+
34
+ @classmethod
35
+ def add_options(cls, parser: "OptionManager") -> None:
36
+ """Register the options (flake8's plugin hook)."""
37
+ parser.add_option(
38
+ "--constricter-level",
39
+ choices=list(LEVELS),
40
+ default="strict",
41
+ parse_from_config=True,
42
+ help="which LVA codes are reported (default: strict)",
43
+ )
44
+ parser.add_option(
45
+ "--constricter-type-comments",
46
+ action="store_true",
47
+ parse_from_config=True,
48
+ help="count `x = 1 # type: int` as annotated",
49
+ )
50
+ parser.add_option(
51
+ "--constricter-all-scopes",
52
+ action="store_true",
53
+ parse_from_config=True,
54
+ help="also check module and class bodies (LVA004)",
55
+ )
56
+ parser.add_option(
57
+ "--constricter-nesting",
58
+ type=int,
59
+ default=NESTING,
60
+ parse_from_config=True,
61
+ help=f"report an annotation nested this deep (LVA006; default: {NESTING})",
62
+ )
63
+
64
+ @classmethod
65
+ def parse_options(cls, options: argparse.Namespace) -> None:
66
+ """Read the parsed options (flake8's plugin hook)."""
67
+ cls.level = LEVELS[cast("str", options.constricter_level)]
68
+ cls.type_comments = cast("bool", options.constricter_type_comments)
69
+ cls.all_scopes = cast("bool", options.constricter_all_scopes)
70
+ cls.nesting = cast("int", options.constricter_nesting)
71
+
72
+ def run(self) -> Iterator[tuple[int, int, str, type["ConstricterChecker"]]]:
73
+ """Check the file.
74
+
75
+ Yields:
76
+ flake8's `(line, col, message, type)` per error-level offence.
77
+
78
+ """
79
+ source: str = "".join(self.lines)
80
+ # flake8's tree has no `# type:` comments; reparse only when the file might have one.
81
+ checks: Checks = Checks(self.type_comments, self.all_scopes, self.nesting)
82
+ offences: list[Offence] = (
83
+ check_source(source, checks=checks)
84
+ if _TYPE_COMMENT in source
85
+ else check_tree(self.tree, checks, lines=self.lines)
86
+ )
87
+ o: Offence
88
+ for o in offences:
89
+ if o.is_error(self.level):
90
+ yield o.line, o.col, f"{o.code} {o.message}", type(self)
constricter/jsonc.py ADDED
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """JSON with comments (`//`, `/* */`) and trailing commas, for the files constricter reads.
3
+
4
+ Comments and trailing commas become spaces (newlines kept), so every position the JSON parser reports
5
+ is still the original file's. What constricter writes stays plain JSON.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from typing import cast
11
+
12
+ # A string (left alone), or a comment.
13
+ _COMMENT: re.Pattern[str] = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*.*?\*/', re.DOTALL)
14
+ # A string (left alone), or a comma with only whitespace before the `]` or `}` it trails.
15
+ _TRAILING: re.Pattern[str] = re.compile(r'"(?:\\.|[^"\\])*"|,(?=\s*[\]}])')
16
+
17
+
18
+ def _blank(match: re.Match[str]) -> str:
19
+ text: str = match.group()
20
+ return text if text.startswith('"') else re.sub(r"[^\n]", " ", text)
21
+
22
+
23
+ def loads(text: str | bytes) -> object:
24
+ """Parse JSON that may have comments and trailing commas.
25
+
26
+ Returns:
27
+ The value. Raises `ValueError` as `json.loads` does.
28
+
29
+ """
30
+ source: str = text.decode("utf-8") if isinstance(text, bytes) else text
31
+ return cast("object", json.loads(_TRAILING.sub(_blank, _COMMENT.sub(_blank, source))))
constricter/noqa.py ADDED
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`# noqa` comments, read the same way by the CLI and the pylint plugin (flake8 reads its own)."""
3
+
4
+ import io
5
+ import re
6
+ from collections.abc import Sequence
7
+
8
+ from constricter.checker import Offence
9
+
10
+ _NOQA: re.Pattern[str] = re.compile(
11
+ r"#\s*noqa(?::\s*(?P<codes>[A-Z]+[0-9]+(?:[,\s]+[A-Z]+[0-9]+)*))?",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+
16
+ def lines(text: str) -> list[str]:
17
+ """Split `text` into lines as Python does (LF, CRLF or CR).
18
+
19
+ Returns:
20
+ The lines, each keeping its ending.
21
+
22
+ """
23
+ return io.StringIO(text, newline="").readlines()
24
+
25
+
26
+ def suppressed(line: str, code: str) -> bool:
27
+ """Check `line` for a `# noqa` covering `code`: a bare one, or one naming it.
28
+
29
+ Returns:
30
+ Whether it has one.
31
+
32
+ """
33
+ match_: re.Match[str] | None
34
+ if (match_ := _NOQA.search(line)) is None:
35
+ return False
36
+ codes: str | None = match_.group("codes")
37
+ return codes is None or code in re.split(r"[,\s]+", codes.upper())
38
+
39
+
40
+ def unsuppressed(offences: Sequence[Offence], source: Sequence[str]) -> list[Offence]:
41
+ """Apply the `# noqa` comments in `source`'s lines.
42
+
43
+ Returns:
44
+ The offences none on their line suppresses.
45
+
46
+ """
47
+ return [o for o in offences if not (o.line <= len(source) and suppressed(source[o.line - 1], o.code))]
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Jupyter notebooks: their code cells, joined into one module, and where each line came from."""
3
+
4
+ import json
5
+ import re
6
+ from collections.abc import Sequence
7
+ from typing import Final, NamedTuple, TypeAlias, cast
8
+
9
+ from constricter import fixes, jsonc
10
+ from constricter.checker import Offence
11
+
12
+ SUFFIX: Final = ".ipynb"
13
+ _Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None"
14
+ # IPython syntax that isn't Python: line magics, shell escapes (`!ls`), and help (`obj?`, `?obj`).
15
+ _MAGIC: re.Pattern[str] = re.compile(r"\s*([%!?]|[\w.]+\?{1,2}\s*$)")
16
+
17
+
18
+ class Line(NamedTuple):
19
+ """Where a line of the joined module is in the notebook: its cell (from 1) and line in it."""
20
+
21
+ cell: int
22
+ line: int
23
+
24
+
25
+ class Cell(NamedTuple):
26
+ """A cell `fix` changed: its number (from 1), and its lines before and after."""
27
+
28
+ number: int
29
+ old: list[str]
30
+ new: list[str]
31
+
32
+
33
+ def _text(source: _Json) -> str:
34
+ """Join a cell's source, which a notebook keeps as a string or a list of lines.
35
+
36
+ Returns:
37
+ The source, as one string.
38
+
39
+ """
40
+ return "".join(str(part) for part in source) if isinstance(source, list) else str(source)
41
+
42
+
43
+ def _cell_lines(source: _Json) -> list[str]:
44
+ """Split a cell into lines; IPython-only lines become blank.
45
+
46
+ Returns:
47
+ The lines, each ending in a newline.
48
+
49
+ """
50
+ lines: list[str] = _text(source).splitlines()
51
+ if lines and lines[0].lstrip().startswith("%%"): # a cell magic: the whole cell isn't Python
52
+ return ["\n"] * len(lines)
53
+ return ["\n" if _MAGIC.match(line) else f"{line}\n" for line in lines]
54
+
55
+
56
+ def parse(raw: str, name: str) -> tuple[str, list[Line]]:
57
+ """Return notebook JSON's code cells as one module, and where each of its lines came from.
58
+
59
+ Returns:
60
+ The module's source, and a `Line` for each of its lines.
61
+
62
+ Raises:
63
+ ValueError: It isn't a notebook (JSON with a `cells` list).
64
+
65
+ """
66
+ document: _Json = cast("_Json", jsonc.loads(raw))
67
+ cells: list[_Json]
68
+ match document:
69
+ case {"cells": list() as cells}:
70
+ pass
71
+ case _:
72
+ message: str = f"{name}: not a Jupyter notebook"
73
+ raise ValueError(message)
74
+ joined: list[str] = []
75
+ where: list[Line] = []
76
+ number: int
77
+ cell: _Json
78
+ source: _Json
79
+ for number, cell in enumerate(cells, start=1):
80
+ match cell:
81
+ case {"cell_type": "code", "source": source}:
82
+ lines: list[str] = _cell_lines(source)
83
+ joined += lines
84
+ where += [Line(number, line) for line in range(1, len(lines) + 1)]
85
+ case _:
86
+ pass
87
+ return "".join(joined), where
88
+
89
+
90
+ def fix(raw: str, offences: Sequence[Offence]) -> tuple[str, list[Cell]]:
91
+ """Add each fixable offence's annotation in its cell of the notebook JSON `raw`.
92
+
93
+ Returns:
94
+ The notebook's new JSON (its indent, key order and final newline kept), and each changed
95
+ cell's number and old and new lines.
96
+
97
+ """
98
+ document: dict[str, _Json] = cast("dict[str, _Json]", jsonc.loads(raw))
99
+ cells: list[_Json] = cast("list[_Json]", document["cells"])
100
+ changed: list[Cell] = []
101
+ number: int
102
+ for number in sorted({o.cell for o in offences if o.fix and o.cell is not None}):
103
+ cell: dict[str, _Json] = cast("dict[str, _Json]", cells[number - 1])
104
+ source: _Json = cell["source"]
105
+ old: list[str] = _text(source).splitlines(keepends=True)
106
+ new: list[str] = fixes.apply(old, [o for o in offences if o.cell == number])
107
+ cell["source"] = list[_Json](new) if isinstance(source, list) else "".join(new)
108
+ changed.append(Cell(number, old, new))
109
+ indent: re.Match[str] | None = re.match(r"\{\r?\n( +)", raw)
110
+ text: str = json.dumps(document, indent=len(indent.group(1)) if indent else 1, ensure_ascii=False)
111
+ return text + ("\n" if raw.endswith("\n") else ""), changed