sarj-python-lint 0.81.5__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.5 → sarj_python_lint-0.81.7}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_project_index.py +63 -1
  4. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_registry.py +2 -0
  5. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +1 -1
  6. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +1 -1
  7. sarj_python_lint-0.81.7/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +238 -0
  8. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_typed_doc_sections.py +1 -1
  9. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +1 -1
  10. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +4 -4
  11. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +328 -4
  12. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +1 -1
  13. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +60 -6
  14. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_class_docstring.py +7 -5
  15. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_docstring.py +7 -5
  16. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_port_for_service.py +78 -34
  17. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +7 -5
  18. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/restated_test_docstring.py +7 -5
  19. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +7 -5
  20. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/.gitignore +0 -0
  21. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/LICENSE +0 -0
  22. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/README.md +0 -0
  23. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__init__.py +0 -0
  24. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__main__.py +0 -0
  25. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_analysis_session.py +0 -0
  26. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_filesystem.py +0 -0
  27. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_python_target.py +0 -0
  28. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  29. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_secret_names.py +0 -0
  30. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_version.py +0 -0
  31. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/py.typed +0 -0
  32. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/ratchet.py +0 -0
  33. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rule_base.py +0 -0
  34. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/__init__.py +0 -0
  35. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  36. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_comments.py +0 -0
  37. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  38. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  39. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_first_party.py +0 -0
  40. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  41. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_imports.py +0 -0
  42. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_logging.py +0 -0
  43. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_paths.py +0 -0
  44. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  45. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_pytest.py +0 -0
  46. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_sql.py +0 -0
  47. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  48. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  49. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  50. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  51. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  52. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  53. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  54. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  55. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  56. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  57. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  58. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  59. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  60. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  61. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  62. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  63. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  64. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  65. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  66. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  67. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  68. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  69. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  70. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  71. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  72. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  73. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  74. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  75. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  76. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  77. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  78. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  79. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  80. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  81. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  82. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  83. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  84. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  85. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  86. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  87. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  88. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  89. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  90. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  91. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  92. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  93. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  94. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  95. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  96. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  97. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  98. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  99. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  100. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  101. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  102. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  103. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  104. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  105. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  106. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  107. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  108. {sarj_python_lint-0.81.5 → 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.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  110. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  111. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  112. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  113. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  114. {sarj_python_lint-0.81.5 → 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.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  116. {sarj_python_lint-0.81.5 → 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.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  118. {sarj_python_lint-0.81.5 → 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.5 → 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.5 → 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.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/stepdown.py +0 -0
  122. {sarj_python_lint-0.81.5 → 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.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  124. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  125. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  126. {sarj_python_lint-0.81.5 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  127. {sarj_python_lint-0.81.5 → 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.5
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.5"
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,
@@ -200,7 +200,7 @@ def _query_argument(call: ast.Call) -> ast.expr | None:
200
200
  tokens = frozenset(part for part in receiver.split("_") if part)
201
201
  if (
202
202
  receiver not in _QUERY_RECEIVER_TOKENS
203
- and not tokens.intersection(_QUERY_RECEIVER_TOKENS)
203
+ and tokens.isdisjoint(_QUERY_RECEIVER_TOKENS)
204
204
  and not any(receiver.endswith(suffix) for suffix in ("conn", "cur", "cursor", "session"))
205
205
  ):
206
206
  return None
@@ -761,7 +761,7 @@ def _representation_fixture_path(node: ast.AST) -> bool:
761
761
  if not isinstance(child, ast.Constant) or not isinstance(child.value, str):
762
762
  continue
763
763
  parts = {part.lower() for part in child.value.replace("\\", "/").split("/")}
764
- if parts & _REPRESENTATION_DIRS:
764
+ if not parts.isdisjoint(_REPRESENTATION_DIRS):
765
765
  return True
766
766
  return False
767
767
 
@@ -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
@@ -121,7 +121,7 @@ def _public_contract_docstring_lines(path: Path, source: str) -> frozenset[int]:
121
121
  protocol_method = isinstance(owner, ast.ClassDef) and any(
122
122
  _terminal_name(base) in _PUBLIC_CONTRACT_BASES for base in owner.bases
123
123
  )
124
- if decorators & _PUBLIC_CONTRACT_DECORATORS or protocol_method:
124
+ if not decorators.isdisjoint(_PUBLIC_CONTRACT_DECORATORS) or protocol_method:
125
125
  excluded.add(first.lineno)
126
126
  return frozenset(excluded)
127
127
 
@@ -647,7 +647,7 @@ def _schema_docstring_consumers(tree: ast.Module, imports: dict[str, str]) -> fr
647
647
  _resolve_imported_name(base.value if isinstance(base, ast.Subscript) else base, imports)
648
648
  for base in class_node.bases
649
649
  }
650
- if base_names & (_KNOWN_SCHEMA_BASES | consumed):
650
+ if not base_names.isdisjoint(_KNOWN_SCHEMA_BASES | consumed):
651
651
  consumed.add(name)
652
652
  changed = True
653
653
  return frozenset(id(classes[name]) for name in consumed)
@@ -299,15 +299,15 @@ def _name_role(identifier: str) -> _Role:
299
299
  def _is_authenticator_name(identifier: str) -> bool:
300
300
  tokens = identifier_tokens(identifier)
301
301
  token_set = set(tokens)
302
- is_auth_header = "header" in token_set and bool(token_set & {"auth", "authorization"})
302
+ is_auth_header = "header" in token_set and not token_set.isdisjoint({"auth", "authorization"})
303
303
  if not is_secret_name(identifier) and not is_auth_header:
304
304
  return False
305
305
  if (token_set == {"token"} and not identifier.isupper()) or not tokens:
306
306
  return False
307
- if tokens[-1] in _DESCRIPTOR_WORDS or token_set & _CATEGORY_WORDS:
307
+ if tokens[-1] in _DESCRIPTOR_WORDS or not token_set.isdisjoint(_CATEGORY_WORDS):
308
308
  return False
309
- return bool(
310
- token_set & _AUTH_WORDS
309
+ return (
310
+ not token_set.isdisjoint(_AUTH_WORDS)
311
311
  or is_auth_header
312
312
  or any(left == "api" and right == "key" for left, right in pairwise(tokens))
313
313
  )