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.
Files changed (127) hide show
  1. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/pyproject.toml +1 -1
  3. sarj_python_lint-0.81.4/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +203 -0
  4. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/stepdown.py +71 -19
  5. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/typed_error_reasons.py +45 -9
  6. sarj_python_lint-0.81.2/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -190
  7. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/.gitignore +0 -0
  8. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/LICENSE +0 -0
  9. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/README.md +0 -0
  10. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_python_target.py +0 -0
  15. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  16. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_secret_names.py +0 -0
  17. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_comments.py +0 -0
  24. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  25. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  26. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
  27. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  28. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_imports.py +0 -0
  29. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_logging.py +0 -0
  30. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_paths.py +0 -0
  31. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_project_index.py +0 -0
  32. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  33. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
  34. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_registry.py +0 -0
  35. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_sql.py +0 -0
  36. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  37. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  38. {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
  39. {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
  40. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  41. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  42. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  43. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  44. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  45. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  46. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  47. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  48. {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
  49. {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
  50. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  51. {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
  52. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  53. {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
  54. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  55. {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
  56. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  57. {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
  58. {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
  59. {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
  60. {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
  61. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  62. {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
  63. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  64. {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
  65. {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
  66. {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
  67. {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
  68. {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
  69. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  70. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  71. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  72. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  73. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  74. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  75. {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
  76. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  77. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  78. {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
  79. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  80. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  81. {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
  82. {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
  83. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  84. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  85. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  86. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  87. {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
  88. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  89. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  90. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  91. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  92. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  94. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  95. {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
  96. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  97. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  98. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  99. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  100. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  101. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  102. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  103. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  104. {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
  105. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  106. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  107. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  108. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  109. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  110. {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
  111. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  112. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  113. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  114. {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
  115. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  116. {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
  117. {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
  118. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  119. {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
  120. {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
  121. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  122. {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
  123. {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
  124. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  125. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  126. {sarj_python_lint-0.81.2 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  127. {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.2
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.81.2"
3
+ version = "0.81.4"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -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", "final", "override"})
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
- diags = _check_module_scope(path, tree, self.code)
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(_check_class_scope(path, cls, self.code, family_external.get(id(cls), frozenset())))
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(path, code, name, node=d, graph=graph, defs=all_defs, ref_lines=ref_lines)
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(path: Path, cls: ast.ClassDef, code: str, external_callers: frozenset[str]) -> list[Diagnostic]:
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 name in pinned or name in shadowed or name in external_callers or _has_order_sensitive_decorator(m):
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(path, code, name, node=m, graph=graph, defs=all_methods, ref_lines=ref_lines)
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(caller_node):
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 _has_order_sensitive_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
342
- for decorator in node.decorator_list:
343
- target = decorator.func if isinstance(decorator, ast.Call) else decorator
344
- match target:
345
- case ast.Name(id=name) | ast.Attribute(attr=name) if name in _ORDER_TRANSPARENT_DECORATORS:
346
- continue
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
- return True
349
- return False
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
- return _argument_names(node.args)
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="Exception aggregates presentation strings instead of typed error reasons.",
35
+ summary="Review joined exception strings for fixed reason identities versus dynamic context.",
36
36
  rationale=(
37
- "A collection of rendered sentences has no stable identity for API clients, UI formatting, telemetry, "
38
- "or exhaustive handling; consumers must display or parse text that should be presentation-only."
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
- "Replace `list[str]` with a nominal reason type: usually a record containing a `StrEnum` code and typed "
42
- "context. Format that structure only at the presentation boundary."
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
- "Only an Error/Exception subclass whose direct Error/Exception base is visible in the class declaration is checked.",
48
- "The constructor must have exactly one non-self parameter, annotated exactly `list[str]`, and join that same parameter inside `super().__init__(...)`.",
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; carry nominal "
139
- "reason codes with typed context and format them at the presentation boundary"
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