refactrail 0.3.1a0__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.
- refactrail/__init__.py +32 -0
- refactrail/__main__.py +6 -0
- refactrail/_version.py +3 -0
- refactrail/analysis_cli.py +82 -0
- refactrail/cli.py +227 -0
- refactrail/config.py +136 -0
- refactrail/correctness.py +94 -0
- refactrail/correctness_batch.py +145 -0
- refactrail/correctness_checks.py +228 -0
- refactrail/data/generic_names.txt +19 -0
- refactrail/data/protocol_hooks.txt +14 -0
- refactrail/data/verbs.txt +484 -0
- refactrail/discovery.py +62 -0
- refactrail/engine.py +389 -0
- refactrail/facts.py +321 -0
- refactrail/fixes.py +196 -0
- refactrail/fixing.py +190 -0
- refactrail/format_operators.py +97 -0
- refactrail/format_statements.py +662 -0
- refactrail/format_tokens.py +144 -0
- refactrail/format_wrapping.py +392 -0
- refactrail/formatting.py +304 -0
- refactrail/general_cli.py +193 -0
- refactrail/json_spans.py +97 -0
- refactrail/lexical.py +346 -0
- refactrail/lexical_scopes.py +263 -0
- refactrail/models.py +145 -0
- refactrail/notebooks.py +188 -0
- refactrail/project_index.py +268 -0
- refactrail/py.typed +0 -0
- refactrail/rename.py +269 -0
- refactrail/report.py +98 -0
- refactrail/rules/__init__.py +51 -0
- refactrail/rules/annotations.py +61 -0
- refactrail/rules/context.py +160 -0
- refactrail/rules/docs.py +287 -0
- refactrail/rules/layout.py +216 -0
- refactrail/rules/naming.py +284 -0
- refactrail/rules/signatures.py +103 -0
- refactrail/rules/size.py +55 -0
- refactrail/sarif.py +61 -0
- refactrail/scopes.py +430 -0
- refactrail/source.py +176 -0
- refactrail/validation.py +26 -0
- refactrail/wordlists.py +20 -0
- refactrail-0.3.1a0.dist-info/METADATA +233 -0
- refactrail-0.3.1a0.dist-info/RECORD +51 -0
- refactrail-0.3.1a0.dist-info/WHEEL +5 -0
- refactrail-0.3.1a0.dist-info/entry_points.txt +2 -0
- refactrail-0.3.1a0.dist-info/licenses/LICENSE +21 -0
- refactrail-0.3.1a0.dist-info/top_level.txt +1 -0
refactrail/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""RefacTrail: check and safely refactor Python against a rule profile."""
|
|
2
|
+
|
|
3
|
+
from refactrail._version import __version__
|
|
4
|
+
from refactrail.config import load_settings
|
|
5
|
+
from refactrail.correctness import check_correctness_list
|
|
6
|
+
from refactrail.engine import check_paths_list, check_text_list
|
|
7
|
+
from refactrail.formatting import (
|
|
8
|
+
FormatPlan, format_source_str, plan_format, write_format_none,
|
|
9
|
+
)
|
|
10
|
+
from refactrail.models import Finding, Settings
|
|
11
|
+
from refactrail.lexical import analyze_lexical_dict
|
|
12
|
+
from refactrail.project_index import analyze_project_dict
|
|
13
|
+
from refactrail.rename import plan_rename_dict
|
|
14
|
+
from refactrail.correctness_batch import check_correctness_paths_list
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"analyze_lexical_dict",
|
|
18
|
+
"analyze_project_dict",
|
|
19
|
+
"plan_rename_dict",
|
|
20
|
+
"check_correctness_paths_list",
|
|
21
|
+
"Finding",
|
|
22
|
+
"FormatPlan",
|
|
23
|
+
"Settings",
|
|
24
|
+
"__version__",
|
|
25
|
+
"check_paths_list",
|
|
26
|
+
"check_text_list",
|
|
27
|
+
"load_settings",
|
|
28
|
+
"check_correctness_list",
|
|
29
|
+
"format_source_str",
|
|
30
|
+
"plan_format",
|
|
31
|
+
"write_format_none",
|
|
32
|
+
]
|
refactrail/__main__.py
ADDED
refactrail/_version.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Thin interfaces for partial scope, project and local rename evidence."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from refactrail.lexical import analyze_lexical_dict
|
|
10
|
+
from refactrail.project_index import analyze_project_dict
|
|
11
|
+
from refactrail.rename import plan_rename_dict
|
|
12
|
+
from refactrail.source import decode_source_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_analysis_commands_none(commands_info: argparse._SubParsersAction
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Register source-linked analysis and read-only rename commands.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
commands_info (argparse._SubParsersAction): Main command registry.
|
|
21
|
+
Returns:
|
|
22
|
+
None: Adds scope, index and rename subcommands.
|
|
23
|
+
Warnings:
|
|
24
|
+
The rename command produces a review proposal without applying it.
|
|
25
|
+
"""
|
|
26
|
+
scope_parser = commands_info.add_parser(
|
|
27
|
+
"scope", help="Inspect lexical evidence")
|
|
28
|
+
scope_parser.add_argument("path")
|
|
29
|
+
index_parser = commands_info.add_parser(
|
|
30
|
+
"index", help="Inspect project import impact")
|
|
31
|
+
index_parser.add_argument(
|
|
32
|
+
"root", help="Directory used as Python import root")
|
|
33
|
+
index_parser.add_argument("--changed", action="append", default=[])
|
|
34
|
+
rename_parser = commands_info.add_parser(
|
|
35
|
+
"rename", help="Preview a local variable rename")
|
|
36
|
+
rename_parser.add_argument("path")
|
|
37
|
+
rename_parser.add_argument("--function", required=True)
|
|
38
|
+
rename_parser.add_argument("--old", required=True)
|
|
39
|
+
rename_parser.add_argument("--new", required=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_scope_report_dict(path_str: str) -> dict:
|
|
43
|
+
"""Read compiler evidence with an original-source fingerprint.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path_str (str): Regular Python source path.
|
|
47
|
+
Returns:
|
|
48
|
+
dict: Versioned partial scope report.
|
|
49
|
+
Warnings:
|
|
50
|
+
Notebook kernel state and unsupported encodings are not inferred.
|
|
51
|
+
"""
|
|
52
|
+
path_info = Path(path_str)
|
|
53
|
+
if path_info.is_symlink() or path_info.is_junction():
|
|
54
|
+
raise ValueError("Linked scope inputs are unsupported")
|
|
55
|
+
raw_bytes = path_info.read_bytes()
|
|
56
|
+
text_str = decode_source_text(raw_bytes)
|
|
57
|
+
if text_str is None:
|
|
58
|
+
raise ValueError("Scope input must be UTF-8 Python source")
|
|
59
|
+
report_dict = analyze_lexical_dict(text_str, path_str)
|
|
60
|
+
return {"schema_version": "refactrail-scope-1", "path": path_str,
|
|
61
|
+
"source_sha256": sha256(raw_bytes).hexdigest(), **report_dict}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_analysis_int(arguments: argparse.Namespace) -> int:
|
|
65
|
+
"""Render exactly one JSON document from the selected analysis API.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
arguments (argparse.Namespace): Parsed source and query options.
|
|
69
|
+
Returns:
|
|
70
|
+
int: Zero for completed partial analysis; errors propagate.
|
|
71
|
+
Warnings:
|
|
72
|
+
Coverage and unresolved semantics are explicit report fields.
|
|
73
|
+
"""
|
|
74
|
+
if arguments.command == "scope":
|
|
75
|
+
report_dict = read_scope_report_dict(arguments.path)
|
|
76
|
+
elif arguments.command == "index":
|
|
77
|
+
report_dict = analyze_project_dict(arguments.root, arguments.changed)
|
|
78
|
+
else:
|
|
79
|
+
report_dict = plan_rename_dict(arguments.path, arguments.function,
|
|
80
|
+
arguments.old, arguments.new)
|
|
81
|
+
sys.stdout.write(json.dumps(report_dict, indent=2) + "\n")
|
|
82
|
+
return 0
|
refactrail/cli.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Command line: refactrail check / rules / version."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from refactrail._version import __version__
|
|
8
|
+
from refactrail.analysis_cli import (
|
|
9
|
+
add_analysis_commands_none, run_analysis_int,
|
|
10
|
+
)
|
|
11
|
+
from refactrail.config import PROFILES_TUPLE, load_settings
|
|
12
|
+
from refactrail.correctness import CORRECTNESS_TITLES_DICT
|
|
13
|
+
from refactrail.discovery import discover_python_files_list
|
|
14
|
+
from refactrail.engine import (
|
|
15
|
+
CACHE_FOLDER_STR, ENGINES_TUPLE, check_paths_list,
|
|
16
|
+
)
|
|
17
|
+
from refactrail.fixing import fix_file, render_diff_str, write_fix_none
|
|
18
|
+
from refactrail.general_cli import (
|
|
19
|
+
add_general_commands_none, run_format_int, run_lint_int,
|
|
20
|
+
)
|
|
21
|
+
from refactrail.report import (
|
|
22
|
+
OUTPUT_FORMATS_TUPLE, render_github_str, render_json_str,
|
|
23
|
+
render_statistics_str, render_text_str,
|
|
24
|
+
)
|
|
25
|
+
from refactrail.rules import RULE_TITLES_DICT
|
|
26
|
+
from refactrail.sarif import render_sarif_str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
QUICK_START_STR = """\
|
|
30
|
+
RefacTrail - the Python refactorizer. Paths default to the current folder.
|
|
31
|
+
|
|
32
|
+
refactrail lint
|
|
33
|
+
find likely bugs (comparisons with None, duplicate keys, ...)
|
|
34
|
+
refactrail format --diff
|
|
35
|
+
show formatting changes; use --write to apply them
|
|
36
|
+
refactrail check
|
|
37
|
+
check structure, naming, type hints and docstrings
|
|
38
|
+
refactrail fix --diff
|
|
39
|
+
preview safe fixes; run without --diff to apply them
|
|
40
|
+
refactrail rules
|
|
41
|
+
list every rule code
|
|
42
|
+
|
|
43
|
+
Run "refactrail COMMAND --help" for options or "refactrail --help" for all
|
|
44
|
+
commands.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_settings_options_none(parser: argparse.ArgumentParser) -> None:
|
|
49
|
+
"""Add the paths and settings options shared by check and fix.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
parser (argparse.ArgumentParser): Sub-command parser.
|
|
53
|
+
Returns:
|
|
54
|
+
None: Adds arguments in place.
|
|
55
|
+
Warnings:
|
|
56
|
+
None.
|
|
57
|
+
"""
|
|
58
|
+
parser.add_argument("paths", nargs="*", default=["."])
|
|
59
|
+
parser.add_argument("--profile", choices=PROFILES_TUPLE)
|
|
60
|
+
parser.add_argument("--line-length", type=int)
|
|
61
|
+
parser.add_argument("--select", help="Comma-separated codes")
|
|
62
|
+
parser.add_argument("--ignore", help="Comma-separated codes")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
66
|
+
"""Define the command-line interface.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
None: The interface is fixed for this version.
|
|
70
|
+
Returns:
|
|
71
|
+
argparse.ArgumentParser: Parser with the check and rules commands.
|
|
72
|
+
Warnings:
|
|
73
|
+
Option names follow Ruff where the meaning is the same.
|
|
74
|
+
"""
|
|
75
|
+
parser = argparse.ArgumentParser(
|
|
76
|
+
prog="refactrail", description="Check Python code against a "
|
|
77
|
+
"refactoring rule profile.")
|
|
78
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
79
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
80
|
+
check_parser = commands.add_parser("check", help="Report findings")
|
|
81
|
+
add_settings_options_none(check_parser)
|
|
82
|
+
check_parser.add_argument("--output-format", default="text",
|
|
83
|
+
choices=OUTPUT_FORMATS_TUPLE)
|
|
84
|
+
check_parser.add_argument("--statistics", action="store_true")
|
|
85
|
+
check_parser.add_argument("--exit-zero", action="store_true")
|
|
86
|
+
check_parser.add_argument("--no-cache", action="store_true")
|
|
87
|
+
check_parser.add_argument("--jobs", type=int, default=0,
|
|
88
|
+
help="Worker processes (0: one per core)")
|
|
89
|
+
check_parser.add_argument("--engine", default="auto",
|
|
90
|
+
choices=ENGINES_TUPLE,
|
|
91
|
+
help="auto uses Rust when installed")
|
|
92
|
+
fix_parser = commands.add_parser(
|
|
93
|
+
"fix", help="Apply bounded refactoring edits")
|
|
94
|
+
add_settings_options_none(fix_parser)
|
|
95
|
+
fix_parser.add_argument("--diff", action="store_true",
|
|
96
|
+
help="Show the fixes; do not write files")
|
|
97
|
+
commands.add_parser("rules", help="List RT style and RC correctness codes")
|
|
98
|
+
add_general_commands_none(commands)
|
|
99
|
+
add_analysis_commands_none(commands)
|
|
100
|
+
return parser
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_fix_int(arguments: argparse.Namespace) -> int:
|
|
104
|
+
"""Run the fix command: write fixes, or print them with --diff.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
arguments (argparse.Namespace): Parsed options.
|
|
108
|
+
Returns:
|
|
109
|
+
int: With --diff, 1 when changes are pending; otherwise 1 when
|
|
110
|
+
findings remain after fixing, else 0.
|
|
111
|
+
Warnings:
|
|
112
|
+
Files are changed in place unless --diff is given.
|
|
113
|
+
"""
|
|
114
|
+
if getattr(arguments, "jobs", 0) < 0:
|
|
115
|
+
raise ValueError("jobs must be nonnegative")
|
|
116
|
+
settings_info = load_settings(Path(arguments.paths[0]), {
|
|
117
|
+
"profile": arguments.profile, "line_length": arguments.line_length,
|
|
118
|
+
"select": split_codes_tuple(arguments.select),
|
|
119
|
+
"ignore": split_codes_tuple(arguments.ignore)})
|
|
120
|
+
files_list = discover_python_files_list(arguments.paths, settings_info)
|
|
121
|
+
fixes_list = [fix_file(path_str, settings_info)
|
|
122
|
+
for path_str in files_list]
|
|
123
|
+
for file_fix in fixes_list:
|
|
124
|
+
if file_fix.skip_reason:
|
|
125
|
+
sys.stderr.write(f"{file_fix.path}: skipped: "
|
|
126
|
+
f"{file_fix.skip_reason}\n")
|
|
127
|
+
elif arguments.diff:
|
|
128
|
+
sys.stdout.write(render_diff_str(file_fix))
|
|
129
|
+
elif file_fix.changed_bool:
|
|
130
|
+
write_fix_none(file_fix)
|
|
131
|
+
sys.stdout.write("".join(f"{file_fix.path}: {line_str}\n" for
|
|
132
|
+
line_str in file_fix.outcome.applied))
|
|
133
|
+
changed_int = sum(file_fix.changed_bool for file_fix in fixes_list)
|
|
134
|
+
skipped_bool = any(fix_info.skip_reason for fix_info in fixes_list)
|
|
135
|
+
if arguments.diff:
|
|
136
|
+
sys.stdout.write(f"{changed_int} file(s) would be changed.\n")
|
|
137
|
+
return int(bool(changed_int) or skipped_bool)
|
|
138
|
+
remaining_list = check_paths_list(files_list, settings_info)
|
|
139
|
+
sys.stdout.write(f"Fixed {changed_int} file(s); {len(remaining_list)} "
|
|
140
|
+
"finding(s) remain for the selected rules (run "
|
|
141
|
+
"'refactrail check').\n")
|
|
142
|
+
return int(bool(remaining_list) or skipped_bool)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def split_codes_tuple(codes_str: str | None) -> tuple[str, ...] | None:
|
|
146
|
+
"""Turn "RT1,RT201" into a tuple of codes.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
codes_str (str | None): Comma-separated codes, or None.
|
|
150
|
+
Returns:
|
|
151
|
+
tuple[str, ...] | None: Codes, or None when not given.
|
|
152
|
+
Warnings:
|
|
153
|
+
Codes are upper-cased.
|
|
154
|
+
"""
|
|
155
|
+
if codes_str is None:
|
|
156
|
+
return None
|
|
157
|
+
return tuple(code_str.strip().upper() for code_str in codes_str.split(",")
|
|
158
|
+
if code_str.strip())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_check_int(arguments: argparse.Namespace) -> int:
|
|
162
|
+
"""Run the check command and print its report.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
arguments (argparse.Namespace): Parsed options.
|
|
166
|
+
Returns:
|
|
167
|
+
int: 0 without findings (or with --exit-zero), 1 with findings.
|
|
168
|
+
Warnings:
|
|
169
|
+
Settings come from the pyproject.toml nearest the first path.
|
|
170
|
+
"""
|
|
171
|
+
if getattr(arguments, "jobs", 0) < 0:
|
|
172
|
+
raise ValueError("jobs must be nonnegative")
|
|
173
|
+
settings_info = load_settings(Path(arguments.paths[0]), {
|
|
174
|
+
"profile": arguments.profile, "line_length": arguments.line_length,
|
|
175
|
+
"select": split_codes_tuple(arguments.select),
|
|
176
|
+
"ignore": split_codes_tuple(arguments.ignore)})
|
|
177
|
+
files_list = discover_python_files_list(arguments.paths, settings_info)
|
|
178
|
+
cache_path = None if arguments.no_cache else (
|
|
179
|
+
Path.cwd() / CACHE_FOLDER_STR / "results-v1.json")
|
|
180
|
+
findings_list = check_paths_list(files_list, settings_info,
|
|
181
|
+
arguments.jobs, cache_path,
|
|
182
|
+
arguments.engine)
|
|
183
|
+
renderers_dict = {
|
|
184
|
+
"text": lambda: render_text_str(findings_list, len(files_list)),
|
|
185
|
+
"json": lambda: render_json_str(findings_list),
|
|
186
|
+
"github": lambda: render_github_str(findings_list),
|
|
187
|
+
"sarif": lambda: render_sarif_str(findings_list),
|
|
188
|
+
}
|
|
189
|
+
sys.stdout.write(renderers_dict[arguments.output_format]())
|
|
190
|
+
if arguments.statistics:
|
|
191
|
+
sys.stderr.write(render_statistics_str(findings_list))
|
|
192
|
+
return 0 if arguments.exit_zero or not findings_list else 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv_list: list[str] | None = None) -> int:
|
|
196
|
+
"""Run the command line and return the exit status.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
argv_list (list[str] | None): Arguments; None reads sys.argv.
|
|
200
|
+
Returns:
|
|
201
|
+
int: 0 success, 1 findings, 2 usage or configuration error.
|
|
202
|
+
Warnings:
|
|
203
|
+
Configuration and path errors are printed, not raised.
|
|
204
|
+
"""
|
|
205
|
+
if not (sys.argv[1:] if argv_list is None else argv_list):
|
|
206
|
+
sys.stdout.write(QUICK_START_STR)
|
|
207
|
+
return 0
|
|
208
|
+
arguments = build_parser().parse_args(argv_list)
|
|
209
|
+
try:
|
|
210
|
+
if arguments.command in ("scope", "index", "rename"):
|
|
211
|
+
return run_analysis_int(arguments)
|
|
212
|
+
if arguments.command == "lint":
|
|
213
|
+
return run_lint_int(arguments)
|
|
214
|
+
if arguments.command == "format":
|
|
215
|
+
return run_format_int(arguments)
|
|
216
|
+
if arguments.command == "fix":
|
|
217
|
+
return run_fix_int(arguments)
|
|
218
|
+
if arguments.command == "rules":
|
|
219
|
+
sys.stdout.write("".join(f"{code_str} {title_str}\n" for
|
|
220
|
+
code_str, title_str in
|
|
221
|
+
{**RULE_TITLES_DICT,
|
|
222
|
+
**CORRECTNESS_TITLES_DICT}.items()))
|
|
223
|
+
return 0
|
|
224
|
+
return run_check_int(arguments)
|
|
225
|
+
except (ValueError, OSError, SyntaxError) as error:
|
|
226
|
+
sys.stderr.write(f"refactrail: error: {error}\n")
|
|
227
|
+
return 2
|
refactrail/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Load RefacTrail settings from pyproject.toml and command-line options."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import fields, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from refactrail.models import Settings
|
|
8
|
+
|
|
9
|
+
PROFILES_TUPLE = ("standard", "strict")
|
|
10
|
+
INTEGER_KEYS_TUPLE = (
|
|
11
|
+
"line_length", "function_preferred_lines", "function_max_lines",
|
|
12
|
+
"main_max_lines",
|
|
13
|
+
)
|
|
14
|
+
LIST_KEYS_TUPLE = ("select", "ignore", "exclude")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_pyproject_path(start_path: Path) -> Path | None:
|
|
18
|
+
"""Find the nearest pyproject.toml at or above a folder.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
start_path (Path): File or folder where the search begins.
|
|
22
|
+
Returns:
|
|
23
|
+
Path | None: The configuration file, or None when there is none.
|
|
24
|
+
Warnings:
|
|
25
|
+
The search stops at the file system root.
|
|
26
|
+
"""
|
|
27
|
+
folder_path = start_path.resolve()
|
|
28
|
+
if folder_path.is_file():
|
|
29
|
+
folder_path = folder_path.parent
|
|
30
|
+
for candidate_path in (folder_path, *folder_path.parents):
|
|
31
|
+
pyproject_path = candidate_path / "pyproject.toml"
|
|
32
|
+
if pyproject_path.is_file():
|
|
33
|
+
return pyproject_path
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_tool_table_dict(pyproject_path: Path | None) -> dict:
|
|
38
|
+
"""Read the [tool.refactrail] table of a pyproject.toml file.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
pyproject_path (Path | None): File to read, or None.
|
|
42
|
+
Returns:
|
|
43
|
+
dict: Table with dashes turned into underscores; empty if absent.
|
|
44
|
+
Warnings:
|
|
45
|
+
Invalid TOML raises ValueError with the file name.
|
|
46
|
+
"""
|
|
47
|
+
if pyproject_path is None:
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
document_dict = tomllib.loads(
|
|
51
|
+
pyproject_path.read_text(encoding="utf-8"))
|
|
52
|
+
except tomllib.TOMLDecodeError as error:
|
|
53
|
+
raise ValueError(f"{pyproject_path}: invalid TOML: {error}") from error
|
|
54
|
+
table_dict = document_dict.get("tool", {}).get("refactrail", {})
|
|
55
|
+
if not isinstance(table_dict, dict):
|
|
56
|
+
raise ValueError("tool.refactrail must be a table")
|
|
57
|
+
return {key_str.replace("-", "_"): setting_value
|
|
58
|
+
for key_str, setting_value in table_dict.items()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_settings(
|
|
62
|
+
table_dict: dict, overrides_dict: dict | None = None,
|
|
63
|
+
) -> Settings:
|
|
64
|
+
"""Validate configuration values and build Settings.
|
|
65
|
+
Args:
|
|
66
|
+
table_dict (dict): Values from pyproject.toml.
|
|
67
|
+
overrides_dict (dict | None): Command-line values, which win.
|
|
68
|
+
Returns:
|
|
69
|
+
Settings: Validated settings.
|
|
70
|
+
Warnings:
|
|
71
|
+
Unknown keys and invalid values raise ValueError naming them.
|
|
72
|
+
"""
|
|
73
|
+
given_dict = {key_str: setting_value for key_str, setting_value
|
|
74
|
+
in (overrides_dict or {}).items()
|
|
75
|
+
if setting_value is not None}
|
|
76
|
+
merged_dict = {**table_dict, **given_dict}
|
|
77
|
+
known_set = {field_info.name for field_info in fields(Settings)}
|
|
78
|
+
unknown_list = sorted(set(merged_dict) - known_set)
|
|
79
|
+
if unknown_list:
|
|
80
|
+
raise ValueError(f"Unknown setting(s): {', '.join(unknown_list)}")
|
|
81
|
+
if merged_dict.get("profile", "standard") not in PROFILES_TUPLE:
|
|
82
|
+
raise ValueError(f"Profile must be one of {PROFILES_TUPLE}")
|
|
83
|
+
for key_str in INTEGER_KEYS_TUPLE:
|
|
84
|
+
setting_value = merged_dict.get(key_str)
|
|
85
|
+
if setting_value is not None and (
|
|
86
|
+
not isinstance(setting_value, int)
|
|
87
|
+
or isinstance(setting_value, bool) or setting_value < 1
|
|
88
|
+
):
|
|
89
|
+
raise ValueError(f"{key_str} must be a positive integer")
|
|
90
|
+
validate_limits_none(merged_dict)
|
|
91
|
+
for key_str in LIST_KEYS_TUPLE:
|
|
92
|
+
if key_str in merged_dict:
|
|
93
|
+
values_list = merged_dict[key_str]
|
|
94
|
+
if not isinstance(values_list, (list, tuple)) or not all(
|
|
95
|
+
isinstance(entry_str, str) and entry_str.strip()
|
|
96
|
+
for entry_str in values_list
|
|
97
|
+
):
|
|
98
|
+
raise ValueError(f"{key_str} must be a list of strings")
|
|
99
|
+
merged_dict[key_str] = tuple(values_list)
|
|
100
|
+
return replace(Settings(), **merged_dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_settings(
|
|
104
|
+
start_path: Path, overrides_dict: dict | None = None,
|
|
105
|
+
) -> Settings:
|
|
106
|
+
"""Load settings for a path from its nearest pyproject.toml.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
start_path (Path): File or folder being checked.
|
|
110
|
+
overrides_dict (dict | None): Command-line values.
|
|
111
|
+
Returns:
|
|
112
|
+
Settings: Validated settings.
|
|
113
|
+
Warnings:
|
|
114
|
+
Command-line values override the file.
|
|
115
|
+
"""
|
|
116
|
+
return build_settings(
|
|
117
|
+
read_tool_table_dict(find_pyproject_path(start_path)),
|
|
118
|
+
overrides_dict)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_limits_none(settings_dict: dict) -> None:
|
|
122
|
+
"""Validate related size limits before constructing settings.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
settings_dict (dict): Validated integer settings.
|
|
126
|
+
Returns:
|
|
127
|
+
None: Raises ValueError for inconsistent limits.
|
|
128
|
+
Warnings:
|
|
129
|
+
Width matches the FuncLoom API used by the fix command.
|
|
130
|
+
"""
|
|
131
|
+
if not 40 <= settings_dict.get("line_length", 79) <= 200:
|
|
132
|
+
raise ValueError("line_length must be from 40 to 200")
|
|
133
|
+
if settings_dict.get("function_preferred_lines", 40) > (
|
|
134
|
+
settings_dict.get("function_max_lines", 50)
|
|
135
|
+
):
|
|
136
|
+
raise ValueError("Preferred function size exceeds maximum")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""General correctness analysis independent of RefacTrail style profiles."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from refactrail.correctness_checks import (
|
|
6
|
+
FUNCTION_NODES_TUPLE, check_comparison_none, check_defaults_none,
|
|
7
|
+
check_dictionary_none, check_finally_none, check_statement_lists_none,
|
|
8
|
+
report_node_none,
|
|
9
|
+
)
|
|
10
|
+
from refactrail.engine import parse_quietly_node
|
|
11
|
+
from refactrail.models import Finding, Settings
|
|
12
|
+
from refactrail.lexical import check_lexical_none
|
|
13
|
+
from refactrail.rules.context import RuleContext
|
|
14
|
+
from refactrail.source import build_source_file, decode_source_text
|
|
15
|
+
|
|
16
|
+
CORRECTNESS_TITLES_DICT = {
|
|
17
|
+
"RC201": "Unresolved lexical name",
|
|
18
|
+
"RC202": "Import without lexical use",
|
|
19
|
+
"RC101": "Mutable default argument",
|
|
20
|
+
"RC102": "Duplicate literal dictionary key",
|
|
21
|
+
"RC103": "Identity comparison with a non-singleton literal",
|
|
22
|
+
"RC104": "Bare except handler",
|
|
23
|
+
"RC105": "Unreachable statement after a direct terminator",
|
|
24
|
+
"RC106": "Nonempty tuple assertion",
|
|
25
|
+
"RC107": "Control-flow exit from finally",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_node_none(context_info: RuleContext, node: ast.AST) -> None:
|
|
30
|
+
"""Dispatch independent checks during a shared syntax traversal.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
context_info (RuleContext): Findings and suppression state.
|
|
34
|
+
node (ast.AST): Current syntax node.
|
|
35
|
+
Returns:
|
|
36
|
+
None: Adds applicable RC observations.
|
|
37
|
+
Warnings:
|
|
38
|
+
Checks are syntax evidence, not whole-program proofs.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(node, FUNCTION_NODES_TUPLE):
|
|
41
|
+
check_defaults_none(context_info, node)
|
|
42
|
+
if isinstance(node, ast.Dict):
|
|
43
|
+
check_dictionary_none(context_info, node)
|
|
44
|
+
if isinstance(node, ast.Compare):
|
|
45
|
+
check_comparison_none(context_info, node)
|
|
46
|
+
if isinstance(node, ast.ExceptHandler) and node.type is None:
|
|
47
|
+
report_node_none(context_info, node, "RC104",
|
|
48
|
+
"Bare except also catches process-control "
|
|
49
|
+
"exceptions such as KeyboardInterrupt.")
|
|
50
|
+
if isinstance(node, ast.Assert) and isinstance(node.test, ast.Tuple):
|
|
51
|
+
if node.test.elts and not any(isinstance(element, ast.Starred)
|
|
52
|
+
for element in node.test.elts):
|
|
53
|
+
report_node_none(context_info, node.test, "RC106",
|
|
54
|
+
"A nonempty tuple assertion tests the tuple's "
|
|
55
|
+
"truthiness, not its first element.")
|
|
56
|
+
if isinstance(node, (ast.Try, ast.TryStar)):
|
|
57
|
+
check_finally_none(context_info, node)
|
|
58
|
+
check_statement_lists_none(context_info, node)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_correctness_list(
|
|
62
|
+
path_str: str, raw_bytes: bytes,
|
|
63
|
+
select_tuple: tuple[str, ...] = ("RC",),
|
|
64
|
+
ignore_tuple: tuple[str, ...] = (),
|
|
65
|
+
) -> list[Finding]:
|
|
66
|
+
"""Check general correctness without importing or executing the source.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path_str (str): Source label for findings.
|
|
70
|
+
raw_bytes (bytes): Original UTF-8 source, optionally with a BOM.
|
|
71
|
+
select_tuple (tuple[str, ...]): Enabled rule prefixes.
|
|
72
|
+
ignore_tuple (tuple[str, ...]): Disabled ordinary rule prefixes.
|
|
73
|
+
Returns:
|
|
74
|
+
list[Finding]: Sorted observations or an unsuppressible input error.
|
|
75
|
+
Warnings:
|
|
76
|
+
This independent engine does not infer domain intent or runtime types.
|
|
77
|
+
"""
|
|
78
|
+
settings_info = Settings(select=select_tuple, ignore=ignore_tuple)
|
|
79
|
+
text_str = decode_source_text(raw_bytes)
|
|
80
|
+
if text_str is None:
|
|
81
|
+
return [Finding(path_str, 1, 1, "RT002", "error",
|
|
82
|
+
"File is not UTF-8 or declares another encoding.")]
|
|
83
|
+
try:
|
|
84
|
+
tree_node = parse_quietly_node(text_str, path_str)
|
|
85
|
+
except (SyntaxError, ValueError) as error:
|
|
86
|
+
return [Finding(path_str, getattr(error, "lineno", None) or 1,
|
|
87
|
+
getattr(error, "offset", None) or 1, "RT001",
|
|
88
|
+
"error", f"Syntax error: {error}")]
|
|
89
|
+
context_info = RuleContext(build_source_file(path_str, text_str),
|
|
90
|
+
tree_node, settings_info)
|
|
91
|
+
for node in ast.walk(tree_node):
|
|
92
|
+
check_node_none(context_info, node)
|
|
93
|
+
check_lexical_none(context_info)
|
|
94
|
+
return sorted(set(context_info.findings_list))
|