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