ruff-legibility 0.1.0__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.
- ruff_legibility/__init__.py +13 -0
- ruff_legibility/__main__.py +3 -0
- ruff_legibility/cli.py +116 -0
- ruff_legibility/config.py +222 -0
- ruff_legibility/core.py +89 -0
- ruff_legibility/noqa.py +22 -0
- ruff_legibility/py.typed +1 -0
- ruff_legibility/rules.py +354 -0
- ruff_legibility-0.1.0.dist-info/METADATA +151 -0
- ruff_legibility-0.1.0.dist-info/RECORD +13 -0
- ruff_legibility-0.1.0.dist-info/WHEEL +4 -0
- ruff_legibility-0.1.0.dist-info/entry_points.txt +2 -0
- ruff_legibility-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Ruff-adjacent legibility checks for Python."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .core import Diagnostic, check_path, check_source
|
|
6
|
+
from .rules import RULES
|
|
7
|
+
|
|
8
|
+
__all__ = ["Diagnostic", "RULES", "check_path", "check_source"]
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("ruff-legibility")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0.1.0"
|
ruff_legibility/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import apply_overrides, load_settings, parse_selectors
|
|
11
|
+
from .core import Diagnostic, check_path, discover_python_files
|
|
12
|
+
from .rules import RULES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
16
|
+
args = _parse_args(list(argv) if argv is not None else sys.argv[1:])
|
|
17
|
+
|
|
18
|
+
if args.command == "rules":
|
|
19
|
+
_print_rules()
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
settings = load_settings(Path(args.config) if args.config else None)
|
|
24
|
+
settings = apply_overrides(
|
|
25
|
+
settings,
|
|
26
|
+
select=parse_selectors(args.select),
|
|
27
|
+
ignore=parse_selectors(args.ignore),
|
|
28
|
+
max_expression_operators=args.max_expression_operators,
|
|
29
|
+
max_if_operators=args.max_if_operators,
|
|
30
|
+
max_ternary_operators=args.max_ternary_operators,
|
|
31
|
+
max_control_flow_depth=args.max_control_flow_depth,
|
|
32
|
+
)
|
|
33
|
+
except ValueError as error:
|
|
34
|
+
print(f"ruff-legibility: {error}", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
paths = [Path(path) for path in args.paths]
|
|
38
|
+
files = discover_python_files(paths, settings)
|
|
39
|
+
diagnostics = _check_files(files, settings)
|
|
40
|
+
_print_diagnostics(diagnostics, output_format=args.output_format)
|
|
41
|
+
|
|
42
|
+
if args.exit_zero:
|
|
43
|
+
return 0
|
|
44
|
+
return 1 if diagnostics else 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
48
|
+
top_level_flags = {"-h", "--help", "--version"}
|
|
49
|
+
if not argv or (argv[0] not in {"check", "rules"} and argv[0] not in top_level_flags):
|
|
50
|
+
argv = ["check", *argv]
|
|
51
|
+
|
|
52
|
+
parser = argparse.ArgumentParser(prog="ruff-legibility")
|
|
53
|
+
parser.add_argument("--version", action="version", version=f"ruff-legibility {__version__}")
|
|
54
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
check = subparsers.add_parser("check", help="check Python files")
|
|
57
|
+
check.add_argument("paths", nargs="*", default=["."], help="files or directories to check")
|
|
58
|
+
check.add_argument("--config", help="path to ruff-legibility.toml or pyproject.toml")
|
|
59
|
+
check.add_argument("--select", help="comma-separated rule selectors to enable")
|
|
60
|
+
check.add_argument("--ignore", help="comma-separated rule selectors to ignore")
|
|
61
|
+
check.add_argument(
|
|
62
|
+
"--output-format",
|
|
63
|
+
"--format",
|
|
64
|
+
choices=("text", "json", "github"),
|
|
65
|
+
default="text",
|
|
66
|
+
help="diagnostic output format",
|
|
67
|
+
)
|
|
68
|
+
check.add_argument("--exit-zero", action="store_true", help="always exit successfully")
|
|
69
|
+
check.add_argument("--max-expression-operators", type=int)
|
|
70
|
+
check.add_argument("--max-if-operators", type=int)
|
|
71
|
+
check.add_argument("--max-ternary-operators", type=int)
|
|
72
|
+
check.add_argument("--max-control-flow-depth", type=int)
|
|
73
|
+
|
|
74
|
+
subparsers.add_parser("rules", help="list available rules")
|
|
75
|
+
return parser.parse_args(argv)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_files(files: list[Path], settings) -> list[Diagnostic]:
|
|
79
|
+
diagnostics: list[Diagnostic] = []
|
|
80
|
+
for file in files:
|
|
81
|
+
diagnostics.extend(check_path(file, settings))
|
|
82
|
+
return sorted(diagnostics)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_diagnostics(diagnostics: list[Diagnostic], *, output_format: str) -> None:
|
|
86
|
+
if output_format == "json":
|
|
87
|
+
print(json.dumps([diagnostic.to_json() for diagnostic in diagnostics], indent=2))
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
for diagnostic in diagnostics:
|
|
91
|
+
if output_format == "github":
|
|
92
|
+
file_name = _escape_github_command_property(diagnostic.path.as_posix())
|
|
93
|
+
message = _escape_github_command_data(f"{diagnostic.code} {diagnostic.message}")
|
|
94
|
+
print(
|
|
95
|
+
f"::warning file={file_name},"
|
|
96
|
+
f"line={diagnostic.line},col={diagnostic.column}::"
|
|
97
|
+
f"{message}"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
print(
|
|
101
|
+
f"{diagnostic.path.as_posix()}:{diagnostic.line}:{diagnostic.column}: "
|
|
102
|
+
f"{diagnostic.code} {diagnostic.message}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _escape_github_command_data(value: str) -> str:
|
|
107
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _escape_github_command_property(value: str) -> str:
|
|
111
|
+
return _escape_github_command_data(value).replace(":", "%3A").replace(",", "%2C")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _print_rules() -> None:
|
|
115
|
+
for code, rule in RULES.items():
|
|
116
|
+
print(f"{code} {rule.name}: {rule.summary}")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .rules import DEFAULT_SELECT, RULES
|
|
10
|
+
|
|
11
|
+
DEFAULT_EXCLUDE = (
|
|
12
|
+
".bzr",
|
|
13
|
+
".direnv",
|
|
14
|
+
".eggs",
|
|
15
|
+
".git",
|
|
16
|
+
".hg",
|
|
17
|
+
".mypy_cache",
|
|
18
|
+
".nox",
|
|
19
|
+
".pants.d",
|
|
20
|
+
".pytype",
|
|
21
|
+
".ruff_cache",
|
|
22
|
+
".svn",
|
|
23
|
+
".tox",
|
|
24
|
+
".venv",
|
|
25
|
+
"__pypackages__",
|
|
26
|
+
"_build",
|
|
27
|
+
"build",
|
|
28
|
+
"dist",
|
|
29
|
+
"node_modules",
|
|
30
|
+
"site-packages",
|
|
31
|
+
"venv",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Settings:
|
|
37
|
+
select: tuple[str, ...] = DEFAULT_SELECT
|
|
38
|
+
ignore: tuple[str, ...] = ()
|
|
39
|
+
exclude: tuple[str, ...] = DEFAULT_EXCLUDE
|
|
40
|
+
per_file_ignores: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
41
|
+
max_expression_operators: int = 4
|
|
42
|
+
max_if_operators: int = 0
|
|
43
|
+
max_ternary_operators: int = 2
|
|
44
|
+
max_control_flow_depth: int = 3
|
|
45
|
+
|
|
46
|
+
def enabled(self, code: str) -> bool:
|
|
47
|
+
return selector_matches(code, self.select) and not selector_matches(code, self.ignore)
|
|
48
|
+
|
|
49
|
+
def ignored_for_path(self, code: str, path: Path) -> bool:
|
|
50
|
+
path_text = path.as_posix()
|
|
51
|
+
for pattern, selectors in self.per_file_ignores.items():
|
|
52
|
+
if fnmatch.fnmatch(path_text, pattern) and selector_matches(code, selectors):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def selector_matches(code: str, selectors: tuple[str, ...] | list[str] | set[str]) -> bool:
|
|
58
|
+
return any(code.startswith(selector) for selector in selectors)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_selectors(value: str | None) -> tuple[str, ...] | None:
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
selectors = tuple(part.strip().upper() for part in value.split(",") if part.strip())
|
|
66
|
+
return selectors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_selectors(selectors: tuple[str, ...]) -> None:
|
|
70
|
+
unknown_rules = [selector for selector in selectors if selector != "LEG" and selector not in RULES]
|
|
71
|
+
if unknown_rules:
|
|
72
|
+
joined = ", ".join(sorted(set(unknown_rules)))
|
|
73
|
+
raise ValueError(f"Unknown rule selector(s): {joined}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
77
|
+
current = (start or Path.cwd()).resolve()
|
|
78
|
+
if current.is_file():
|
|
79
|
+
current = current.parent
|
|
80
|
+
|
|
81
|
+
for candidate in _candidate_config_paths(current):
|
|
82
|
+
if not candidate.is_file():
|
|
83
|
+
continue
|
|
84
|
+
if candidate.name == "pyproject.toml" and not _pyproject_has_config(candidate):
|
|
85
|
+
continue
|
|
86
|
+
return candidate
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_settings(config_path: Path | None = None, cwd: Path | None = None) -> Settings:
|
|
92
|
+
settings = Settings()
|
|
93
|
+
path = config_path or find_config(cwd)
|
|
94
|
+
if path is None:
|
|
95
|
+
return settings
|
|
96
|
+
|
|
97
|
+
data = tomllib.loads(path.read_text())
|
|
98
|
+
table = _config_table(data, path)
|
|
99
|
+
return apply_config(settings, table)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def apply_overrides(
|
|
103
|
+
settings: Settings,
|
|
104
|
+
*,
|
|
105
|
+
select: tuple[str, ...] | None = None,
|
|
106
|
+
ignore: tuple[str, ...] | None = None,
|
|
107
|
+
max_expression_operators: int | None = None,
|
|
108
|
+
max_if_operators: int | None = None,
|
|
109
|
+
max_ternary_operators: int | None = None,
|
|
110
|
+
max_control_flow_depth: int | None = None,
|
|
111
|
+
) -> Settings:
|
|
112
|
+
updates: dict[str, Any] = {}
|
|
113
|
+
if select is not None:
|
|
114
|
+
validate_selectors(select)
|
|
115
|
+
updates["select"] = select
|
|
116
|
+
if ignore is not None:
|
|
117
|
+
validate_selectors(ignore)
|
|
118
|
+
updates["ignore"] = ignore
|
|
119
|
+
if max_expression_operators is not None:
|
|
120
|
+
updates["max_expression_operators"] = max_expression_operators
|
|
121
|
+
if max_if_operators is not None:
|
|
122
|
+
updates["max_if_operators"] = max_if_operators
|
|
123
|
+
if max_ternary_operators is not None:
|
|
124
|
+
updates["max_ternary_operators"] = max_ternary_operators
|
|
125
|
+
if max_control_flow_depth is not None:
|
|
126
|
+
updates["max_control_flow_depth"] = max_control_flow_depth
|
|
127
|
+
return replace(settings, **updates)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_config(settings: Settings, table: dict[str, Any]) -> Settings:
|
|
131
|
+
updates: dict[str, Any] = {}
|
|
132
|
+
|
|
133
|
+
selectors = _string_list(table.get("select"), "select")
|
|
134
|
+
if selectors is not None:
|
|
135
|
+
updates["select"] = tuple(selector.upper() for selector in selectors)
|
|
136
|
+
|
|
137
|
+
extend_selectors = _string_list(table.get("extend-select", table.get("extend_select")), "extend-select")
|
|
138
|
+
if extend_selectors is not None:
|
|
139
|
+
existing_selectors = updates.get("select", settings.select)
|
|
140
|
+
updates["select"] = (*existing_selectors, *(selector.upper() for selector in extend_selectors))
|
|
141
|
+
|
|
142
|
+
ignored = _string_list(table.get("ignore"), "ignore")
|
|
143
|
+
if ignored is not None:
|
|
144
|
+
updates["ignore"] = tuple(selector.upper() for selector in ignored)
|
|
145
|
+
|
|
146
|
+
extend_ignored = _string_list(table.get("extend-ignore", table.get("extend_ignore")), "extend-ignore")
|
|
147
|
+
if extend_ignored is not None:
|
|
148
|
+
existing_ignored = updates.get("ignore", settings.ignore)
|
|
149
|
+
updates["ignore"] = (*existing_ignored, *(selector.upper() for selector in extend_ignored))
|
|
150
|
+
|
|
151
|
+
excluded = _string_list(table.get("exclude"), "exclude")
|
|
152
|
+
if excluded is not None:
|
|
153
|
+
updates["exclude"] = tuple(excluded)
|
|
154
|
+
|
|
155
|
+
per_file_ignores = table.get("per-file-ignores", table.get("per_file_ignores"))
|
|
156
|
+
if per_file_ignores is not None:
|
|
157
|
+
if not isinstance(per_file_ignores, dict):
|
|
158
|
+
raise ValueError("per-file-ignores must be a table")
|
|
159
|
+
updates["per_file_ignores"] = {
|
|
160
|
+
str(pattern): tuple(code.upper() for code in _required_string_list(codes, str(pattern)))
|
|
161
|
+
for pattern, codes in per_file_ignores.items()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
int_options = {
|
|
165
|
+
"max-expression-operators": "max_expression_operators",
|
|
166
|
+
"max-if-operators": "max_if_operators",
|
|
167
|
+
"max-ternary-operators": "max_ternary_operators",
|
|
168
|
+
"max-control-flow-depth": "max_control_flow_depth",
|
|
169
|
+
}
|
|
170
|
+
for option_name, field_name in int_options.items():
|
|
171
|
+
if option_name not in table:
|
|
172
|
+
continue
|
|
173
|
+
value = table[option_name]
|
|
174
|
+
if not isinstance(value, int):
|
|
175
|
+
raise ValueError(f"{option_name} must be an integer")
|
|
176
|
+
updates[field_name] = value
|
|
177
|
+
|
|
178
|
+
validate_selectors(updates.get("select", settings.select))
|
|
179
|
+
validate_selectors(updates.get("ignore", settings.ignore))
|
|
180
|
+
|
|
181
|
+
return replace(settings, **updates)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _pyproject_has_config(path: Path) -> bool:
|
|
185
|
+
try:
|
|
186
|
+
data = tomllib.loads(path.read_text())
|
|
187
|
+
except tomllib.TOMLDecodeError:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
tool = data.get("tool")
|
|
191
|
+
return isinstance(tool, dict) and isinstance(tool.get("ruff-legibility"), dict)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _candidate_config_paths(current: Path) -> tuple[Path, ...]:
|
|
195
|
+
directories = (current, *current.parents)
|
|
196
|
+
names = ("ruff-legibility.toml", ".ruff-legibility.toml", "pyproject.toml")
|
|
197
|
+
return tuple(directory / name for directory in directories for name in names)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _config_table(data: dict[str, Any], path: Path) -> dict[str, Any]:
|
|
201
|
+
if path.name == "pyproject.toml":
|
|
202
|
+
tool = data.get("tool", {})
|
|
203
|
+
if not isinstance(tool, dict):
|
|
204
|
+
return {}
|
|
205
|
+
table = tool.get("ruff-legibility", {})
|
|
206
|
+
if not isinstance(table, dict):
|
|
207
|
+
raise ValueError("[tool.ruff-legibility] must be a table")
|
|
208
|
+
return table
|
|
209
|
+
|
|
210
|
+
return data
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _string_list(value: Any, name: str) -> list[str] | None:
|
|
214
|
+
if value is None:
|
|
215
|
+
return None
|
|
216
|
+
return _required_string_list(value, name)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _required_string_list(value: Any, name: str) -> list[str]:
|
|
220
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
221
|
+
raise ValueError(f"{name} must be a list of strings")
|
|
222
|
+
return value
|
ruff_legibility/core.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import Settings
|
|
9
|
+
from .noqa import is_noqa_suppressed
|
|
10
|
+
from .rules import LegibilityVisitor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, order=True)
|
|
14
|
+
class Diagnostic:
|
|
15
|
+
path: Path
|
|
16
|
+
line: int
|
|
17
|
+
column: int
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|
|
20
|
+
end_line: int | None = None
|
|
21
|
+
end_column: int | None = None
|
|
22
|
+
|
|
23
|
+
def to_json(self) -> dict[str, object]:
|
|
24
|
+
return {
|
|
25
|
+
"filename": self.path.as_posix(),
|
|
26
|
+
"location": {"row": self.line, "column": self.column},
|
|
27
|
+
"end_location": {
|
|
28
|
+
"row": self.end_line or self.line,
|
|
29
|
+
"column": self.end_column or self.column,
|
|
30
|
+
},
|
|
31
|
+
"code": self.code,
|
|
32
|
+
"message": self.message,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_path(path: Path, settings: Settings) -> list[Diagnostic]:
|
|
37
|
+
source = path.read_text(encoding="utf-8")
|
|
38
|
+
return check_source(source, path=path, settings=settings)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_source(source: str, *, path: Path, settings: Settings) -> list[Diagnostic]:
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(source, filename=path.as_posix(), type_comments=True)
|
|
44
|
+
except SyntaxError as error:
|
|
45
|
+
line = error.lineno or 1
|
|
46
|
+
column = (error.offset or 1) - 1
|
|
47
|
+
return [
|
|
48
|
+
Diagnostic(
|
|
49
|
+
path=path,
|
|
50
|
+
line=line,
|
|
51
|
+
column=column + 1,
|
|
52
|
+
code="LEG999",
|
|
53
|
+
message=f"SyntaxError: {error.msg}",
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
visitor = LegibilityVisitor(path=path, settings=settings)
|
|
58
|
+
visitor.visit(tree)
|
|
59
|
+
lines = source.splitlines()
|
|
60
|
+
diagnostics = [
|
|
61
|
+
diagnostic
|
|
62
|
+
for diagnostic in visitor.diagnostics
|
|
63
|
+
if not settings.ignored_for_path(diagnostic.code, path)
|
|
64
|
+
and not is_noqa_suppressed(lines, diagnostic.line, diagnostic.code)
|
|
65
|
+
]
|
|
66
|
+
return sorted(diagnostics)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def discover_python_files(paths: Iterable[Path], settings: Settings) -> list[Path]:
|
|
70
|
+
files: list[Path] = []
|
|
71
|
+
for path in paths:
|
|
72
|
+
if path.is_file():
|
|
73
|
+
if path.suffix == ".py" and not _is_excluded(path, settings):
|
|
74
|
+
files.append(path)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if path.is_dir():
|
|
78
|
+
files.extend(_discover_directory_python_files(path, settings))
|
|
79
|
+
|
|
80
|
+
return sorted(set(files))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _discover_directory_python_files(path: Path, settings: Settings) -> list[Path]:
|
|
84
|
+
return [candidate for candidate in path.rglob("*.py") if not _is_excluded(candidate, settings)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_excluded(path: Path, settings: Settings) -> bool:
|
|
88
|
+
parts = set(path.parts)
|
|
89
|
+
return any(excluded in parts or path.match(excluded) for excluded in settings.exclude)
|
ruff_legibility/noqa.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
NOQA_PATTERN = re.compile(r"#\s*noqa(?::\s*(?P<codes>[A-Z0-9,\s]+))?", re.IGNORECASE)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_noqa_suppressed(lines: list[str], line_number: int, code: str) -> bool:
|
|
9
|
+
if line_number < 1 or line_number > len(lines):
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
line = lines[line_number - 1]
|
|
13
|
+
match = NOQA_PATTERN.search(line)
|
|
14
|
+
if match is None:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
codes = match.group("codes")
|
|
18
|
+
if codes is None:
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
selectors = [part.strip().upper() for part in codes.split(",") if part.strip()]
|
|
22
|
+
return any(code.startswith(selector) for selector in selectors)
|
ruff_legibility/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
ruff_legibility/rules.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .config import Settings
|
|
12
|
+
from .core import Diagnostic
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Rule:
|
|
17
|
+
code: str
|
|
18
|
+
name: str
|
|
19
|
+
summary: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
RULES = {
|
|
23
|
+
"LEG001": Rule(
|
|
24
|
+
"LEG001",
|
|
25
|
+
"max-expression-operators",
|
|
26
|
+
"Limit readability operators inside a single expression.",
|
|
27
|
+
),
|
|
28
|
+
"LEG002": Rule(
|
|
29
|
+
"LEG002",
|
|
30
|
+
"hoist-if-operators",
|
|
31
|
+
"Prefer named booleans before operator-heavy conditions.",
|
|
32
|
+
),
|
|
33
|
+
"LEG003": Rule(
|
|
34
|
+
"LEG003",
|
|
35
|
+
"max-control-flow-depth",
|
|
36
|
+
"Limit nested control-flow depth.",
|
|
37
|
+
),
|
|
38
|
+
"LEG004": Rule(
|
|
39
|
+
"LEG004",
|
|
40
|
+
"no-complex-ternary",
|
|
41
|
+
"Avoid complex ternary expressions.",
|
|
42
|
+
),
|
|
43
|
+
"LEG005": Rule(
|
|
44
|
+
"LEG005",
|
|
45
|
+
"no-quadratic-patterns",
|
|
46
|
+
"Flag likely quadratic loops and repeated searches.",
|
|
47
|
+
),
|
|
48
|
+
"LEG006": Rule(
|
|
49
|
+
"LEG006",
|
|
50
|
+
"no-redundant-boolean-logic",
|
|
51
|
+
"Avoid redundant boolean comparisons.",
|
|
52
|
+
),
|
|
53
|
+
"LEG007": Rule(
|
|
54
|
+
"LEG007",
|
|
55
|
+
"prefer-positive-condition-names",
|
|
56
|
+
"Prefer positive condition names.",
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
DEFAULT_SELECT = ("LEG",)
|
|
61
|
+
|
|
62
|
+
CONTROL_FLOW_NODES = (
|
|
63
|
+
ast.AsyncFor,
|
|
64
|
+
ast.AsyncWith,
|
|
65
|
+
ast.For,
|
|
66
|
+
ast.If,
|
|
67
|
+
ast.Match,
|
|
68
|
+
ast.Try,
|
|
69
|
+
ast.While,
|
|
70
|
+
ast.With,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
BOOLEAN_NAME_PATTERN = re.compile(r"^(?:is|are|was|were|has|have|had|can|could|should|will|would|did|does)_")
|
|
74
|
+
NEGATIVE_CONDITION_PATTERN = re.compile(
|
|
75
|
+
r"^(?:is|are|was|were|has|have|had|can|could|should|will|would|did|does)_"
|
|
76
|
+
r"(?:not|no|without)_"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LegibilityVisitor(ast.NodeVisitor):
|
|
81
|
+
def __init__(self, *, path: Path, settings: Settings) -> None:
|
|
82
|
+
self.path = path
|
|
83
|
+
self.settings = settings
|
|
84
|
+
self.diagnostics: list[Diagnostic] = []
|
|
85
|
+
self.control_depth = 0
|
|
86
|
+
self.loop_depth = 0
|
|
87
|
+
|
|
88
|
+
def visit_If(self, node: ast.If) -> None:
|
|
89
|
+
self._check_condition(node.test)
|
|
90
|
+
self._visit_control_body(node, body=node.body, orelse=node.orelse)
|
|
91
|
+
|
|
92
|
+
def visit_While(self, node: ast.While) -> None:
|
|
93
|
+
self._check_condition(node.test)
|
|
94
|
+
self._visit_loop(node)
|
|
95
|
+
|
|
96
|
+
def visit_For(self, node: ast.For) -> None:
|
|
97
|
+
self._visit_loop(node)
|
|
98
|
+
|
|
99
|
+
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
|
100
|
+
self._visit_loop(node)
|
|
101
|
+
|
|
102
|
+
def visit_With(self, node: ast.With) -> None:
|
|
103
|
+
self._visit_control_body(node, body=node.body)
|
|
104
|
+
|
|
105
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
|
106
|
+
self._visit_control_body(node, body=node.body)
|
|
107
|
+
|
|
108
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
109
|
+
self._enter_control(node)
|
|
110
|
+
self._visit_many(node.body)
|
|
111
|
+
for handler in node.handlers:
|
|
112
|
+
self.visit(handler)
|
|
113
|
+
self._visit_many(node.orelse)
|
|
114
|
+
self._visit_many(node.finalbody)
|
|
115
|
+
self._leave_control()
|
|
116
|
+
|
|
117
|
+
def visit_Match(self, node: ast.Match) -> None:
|
|
118
|
+
self._enter_control(node)
|
|
119
|
+
for case in node.cases:
|
|
120
|
+
self._visit_many(case.body)
|
|
121
|
+
self._leave_control()
|
|
122
|
+
|
|
123
|
+
def visit_IfExp(self, node: ast.IfExp) -> None:
|
|
124
|
+
if self.settings.enabled("LEG004"):
|
|
125
|
+
count = count_readability_operators(node)
|
|
126
|
+
if count > self.settings.max_ternary_operators:
|
|
127
|
+
self._add(
|
|
128
|
+
node,
|
|
129
|
+
"LEG004",
|
|
130
|
+
f"Ternary expression has {count} readability operators "
|
|
131
|
+
f"(max {self.settings.max_ternary_operators}). Extract it into named branches.",
|
|
132
|
+
)
|
|
133
|
+
self.generic_visit(node)
|
|
134
|
+
|
|
135
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
136
|
+
self._check_expression(node.value)
|
|
137
|
+
for target in node.targets:
|
|
138
|
+
self._check_condition_name(target)
|
|
139
|
+
self.generic_visit(node)
|
|
140
|
+
|
|
141
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
142
|
+
if node.value is not None:
|
|
143
|
+
self._check_expression(node.value)
|
|
144
|
+
self._check_condition_name(node.target)
|
|
145
|
+
self.generic_visit(node)
|
|
146
|
+
|
|
147
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
148
|
+
self._check_condition_name(node.target)
|
|
149
|
+
self._check_expression(node.value)
|
|
150
|
+
self.generic_visit(node)
|
|
151
|
+
|
|
152
|
+
def visit_Return(self, node: ast.Return) -> None:
|
|
153
|
+
if node.value is not None:
|
|
154
|
+
self._check_expression(node.value)
|
|
155
|
+
self.generic_visit(node)
|
|
156
|
+
|
|
157
|
+
def visit_Expr(self, node: ast.Expr) -> None:
|
|
158
|
+
self._check_expression(node.value)
|
|
159
|
+
self.generic_visit(node)
|
|
160
|
+
|
|
161
|
+
def visit_arg(self, node: ast.arg) -> None:
|
|
162
|
+
self._check_name(node.arg, node)
|
|
163
|
+
|
|
164
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
165
|
+
self._check_name(node.name, node)
|
|
166
|
+
self.generic_visit(node)
|
|
167
|
+
|
|
168
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
169
|
+
self._check_name(node.name, node)
|
|
170
|
+
self.generic_visit(node)
|
|
171
|
+
|
|
172
|
+
def visit_Compare(self, node: ast.Compare) -> None:
|
|
173
|
+
if self.settings.enabled("LEG006") and _has_redundant_boolean_compare(node):
|
|
174
|
+
self._add(
|
|
175
|
+
node,
|
|
176
|
+
"LEG006",
|
|
177
|
+
"Avoid redundant boolean comparisons. Use the boolean value directly.",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if self.loop_depth > 0 and self.settings.enabled("LEG005") and _has_membership_search(node):
|
|
181
|
+
self._add(
|
|
182
|
+
node,
|
|
183
|
+
"LEG005",
|
|
184
|
+
"Membership test inside a loop can become O(n^2). Use a set or dict for repeated lookups.",
|
|
185
|
+
)
|
|
186
|
+
self.generic_visit(node)
|
|
187
|
+
|
|
188
|
+
def visit_BoolOp(self, node: ast.BoolOp) -> None:
|
|
189
|
+
if self.settings.enabled("LEG006") and _has_redundant_boolean_operand(node):
|
|
190
|
+
self._add(
|
|
191
|
+
node,
|
|
192
|
+
"LEG006",
|
|
193
|
+
"Avoid redundant boolean operands like `and True` or `or False`.",
|
|
194
|
+
)
|
|
195
|
+
self.generic_visit(node)
|
|
196
|
+
|
|
197
|
+
def _visit_loop(self, node: ast.For | ast.AsyncFor | ast.While) -> None:
|
|
198
|
+
if self.loop_depth > 0 and self.settings.enabled("LEG005"):
|
|
199
|
+
self._add(
|
|
200
|
+
node,
|
|
201
|
+
"LEG005",
|
|
202
|
+
"Nested loop detected. Consider restructuring around a set, dict, or precomputed lookup.",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
self.loop_depth += 1
|
|
206
|
+
self._visit_control_body(node, body=node.body, orelse=node.orelse)
|
|
207
|
+
self.loop_depth -= 1
|
|
208
|
+
|
|
209
|
+
def _visit_control_body(
|
|
210
|
+
self,
|
|
211
|
+
node: ast.AST,
|
|
212
|
+
*,
|
|
213
|
+
body: Iterable[ast.stmt],
|
|
214
|
+
orelse: Iterable[ast.stmt] = (),
|
|
215
|
+
) -> None:
|
|
216
|
+
self._enter_control(node)
|
|
217
|
+
self._visit_many(body)
|
|
218
|
+
self._visit_many(orelse)
|
|
219
|
+
self._leave_control()
|
|
220
|
+
|
|
221
|
+
def _enter_control(self, node: ast.AST) -> None:
|
|
222
|
+
self.control_depth += 1
|
|
223
|
+
if self.settings.enabled("LEG003") and self.control_depth > self.settings.max_control_flow_depth:
|
|
224
|
+
self._add(
|
|
225
|
+
node,
|
|
226
|
+
"LEG003",
|
|
227
|
+
f"Control-flow depth is {self.control_depth} "
|
|
228
|
+
f"(max {self.settings.max_control_flow_depth}). Prefer guard clauses or extraction.",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _leave_control(self) -> None:
|
|
232
|
+
self.control_depth -= 1
|
|
233
|
+
|
|
234
|
+
def _visit_many(self, nodes: Iterable[ast.AST]) -> None:
|
|
235
|
+
for node in nodes:
|
|
236
|
+
self.visit(node)
|
|
237
|
+
|
|
238
|
+
def _check_condition(self, expression: ast.expr) -> None:
|
|
239
|
+
if not self.settings.enabled("LEG002"):
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
count = count_condition_operators(expression)
|
|
243
|
+
if count > self.settings.max_if_operators:
|
|
244
|
+
self._add(
|
|
245
|
+
expression,
|
|
246
|
+
"LEG002",
|
|
247
|
+
f"If condition has {count} boolean operators "
|
|
248
|
+
f"(max {self.settings.max_if_operators}). Hoist it into a named boolean.",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _check_expression(self, expression: ast.expr) -> None:
|
|
252
|
+
if not self.settings.enabled("LEG001"):
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
if isinstance(expression, (ast.Constant, ast.Name)):
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
count = count_readability_operators(expression)
|
|
259
|
+
if count > self.settings.max_expression_operators:
|
|
260
|
+
self._add(
|
|
261
|
+
expression,
|
|
262
|
+
"LEG001",
|
|
263
|
+
f"Expression has {count} readability operators "
|
|
264
|
+
f"(max {self.settings.max_expression_operators}). Extract named sub-expressions.",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _check_condition_name(self, target: ast.AST) -> None:
|
|
268
|
+
if isinstance(target, ast.Name):
|
|
269
|
+
self._check_name(target.id, target)
|
|
270
|
+
|
|
271
|
+
def _check_name(self, name: str, node: ast.AST) -> None:
|
|
272
|
+
if not self.settings.enabled("LEG007"):
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if BOOLEAN_NAME_PATTERN.match(name) and NEGATIVE_CONDITION_PATTERN.match(name):
|
|
276
|
+
self._add(
|
|
277
|
+
node,
|
|
278
|
+
"LEG007",
|
|
279
|
+
f"Prefer a positive condition name instead of `{name}`.",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _add(self, node: ast.AST, code: str, message: str) -> None:
|
|
283
|
+
if not self.settings.enabled(code):
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
from .core import Diagnostic
|
|
287
|
+
|
|
288
|
+
self.diagnostics.append(
|
|
289
|
+
Diagnostic(
|
|
290
|
+
path=self.path,
|
|
291
|
+
line=getattr(node, "lineno", 1),
|
|
292
|
+
column=getattr(node, "col_offset", 0) + 1,
|
|
293
|
+
end_line=getattr(node, "end_lineno", None),
|
|
294
|
+
end_column=(getattr(node, "end_col_offset", 0) or 0) + 1,
|
|
295
|
+
code=code,
|
|
296
|
+
message=message,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def count_condition_operators(node: ast.AST) -> int:
|
|
302
|
+
count = 0
|
|
303
|
+
for child in _walk_expression(node):
|
|
304
|
+
if isinstance(child, ast.BoolOp):
|
|
305
|
+
count += max(len(child.values) - 1, 0)
|
|
306
|
+
elif isinstance(child, ast.IfExp) or (isinstance(child, ast.UnaryOp) and isinstance(child.op, ast.Not)):
|
|
307
|
+
count += 1
|
|
308
|
+
return count
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def count_readability_operators(node: ast.AST) -> int:
|
|
312
|
+
count = 0
|
|
313
|
+
for child in _walk_expression(node):
|
|
314
|
+
if isinstance(child, ast.BoolOp):
|
|
315
|
+
count += max(len(child.values) - 1, 0)
|
|
316
|
+
elif isinstance(child, ast.BinOp):
|
|
317
|
+
count += 1
|
|
318
|
+
elif isinstance(child, ast.Compare):
|
|
319
|
+
count += len(child.ops)
|
|
320
|
+
elif isinstance(child, ast.IfExp) or (
|
|
321
|
+
isinstance(child, ast.UnaryOp) and isinstance(child.op, (ast.Not, ast.Invert))
|
|
322
|
+
):
|
|
323
|
+
count += 1
|
|
324
|
+
return count
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _walk_expression(node: ast.AST) -> Iterable[ast.AST]:
|
|
328
|
+
for child in ast.walk(node):
|
|
329
|
+
if child is not node and isinstance(child, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
330
|
+
continue
|
|
331
|
+
yield child
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _has_redundant_boolean_compare(node: ast.Compare) -> bool:
|
|
335
|
+
comparisons = zip(node.ops, node.comparators, strict=False)
|
|
336
|
+
return any(
|
|
337
|
+
isinstance(operator, (ast.Eq, ast.NotEq))
|
|
338
|
+
and isinstance(comparator, ast.Constant)
|
|
339
|
+
and isinstance(comparator.value, bool)
|
|
340
|
+
for operator, comparator in comparisons
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _has_redundant_boolean_operand(node: ast.BoolOp) -> bool:
|
|
345
|
+
constants = [value for value in node.values if isinstance(value, ast.Constant)]
|
|
346
|
+
if isinstance(node.op, ast.And):
|
|
347
|
+
return any(value.value is True for value in constants)
|
|
348
|
+
if isinstance(node.op, ast.Or):
|
|
349
|
+
return any(value.value is False for value in constants)
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _has_membership_search(node: ast.Compare) -> bool:
|
|
354
|
+
return any(isinstance(operator, (ast.In, ast.NotIn)) for operator in node.ops)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ruff-legibility
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ruff-adjacent Python legibility rules for readable, reviewable code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yowainwright/ruff-legibility
|
|
6
|
+
Project-URL: Issues, https://github.com/yowainwright/ruff-legibility/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/yowainwright/ruff-legibility
|
|
8
|
+
Author: Jeffry Wainwright
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: legibility,lint,python,readability,ruff
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ruff-legibility
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/ruff-legibility/)
|
|
28
|
+
[](https://github.com/yowainwright/ruff-legibility/actions/workflows/ci.yml)
|
|
29
|
+
[](https://scorecard.dev/viewer/?uri=github.com/yowainwright/ruff-legibility)
|
|
30
|
+
|
|
31
|
+
`ruff-legibility` is a Ruff-adjacent Python linter for readability and reviewability rules inspired by `eslint-plugin-legibility`.
|
|
32
|
+
|
|
33
|
+
Ruff does not currently load third-party rule implementations from Python packages. This package therefore runs beside Ruff and emits Ruff-style diagnostics with `LEG###` codes.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
ruff check .
|
|
37
|
+
ruff-legibility check .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To keep `# noqa: LEG001` comments valid when Ruff checks unused or unknown `noqa` codes, add `LEG` as an external prefix in Ruff:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
external = ["LEG"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pip install ruff-legibility
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For local development:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uv sync --all-groups
|
|
57
|
+
make check
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
ruff-legibility check .
|
|
64
|
+
ruff-legibility check src tests --output-format json
|
|
65
|
+
ruff-legibility check . --select LEG001,LEG002 --ignore LEG007
|
|
66
|
+
ruff-legibility check . --exit-zero
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Default text output is intentionally close to Ruff:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
example.py:4:8: LEG002 If condition has 2 boolean operators (max 0). Hoist it into a named boolean.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
Configuration can live in `pyproject.toml` under `[tool.ruff-legibility]`, or in `ruff-legibility.toml` / `.ruff-legibility.toml`.
|
|
78
|
+
|
|
79
|
+
```toml
|
|
80
|
+
[tool.ruff-legibility]
|
|
81
|
+
select = ["LEG"]
|
|
82
|
+
extend-select = []
|
|
83
|
+
ignore = ["LEG007"]
|
|
84
|
+
extend-ignore = []
|
|
85
|
+
exclude = [".venv", "build", "dist"]
|
|
86
|
+
max-expression-operators = 4
|
|
87
|
+
max-if-operators = 0
|
|
88
|
+
max-ternary-operators = 2
|
|
89
|
+
max-control-flow-depth = 3
|
|
90
|
+
|
|
91
|
+
[tool.ruff-legibility.per-file-ignores]
|
|
92
|
+
"tests/*" = ["LEG003"]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Standalone config files omit the `tool.ruff-legibility` wrapper:
|
|
96
|
+
|
|
97
|
+
```toml
|
|
98
|
+
select = ["LEG"]
|
|
99
|
+
ignore = ["LEG007"]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This repository includes a `ruff-legibility.toml` for its own source. The default package thresholds stay stricter than the project-local development config.
|
|
103
|
+
|
|
104
|
+
## Rules
|
|
105
|
+
|
|
106
|
+
| Code | Rule | Default |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `LEG001` | Limit readability operators inside a single expression. | on |
|
|
109
|
+
| `LEG002` | Prefer a named boolean before operator-heavy `if` / `while` conditions. | on |
|
|
110
|
+
| `LEG003` | Limit nested control-flow depth. | on |
|
|
111
|
+
| `LEG004` | Avoid complex ternary expressions. | on |
|
|
112
|
+
| `LEG005` | Flag likely quadratic patterns such as nested loops and repeated membership checks in loops. | on |
|
|
113
|
+
| `LEG006` | Avoid redundant boolean comparisons like `flag == True`. | on |
|
|
114
|
+
| `LEG007` | Prefer positive condition names over names like `is_not_ready`. | on |
|
|
115
|
+
|
|
116
|
+
## Pre-commit
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
repos:
|
|
120
|
+
- repo: local
|
|
121
|
+
hooks:
|
|
122
|
+
- id: ruff-legibility
|
|
123
|
+
name: ruff-legibility
|
|
124
|
+
entry: ruff-legibility check
|
|
125
|
+
language: system
|
|
126
|
+
types: [python]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
Common commands:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
uv sync --all-groups
|
|
135
|
+
uv run ruff check .
|
|
136
|
+
uv run ruff-legibility check src tests
|
|
137
|
+
uv run pytest
|
|
138
|
+
uv build
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Release builds should use:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
uv build --no-sources
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Publishing is configured for PyPI Trusted Publishing:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
uv publish
|
|
151
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ruff_legibility/__init__.py,sha256=O0T487GI-lHC7ONZEVvX_nmGeOZLlp6oqEf1LjtKtmU,364
|
|
2
|
+
ruff_legibility/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
ruff_legibility/cli.py,sha256=u8uUh6zZ89PP8-K_PnZ7KZgzUpMkM-zlZsbsNr0bPU8,4387
|
|
4
|
+
ruff_legibility/config.py,sha256=FwlXFs7vHINECyE4pwG_MQI2ndQ9ccPtorO0UBFlL8s,7533
|
|
5
|
+
ruff_legibility/core.py,sha256=fvjeHBKPsYp6w7HzKi636aEN3ZbOnExxO7IVR_2_vYM,2723
|
|
6
|
+
ruff_legibility/noqa.py,sha256=m6PkvGLA4VDv_09Yi7Raj7YxVqBc8Hna1_JWCNxBxhg,632
|
|
7
|
+
ruff_legibility/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
ruff_legibility/rules.py,sha256=zuCVYbbH_wWZT3irUoQc1TOBlVzLVX8CmepgzjTSgK8,11551
|
|
9
|
+
ruff_legibility-0.1.0.dist-info/METADATA,sha256=jGdLGJ4wxhTmAm2sa3c81339C20nL1bezAbtsJJcbfU,4369
|
|
10
|
+
ruff_legibility-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
ruff_legibility-0.1.0.dist-info/entry_points.txt,sha256=x_ubGENVPXMQ1lk294NEFVRLxL8wF8MZ76PUr8sBmW8,61
|
|
12
|
+
ruff_legibility-0.1.0.dist-info/licenses/LICENSE,sha256=YfsKGD4MeecsEoE27qIRRfgbcN_LPOWTPnjU5rkRgSw,1074
|
|
13
|
+
ruff_legibility-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeffry Wainwright
|
|
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.
|