sarj-python-lint 0.70.0__tar.gz → 0.71.0__tar.gz
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.
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/__main__.py +15 -6
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_comments.py +14 -8
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_fastapi.py +42 -25
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +10 -6
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +2 -2
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +22 -11
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +24 -12
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +11 -4
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_long_comment.py +1 -1
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +53 -39
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/over_mocked_test.py +18 -8
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +21 -9
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +25 -12
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +11 -6
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +190 -24
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +2 -2
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_str_enum.py +29 -11
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +39 -6
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +16 -7
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +15 -5
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_docstring.py +5 -2
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +1 -1
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +31 -18
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_port_for_service.py +13 -5
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +164 -17
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/source_coupled_test.py +7 -1
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +11 -5
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +16 -8
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/.gitignore +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/LICENSE +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/README.md +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.71.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
|
|
6
6
|
Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
import sys
|
|
11
11
|
from types import MappingProxyType
|
|
12
|
+
from typing import NamedTuple
|
|
12
13
|
|
|
13
14
|
from sarj_python_lint import __version__
|
|
14
15
|
from sarj_python_lint._filesystem import atomic_write_text
|
|
@@ -120,6 +121,11 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
|
|
|
120
121
|
)
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
class _OwnerLocation(NamedTuple):
|
|
125
|
+
line: int
|
|
126
|
+
column: int
|
|
127
|
+
|
|
128
|
+
|
|
123
129
|
def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
|
|
124
130
|
"""Keep the most specific remediation at a source location."""
|
|
125
131
|
codes = frozenset(diagnostic.code for diagnostic in diags)
|
|
@@ -130,15 +136,17 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
|
|
|
130
136
|
_function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
|
|
131
137
|
)
|
|
132
138
|
|
|
133
|
-
def owner_location(diagnostic: Diagnostic) ->
|
|
139
|
+
def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
|
|
134
140
|
if diagnostic.code in {"SARJ034", "SARJ093"}:
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
|
|
142
|
+
else:
|
|
143
|
+
line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
|
|
144
|
+
return _OwnerLocation(line, column)
|
|
137
145
|
|
|
138
146
|
present: dict[tuple[Path, int, int], dict[str, set[Severity]]] = {}
|
|
139
147
|
for diagnostic in diags:
|
|
140
|
-
|
|
141
|
-
by_code = present.setdefault((diagnostic.path, line,
|
|
148
|
+
location = owner_location(diagnostic)
|
|
149
|
+
by_code = present.setdefault((diagnostic.path, location.line, location.column), {})
|
|
142
150
|
by_code.setdefault(diagnostic.code, set()).add(diagnostic.severity)
|
|
143
151
|
suppressed = {
|
|
144
152
|
(location, generic, generic_severity)
|
|
@@ -156,7 +164,8 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
|
|
|
156
164
|
(
|
|
157
165
|
(
|
|
158
166
|
diagnostic.path,
|
|
159
|
-
|
|
167
|
+
owner_location(diagnostic).line,
|
|
168
|
+
owner_location(diagnostic).column,
|
|
160
169
|
),
|
|
161
170
|
diagnostic.code,
|
|
162
171
|
diagnostic.severity,
|
|
@@ -314,15 +314,21 @@ class _CommentScan[T](NamedTuple):
|
|
|
314
314
|
first_code_line: int
|
|
315
315
|
|
|
316
316
|
|
|
317
|
-
_Scan
|
|
317
|
+
class _Scan(NamedTuple):
|
|
318
|
+
standalone: list[tuple[int, int, str]]
|
|
319
|
+
trailing: list[tuple[int, int, str]]
|
|
320
|
+
nested: set[int]
|
|
321
|
+
first_code_line: int
|
|
322
|
+
ordered: _Ordered
|
|
323
|
+
|
|
318
324
|
|
|
319
325
|
_last_scan: tuple[str, _Scan] | None = None
|
|
320
326
|
|
|
321
327
|
|
|
322
328
|
def all_comments(source: str) -> _CommentScan[_Ordered]:
|
|
323
329
|
"""Return every comment as `(line, col0, body, standalone)`, plus the first code line."""
|
|
324
|
-
|
|
325
|
-
return _CommentScan(ordered, first_code_line)
|
|
330
|
+
scan = _scan_memo(source)
|
|
331
|
+
return _CommentScan(scan.ordered, scan.first_code_line)
|
|
326
332
|
|
|
327
333
|
|
|
328
334
|
def _scan_memo(source: str) -> _Scan:
|
|
@@ -361,23 +367,23 @@ def _scan(source: str) -> _Scan:
|
|
|
361
367
|
prev_end_row = tok.end[0]
|
|
362
368
|
if tok.type not in _NON_CODE_TOKENS:
|
|
363
369
|
first_code_line = min(first_code_line, tok.start[0])
|
|
364
|
-
return standalone, trailing, nested, first_code_line, ordered
|
|
370
|
+
return _Scan(standalone, trailing, nested, first_code_line, ordered)
|
|
365
371
|
|
|
366
372
|
|
|
367
373
|
def trailing_comments(source: str) -> list[tuple[int, int, str]]:
|
|
368
374
|
"""Return every comment that shares its line with code, as `(line, col, body)`."""
|
|
369
|
-
return _scan_memo(source)
|
|
375
|
+
return _scan_memo(source).trailing
|
|
370
376
|
|
|
371
377
|
|
|
372
378
|
def nested_comment_lines(source: str) -> set[int]:
|
|
373
379
|
"""Return the lines of comments sitting INSIDE a bracketed expression."""
|
|
374
|
-
return _scan_memo(source)
|
|
380
|
+
return _scan_memo(source).nested
|
|
375
381
|
|
|
376
382
|
|
|
377
383
|
def standalone_comments(source: str) -> _CommentScan[list[tuple[int, int, str]]]:
|
|
378
384
|
"""Return every own-line comment as `(line, col, body)`, plus the first code line."""
|
|
379
|
-
|
|
380
|
-
return _CommentScan(standalone, first_code_line)
|
|
385
|
+
scan = _scan_memo(source)
|
|
386
|
+
return _CommentScan(scan.standalone, scan.first_code_line)
|
|
381
387
|
|
|
382
388
|
|
|
383
389
|
def comment_runs(standalone: Sequence[tuple[int, int, str]]) -> list[list[tuple[int, int, str]]]:
|
|
@@ -74,6 +74,16 @@ class ParameterMarker(NamedTuple):
|
|
|
74
74
|
call: ast.Call
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
class _ReceiverKey(NamedTuple):
|
|
78
|
+
scope: int
|
|
79
|
+
name: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _DecoratorBinding(NamedTuple):
|
|
83
|
+
receiver: str
|
|
84
|
+
method: str
|
|
85
|
+
|
|
86
|
+
|
|
77
87
|
@dataclass(frozen=True, slots=True)
|
|
78
88
|
class _ImportedReference:
|
|
79
89
|
module: str
|
|
@@ -113,12 +123,12 @@ class FastapiIndex:
|
|
|
113
123
|
self.annotated: set[str] = set()
|
|
114
124
|
self.symbols: dict[str, str] = {}
|
|
115
125
|
self.type_aliases: dict[str, ast.expr] = {}
|
|
116
|
-
self.receivers: set[
|
|
117
|
-
self.receiver_origins: dict[
|
|
118
|
-
self.receiver_kinds: dict[
|
|
119
|
-
self.hidden_receivers: set[
|
|
120
|
-
self.decorators: dict[
|
|
121
|
-
self.bound_names: set[
|
|
126
|
+
self.receivers: set[_ReceiverKey] = set()
|
|
127
|
+
self.receiver_origins: dict[_ReceiverKey, _ReceiverKey] = {}
|
|
128
|
+
self.receiver_kinds: dict[_ReceiverKey, Literal["FastAPI", "APIRouter"]] = {}
|
|
129
|
+
self.hidden_receivers: set[_ReceiverKey] = set()
|
|
130
|
+
self.decorators: dict[_ReceiverKey, _DecoratorBinding] = {}
|
|
131
|
+
self.bound_names: set[_ReceiverKey] = set()
|
|
122
132
|
self._node_scopes: dict[int, int] = {}
|
|
123
133
|
self._node_classes: dict[int, int | None] = {}
|
|
124
134
|
self._route_scopes: dict[int, int] = {}
|
|
@@ -182,31 +192,37 @@ class FastapiIndex:
|
|
|
182
192
|
assignments: list[tuple[int, str, ast.expr]] = []
|
|
183
193
|
for node in ast.walk(tree):
|
|
184
194
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
185
|
-
self.bound_names.add((self._node_scopes[id(node)], node.name))
|
|
195
|
+
self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
|
|
186
196
|
arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
|
|
187
197
|
if node.args.vararg is not None:
|
|
188
198
|
arguments.append(node.args.vararg)
|
|
189
199
|
if node.args.kwarg is not None:
|
|
190
200
|
arguments.append(node.args.kwarg)
|
|
191
|
-
self.bound_names.update((node.lineno, argument.arg) for argument in arguments)
|
|
201
|
+
self.bound_names.update(_ReceiverKey(node.lineno, argument.arg) for argument in arguments)
|
|
192
202
|
elif isinstance(node, ast.ClassDef):
|
|
193
|
-
self.bound_names.add((self._node_scopes[id(node)], node.name))
|
|
203
|
+
self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
|
|
194
204
|
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
195
205
|
self.bound_names.update(
|
|
196
|
-
(self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0])
|
|
206
|
+
_ReceiverKey(self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0])
|
|
207
|
+
for alias in node.names
|
|
197
208
|
)
|
|
198
209
|
elif isinstance(node, (ast.For, ast.AsyncFor)):
|
|
199
|
-
self.bound_names.update(
|
|
210
|
+
self.bound_names.update(
|
|
211
|
+
_ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
|
|
212
|
+
)
|
|
200
213
|
elif isinstance(node, ast.With):
|
|
201
214
|
for item in node.items:
|
|
202
215
|
if item.optional_vars is not None:
|
|
203
216
|
self.bound_names.update(
|
|
204
|
-
(self._node_scopes[id(node)], name)
|
|
217
|
+
_ReceiverKey(self._node_scopes[id(node)], name)
|
|
218
|
+
for name in self._target_names(item.optional_vars)
|
|
205
219
|
)
|
|
206
220
|
elif isinstance(node, ast.ExceptHandler) and node.name is not None:
|
|
207
|
-
self.bound_names.add((self._node_scopes[id(node)], node.name))
|
|
221
|
+
self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
|
|
208
222
|
elif isinstance(node, ast.NamedExpr):
|
|
209
|
-
self.bound_names.update(
|
|
223
|
+
self.bound_names.update(
|
|
224
|
+
_ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
|
|
225
|
+
)
|
|
210
226
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
211
227
|
name = _binding_name(node.targets[0])
|
|
212
228
|
if name:
|
|
@@ -219,12 +235,12 @@ class FastapiIndex:
|
|
|
219
235
|
assignments.append((self._node_scopes[id(node)], node.name.id, node.value))
|
|
220
236
|
|
|
221
237
|
changed = True
|
|
222
|
-
counts = Counter((scope, name) for scope, name, _value in assignments)
|
|
238
|
+
counts = Counter(_ReceiverKey(scope, name) for scope, name, _value in assignments)
|
|
223
239
|
self.bound_names.update(counts)
|
|
224
240
|
while changed:
|
|
225
241
|
changed = False
|
|
226
242
|
for scope, name, value in assignments:
|
|
227
|
-
key = (scope, name)
|
|
243
|
+
key = _ReceiverKey(scope, name)
|
|
228
244
|
if counts[key] != 1:
|
|
229
245
|
continue
|
|
230
246
|
if key in self.receivers or key in self.decorators:
|
|
@@ -250,7 +266,7 @@ class FastapiIndex:
|
|
|
250
266
|
if isinstance(value, ast.Attribute) and value.attr in HTTP_METHODS:
|
|
251
267
|
receiver = _binding_name(value.value)
|
|
252
268
|
if self._receiver_key(scope, receiver) is not None:
|
|
253
|
-
self.decorators[key] = (receiver, value.attr)
|
|
269
|
+
self.decorators[key] = _DecoratorBinding(receiver, value.attr)
|
|
254
270
|
changed = True
|
|
255
271
|
continue
|
|
256
272
|
if self._is_annotated(value) or (isinstance(value, ast.Name) and value.id in self.type_aliases):
|
|
@@ -268,12 +284,12 @@ class FastapiIndex:
|
|
|
268
284
|
return self._node_classes[id(node)] or self._node_scopes[id(node)]
|
|
269
285
|
return self._node_scopes[id(node)]
|
|
270
286
|
|
|
271
|
-
def _receiver_key(self, scope: int, name: str) ->
|
|
287
|
+
def _receiver_key(self, scope: int, name: str) -> _ReceiverKey | None:
|
|
272
288
|
if not name:
|
|
273
289
|
return None
|
|
274
290
|
current: int | None = scope
|
|
275
291
|
while current is not None:
|
|
276
|
-
binding = (current, name)
|
|
292
|
+
binding = _ReceiverKey(current, name)
|
|
277
293
|
if binding in self.receivers:
|
|
278
294
|
return binding
|
|
279
295
|
if binding in self.bound_names:
|
|
@@ -325,7 +341,8 @@ class FastapiIndex:
|
|
|
325
341
|
if receiver_key is not None:
|
|
326
342
|
method = decorator.func.attr
|
|
327
343
|
elif isinstance(decorator.func, ast.Name) and (resolved := self._decorator(scope, decorator.func.id)):
|
|
328
|
-
receiver
|
|
344
|
+
receiver = resolved.receiver
|
|
345
|
+
method = resolved.method
|
|
329
346
|
receiver_key = self._receiver_key(scope, receiver)
|
|
330
347
|
if method not in HTTP_METHODS or receiver_key is None:
|
|
331
348
|
continue
|
|
@@ -336,11 +353,11 @@ class FastapiIndex:
|
|
|
336
353
|
)
|
|
337
354
|
path = path_node.value if isinstance(path_node, ast.Constant) and isinstance(path_node.value, str) else None
|
|
338
355
|
route_methods = self._route_methods(decorator) if method == "api_route" else (method,)
|
|
339
|
-
|
|
356
|
+
origin = self.receiver_origins[receiver_key]
|
|
340
357
|
routes.extend(
|
|
341
358
|
Route(
|
|
342
359
|
decorator=decorator,
|
|
343
|
-
receiver=f"{
|
|
360
|
+
receiver=f"{origin.scope}:{origin.name}",
|
|
344
361
|
method=route_method,
|
|
345
362
|
path=path,
|
|
346
363
|
receiver_kind=self.receiver_kinds[receiver_key],
|
|
@@ -350,10 +367,10 @@ class FastapiIndex:
|
|
|
350
367
|
)
|
|
351
368
|
return tuple(routes)
|
|
352
369
|
|
|
353
|
-
def _decorator(self, scope: int, name: str) ->
|
|
370
|
+
def _decorator(self, scope: int, name: str) -> _DecoratorBinding | None:
|
|
354
371
|
current: int | None = scope
|
|
355
372
|
while current is not None:
|
|
356
|
-
binding = (current, name)
|
|
373
|
+
binding = _ReceiverKey(current, name)
|
|
357
374
|
if binding in self.decorators:
|
|
358
375
|
return self.decorators[binding]
|
|
359
376
|
if binding in self.bound_names:
|
|
@@ -473,7 +490,7 @@ class FastapiIndex:
|
|
|
473
490
|
# it, so begin with that function's parent scope.
|
|
474
491
|
scope: int | None = self._scope_parents.get(node_scope)
|
|
475
492
|
while scope is not None and scope != 0:
|
|
476
|
-
if (scope, root) in self.bound_names:
|
|
493
|
+
if _ReceiverKey(scope, root) in self.bound_names:
|
|
477
494
|
return True
|
|
478
495
|
scope = self._scope_parents.get(scope)
|
|
479
496
|
return False
|
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import ast
|
|
9
9
|
from pathlib import PurePosixPath
|
|
10
|
-
from typing import TYPE_CHECKING, ClassVar, override
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, NamedTuple, override
|
|
11
11
|
|
|
12
12
|
from sarj_python_lint.rule_base import (
|
|
13
13
|
Diagnostic,
|
|
@@ -34,6 +34,11 @@ _MIN_FIELDS = 2
|
|
|
34
34
|
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class _FixtureDecorators(NamedTuple):
|
|
38
|
+
names: frozenset[str]
|
|
39
|
+
roots: frozenset[str]
|
|
40
|
+
|
|
41
|
+
|
|
37
42
|
class FixtureReturnsBareTuple(Rule):
|
|
38
43
|
id: str = "fixture-returns-bare-tuple"
|
|
39
44
|
code: str = "SARJ044"
|
|
@@ -132,7 +137,7 @@ def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
|
|
|
132
137
|
return hits
|
|
133
138
|
|
|
134
139
|
|
|
135
|
-
def _fixture_decorator_names(tree: ast.Module) ->
|
|
140
|
+
def _fixture_decorator_names(tree: ast.Module) -> _FixtureDecorators:
|
|
136
141
|
names: set[str] = set()
|
|
137
142
|
roots: set[str] = set()
|
|
138
143
|
for statement in tree.body:
|
|
@@ -144,15 +149,14 @@ def _fixture_decorator_names(tree: ast.Module) -> tuple[frozenset[str], frozense
|
|
|
144
149
|
for alias in statement.names:
|
|
145
150
|
if alias.name == "fixture":
|
|
146
151
|
names.add(alias.asname or alias.name)
|
|
147
|
-
return frozenset(names), frozenset(roots)
|
|
152
|
+
return _FixtureDecorators(frozenset(names), frozenset(roots))
|
|
148
153
|
|
|
149
154
|
|
|
150
155
|
def _is_fixture(
|
|
151
156
|
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
152
|
-
fixture_imports:
|
|
157
|
+
fixture_imports: _FixtureDecorators,
|
|
153
158
|
) -> bool:
|
|
154
|
-
names, roots
|
|
155
|
-
return any(_names_fixture(dec, names, roots) for dec in node.decorator_list)
|
|
159
|
+
return any(_names_fixture(dec, fixture_imports.names, fixture_imports.roots) for dec in node.decorator_list)
|
|
156
160
|
|
|
157
161
|
|
|
158
162
|
def _names_fixture(dec: ast.expr, fixture_names: frozenset[str], fixture_roots: frozenset[str]) -> bool:
|
|
@@ -45,7 +45,7 @@ class IacSourceCoupledTest(Rule):
|
|
|
45
45
|
limitations=(
|
|
46
46
|
"The rule follows local aliases, path collections, context-managed reads, and common normalization; interprocedural flows remain unreported.",
|
|
47
47
|
"Files produced beneath recognized temporary-directory fixtures are generated outputs and remain unreported.",
|
|
48
|
-
"The
|
|
48
|
+
"The rule remains suppressible for exceptional compatibility boundaries.",
|
|
49
49
|
),
|
|
50
50
|
examples=(
|
|
51
51
|
RuleExample(
|
|
@@ -98,7 +98,7 @@ class IacSourceCoupledTest(Rule):
|
|
|
98
98
|
line=assertion.lineno,
|
|
99
99
|
col=assertion.col_offset + 1,
|
|
100
100
|
code=self.code,
|
|
101
|
-
severity=Severity.
|
|
101
|
+
severity=Severity.ERROR,
|
|
102
102
|
message=(
|
|
103
103
|
"raw IaC source text is the test oracle; inspect rendered plan JSON, provider state, or runtime behavior instead."
|
|
104
104
|
),
|
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import ast
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import PurePosixPath
|
|
11
|
-
from typing import TYPE_CHECKING, final, override
|
|
11
|
+
from typing import TYPE_CHECKING, NamedTuple, final, override
|
|
12
12
|
|
|
13
13
|
from sarj_python_lint.rule_base import (
|
|
14
14
|
AutofixPolicy,
|
|
@@ -84,7 +84,17 @@ _MISSING = object()
|
|
|
84
84
|
|
|
85
85
|
@dataclass(frozen=True, slots=True)
|
|
86
86
|
class _LiteralDomain:
|
|
87
|
-
values: frozenset[
|
|
87
|
+
values: frozenset[_LiteralKey]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _LiteralKey(NamedTuple):
|
|
91
|
+
value_type: type[object]
|
|
92
|
+
value: object
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _BoundViolation(NamedTuple):
|
|
96
|
+
bound_name: str
|
|
97
|
+
bound: object
|
|
88
98
|
|
|
89
99
|
|
|
90
100
|
@final
|
|
@@ -221,7 +231,7 @@ def _invalid_default_message(
|
|
|
221
231
|
|
|
222
232
|
domain = _literal_domain(annotation, imports)
|
|
223
233
|
if domain is not None and not isinstance(literal, float) and _literal_key(literal) not in domain.values:
|
|
224
|
-
allowed = ", ".join(sorted(repr(value) for
|
|
234
|
+
allowed = ", ".join(sorted(repr(item.value) for item in domain.values))
|
|
225
235
|
return (
|
|
226
236
|
f"`{field_name}` defaults to {literal!r}, outside its direct `Literal` domain ({allowed}); "
|
|
227
237
|
"use one of the declared literal values."
|
|
@@ -229,7 +239,8 @@ def _invalid_default_message(
|
|
|
229
239
|
|
|
230
240
|
violation = _bound_violation(literal, call)
|
|
231
241
|
if violation is not None:
|
|
232
|
-
bound_name
|
|
242
|
+
bound_name = violation.bound_name
|
|
243
|
+
bound = violation.bound
|
|
233
244
|
return (
|
|
234
245
|
f"`{field_name}` defaults to {literal!r}, which violates `Field({bound_name}={bound!r})`; "
|
|
235
246
|
"choose a default inside the declared bounds."
|
|
@@ -249,8 +260,8 @@ def _literal_value(node: ast.expr) -> object:
|
|
|
249
260
|
return _MISSING
|
|
250
261
|
|
|
251
262
|
|
|
252
|
-
def _literal_key(value: object) ->
|
|
253
|
-
return type(value), value
|
|
263
|
+
def _literal_key(value: object) -> _LiteralKey:
|
|
264
|
+
return _LiteralKey(type(value), value)
|
|
254
265
|
|
|
255
266
|
|
|
256
267
|
def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | None:
|
|
@@ -273,7 +284,7 @@ def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | No
|
|
|
273
284
|
return _merge_domains(tuple(_literal_domain(member, imports) for member in _slice_members(node.slice)))
|
|
274
285
|
if not imports.resolves(node.value, sources=_TYPING_SOURCES, symbol="Literal"):
|
|
275
286
|
return None
|
|
276
|
-
values: set[
|
|
287
|
+
values: set[_LiteralKey] = set()
|
|
277
288
|
for member in _slice_members(node.slice):
|
|
278
289
|
value = _literal_value(member)
|
|
279
290
|
if value is _MISSING or isinstance(value, float):
|
|
@@ -285,7 +296,7 @@ def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | No
|
|
|
285
296
|
def _merge_domains(domains: tuple[_LiteralDomain | None, ...]) -> _LiteralDomain | None:
|
|
286
297
|
if not domains or any(domain is None for domain in domains):
|
|
287
298
|
return None
|
|
288
|
-
values: set[
|
|
299
|
+
values: set[_LiteralKey] = set()
|
|
289
300
|
for domain in domains:
|
|
290
301
|
if domain is not None:
|
|
291
302
|
values.update(domain.values)
|
|
@@ -318,7 +329,7 @@ def _provably_non_null(node: ast.expr, imports: ImportIndex) -> bool:
|
|
|
318
329
|
)
|
|
319
330
|
|
|
320
331
|
|
|
321
|
-
def _bound_violation(default: object, call: ast.Call) ->
|
|
332
|
+
def _bound_violation(default: object, call: ast.Call) -> _BoundViolation | None:
|
|
322
333
|
bounds = {keyword.arg: _literal_value(keyword.value) for keyword in call.keywords if keyword.arg is not None}
|
|
323
334
|
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
|
324
335
|
for name in ("gt", "ge", "lt", "le"):
|
|
@@ -326,7 +337,7 @@ def _bound_violation(default: object, call: ast.Call) -> tuple[str, object] | No
|
|
|
326
337
|
if not isinstance(bound, (int, float)) or isinstance(bound, bool):
|
|
327
338
|
continue
|
|
328
339
|
if _numeric_bound_is_violated(name, default, bound):
|
|
329
|
-
return name, bound
|
|
340
|
+
return _BoundViolation(name, bound)
|
|
330
341
|
return None
|
|
331
342
|
if isinstance(default, (str, bytes)):
|
|
332
343
|
for name in ("min_length", "max_length"):
|
|
@@ -334,7 +345,7 @@ def _bound_violation(default: object, call: ast.Call) -> tuple[str, object] | No
|
|
|
334
345
|
if not isinstance(bound, int) or isinstance(bound, bool) or bound < 0:
|
|
335
346
|
continue
|
|
336
347
|
if (name == "min_length" and len(default) < bound) or (name == "max_length" and len(default) > bound):
|
|
337
|
-
return name, bound
|
|
348
|
+
return _BoundViolation(name, bound)
|
|
338
349
|
return None
|
|
339
350
|
|
|
340
351
|
|
|
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|
|
10
10
|
from functools import lru_cache
|
|
11
11
|
import os
|
|
12
12
|
from pathlib import Path, PurePosixPath
|
|
13
|
-
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
13
|
+
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
|
|
14
14
|
|
|
15
15
|
from sarj_python_lint.rule_base import (
|
|
16
16
|
Diagnostic,
|
|
@@ -68,6 +68,16 @@ class _HiddenParameter:
|
|
|
68
68
|
uses_boolean_or: bool
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class _LoadedModule(NamedTuple):
|
|
72
|
+
tree: ast.Module
|
|
73
|
+
path: Path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _CanonicalSymbol(NamedTuple):
|
|
77
|
+
module: str
|
|
78
|
+
symbol: str
|
|
79
|
+
|
|
80
|
+
|
|
71
81
|
@final
|
|
72
82
|
class NoHiddenConstructorFallback(Rule):
|
|
73
83
|
id = "no-hidden-constructor-fallback"
|
|
@@ -379,7 +389,8 @@ class _RuntimeConfigResolver:
|
|
|
379
389
|
if loaded is None:
|
|
380
390
|
self._settings_cache[key] = False
|
|
381
391
|
return False
|
|
382
|
-
tree
|
|
392
|
+
tree = loaded.tree
|
|
393
|
+
module_path = loaded.path
|
|
383
394
|
imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
|
|
384
395
|
classes = _base_settings_classes(tree, imports)
|
|
385
396
|
factory = _assigned_factory(tree, symbol)
|
|
@@ -419,7 +430,8 @@ class _RuntimeConfigResolver:
|
|
|
419
430
|
if loaded is None:
|
|
420
431
|
self._settings_class_cache[key] = False
|
|
421
432
|
return False
|
|
422
|
-
tree
|
|
433
|
+
tree = loaded.tree
|
|
434
|
+
module_path = loaded.path
|
|
423
435
|
imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
|
|
424
436
|
if symbol in _base_settings_classes(tree, imports):
|
|
425
437
|
self._settings_class_cache[key] = True
|
|
@@ -441,13 +453,13 @@ class _RuntimeConfigResolver:
|
|
|
441
453
|
self._settings_class_cache[key] = result
|
|
442
454
|
return result
|
|
443
455
|
|
|
444
|
-
def _load_module(self, module: str) ->
|
|
456
|
+
def _load_module(self, module: str) -> _LoadedModule | None:
|
|
445
457
|
if module == self._module:
|
|
446
|
-
return self._tree, self._path
|
|
458
|
+
return _LoadedModule(self._tree, self._path)
|
|
447
459
|
path = _module_path(module, self._root)
|
|
448
460
|
if path is None:
|
|
449
461
|
return None
|
|
450
|
-
return _read_module(path), path
|
|
462
|
+
return _LoadedModule(_read_module(path), path)
|
|
451
463
|
|
|
452
464
|
|
|
453
465
|
def _imports(tree: ast.Module, current_module: str | None, *, is_package: bool = False) -> dict[str, _Binding]:
|
|
@@ -635,13 +647,13 @@ def _distribution_calls_class(root: Path, target_module: str, class_name: str) -
|
|
|
635
647
|
if called_symbol != class_name:
|
|
636
648
|
continue
|
|
637
649
|
canonical = _canonical_symbol(root, called_module, called_symbol)
|
|
638
|
-
if canonical == (target_module, class_name):
|
|
650
|
+
if canonical == _CanonicalSymbol(target_module, class_name):
|
|
639
651
|
return True
|
|
640
652
|
return False
|
|
641
653
|
|
|
642
654
|
|
|
643
655
|
@lru_cache(maxsize=4096)
|
|
644
|
-
def _canonical_symbol(root: Path, module: str, symbol: str) ->
|
|
656
|
+
def _canonical_symbol(root: Path, module: str, symbol: str) -> _CanonicalSymbol:
|
|
645
657
|
return _canonical_symbol_inner(root, module, symbol, frozenset())
|
|
646
658
|
|
|
647
659
|
|
|
@@ -650,16 +662,16 @@ def _canonical_symbol_inner(
|
|
|
650
662
|
module: str,
|
|
651
663
|
symbol: str,
|
|
652
664
|
seen: frozenset[tuple[str, str]],
|
|
653
|
-
) ->
|
|
665
|
+
) -> _CanonicalSymbol:
|
|
654
666
|
key = (module, symbol)
|
|
655
667
|
if key in seen:
|
|
656
|
-
return
|
|
668
|
+
return _CanonicalSymbol(module, symbol)
|
|
657
669
|
path = _module_path(module, root)
|
|
658
670
|
if path is None:
|
|
659
|
-
return
|
|
671
|
+
return _CanonicalSymbol(module, symbol)
|
|
660
672
|
binding = _imports(_read_module(path), module, is_package=path.name == "__init__.py").get(symbol)
|
|
661
673
|
if binding is None or binding.symbol is None:
|
|
662
|
-
return
|
|
674
|
+
return _CanonicalSymbol(module, symbol)
|
|
663
675
|
return _canonical_symbol_inner(root, binding.module, binding.symbol, seen | {key})
|
|
664
676
|
|
|
665
677
|
|
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import ast
|
|
9
9
|
from pathlib import PurePosixPath
|
|
10
|
-
from typing import TYPE_CHECKING, final, override
|
|
10
|
+
from typing import TYPE_CHECKING, NamedTuple, final, override
|
|
11
11
|
|
|
12
12
|
from sarj_python_lint.rule_base import (
|
|
13
13
|
AutofixPolicy,
|
|
@@ -34,6 +34,12 @@ _MIN_CHAIN_LENGTH = 2
|
|
|
34
34
|
# `isinstance(x, T)` takes exactly two positional arguments.
|
|
35
35
|
_ISINSTANCE_ARG_COUNT = 2
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
class _IsinstanceTypeTest(NamedTuple):
|
|
39
|
+
subject: ast.expr
|
|
40
|
+
type_expression: ast.expr
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
# Exclude builtins and ABCs even when a same-named local class shadows them.
|
|
38
44
|
_EXCLUDED_TYPE_NAMES = frozenset(
|
|
39
45
|
{
|
|
@@ -218,7 +224,8 @@ def _qualifying_chain_length(head: ast.If, local_classes: frozenset[str]) -> int
|
|
|
218
224
|
parsed = _isinstance_single_type(current.test)
|
|
219
225
|
if parsed is None:
|
|
220
226
|
return 0
|
|
221
|
-
target
|
|
227
|
+
target = parsed.subject
|
|
228
|
+
type_node = parsed.type_expression
|
|
222
229
|
if not isinstance(target, ast.Name):
|
|
223
230
|
# Match evaluates its subject once, while an isinstance ladder
|
|
224
231
|
# repeats attributes, subscriptions, and calls for every arm.
|
|
@@ -299,7 +306,7 @@ def _ast_equal(a: ast.expr, b: ast.expr) -> bool:
|
|
|
299
306
|
return ast.dump(a) == ast.dump(b)
|
|
300
307
|
|
|
301
308
|
|
|
302
|
-
def _isinstance_single_type(test: ast.expr) ->
|
|
309
|
+
def _isinstance_single_type(test: ast.expr) -> _IsinstanceTypeTest | None:
|
|
303
310
|
"""Parse `test` as `isinstance(x, T)` with a single (non-tuple) type argument."""
|
|
304
311
|
if not isinstance(test, ast.Call):
|
|
305
312
|
return None
|
|
@@ -308,4 +315,4 @@ def _isinstance_single_type(test: ast.expr) -> tuple[ast.expr, ast.expr] | None:
|
|
|
308
315
|
if len(test.args) != _ISINSTANCE_ARG_COUNT or test.keywords:
|
|
309
316
|
return None
|
|
310
317
|
target, type_node = test.args
|
|
311
|
-
return target, type_node
|
|
318
|
+
return _IsinstanceTypeTest(target, type_node)
|