sarj-python-lint 0.81.6__tar.gz → 0.81.7__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.6 → sarj_python_lint-0.81.7}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_project_index.py +63 -1
  4. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_registry.py +2 -0
  5. sarj_python_lint-0.81.7/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +238 -0
  6. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +328 -4
  7. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_class_docstring.py +7 -5
  8. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_docstring.py +7 -5
  9. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_port_for_service.py +78 -34
  10. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +7 -5
  11. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/restated_test_docstring.py +7 -5
  12. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +7 -5
  13. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/.gitignore +0 -0
  14. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/LICENSE +0 -0
  15. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/README.md +0 -0
  16. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__init__.py +0 -0
  17. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__main__.py +0 -0
  18. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_analysis_session.py +0 -0
  19. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_filesystem.py +0 -0
  20. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_python_target.py +0 -0
  21. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  22. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_secret_names.py +0 -0
  23. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_version.py +0 -0
  24. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/ratchet.py +0 -0
  26. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rule_base.py +0 -0
  27. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/__init__.py +0 -0
  28. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  29. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_comments.py +0 -0
  30. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  31. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  32. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_first_party.py +0 -0
  33. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  34. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_imports.py +0 -0
  35. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_logging.py +0 -0
  36. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_paths.py +0 -0
  37. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  38. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_pytest.py +0 -0
  39. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_sql.py +0 -0
  40. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  41. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  42. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  43. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  44. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  45. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  46. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  47. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  48. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  49. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  50. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  51. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  52. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  53. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  54. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  55. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  56. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  57. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  58. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  59. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  60. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  61. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  62. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  63. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  64. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  65. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  66. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  67. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  68. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  69. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  70. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  71. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  72. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  73. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  74. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  75. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  76. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  77. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  78. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  79. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  80. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  81. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  82. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  83. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  84. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  85. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  86. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  87. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  88. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  89. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  90. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  91. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  92. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  93. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  94. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  95. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  96. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  97. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  98. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  99. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  100. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  101. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  102. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  103. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  104. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  105. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  106. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  107. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  108. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  109. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  110. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  111. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  112. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  113. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  114. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  115. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  116. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  117. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  118. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  119. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  120. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  121. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/stepdown.py +0 -0
  122. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  123. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  124. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  125. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  126. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  127. {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/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.6
3
+ Version: 0.81.7
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.6"
3
+ version = "0.81.7"
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" }]
@@ -193,6 +193,37 @@ class ProjectIndexSet:
193
193
  def source_unit(self, module: str) -> SourceUnit | None:
194
194
  return self._by_module.get(module)
195
195
 
196
+ def constructor_consumers(self, unit: SourceUnit, name: str) -> frozenset[tuple[Path, str]]:
197
+ if unit.module is None:
198
+ return frozenset()
199
+ target = SymbolRef(unit.module, name)
200
+ consumers: set[tuple[Path, str]] = set()
201
+ for candidate in self._units.values():
202
+ if candidate.tree is None:
203
+ continue
204
+ for owner in candidate.tree.body:
205
+ if not isinstance(owner, ast.ClassDef):
206
+ continue
207
+ initializer = next(
208
+ (
209
+ statement
210
+ for statement in owner.body
211
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
212
+ and statement.name == "__init__"
213
+ ),
214
+ None,
215
+ )
216
+ if initializer is None:
217
+ continue
218
+ parameters = (*initializer.args.posonlyargs, *initializer.args.args, *initializer.args.kwonlyargs)
219
+ if any(
220
+ _annotation_contains_symbol(candidate, parameter.annotation, target)
221
+ for parameter in parameters
222
+ if parameter.arg != "self"
223
+ ):
224
+ consumers.add((candidate.path, owner.name))
225
+ return frozenset(consumers)
226
+
196
227
  def class_inherits_from(self, unit: SourceUnit, name: str, qualified_bases: frozenset[str]) -> bool:
197
228
  if unit.module is None:
198
229
  return False
@@ -328,7 +359,7 @@ def _imports(module: str | None, tree: ast.Module | None, *, is_package: bool) -
328
359
  return {}
329
360
  result: dict[str, SymbolRef] = {}
330
361
  package = module if is_package else module.rpartition(".")[0]
331
- for node in tree.body:
362
+ for node in _module_import_statements(tree):
332
363
  if isinstance(node, ast.ImportFrom) and not any(alias.name == "*" for alias in node.names):
333
364
  target = _relative_module(package, node.level, node.module)
334
365
  if target is None:
@@ -343,6 +374,37 @@ def _imports(module: str | None, tree: ast.Module | None, *, is_package: bool) -
343
374
  return result
344
375
 
345
376
 
377
+ def _module_import_statements(tree: ast.Module) -> tuple[ast.stmt, ...]:
378
+ statements: list[ast.stmt] = list(tree.body)
379
+ for statement in tree.body:
380
+ if not isinstance(statement, ast.If) or not _is_type_checking_guard(statement.test):
381
+ continue
382
+ statements.extend(child for child in statement.body if isinstance(child, (ast.Import, ast.ImportFrom)))
383
+ return tuple(statements)
384
+
385
+
386
+ def _is_type_checking_guard(node: ast.expr) -> bool:
387
+ return (isinstance(node, ast.Name) and node.id == "TYPE_CHECKING") or (
388
+ isinstance(node, ast.Attribute)
389
+ and isinstance(node.value, ast.Name)
390
+ and node.value.id in {"typing", "typing_extensions"}
391
+ and node.attr == "TYPE_CHECKING"
392
+ )
393
+
394
+
395
+ def _annotation_contains_symbol(unit: SourceUnit, annotation: ast.expr | None, target: SymbolRef) -> bool:
396
+ if annotation is None:
397
+ return False
398
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
399
+ with suppress(SyntaxError):
400
+ annotation = ast.parse(annotation.value, mode="eval").body
401
+ return any(
402
+ _resolve(unit, candidate) == target
403
+ for candidate in ast.walk(annotation)
404
+ if isinstance(candidate, (ast.Name, ast.Attribute))
405
+ )
406
+
407
+
346
408
  def _relative_module(package: str, level: int, module: str | None) -> str | None:
347
409
  if level == 0:
348
410
  return module
@@ -65,6 +65,7 @@ from sarj_python_lint.rules.no_repeated_test_body import NoRepeatedTestBody
65
65
  from sarj_python_lint.rules.no_restated_comment import NoRestatedComment
66
66
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
67
67
  from sarj_python_lint.rules.no_select_star import NoSelectStar
68
+ from sarj_python_lint.rules.no_service_behavior_in_settings import NoServiceBehaviorInSettings
68
69
  from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
69
70
  from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
70
71
  from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
@@ -186,6 +187,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
186
187
  NoRawConnectionInTests.id: NoRawConnectionInTests,
187
188
  NoAnalyticalAggregationInPostgresStore.id: NoAnalyticalAggregationInPostgresStore,
188
189
  NoSelectStar.id: NoSelectStar,
190
+ NoServiceBehaviorInSettings.id: NoServiceBehaviorInSettings,
189
191
  NoGenericSingleExportModule.id: NoGenericSingleExportModule,
190
192
  Stepdown.id: Stepdown,
191
193
  NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ import re
6
+ from typing import TYPE_CHECKING, ClassVar, final, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
17
+ Severity,
18
+ is_suppressed,
19
+ parse_or_none,
20
+ )
21
+ from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from pathlib import Path
26
+
27
+
28
+ _DATA_NAME_RE = re.compile(r"(?:Settings|Config|Configuration|Options)$")
29
+ _DATA_CLASS_RE = re.compile(r"\bclass\s+\w+(?:Settings|Config|Configuration|Options)\b")
30
+ _COLLABORATOR_RE = re.compile(
31
+ r"(?:Client|Service|Store|Repository|Repo|Gateway|Provider|Pool|Publisher|Queue|Scheduler)$"
32
+ )
33
+ _NON_BEHAVIORAL_DECORATORS = frozenset(
34
+ {
35
+ "cached_property",
36
+ "classmethod",
37
+ "computed_field",
38
+ "field_serializer",
39
+ "field_validator",
40
+ "model_serializer",
41
+ "model_validator",
42
+ "property",
43
+ "root_validator",
44
+ "staticmethod",
45
+ "validator",
46
+ }
47
+ )
48
+
49
+
50
+ @final
51
+ class NoServiceBehaviorInSettings(Rule):
52
+ id: str = "no-service-behavior-in-settings"
53
+ code: str = "SARJ441"
54
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
55
+ summary="Settings and configuration types should not orchestrate injected collaborators.",
56
+ rationale=(
57
+ "A class named as settings or configuration promises passive data. Calling injected stores, clients, "
58
+ "or services from that object hides orchestration behind a data boundary and couples configuration "
59
+ "construction to runtime behavior."
60
+ ),
61
+ remediation=(
62
+ "Move collaborator calls into a service, coordinator, or compiler with an honest behavioral name; keep "
63
+ "the settings/configuration object as a TypedDict, Pydantic model, attrs class, or dataclass."
64
+ ),
65
+ category=RuleCategory.ARCHITECTURE,
66
+ autofix=AutofixPolicy.NONE,
67
+ limitations=(
68
+ "Only direct module classes named *Settings, *Config, *Configuration, or *Options are checked.",
69
+ "A finding requires a typed collaborator field and an ordinary public method that calls that field; pure computations, properties, validators, serializers, factories, generated files, and tests are excluded.",
70
+ "The rule does not require every data object to use a particular record library and does not infer behavior from a filename alone.",
71
+ ),
72
+ examples=(
73
+ RuleExample(
74
+ example_id="settings-orchestrates-store",
75
+ title="A settings-named class calls injected persistence",
76
+ outcome=ExampleOutcome.MATCH,
77
+ files=(
78
+ ExampleFile.python(
79
+ "app/batch_settings.py",
80
+ "class BatchSettings:\n"
81
+ " def __init__(self, store: ScheduleStore) -> None:\n"
82
+ " self._store = store\n\n"
83
+ " async def repoint(self, batch_id: str) -> int:\n"
84
+ " return await self._store.repoint(batch_id)\n",
85
+ ),
86
+ ),
87
+ focus_path=PurePosixPath("app/batch_settings.py"),
88
+ expected_count=1,
89
+ public=True,
90
+ ),
91
+ RuleExample(
92
+ example_id="settings-is-data-only",
93
+ title="A settings type remains a passive record",
94
+ outcome=ExampleOutcome.NO_MATCH,
95
+ files=(
96
+ ExampleFile.python(
97
+ "app/batch_settings.py",
98
+ "from dataclasses import dataclass\n\n"
99
+ "@dataclass(frozen=True, slots=True)\n"
100
+ "class BatchSettings:\n"
101
+ " retry_limit: int\n",
102
+ ),
103
+ ),
104
+ focus_path=PurePosixPath("app/batch_settings.py"),
105
+ expected_count=0,
106
+ public=True,
107
+ ),
108
+ ),
109
+ )
110
+ description: str = documentation.summary
111
+
112
+ @override
113
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
114
+ if (
115
+ is_test_path(path)
116
+ or is_test_support_path(path)
117
+ or is_generated(path, source)
118
+ or _DATA_CLASS_RE.search(source) is None
119
+ ):
120
+ return []
121
+ tree = parse_or_none(path, source)
122
+ if tree is None:
123
+ return []
124
+ source_lines = source.splitlines()
125
+ diagnostics: list[Diagnostic] = []
126
+ for node in tree.body:
127
+ if not isinstance(node, ast.ClassDef) or _DATA_NAME_RE.search(node.name) is None:
128
+ continue
129
+ collaborator_fields = _collaborator_fields(node)
130
+ behavioral_methods = [
131
+ method.name
132
+ for method in _methods(node)
133
+ if _is_behavioral_method(method) and _calls_collaborator(method, collaborator_fields)
134
+ ]
135
+ if not behavioral_methods or is_suppressed(source_lines, node.lineno, self.code):
136
+ continue
137
+ methods = ", ".join(f"`{name}`" for name in behavioral_methods)
138
+ diagnostics.append(
139
+ Diagnostic(
140
+ path=path,
141
+ line=node.lineno,
142
+ col=node.col_offset + 1,
143
+ code=self.code,
144
+ severity=Severity.WARNING,
145
+ message=(
146
+ f"`{node.name}` is named as passive data but {methods} calls an injected collaborator — "
147
+ "move orchestration to a service/coordinator and keep settings or configuration data-only."
148
+ ),
149
+ )
150
+ )
151
+ return diagnostics
152
+
153
+
154
+ def _methods(node: ast.ClassDef) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
155
+ return [statement for statement in node.body if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))]
156
+
157
+
158
+ def _collaborator_fields(node: ast.ClassDef) -> frozenset[str]:
159
+ fields = {
160
+ statement.target.id
161
+ for statement in node.body
162
+ if isinstance(statement, ast.AnnAssign)
163
+ and isinstance(statement.target, ast.Name)
164
+ and _is_collaborator_annotation(statement.annotation)
165
+ }
166
+ init = next((method for method in _methods(node) if method.name == "__init__"), None)
167
+ if init is None:
168
+ return frozenset(fields)
169
+ collaborator_parameters = {
170
+ parameter.arg
171
+ for parameter in (*init.args.posonlyargs, *init.args.args, *init.args.kwonlyargs)
172
+ if parameter.arg != "self" and _is_collaborator_annotation(parameter.annotation)
173
+ }
174
+ for statement in init.body:
175
+ if not isinstance(statement, (ast.Assign, ast.AnnAssign)):
176
+ continue
177
+ value = statement.value
178
+ if not isinstance(value, ast.Name) or value.id not in collaborator_parameters:
179
+ continue
180
+ targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
181
+ fields.update(
182
+ target.attr
183
+ for target in targets
184
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self"
185
+ )
186
+ return frozenset(fields)
187
+
188
+
189
+ def _is_collaborator_annotation(annotation: ast.expr | None) -> bool:
190
+ if annotation is None:
191
+ return False
192
+ return any(
193
+ _COLLABORATOR_RE.search(_dotted_tail(candidate) or "") is not None
194
+ for candidate in ast.walk(annotation)
195
+ if isinstance(candidate, (ast.Name, ast.Attribute))
196
+ )
197
+
198
+
199
+ def _is_behavioral_method(method: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
200
+ if method.name.startswith("_"):
201
+ return False
202
+ return not any(
203
+ (_dotted_tail(decorator.func if isinstance(decorator, ast.Call) else decorator) or "")
204
+ in _NON_BEHAVIORAL_DECORATORS
205
+ for decorator in method.decorator_list
206
+ )
207
+
208
+
209
+ def _calls_collaborator(method: ast.FunctionDef | ast.AsyncFunctionDef, fields: frozenset[str]) -> bool:
210
+ return any(
211
+ (field := _self_field(call.func)) is not None and field in fields
212
+ for call in ast.walk(method)
213
+ if isinstance(call, ast.Call)
214
+ )
215
+
216
+
217
+ def _self_field(node: ast.expr) -> str | None:
218
+ current = node
219
+ while isinstance(current, ast.Attribute):
220
+ if isinstance(current.value, ast.Name) and current.value.id == "self":
221
+ return current.attr
222
+ current = current.value
223
+ return None
224
+
225
+
226
+ def _dotted_tail(node: ast.expr) -> str | None:
227
+ current = node
228
+ if isinstance(current, ast.Call):
229
+ current = current.func
230
+ if isinstance(current, ast.Subscript):
231
+ current = current.value
232
+ match current:
233
+ case ast.Name():
234
+ return current.id
235
+ case ast.Attribute():
236
+ return current.attr
237
+ case _:
238
+ return None