sarj-python-lint 0.71.0__tar.gz → 0.72.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.71.0 → sarj_python_lint-0.72.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__main__.py +12 -4
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rule_base.py +12 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_first_party.py +5 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/_project_index.py +366 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_registry.py +12 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +143 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_enum_types.py +136 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +171 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_validated_row_factory.py +159 -0
- sarj_python_lint-0.72.0/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +207 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/.gitignore +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/LICENSE +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/README.md +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.72.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.72.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/
|
|
@@ -13,9 +13,10 @@ from typing import NamedTuple
|
|
|
13
13
|
|
|
14
14
|
from sarj_python_lint import __version__
|
|
15
15
|
from sarj_python_lint._filesystem import atomic_write_text
|
|
16
|
-
from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
|
|
16
|
+
from sarj_python_lint.rule_base import Diagnostic, ProjectRule, Severity, is_suppressed
|
|
17
17
|
from sarj_python_lint.rules import REGISTRY
|
|
18
18
|
from sarj_python_lint.rules._paths import clear_path_caches
|
|
19
|
+
from sarj_python_lint.rules._project_index import ProjectIndexSet
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
SKIP_DIR_NAMES = frozenset(
|
|
@@ -80,12 +81,19 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
|
80
81
|
rules = [REGISTRY[rid]() for rid in rule_ids]
|
|
81
82
|
clear_path_caches()
|
|
82
83
|
expanded = _expand_paths(paths)
|
|
83
|
-
|
|
84
|
-
for
|
|
84
|
+
loaded: dict[Path, str] = {}
|
|
85
|
+
for path in expanded:
|
|
85
86
|
try:
|
|
86
|
-
|
|
87
|
+
loaded[path] = path.read_text(encoding="utf-8", errors="replace")
|
|
87
88
|
except OSError:
|
|
88
89
|
continue
|
|
90
|
+
project_rules = [rule for rule in rules if isinstance(rule, ProjectRule)]
|
|
91
|
+
if project_rules:
|
|
92
|
+
indexes = ProjectIndexSet.build(expanded, loaded)
|
|
93
|
+
for rule in project_rules:
|
|
94
|
+
rule.prepare(indexes)
|
|
95
|
+
diags: list[Diagnostic] = []
|
|
96
|
+
for p, source in loaded.items():
|
|
89
97
|
source_lines = source.splitlines()
|
|
90
98
|
raw = [diagnostic for rule in rules for diagnostic in rule.check(p, source)]
|
|
91
99
|
diags.extend(
|
|
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from collections.abc import Sequence
|
|
16
16
|
|
|
17
|
+
from sarj_python_lint.rules._project_index import ProjectIndexSet
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
# Each rule points directly to its executable examples.
|
|
19
21
|
REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
|
|
@@ -309,6 +311,16 @@ class Rule(ABC):
|
|
|
309
311
|
return () if spec is None else spec.public_examples
|
|
310
312
|
|
|
311
313
|
|
|
314
|
+
class ProjectRule(Rule):
|
|
315
|
+
"""A rule that may resolve first-party symbols prepared once per CLI run."""
|
|
316
|
+
|
|
317
|
+
_project_indexes: ProjectIndexSet | None = None
|
|
318
|
+
|
|
319
|
+
def prepare(self, indexes: ProjectIndexSet) -> None:
|
|
320
|
+
"""Attach immutable project symbols before checking the selected files."""
|
|
321
|
+
self._project_indexes = indexes
|
|
322
|
+
|
|
323
|
+
|
|
312
324
|
_last_parse: tuple[str, str, ast.Module | None] | None = None
|
|
313
325
|
|
|
314
326
|
|
{sarj_python_lint-0.71.0 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
@@ -126,6 +126,11 @@ def has_first_party_source(module: str, path: Path) -> bool:
|
|
|
126
126
|
)
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def project_root(path: Path) -> Path | None:
|
|
130
|
+
"""Return the conservative repository/workspace root used by project-aware rules."""
|
|
131
|
+
return _project_root(path)
|
|
132
|
+
|
|
133
|
+
|
|
129
134
|
def _declares_module(package_dir: Path, segments: list[str]) -> bool:
|
|
130
135
|
target = package_dir.joinpath(*segments)
|
|
131
136
|
try:
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Bounded, run-scoped first-party symbol index for project-aware rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, final
|
|
14
|
+
|
|
15
|
+
from sarj_python_lint.rules._first_party import project_root
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterator, Sequence
|
|
20
|
+
from typing import Self
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_SKIP_DIRS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
".git",
|
|
26
|
+
".mypy_cache",
|
|
27
|
+
".pytest_cache",
|
|
28
|
+
".ruff_cache",
|
|
29
|
+
".tox",
|
|
30
|
+
".venv",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
"node_modules",
|
|
35
|
+
"site-packages",
|
|
36
|
+
"vendor",
|
|
37
|
+
"vendored",
|
|
38
|
+
"venv",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
_MAX_ROOTS = 8
|
|
42
|
+
_MAX_DIRS_PER_ROOT = 3_000
|
|
43
|
+
_MAX_FILES_PER_ROOT = 10_000
|
|
44
|
+
_MAX_FILE_BYTES = 500_000
|
|
45
|
+
_NEW_TYPE_MIN_ARGS = 2
|
|
46
|
+
_MATCH_CLASS_RE: re.Pattern[str] = re.compile(r"\bcase\s+([A-Z][A-Za-z0-9_]*)\s*\(")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class SymbolRef:
|
|
51
|
+
module: str
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ClassSummary:
|
|
57
|
+
symbol: SymbolRef
|
|
58
|
+
fields: Mapping[str, ast.expr]
|
|
59
|
+
is_enum: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class SourceUnit:
|
|
64
|
+
path: Path
|
|
65
|
+
module: str | None
|
|
66
|
+
source: str
|
|
67
|
+
tree: ast.Module | None
|
|
68
|
+
imports: Mapping[str, SymbolRef]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class LoadedSource:
|
|
73
|
+
path: Path
|
|
74
|
+
source: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@final
|
|
78
|
+
class ProjectIndexSet:
|
|
79
|
+
"""Immutable summaries for every bounded first-party root in one lint run."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, units: Mapping[Path, SourceUnit]) -> None:
|
|
82
|
+
self._units = MappingProxyType(dict(units))
|
|
83
|
+
by_module = {unit.module: unit for unit in units.values() if unit.module is not None}
|
|
84
|
+
self._by_module = MappingProxyType(by_module)
|
|
85
|
+
classes: dict[SymbolRef, ClassSummary] = {}
|
|
86
|
+
nominals: dict[str, set[SymbolRef]] = {}
|
|
87
|
+
for unit in units.values():
|
|
88
|
+
if unit.module is None or unit.tree is None:
|
|
89
|
+
continue
|
|
90
|
+
for statement in unit.tree.body:
|
|
91
|
+
if isinstance(statement, ast.ClassDef):
|
|
92
|
+
symbol = SymbolRef(unit.module, statement.name)
|
|
93
|
+
fields = {
|
|
94
|
+
item.target.id: item.annotation
|
|
95
|
+
for item in statement.body
|
|
96
|
+
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name)
|
|
97
|
+
}
|
|
98
|
+
classes[symbol] = ClassSummary(
|
|
99
|
+
symbol=symbol,
|
|
100
|
+
fields=MappingProxyType(fields),
|
|
101
|
+
is_enum=any(_tail(base) in {"Enum", "IntEnum", "StrEnum"} for base in statement.bases),
|
|
102
|
+
)
|
|
103
|
+
nominal = _new_type(statement, unit.module)
|
|
104
|
+
if nominal is not None:
|
|
105
|
+
nominals.setdefault(_field_key(nominal.name), set()).add(nominal)
|
|
106
|
+
self._classes = MappingProxyType(classes)
|
|
107
|
+
self._nominals = MappingProxyType({key: frozenset(value) for key, value in nominals.items()})
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def build(cls, paths: Sequence[Path], loaded: Mapping[Path, str]) -> Self:
|
|
111
|
+
roots = _project_roots(paths)
|
|
112
|
+
sources: dict[Path, str] = {}
|
|
113
|
+
for path, source in loaded.items():
|
|
114
|
+
try:
|
|
115
|
+
sources[path.resolve()] = source
|
|
116
|
+
except OSError:
|
|
117
|
+
continue
|
|
118
|
+
for root in roots:
|
|
119
|
+
count = 0
|
|
120
|
+
for path in _python_files(root):
|
|
121
|
+
if count >= _MAX_FILES_PER_ROOT:
|
|
122
|
+
break
|
|
123
|
+
try:
|
|
124
|
+
if path.resolve() in sources:
|
|
125
|
+
count += 1
|
|
126
|
+
continue
|
|
127
|
+
except OSError:
|
|
128
|
+
continue
|
|
129
|
+
loaded_source = _read_bounded_source(root, path)
|
|
130
|
+
if loaded_source is None:
|
|
131
|
+
continue
|
|
132
|
+
sources.setdefault(loaded_source.path, loaded_source.source)
|
|
133
|
+
count += 1
|
|
134
|
+
return cls(_units(sources, roots))
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def single(cls, path: Path, source: str) -> Self:
|
|
138
|
+
return cls(_units({path: source}))
|
|
139
|
+
|
|
140
|
+
def unit(self, path: Path) -> SourceUnit | None:
|
|
141
|
+
direct = self._units.get(path)
|
|
142
|
+
if direct is not None:
|
|
143
|
+
return direct
|
|
144
|
+
try:
|
|
145
|
+
return self._units.get(path.resolve())
|
|
146
|
+
except OSError:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def nominal_for_field(self, name: str) -> SymbolRef | None:
|
|
150
|
+
matches = self._nominals.get(name)
|
|
151
|
+
return next(iter(matches)) if matches is not None and len(matches) == 1 else None
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def resolve(unit: SourceUnit, expression: ast.expr) -> SymbolRef | None:
|
|
155
|
+
if unit.module is None:
|
|
156
|
+
return None
|
|
157
|
+
if isinstance(expression, ast.Name):
|
|
158
|
+
return unit.imports.get(expression.id) or SymbolRef(unit.module, expression.id)
|
|
159
|
+
if isinstance(expression, ast.Attribute) and isinstance(expression.value, ast.Name):
|
|
160
|
+
root = unit.imports.get(expression.value.id)
|
|
161
|
+
if root is not None:
|
|
162
|
+
return SymbolRef(root.module, expression.attr) if not root.name else None
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def class_for(self, unit: SourceUnit, expression: ast.expr) -> ClassSummary | None:
|
|
166
|
+
symbol = self.resolve(unit, expression)
|
|
167
|
+
return self._classes.get(symbol) if symbol is not None else None
|
|
168
|
+
|
|
169
|
+
def annotation_contains_enum(self, unit: SourceUnit, annotation: ast.expr) -> bool:
|
|
170
|
+
for member in ast.walk(annotation):
|
|
171
|
+
if not isinstance(member, (ast.Name, ast.Attribute)):
|
|
172
|
+
continue
|
|
173
|
+
symbol = self.resolve(unit, member)
|
|
174
|
+
summary = self._classes.get(symbol) if symbol is not None else None
|
|
175
|
+
if summary is not None and summary.is_enum:
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def source_unit(self, module: str) -> SourceUnit | None:
|
|
180
|
+
return self._by_module.get(module)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _units(sources: Mapping[Path, str], roots: Sequence[Path] = ()) -> dict[Path, SourceUnit]:
|
|
184
|
+
matched_classes = {
|
|
185
|
+
match.group(1)
|
|
186
|
+
for source in sources.values()
|
|
187
|
+
if "match " in source and "str(" in source
|
|
188
|
+
for match in _MATCH_CLASS_RE.finditer(source)
|
|
189
|
+
}
|
|
190
|
+
parsed: dict[Path, tuple[str | None, str, ast.Module | None]] = {}
|
|
191
|
+
for path, source in sources.items():
|
|
192
|
+
if not _is_index_candidate(source, matched_classes):
|
|
193
|
+
continue
|
|
194
|
+
tree: ast.Module | None = None
|
|
195
|
+
with suppress(SyntaxError):
|
|
196
|
+
tree = ast.parse(source, filename=str(path))
|
|
197
|
+
parsed[path] = (_module_name(path, roots), source, tree)
|
|
198
|
+
return {
|
|
199
|
+
path: SourceUnit(
|
|
200
|
+
path=path,
|
|
201
|
+
module=module,
|
|
202
|
+
source=source,
|
|
203
|
+
tree=tree,
|
|
204
|
+
imports=MappingProxyType(_imports(module, tree, is_package=path.name == "__init__.py")),
|
|
205
|
+
)
|
|
206
|
+
for path, (module, source, tree) in parsed.items()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_index_candidate(source: str, matched_classes: set[str]) -> bool:
|
|
211
|
+
return (
|
|
212
|
+
"NewType(" in source
|
|
213
|
+
or ("class " in source and any(marker in source for marker in ("Enum", "IntEnum", "StrEnum")))
|
|
214
|
+
or ("match " in source and "str(" in source)
|
|
215
|
+
or any(f"class {name}" in source for name in matched_classes)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _module_name(path: Path, roots: Sequence[Path]) -> str | None:
|
|
220
|
+
resolved = path.resolve()
|
|
221
|
+
root = next((candidate for candidate in roots if resolved == candidate or candidate in resolved.parents), None)
|
|
222
|
+
if root is not None:
|
|
223
|
+
package_dir: Path | None = None
|
|
224
|
+
for ancestor in resolved.parents:
|
|
225
|
+
if ancestor == root:
|
|
226
|
+
break
|
|
227
|
+
try:
|
|
228
|
+
if (ancestor / "__init__.py").is_file():
|
|
229
|
+
package_dir = ancestor
|
|
230
|
+
except OSError:
|
|
231
|
+
return None
|
|
232
|
+
if package_dir is not None:
|
|
233
|
+
relative = resolved.relative_to(package_dir)
|
|
234
|
+
suffix = relative.parts[:-1] if relative.name == "__init__.py" else (*relative.parts[:-1], relative.stem)
|
|
235
|
+
return ".".join((package_dir.name, *suffix))
|
|
236
|
+
parts: list[str] = []
|
|
237
|
+
parent = path.parent
|
|
238
|
+
try:
|
|
239
|
+
while (parent / "__init__.py").is_file():
|
|
240
|
+
parts.append(parent.name)
|
|
241
|
+
parent = parent.parent
|
|
242
|
+
except OSError:
|
|
243
|
+
return None
|
|
244
|
+
if not parts:
|
|
245
|
+
if path.is_absolute():
|
|
246
|
+
return None
|
|
247
|
+
relative = [part for part in path.parts if part not in {".", ".."}]
|
|
248
|
+
if not relative:
|
|
249
|
+
return None
|
|
250
|
+
if path.name == "__init__.py":
|
|
251
|
+
return ".".join(relative[:-1]) or None
|
|
252
|
+
return ".".join([*relative[:-1], path.stem])
|
|
253
|
+
parts.reverse()
|
|
254
|
+
if path.name != "__init__.py":
|
|
255
|
+
parts.append(path.stem)
|
|
256
|
+
return ".".join(parts)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _project_roots(paths: Sequence[Path]) -> tuple[Path, ...]:
|
|
260
|
+
roots: list[Path] = []
|
|
261
|
+
for path in paths:
|
|
262
|
+
try:
|
|
263
|
+
resolved = path.resolve()
|
|
264
|
+
except OSError:
|
|
265
|
+
continue
|
|
266
|
+
if any(resolved == root or root in resolved.parents for root in roots):
|
|
267
|
+
continue
|
|
268
|
+
root = project_root(path)
|
|
269
|
+
if root is not None and root not in roots:
|
|
270
|
+
roots.append(root)
|
|
271
|
+
if len(roots) >= _MAX_ROOTS:
|
|
272
|
+
break
|
|
273
|
+
return tuple(sorted(roots))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _python_files(root: Path) -> Iterator[Path]:
|
|
277
|
+
for scanned, (directory, dir_names, file_names) in enumerate(os.walk(root), start=1):
|
|
278
|
+
if scanned > _MAX_DIRS_PER_ROOT:
|
|
279
|
+
return
|
|
280
|
+
parent = Path(directory)
|
|
281
|
+
dir_names[:] = [
|
|
282
|
+
name
|
|
283
|
+
for name in sorted(dir_names)
|
|
284
|
+
if not name.startswith(".") and name not in _SKIP_DIRS and not (parent / name / ".git").exists()
|
|
285
|
+
]
|
|
286
|
+
for name in sorted(file_names):
|
|
287
|
+
if name.endswith(".py"):
|
|
288
|
+
yield parent / name
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _imports(module: str | None, tree: ast.Module | None, *, is_package: bool) -> dict[str, SymbolRef]:
|
|
292
|
+
if module is None or tree is None:
|
|
293
|
+
return {}
|
|
294
|
+
result: dict[str, SymbolRef] = {}
|
|
295
|
+
package = module if is_package else module.rpartition(".")[0]
|
|
296
|
+
for node in tree.body:
|
|
297
|
+
if isinstance(node, ast.ImportFrom) and not any(alias.name == "*" for alias in node.names):
|
|
298
|
+
target = _relative_module(package, node.level, node.module)
|
|
299
|
+
if target is None:
|
|
300
|
+
continue
|
|
301
|
+
for alias in node.names:
|
|
302
|
+
result[alias.asname or alias.name] = SymbolRef(target, alias.name)
|
|
303
|
+
elif isinstance(node, ast.Import):
|
|
304
|
+
for alias in node.names:
|
|
305
|
+
if alias.asname:
|
|
306
|
+
result[alias.asname] = SymbolRef(alias.name, "")
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _relative_module(package: str, level: int, module: str | None) -> str | None:
|
|
311
|
+
if level == 0:
|
|
312
|
+
return module
|
|
313
|
+
parts = package.split(".") if package else []
|
|
314
|
+
if level > len(parts) + 1:
|
|
315
|
+
return None
|
|
316
|
+
base = parts[: len(parts) - level + 1]
|
|
317
|
+
if module:
|
|
318
|
+
base.extend(module.split("."))
|
|
319
|
+
return ".".join(base) if base else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _new_type(statement: ast.stmt, module: str) -> SymbolRef | None:
|
|
323
|
+
target: ast.Name | None = None
|
|
324
|
+
value: ast.expr | None = None
|
|
325
|
+
if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
|
|
326
|
+
target, value = statement.targets[0], statement.value
|
|
327
|
+
elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
|
|
328
|
+
target, value = statement.target, statement.value
|
|
329
|
+
if (
|
|
330
|
+
target is None
|
|
331
|
+
or not isinstance(value, ast.Call)
|
|
332
|
+
or _tail(value.func) != "NewType"
|
|
333
|
+
or len(value.args) < _NEW_TYPE_MIN_ARGS
|
|
334
|
+
):
|
|
335
|
+
return None
|
|
336
|
+
declared = value.args[0]
|
|
337
|
+
carrier = value.args[1]
|
|
338
|
+
if not (
|
|
339
|
+
isinstance(declared, ast.Constant) and declared.value == target.id and _tail(carrier) in {"UUID", "int", "str"}
|
|
340
|
+
):
|
|
341
|
+
return None
|
|
342
|
+
return SymbolRef(module, target.id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _field_key(type_name: str) -> str:
|
|
346
|
+
first = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", type_name)
|
|
347
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", first).lower()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _tail(node: ast.expr) -> str:
|
|
351
|
+
if isinstance(node, ast.Name):
|
|
352
|
+
return node.id
|
|
353
|
+
if isinstance(node, ast.Attribute):
|
|
354
|
+
return node.attr
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _read_bounded_source(root: Path, path: Path) -> LoadedSource | None:
|
|
359
|
+
try:
|
|
360
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > _MAX_FILE_BYTES:
|
|
361
|
+
return None
|
|
362
|
+
resolved = path.resolve()
|
|
363
|
+
resolved.relative_to(root.resolve())
|
|
364
|
+
return LoadedSource(resolved, resolved.read_text(encoding="utf-8", errors="replace"))
|
|
365
|
+
except OSError, ValueError:
|
|
366
|
+
return None
|
|
@@ -109,6 +109,8 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
|
|
|
109
109
|
)
|
|
110
110
|
from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
|
|
111
111
|
from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
|
|
112
|
+
from sarj_python_lint.rules.preserve_declared_nominal_id import PreserveDeclaredNominalId
|
|
113
|
+
from sarj_python_lint.rules.preserve_enum_types import PreserveEnumTypes
|
|
112
114
|
from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
|
|
113
115
|
from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
114
116
|
from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
|
|
@@ -122,8 +124,13 @@ from sarj_python_lint.rules.require_port_for_service import RequirePortForServic
|
|
|
122
124
|
from sarj_python_lint.rules.require_pydantic_for_external_json import (
|
|
123
125
|
RequirePydanticForExternalJson,
|
|
124
126
|
)
|
|
127
|
+
from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
|
|
128
|
+
RequirePydanticOrdinalLowerBound,
|
|
129
|
+
)
|
|
130
|
+
from sarj_python_lint.rules.require_validated_row_factory import RequireValidatedRowFactory
|
|
125
131
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
126
132
|
from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
|
|
133
|
+
from sarj_python_lint.rules.sql_requires_injected_pool_owner import SqlRequiresInjectedPoolOwner
|
|
127
134
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
128
135
|
from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
129
136
|
StoreInsertRequiresOnConflict,
|
|
@@ -220,6 +227,11 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
220
227
|
ProductionDerivedTestCases.id: ProductionDerivedTestCases,
|
|
221
228
|
UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
|
|
222
229
|
RepeatedStaticCallCases.id: RepeatedStaticCallCases,
|
|
230
|
+
RequireValidatedRowFactory.id: RequireValidatedRowFactory,
|
|
231
|
+
SqlRequiresInjectedPoolOwner.id: SqlRequiresInjectedPoolOwner,
|
|
232
|
+
PreserveDeclaredNominalId.id: PreserveDeclaredNominalId,
|
|
233
|
+
PreserveEnumTypes.id: PreserveEnumTypes,
|
|
234
|
+
RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
|
|
223
235
|
}
|
|
224
236
|
)
|
|
225
237
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""SARJ416 preserves project-declared nominal identifier roles.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_preserve_declared_nominal_id.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
ProjectRule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
parse_or_none,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
25
|
+
from sarj_python_lint.rules._project_index import ProjectIndexSet
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
class PreserveDeclaredNominalId(ProjectRule):
|
|
34
|
+
id = "preserve-declared-nominal-id"
|
|
35
|
+
code = "SARJ416"
|
|
36
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
37
|
+
summary="Keep project-declared nominal identifier types in test overrides.",
|
|
38
|
+
rationale="A fake override that widens a declared ID role back to its primitive carrier defeats type-checker swap protection.",
|
|
39
|
+
remediation="Import and propagate the matching project `NewType` instead of annotating the role with its carrier.",
|
|
40
|
+
category=RuleCategory.CORRECTNESS,
|
|
41
|
+
autofix=AutofixPolicy.NONE,
|
|
42
|
+
limitations=(
|
|
43
|
+
"The field name must map exactly and unambiguously to a first-party NewType declaration.",
|
|
44
|
+
"Only explicit override methods in test files are inspected; production boundary discovery remains SARJ093's responsibility.",
|
|
45
|
+
),
|
|
46
|
+
examples=(
|
|
47
|
+
RuleExample(
|
|
48
|
+
example_id="declared-id-erased",
|
|
49
|
+
title="A declared nominal ID is widened to string",
|
|
50
|
+
outcome=ExampleOutcome.MATCH,
|
|
51
|
+
files=(
|
|
52
|
+
ExampleFile.python(
|
|
53
|
+
"tests/fake.py",
|
|
54
|
+
"from typing import NewType, override\nSipTrunkId = NewType('SipTrunkId', str)\nclass Fake:\n @override\n def route(self, sip_trunk_id: str): ...\n",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
focus_path=PurePosixPath("tests/fake.py"),
|
|
58
|
+
expected_count=1,
|
|
59
|
+
public=True,
|
|
60
|
+
),
|
|
61
|
+
RuleExample(
|
|
62
|
+
example_id="declared-id-preserved",
|
|
63
|
+
title="A declared nominal ID remains nominal",
|
|
64
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
65
|
+
files=(
|
|
66
|
+
ExampleFile.python(
|
|
67
|
+
"tests/fake.py",
|
|
68
|
+
"from typing import NewType, override\nSipTrunkId = NewType('SipTrunkId', str)\nclass Fake:\n @override\n def route(self, sip_trunk_id: SipTrunkId): ...\n",
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
focus_path=PurePosixPath("tests/fake.py"),
|
|
72
|
+
expected_count=0,
|
|
73
|
+
public=True,
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
description = documentation.summary
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
81
|
+
if (
|
|
82
|
+
("@override" not in source and ".override" not in source)
|
|
83
|
+
or is_generated(path, source)
|
|
84
|
+
or not is_test_path(path)
|
|
85
|
+
or "migrations" in {part.lower() for part in path.parts}
|
|
86
|
+
):
|
|
87
|
+
return []
|
|
88
|
+
tree = parse_or_none(path, source)
|
|
89
|
+
if tree is None:
|
|
90
|
+
return []
|
|
91
|
+
indexes = self._project_indexes or ProjectIndexSet.single(path, source)
|
|
92
|
+
diagnostics: list[Diagnostic] = []
|
|
93
|
+
for node in ast.walk(tree):
|
|
94
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
95
|
+
if not any(_tail(item) == "override" for item in node.decorator_list):
|
|
96
|
+
continue
|
|
97
|
+
for argument in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]:
|
|
98
|
+
diagnostics.extend(_diagnose(path, argument.arg, argument.annotation, indexes, self.code))
|
|
99
|
+
return sorted(diagnostics, key=lambda item: (item.line, item.col))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _diagnose(
|
|
103
|
+
path: Path,
|
|
104
|
+
name: str,
|
|
105
|
+
annotation: ast.expr | None,
|
|
106
|
+
indexes: ProjectIndexSet,
|
|
107
|
+
code: str,
|
|
108
|
+
) -> list[Diagnostic]:
|
|
109
|
+
nominal = indexes.nominal_for_field(name)
|
|
110
|
+
if nominal is None or annotation is None or not _raw_primitive(annotation):
|
|
111
|
+
return []
|
|
112
|
+
return [
|
|
113
|
+
Diagnostic(
|
|
114
|
+
path=path,
|
|
115
|
+
line=annotation.lineno,
|
|
116
|
+
col=annotation.col_offset + 1,
|
|
117
|
+
code=code,
|
|
118
|
+
severity=Severity.WARNING,
|
|
119
|
+
message=f"`{name}` erases declared nominal `{nominal.name}`; propagate the nominal ID type through this boundary",
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _raw_primitive(node: ast.expr | None) -> bool:
|
|
125
|
+
match node:
|
|
126
|
+
case ast.Name(id=name):
|
|
127
|
+
return name in {"UUID", "int", "str"}
|
|
128
|
+
case ast.BinOp(left=left, op=ast.BitOr(), right=right):
|
|
129
|
+
return _raw_primitive(left) or _raw_primitive(right)
|
|
130
|
+
case ast.Subscript(slice=member):
|
|
131
|
+
return _raw_primitive(member)
|
|
132
|
+
case ast.Tuple(elts=members):
|
|
133
|
+
return any(_raw_primitive(item) for item in members)
|
|
134
|
+
case _:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tail(node: ast.expr) -> str:
|
|
139
|
+
if isinstance(node, ast.Name):
|
|
140
|
+
return node.id
|
|
141
|
+
if isinstance(node, ast.Attribute):
|
|
142
|
+
return node.attr
|
|
143
|
+
return ""
|