sarj-python-lint 0.81.2__tar.gz → 0.81.4__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.81.2 → sarj_python_lint-0.81.4}/PKG-INFO +1 -1
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/pyproject.toml +1 -1
- sarj_python_lint-0.81.4/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +203 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/stepdown.py +71 -19
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/typed_error_reasons.py +45 -9
- sarj_python_lint-0.81.2/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -190
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/.gitignore +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/LICENSE +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/README.md +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.81.
|
|
3
|
+
Version: 0.81.4
|
|
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/
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
parse_or_none,
|
|
17
|
+
)
|
|
18
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
19
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SYS_SOURCES = frozenset({"sys"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@final
|
|
30
|
+
class NoRedundantModuleAliasExports(Rule):
|
|
31
|
+
id: str = "no-redundant-module-alias-exports"
|
|
32
|
+
code: str = "SARJ440"
|
|
33
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
34
|
+
summary="Do not replace the current module through `sys.modules`.",
|
|
35
|
+
rationale=(
|
|
36
|
+
"Replacing the current module mutates import identity at runtime in a way static tools cannot model. "
|
|
37
|
+
"It makes introspection, reloads, circular imports, state, and monkeypatch behavior surprising."
|
|
38
|
+
),
|
|
39
|
+
remediation=(
|
|
40
|
+
"Import the canonical path inside maintained code. If a compatibility module is still required, expose "
|
|
41
|
+
"its supported names with explicit same-name imports grouped by source module."
|
|
42
|
+
),
|
|
43
|
+
category=RuleCategory.MAINTAINABILITY,
|
|
44
|
+
autofix=AutofixPolicy.NONE,
|
|
45
|
+
limitations=(
|
|
46
|
+
"Only module-scope assignments through an unconditional, unshadowed stdlib `sys` import are checked.",
|
|
47
|
+
"Assignments inside functions, classes, and `TYPE_CHECKING` branches are excluded.",
|
|
48
|
+
"Literal-key registrations, child-module registrations, method calls, reads, and arbitrary registries are excluded.",
|
|
49
|
+
"Generated source, malformed source, and stub files (`.pyi`) are outside this rule's scope.",
|
|
50
|
+
"No autofix is offered because the intended compatibility surface and identity requirements need review.",
|
|
51
|
+
),
|
|
52
|
+
examples=(
|
|
53
|
+
RuleExample(
|
|
54
|
+
example_id="current-module-replacement",
|
|
55
|
+
title="Do not replace a compatibility module at runtime",
|
|
56
|
+
outcome=ExampleOutcome.MATCH,
|
|
57
|
+
files=(
|
|
58
|
+
ExampleFile.python(
|
|
59
|
+
"legacy/settings.py",
|
|
60
|
+
"import sys\n\n"
|
|
61
|
+
"from canonical import settings as _canonical\n\n\n"
|
|
62
|
+
"sys.modules[__name__] = _canonical\n",
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
focus_path=PurePosixPath("legacy/settings.py"),
|
|
66
|
+
expected_count=1,
|
|
67
|
+
public=True,
|
|
68
|
+
),
|
|
69
|
+
RuleExample(
|
|
70
|
+
example_id="explicit-compatibility-exports",
|
|
71
|
+
title="Expose the supported compatibility surface explicitly",
|
|
72
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
73
|
+
files=(
|
|
74
|
+
ExampleFile.python(
|
|
75
|
+
"legacy/settings.py",
|
|
76
|
+
"from canonical.settings import (\n Settings as Settings,\n load as load,\n)\n",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
focus_path=PurePosixPath("legacy/settings.py"),
|
|
80
|
+
expected_count=0,
|
|
81
|
+
public=True,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
description: str = documentation.summary
|
|
86
|
+
|
|
87
|
+
@override
|
|
88
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
89
|
+
if path.suffix != ".py" or "modules" not in source or "__name__" not in source or is_generated(path, source):
|
|
90
|
+
return []
|
|
91
|
+
tree = parse_or_none(path, source)
|
|
92
|
+
if tree is None:
|
|
93
|
+
return []
|
|
94
|
+
imports = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
95
|
+
if not imports.builtin_is_unshadowed("__name__"):
|
|
96
|
+
return []
|
|
97
|
+
direct_sys_bindings = _direct_sys_bindings(tree)
|
|
98
|
+
findings = [
|
|
99
|
+
Diagnostic(
|
|
100
|
+
path=path,
|
|
101
|
+
line=target.lineno,
|
|
102
|
+
col=target.col_offset + 1,
|
|
103
|
+
code=self.code,
|
|
104
|
+
message=(
|
|
105
|
+
"Do not replace the current module through `sys.modules[__name__]`; expose compatibility names "
|
|
106
|
+
"with explicit imports and update maintained callers to the canonical module."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
for statement in _module_statements(tree.body)
|
|
110
|
+
for target in _assignment_targets(statement)
|
|
111
|
+
if _is_current_module_target(target, imports, direct_sys_bindings)
|
|
112
|
+
]
|
|
113
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col, finding.code))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _direct_sys_bindings(tree: ast.Module) -> frozenset[str]:
|
|
117
|
+
bindings: set[str] = set()
|
|
118
|
+
for statement in tree.body:
|
|
119
|
+
match statement:
|
|
120
|
+
case ast.Import(names=names):
|
|
121
|
+
bindings.update(alias.asname or "sys" for alias in names if alias.name == "sys")
|
|
122
|
+
case ast.ImportFrom(module="sys", level=0, names=names):
|
|
123
|
+
bindings.update(alias.asname or "modules" for alias in names if alias.name == "modules")
|
|
124
|
+
case _:
|
|
125
|
+
pass
|
|
126
|
+
return frozenset(bindings)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _module_statements(statements: list[ast.stmt]) -> list[ast.stmt]:
|
|
130
|
+
result: list[ast.stmt] = []
|
|
131
|
+
pending = list(reversed(statements))
|
|
132
|
+
while pending:
|
|
133
|
+
statement = pending.pop()
|
|
134
|
+
result.append(statement)
|
|
135
|
+
pending.extend(reversed(_module_control_flow_bodies(statement)))
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _module_control_flow_bodies(statement: ast.stmt) -> list[ast.stmt]:
|
|
140
|
+
match statement:
|
|
141
|
+
case ast.If() if _is_type_checking_guard(statement.test):
|
|
142
|
+
return list(statement.orelse)
|
|
143
|
+
case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
|
|
144
|
+
return [*statement.body, *statement.orelse]
|
|
145
|
+
case ast.With() | ast.AsyncWith():
|
|
146
|
+
return list(statement.body)
|
|
147
|
+
case ast.Try() | ast.TryStar():
|
|
148
|
+
return [
|
|
149
|
+
*statement.body,
|
|
150
|
+
*(nested for handler in statement.handlers for nested in handler.body),
|
|
151
|
+
*statement.orelse,
|
|
152
|
+
*statement.finalbody,
|
|
153
|
+
]
|
|
154
|
+
case ast.Match():
|
|
155
|
+
return [nested for match_case in statement.cases for nested in match_case.body]
|
|
156
|
+
case _:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _assignment_targets(statement: ast.stmt) -> list[ast.expr]:
|
|
161
|
+
match statement:
|
|
162
|
+
case ast.Assign(targets=targets):
|
|
163
|
+
return [nested for target in targets for nested in _nested_targets(target)]
|
|
164
|
+
case ast.AnnAssign(target=target):
|
|
165
|
+
return _nested_targets(target)
|
|
166
|
+
case _:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _nested_targets(target: ast.expr) -> list[ast.expr]:
|
|
171
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
172
|
+
return [nested for element in target.elts for nested in _nested_targets(element)]
|
|
173
|
+
return [target]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_current_module_target(
|
|
177
|
+
target: ast.expr,
|
|
178
|
+
imports: ImportIndex,
|
|
179
|
+
direct_sys_bindings: frozenset[str],
|
|
180
|
+
) -> bool:
|
|
181
|
+
match target:
|
|
182
|
+
case ast.Subscript(
|
|
183
|
+
value=value,
|
|
184
|
+
slice=ast.Name(id="__name__", ctx=ast.Load()),
|
|
185
|
+
ctx=ast.Store(),
|
|
186
|
+
):
|
|
187
|
+
root = value.value if isinstance(value, ast.Attribute) else value
|
|
188
|
+
return (
|
|
189
|
+
isinstance(root, ast.Name)
|
|
190
|
+
and root.id in direct_sys_bindings
|
|
191
|
+
and imports.resolves(value, sources=_SYS_SOURCES, symbol="modules")
|
|
192
|
+
)
|
|
193
|
+
case _:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_type_checking_guard(node: ast.expr) -> bool:
|
|
198
|
+
return (isinstance(node, ast.Name) and node.id == "TYPE_CHECKING") or (
|
|
199
|
+
isinstance(node, ast.Attribute)
|
|
200
|
+
and isinstance(node.value, ast.Name)
|
|
201
|
+
and node.value.id in {"typing", "typing_extensions"}
|
|
202
|
+
and node.attr == "TYPE_CHECKING"
|
|
203
|
+
)
|
|
@@ -34,7 +34,7 @@ _SELF_NAMES = frozenset({"self", "cls"})
|
|
|
34
34
|
|
|
35
35
|
# These decorators preserve an ordinary callable definition and do not register
|
|
36
36
|
# it through user code at definition time. Unknown decorators are movement barriers.
|
|
37
|
-
_ORDER_TRANSPARENT_DECORATORS = frozenset({"classmethod", "staticmethod"
|
|
37
|
+
_ORDER_TRANSPARENT_DECORATORS = frozenset({"classmethod", "staticmethod"})
|
|
38
38
|
|
|
39
39
|
#: A repeated singledispatch implementation name cannot identify one movable target.
|
|
40
40
|
_DISCARD_NAME = "_"
|
|
@@ -65,6 +65,7 @@ class Stepdown(Rule):
|
|
|
65
65
|
limitations=(
|
|
66
66
|
"Generated files, tests, `__main__.py`, mutual recursion, and helpers with multiple callers are excluded.",
|
|
67
67
|
"Decorated definitions and dynamic references that cannot prove a sole caller are not reported.",
|
|
68
|
+
"Only bare builtin classmethod/staticmethod decorators without any visible rebinding are treated as transparent; other decorators remain ordering barriers.",
|
|
68
69
|
),
|
|
69
70
|
examples=(
|
|
70
71
|
RuleExample(
|
|
@@ -108,11 +109,14 @@ class Stepdown(Rule):
|
|
|
108
109
|
tree = parse_or_none(path, source)
|
|
109
110
|
if tree is None:
|
|
110
111
|
return []
|
|
111
|
-
|
|
112
|
+
transparent = _unshadowed_builtin_decorators(tree)
|
|
113
|
+
diags = _check_module_scope(path, tree, self.code, transparent)
|
|
112
114
|
classes = [node for node in _walk(tree) if isinstance(node, ast.ClassDef)]
|
|
113
115
|
family_external = _family_external_refs(classes)
|
|
114
116
|
for cls in classes:
|
|
115
|
-
diags.extend(
|
|
117
|
+
diags.extend(
|
|
118
|
+
_check_class_scope(path, cls, self.code, family_external.get(id(cls), frozenset()), transparent)
|
|
119
|
+
)
|
|
116
120
|
diags.sort(key=lambda d: (d.line, d.col))
|
|
117
121
|
return diags
|
|
118
122
|
|
|
@@ -121,7 +125,7 @@ def _last_by_name[DefT: _Def](defs: Sequence[DefT]) -> dict[str, DefT]:
|
|
|
121
125
|
return {definition.name: definition for definition in defs}
|
|
122
126
|
|
|
123
127
|
|
|
124
|
-
def _check_module_scope(path: Path, tree: ast.Module, code: str) -> list[Diagnostic]:
|
|
128
|
+
def _check_module_scope(path: Path, tree: ast.Module, code: str, transparent: frozenset[str]) -> list[Diagnostic]:
|
|
125
129
|
defs = [n for n in tree.body if isinstance(n, _SCOPE_NODES)]
|
|
126
130
|
counts = Counter(d.name for d in defs)
|
|
127
131
|
unique_defs = {name: d for d in defs if counts[name := d.name] == 1}
|
|
@@ -156,15 +160,19 @@ def _check_module_scope(path: Path, tree: ast.Module, code: str) -> list[Diagnos
|
|
|
156
160
|
for name, d in unique_defs.items():
|
|
157
161
|
if not isinstance(d, _DEF_NODES) or not _is_private_helper_name(name):
|
|
158
162
|
continue
|
|
159
|
-
if name in pinned or name in shadowed or _has_order_sensitive_decorator(d):
|
|
163
|
+
if name in pinned or name in shadowed or _has_order_sensitive_decorator(d, transparent):
|
|
160
164
|
continue
|
|
161
165
|
diags.extend(
|
|
162
|
-
_flag_if_above_single_caller(
|
|
166
|
+
_flag_if_above_single_caller(
|
|
167
|
+
path, code, name, node=d, graph=graph, defs=all_defs, ref_lines=ref_lines, transparent=transparent
|
|
168
|
+
)
|
|
163
169
|
)
|
|
164
170
|
return diags
|
|
165
171
|
|
|
166
172
|
|
|
167
|
-
def _check_class_scope(
|
|
173
|
+
def _check_class_scope(
|
|
174
|
+
path: Path, cls: ast.ClassDef, code: str, external_callers: frozenset[str], transparent: frozenset[str]
|
|
175
|
+
) -> list[Diagnostic]:
|
|
168
176
|
methods = [n for n in cls.body if isinstance(n, _DEF_NODES)]
|
|
169
177
|
counts = Counter(m.name for m in methods)
|
|
170
178
|
unique = {name: m for m in methods if counts[name := m.name] == 1}
|
|
@@ -197,10 +205,17 @@ def _check_class_scope(path: Path, cls: ast.ClassDef, code: str, external_caller
|
|
|
197
205
|
for name, m in unique.items():
|
|
198
206
|
if not _is_private_helper_name(name):
|
|
199
207
|
continue
|
|
200
|
-
if
|
|
208
|
+
if (
|
|
209
|
+
name in pinned
|
|
210
|
+
or name in shadowed
|
|
211
|
+
or name in external_callers
|
|
212
|
+
or _has_order_sensitive_decorator(m, transparent)
|
|
213
|
+
):
|
|
201
214
|
continue
|
|
202
215
|
diags.extend(
|
|
203
|
-
_flag_if_above_single_caller(
|
|
216
|
+
_flag_if_above_single_caller(
|
|
217
|
+
path, code, name, node=m, graph=graph, defs=all_methods, ref_lines=ref_lines, transparent=transparent
|
|
218
|
+
)
|
|
204
219
|
)
|
|
205
220
|
return diags
|
|
206
221
|
|
|
@@ -214,6 +229,7 @@ def _flag_if_above_single_caller(
|
|
|
214
229
|
graph: dict[str, set[str]],
|
|
215
230
|
defs: Mapping[str, ast.stmt],
|
|
216
231
|
ref_lines: dict[tuple[str, str], int],
|
|
232
|
+
transparent: frozenset[str],
|
|
217
233
|
) -> list[Diagnostic]:
|
|
218
234
|
callers = [c for c, callees in graph.items() if name in callees]
|
|
219
235
|
if len(callers) != 1:
|
|
@@ -224,7 +240,9 @@ def _flag_if_above_single_caller(
|
|
|
224
240
|
if isinstance(defs[caller], ast.ClassDef):
|
|
225
241
|
return []
|
|
226
242
|
caller_node = defs[caller]
|
|
227
|
-
if isinstance(caller_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_order_sensitive_decorator(
|
|
243
|
+
if isinstance(caller_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_order_sensitive_decorator(
|
|
244
|
+
caller_node, transparent
|
|
245
|
+
):
|
|
228
246
|
return []
|
|
229
247
|
if _reaches(graph, name, caller):
|
|
230
248
|
return []
|
|
@@ -338,15 +356,38 @@ def _is_private_helper_name(name: str) -> bool:
|
|
|
338
356
|
return not (name.startswith("__") and name.endswith("__"))
|
|
339
357
|
|
|
340
358
|
|
|
341
|
-
def
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
match
|
|
345
|
-
case ast.
|
|
346
|
-
|
|
359
|
+
def _unshadowed_builtin_decorators(tree: ast.Module) -> frozenset[str]:
|
|
360
|
+
bound: set[str] = set()
|
|
361
|
+
for node in _walk(tree):
|
|
362
|
+
match node:
|
|
363
|
+
case ast.ImportFrom(names=names) if any(alias.name == "*" for alias in names):
|
|
364
|
+
return frozenset()
|
|
365
|
+
case (
|
|
366
|
+
ast.Name(id=name, ctx=ast.Store() | ast.Del())
|
|
367
|
+
| ast.arg(arg=name)
|
|
368
|
+
| ast.FunctionDef(name=name)
|
|
369
|
+
| ast.AsyncFunctionDef(name=name)
|
|
370
|
+
| ast.ClassDef(name=name)
|
|
371
|
+
):
|
|
372
|
+
bound.add(name)
|
|
373
|
+
case ast.alias(name=name, asname=asname):
|
|
374
|
+
bound.add(asname or name.split(".")[0])
|
|
375
|
+
case (
|
|
376
|
+
ast.ExceptHandler(name=str() as name)
|
|
377
|
+
| ast.MatchAs(name=str() as name)
|
|
378
|
+
| ast.MatchStar(name=str() as name)
|
|
379
|
+
| ast.MatchMapping(rest=str() as name)
|
|
380
|
+
):
|
|
381
|
+
bound.add(name)
|
|
347
382
|
case _:
|
|
348
|
-
|
|
349
|
-
return
|
|
383
|
+
pass
|
|
384
|
+
return _ORDER_TRANSPARENT_DECORATORS - bound
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _has_order_sensitive_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef, transparent: frozenset[str]) -> bool:
|
|
388
|
+
return any(
|
|
389
|
+
not isinstance(decorator, ast.Name) or decorator.id not in transparent for decorator in node.decorator_list
|
|
390
|
+
)
|
|
350
391
|
|
|
351
392
|
|
|
352
393
|
def _deferred_body(node: ast.stmt) -> list[ast.stmt]:
|
|
@@ -626,7 +667,18 @@ def _direct_scope_bindings(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[
|
|
|
626
667
|
|
|
627
668
|
|
|
628
669
|
def _lambda_bindings(node: ast.Lambda) -> set[str]:
|
|
629
|
-
|
|
670
|
+
bound = _argument_names(node.args)
|
|
671
|
+
stack: list[ast.AST] = [node.body]
|
|
672
|
+
while stack:
|
|
673
|
+
current = stack.pop()
|
|
674
|
+
if isinstance(current, ast.Lambda):
|
|
675
|
+
stack.extend(current.args.defaults)
|
|
676
|
+
stack.extend(default for default in current.args.kw_defaults if default is not None)
|
|
677
|
+
continue
|
|
678
|
+
if isinstance(current, ast.NamedExpr):
|
|
679
|
+
bound.update(_target_names(current.target))
|
|
680
|
+
stack.extend(_child_nodes(current))
|
|
681
|
+
return bound
|
|
630
682
|
|
|
631
683
|
|
|
632
684
|
def _argument_names(args: ast.arguments) -> set[str]:
|
|
@@ -32,23 +32,54 @@ class TypedErrorReasons(Rule):
|
|
|
32
32
|
id = "typed-error-reasons"
|
|
33
33
|
code = "SARJ435"
|
|
34
34
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
35
|
-
summary="
|
|
35
|
+
summary="Review joined exception strings for fixed reason identities versus dynamic context.",
|
|
36
36
|
rationale=(
|
|
37
|
-
"
|
|
38
|
-
"
|
|
37
|
+
"Fixed reason identities benefit from stable codes for API clients, UI formatting, telemetry, and "
|
|
38
|
+
"exhaustive handling. Open-ended values such as file paths remain typed context, not an enum domain."
|
|
39
39
|
),
|
|
40
40
|
remediation=(
|
|
41
|
-
"
|
|
42
|
-
"
|
|
41
|
+
"If the strings are fixed reason identities, use a `StrEnum` code with typed context. Keep dynamic "
|
|
42
|
+
"paths and messages as typed context; format presentation separately."
|
|
43
43
|
),
|
|
44
44
|
category=RuleCategory.ARCHITECTURE,
|
|
45
45
|
autofix=AutofixPolicy.NONE,
|
|
46
46
|
limitations=(
|
|
47
|
-
"
|
|
48
|
-
"The constructor must have exactly one non-self parameter, annotated exactly `list[str]`, and
|
|
47
|
+
"The class name and a direct base name must end in Error or Exception; this naming convention does not prove exception inheritance or resolve shadowed bases.",
|
|
48
|
+
"The constructor must have exactly one non-self parameter and no variadic parameters, annotated exactly `list[str]`, and a literal string separator must join it inside `super().__init__(...)`.",
|
|
49
|
+
"This warning requests manual review; joining strings does not prove a finite reason domain, so contextual string lists may also match and must not automatically become enums.",
|
|
49
50
|
"Formatting delegated to another function, legacy `typing.List`, mixed constructor context, tests, and generated files are intentionally not inferred.",
|
|
50
51
|
),
|
|
51
52
|
examples=(
|
|
53
|
+
RuleExample(
|
|
54
|
+
example_id="dynamic-context-review",
|
|
55
|
+
title="Review a contextual list without converting paths to enum values",
|
|
56
|
+
outcome=ExampleOutcome.MATCH,
|
|
57
|
+
files=(
|
|
58
|
+
ExampleFile.python(
|
|
59
|
+
"app/errors.py",
|
|
60
|
+
"class MissingFilesError(Exception):\n def __init__(self, paths: list[str]) -> None:\n super().__init__(', '.join(paths))\n",
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
focus_path=PurePosixPath("app/errors.py"),
|
|
64
|
+
expected_count=1,
|
|
65
|
+
public=True,
|
|
66
|
+
scenario="dynamic-context",
|
|
67
|
+
),
|
|
68
|
+
RuleExample(
|
|
69
|
+
example_id="dynamic-context-kept-typed",
|
|
70
|
+
title="Keep arbitrary paths as typed context without an enum",
|
|
71
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
72
|
+
files=(
|
|
73
|
+
ExampleFile.python(
|
|
74
|
+
"app/errors.py",
|
|
75
|
+
"class MissingFilesError(Exception):\n def __init__(self, paths: list[str]) -> None:\n self.paths = paths\n super().__init__('Files are missing')\n\ndef render_error(error: MissingFilesError) -> str:\n return ', '.join(error.paths)\n",
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
focus_path=PurePosixPath("app/errors.py"),
|
|
79
|
+
expected_count=0,
|
|
80
|
+
public=True,
|
|
81
|
+
scenario="dynamic-context",
|
|
82
|
+
),
|
|
52
83
|
RuleExample(
|
|
53
84
|
example_id="rendered-reason-list",
|
|
54
85
|
title="An exception renders raw reason strings",
|
|
@@ -135,8 +166,9 @@ class TypedErrorReasons(Rule):
|
|
|
135
166
|
code=self.code,
|
|
136
167
|
severity=Severity.WARNING,
|
|
137
168
|
message=(
|
|
138
|
-
f"`{error_class.name}` joins `{parameter.arg}: list[str]` into its message;
|
|
139
|
-
"reason
|
|
169
|
+
f"`{error_class.name}` joins `{parameter.arg}: list[str]` into its message; if these are "
|
|
170
|
+
"fixed reason identities, carry typed codes. Keep dynamic context such as paths or messages "
|
|
171
|
+
"as typed context and format presentation separately"
|
|
140
172
|
),
|
|
141
173
|
)
|
|
142
174
|
)
|
|
@@ -156,6 +188,8 @@ def _tail(node: ast.expr) -> str:
|
|
|
156
188
|
|
|
157
189
|
|
|
158
190
|
def _sole_string_list_parameter(function: ast.FunctionDef) -> ast.arg | None:
|
|
191
|
+
if function.args.vararg is not None or function.args.kwarg is not None:
|
|
192
|
+
return None
|
|
159
193
|
parameters = [
|
|
160
194
|
argument
|
|
161
195
|
for argument in (
|
|
@@ -221,6 +255,8 @@ def _joins_parameter(node: ast.AST, parameter: str) -> TypeGuard[ast.Call]:
|
|
|
221
255
|
isinstance(node, ast.Call)
|
|
222
256
|
and isinstance(node.func, ast.Attribute)
|
|
223
257
|
and node.func.attr == "join"
|
|
258
|
+
and isinstance(node.func.value, ast.Constant)
|
|
259
|
+
and isinstance(node.func.value.value, str)
|
|
224
260
|
and len(node.args) == 1
|
|
225
261
|
and not node.keywords
|
|
226
262
|
and isinstance(node.args[0], ast.Name)
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import ast
|
|
4
|
-
from pathlib import PurePosixPath
|
|
5
|
-
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
-
|
|
7
|
-
from sarj_python_lint.rule_base import (
|
|
8
|
-
AutofixPolicy,
|
|
9
|
-
Diagnostic,
|
|
10
|
-
ExampleFile,
|
|
11
|
-
ExampleOutcome,
|
|
12
|
-
Rule,
|
|
13
|
-
RuleCategory,
|
|
14
|
-
RuleDocumentation,
|
|
15
|
-
RuleExample,
|
|
16
|
-
Severity,
|
|
17
|
-
parse_or_none,
|
|
18
|
-
)
|
|
19
|
-
from sarj_python_lint.rules._paths import is_generated
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
_MIN_ALIAS_STATEMENTS = 4
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@final
|
|
30
|
-
class NoRedundantModuleAliasExports(Rule):
|
|
31
|
-
id: str = "no-redundant-module-alias-exports"
|
|
32
|
-
code: str = "SARJ440"
|
|
33
|
-
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
34
|
-
summary="Do not copy names before replacing a compatibility module.",
|
|
35
|
-
rationale=(
|
|
36
|
-
"A compatibility module that replaces itself in `sys.modules` already exposes the canonical module. "
|
|
37
|
-
"Copying every public attribute first duplicates the API surface and invites drift."
|
|
38
|
-
),
|
|
39
|
-
remediation=(
|
|
40
|
-
"Delete the forwarding assignments and keep the canonical module import plus the final "
|
|
41
|
-
"`sys.modules[__name__] = alias` replacement. Import the canonical path inside maintained code."
|
|
42
|
-
),
|
|
43
|
-
category=RuleCategory.MAINTAINABILITY,
|
|
44
|
-
autofix=AutofixPolicy.NONE,
|
|
45
|
-
limitations=(
|
|
46
|
-
"Only otherwise-pure compatibility modules with a final, unconditional self-replacement are checked.",
|
|
47
|
-
"Forwarding must use exact same-name assignments such as `Thing = canonical.Thing`.",
|
|
48
|
-
"Conditional, nested, computed, annotated, chained, destructured, or mixed-purpose modules are excluded.",
|
|
49
|
-
"Generated source, malformed source, and stub files (`.pyi`) are outside this rule's scope.",
|
|
50
|
-
"No autofix is offered because attribute access can have side effects and circular imports require review.",
|
|
51
|
-
),
|
|
52
|
-
examples=(
|
|
53
|
-
RuleExample(
|
|
54
|
-
example_id="self-replacing-compatibility-module",
|
|
55
|
-
title="Remove bindings that the canonical module replacement supersedes",
|
|
56
|
-
outcome=ExampleOutcome.MATCH,
|
|
57
|
-
files=(
|
|
58
|
-
ExampleFile.python(
|
|
59
|
-
"legacy/settings.py",
|
|
60
|
-
"import sys\n\n"
|
|
61
|
-
"from canonical import settings as _canonical\n\n\n"
|
|
62
|
-
"Settings = _canonical.Settings\n"
|
|
63
|
-
"load = _canonical.load\n"
|
|
64
|
-
"sys.modules[__name__] = _canonical\n",
|
|
65
|
-
),
|
|
66
|
-
),
|
|
67
|
-
focus_path=PurePosixPath("legacy/settings.py"),
|
|
68
|
-
expected_count=1,
|
|
69
|
-
public=True,
|
|
70
|
-
),
|
|
71
|
-
RuleExample(
|
|
72
|
-
example_id="pure-module-alias",
|
|
73
|
-
title="Replace the compatibility module without copying its attributes",
|
|
74
|
-
outcome=ExampleOutcome.NO_MATCH,
|
|
75
|
-
files=(
|
|
76
|
-
ExampleFile.python(
|
|
77
|
-
"legacy/settings.py",
|
|
78
|
-
"import sys\n\n"
|
|
79
|
-
"from canonical import settings as _canonical\n\n\n"
|
|
80
|
-
"sys.modules[__name__] = _canonical\n",
|
|
81
|
-
),
|
|
82
|
-
),
|
|
83
|
-
focus_path=PurePosixPath("legacy/settings.py"),
|
|
84
|
-
expected_count=0,
|
|
85
|
-
public=True,
|
|
86
|
-
),
|
|
87
|
-
),
|
|
88
|
-
)
|
|
89
|
-
description: str = documentation.summary
|
|
90
|
-
|
|
91
|
-
@override
|
|
92
|
-
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
93
|
-
if path.suffix != ".py" or "sys.modules" not in source or is_generated(path, source):
|
|
94
|
-
return []
|
|
95
|
-
tree = parse_or_none(path, source)
|
|
96
|
-
if tree is None:
|
|
97
|
-
return []
|
|
98
|
-
first_forward = _redundant_forward(tree)
|
|
99
|
-
if first_forward is None:
|
|
100
|
-
return []
|
|
101
|
-
return [
|
|
102
|
-
Diagnostic(
|
|
103
|
-
path=path,
|
|
104
|
-
line=first_forward.lineno,
|
|
105
|
-
col=first_forward.col_offset + 1,
|
|
106
|
-
code=self.code,
|
|
107
|
-
severity=Severity.ERROR,
|
|
108
|
-
message=(
|
|
109
|
-
"Delete the forwarding assignments; this compatibility module already replaces itself with "
|
|
110
|
-
"the canonical module."
|
|
111
|
-
),
|
|
112
|
-
)
|
|
113
|
-
]
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _redundant_forward(tree: ast.Module) -> ast.Assign | None:
|
|
117
|
-
body = list(tree.body)
|
|
118
|
-
if body and _is_docstring(body[0]):
|
|
119
|
-
body.pop(0)
|
|
120
|
-
while body and isinstance(body[0], ast.ImportFrom) and body[0].module == "__future__":
|
|
121
|
-
body.pop(0)
|
|
122
|
-
if len(body) < _MIN_ALIAS_STATEMENTS or not _imports_sys(body[0]):
|
|
123
|
-
return None
|
|
124
|
-
alias = _canonical_alias(body[1])
|
|
125
|
-
if alias is None or not _replaces_current_module(body[-1], alias):
|
|
126
|
-
return None
|
|
127
|
-
forwards = body[2:-1]
|
|
128
|
-
if not forwards:
|
|
129
|
-
return None
|
|
130
|
-
for statement in forwards:
|
|
131
|
-
if not _is_same_name_forward(statement, alias):
|
|
132
|
-
return None
|
|
133
|
-
return forwards[0] if isinstance(forwards[0], ast.Assign) else None
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _is_docstring(statement: ast.stmt) -> bool:
|
|
137
|
-
return (
|
|
138
|
-
isinstance(statement, ast.Expr)
|
|
139
|
-
and isinstance(statement.value, ast.Constant)
|
|
140
|
-
and isinstance(statement.value.value, str)
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _imports_sys(statement: ast.stmt) -> bool:
|
|
145
|
-
return (
|
|
146
|
-
isinstance(statement, ast.Import)
|
|
147
|
-
and len(statement.names) == 1
|
|
148
|
-
and statement.names[0].name == "sys"
|
|
149
|
-
and (statement.names[0].asname is None)
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _canonical_alias(statement: ast.stmt) -> str | None:
|
|
154
|
-
match statement:
|
|
155
|
-
case ast.Import(names=[ast.alias(asname=alias)]) if alias is not None:
|
|
156
|
-
return alias
|
|
157
|
-
case ast.ImportFrom(module=module, names=[ast.alias(name=name, asname=alias)]) if (
|
|
158
|
-
module is not None and name != "*" and alias is not None
|
|
159
|
-
):
|
|
160
|
-
return alias
|
|
161
|
-
case _:
|
|
162
|
-
return None
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _is_same_name_forward(statement: ast.stmt, alias: str) -> bool:
|
|
166
|
-
match statement:
|
|
167
|
-
case ast.Assign(
|
|
168
|
-
targets=[ast.Name(id=target, ctx=ast.Store())],
|
|
169
|
-
value=ast.Attribute(value=ast.Name(id=owner, ctx=ast.Load()), attr=attribute, ctx=ast.Load()),
|
|
170
|
-
):
|
|
171
|
-
return owner == alias and target == attribute and target not in {"sys", "__name__", alias}
|
|
172
|
-
case _:
|
|
173
|
-
return False
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _replaces_current_module(statement: ast.stmt, alias: str) -> bool:
|
|
177
|
-
match statement:
|
|
178
|
-
case ast.Assign(
|
|
179
|
-
targets=[
|
|
180
|
-
ast.Subscript(
|
|
181
|
-
value=ast.Attribute(value=ast.Name(id="sys", ctx=ast.Load()), attr="modules", ctx=ast.Load()),
|
|
182
|
-
slice=ast.Name(id="__name__", ctx=ast.Load()),
|
|
183
|
-
ctx=ast.Store(),
|
|
184
|
-
)
|
|
185
|
-
],
|
|
186
|
-
value=ast.Name(id=replacement, ctx=ast.Load()),
|
|
187
|
-
):
|
|
188
|
-
return replacement == alias
|
|
189
|
-
case _:
|
|
190
|
-
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_analysis_session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fixed_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_project_index.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_prose_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_test_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_dunder_all.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|