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/__init__.py +42 -0
- constricter/__main__.py +9 -0
- constricter/annotations.py +290 -0
- constricter/baseline.py +111 -0
- constricter/checker.py +651 -0
- constricter/cli.py +791 -0
- constricter/config.py +177 -0
- constricter/explain.py +61 -0
- constricter/fixes.py +22 -0
- constricter/flake8_plugin.py +90 -0
- constricter/jsonc.py +31 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +111 -0
- constricter/project.py +229 -0
- constricter/py.typed +0 -0
- constricter/pylint_plugin.py +127 -0
- constricter/report.py +252 -0
- python_constricter-0.2.2.dist-info/METADATA +456 -0
- python_constricter-0.2.2.dist-info/RECORD +22 -0
- python_constricter-0.2.2.dist-info/WHEEL +4 -0
- python_constricter-0.2.2.dist-info/entry_points.txt +6 -0
- python_constricter-0.2.2.dist-info/licenses/LICENSE.md +21 -0
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))]
|
constricter/notebook.py
ADDED
|
@@ -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
|