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.
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/PKG-INFO +1 -1
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/pyproject.toml +1 -1
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_project_index.py +63 -1
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_registry.py +2 -0
- sarj_python_lint-0.81.7/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +238 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +328 -4
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_class_docstring.py +7 -5
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_docstring.py +7 -5
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/require_port_for_service.py +78 -34
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/restated_test_docstring.py +7 -5
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/.gitignore +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/LICENSE +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/README.md +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {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
- {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
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {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
- {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
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {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
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {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.
|
|
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/
|
{sarj_python_lint-0.81.6 → sarj_python_lint-0.81.7}/src/sarj_python_lint/rules/_project_index.py
RENAMED
|
@@ -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
|
|
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
|