sarj-python-lint 0.83.1__tar.gz → 0.84.1__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.83.1 → sarj_python_lint-0.84.1}/PKG-INFO +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/pyproject.toml +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/__main__.py +8 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_registry.py +10 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +1 -1
- sarj_python_lint-0.84.1/src/sarj_python_lint/rules/fastapi_class_router_contract.py +191 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +1 -1
- sarj_python_lint-0.84.1/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +94 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +2 -2
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +1 -1
- sarj_python_lint-0.84.1/src/sarj_python_lint/rules/no_vague_annotations.py +173 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/phase_label_comment.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_class_row.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +35 -63
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +9 -21
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +2 -2
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_str_enum.py +25 -8
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +1 -1
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +1 -1
- sarj_python_lint-0.84.1/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +157 -0
- sarj_python_lint-0.84.1/src/sarj_python_lint/rules/require_typed_http_test_response.py +118 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/restated_test_docstring.py +2 -2
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/.gitignore +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/LICENSE +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/README.md +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.84.1
|
|
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/
|
|
@@ -209,7 +209,7 @@ def _suppressed_diagnostics(
|
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
def _suppressed_codes(codes: dict[str, set[Severity]]) -> set[tuple[str, Severity]]:
|
|
212
|
-
|
|
212
|
+
suppressed = {
|
|
213
213
|
(generic, generic_severity)
|
|
214
214
|
for specific, generics in _DIAGNOSTIC_PRECEDENCE.items()
|
|
215
215
|
if specific in codes
|
|
@@ -217,6 +217,13 @@ def _suppressed_codes(codes: dict[str, set[Severity]]) -> set[tuple[str, Severit
|
|
|
217
217
|
for generic_severity in codes.get(generic, set())
|
|
218
218
|
if generic_severity is Severity.WARNING or Severity.ERROR in codes[specific]
|
|
219
219
|
}
|
|
220
|
+
# The promoted signature-restatement rules now block. Keep their errors and
|
|
221
|
+
# drop only the lower-severity typed-docstring twin at the same owner.
|
|
222
|
+
if Severity.WARNING in codes.get("SARJ092", set()) and any(
|
|
223
|
+
Severity.ERROR in codes.get(generic, set()) for generic in ("SARJ086", "SARJ087")
|
|
224
|
+
):
|
|
225
|
+
suppressed.add(("SARJ092", Severity.WARNING))
|
|
226
|
+
return suppressed
|
|
220
227
|
|
|
221
228
|
|
|
222
229
|
def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
|
|
@@ -15,6 +15,7 @@ from sarj_python_lint.rules.docstring_returns_restate_signature import (
|
|
|
15
15
|
)
|
|
16
16
|
from sarj_python_lint.rules.excessive_commentary import ExcessiveCommentary
|
|
17
17
|
from sarj_python_lint.rules.fakes_in_shared_location import FakesInSharedLocation
|
|
18
|
+
from sarj_python_lint.rules.fastapi_class_router_contract import FastapiClassRouterContract
|
|
18
19
|
from sarj_python_lint.rules.fastapi_explicit_openapi_contract import FastapiExplicitOpenapiContract
|
|
19
20
|
from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
|
|
20
21
|
from sarj_python_lint.rules.invalid_pydantic_field_default import (
|
|
@@ -51,6 +52,7 @@ from sarj_python_lint.rules.no_generic_single_export_module import NoGenericSing
|
|
|
51
52
|
from sarj_python_lint.rules.no_hidden_constructor_fallback import (
|
|
52
53
|
NoHiddenConstructorFallback,
|
|
53
54
|
)
|
|
55
|
+
from sarj_python_lint.rules.no_invalid_argument_name_suppression import NoInvalidArgumentNameSuppression
|
|
54
56
|
from sarj_python_lint.rules.no_nested_pydantic_field_validator import NoNestedPydanticFieldValidator
|
|
55
57
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
56
58
|
from sarj_python_lint.rules.no_positional_psycopg_row_escape import NoPositionalPsycopgRowEscape
|
|
@@ -75,6 +77,7 @@ from sarj_python_lint.rules.no_unique_violation_message_match import (
|
|
|
75
77
|
NoUniqueViolationMessageMatch,
|
|
76
78
|
)
|
|
77
79
|
from sarj_python_lint.rules.no_unnecessary_docstring import NoUnnecessaryDocstring
|
|
80
|
+
from sarj_python_lint.rules.no_vague_annotations import NoVagueAnnotations
|
|
78
81
|
from sarj_python_lint.rules.no_vague_suppression_description import (
|
|
79
82
|
NoVagueSuppressionDescription,
|
|
80
83
|
)
|
|
@@ -147,9 +150,11 @@ from sarj_python_lint.rules.require_port_for_service import RequirePortForServic
|
|
|
147
150
|
from sarj_python_lint.rules.require_pydantic_for_external_json import (
|
|
148
151
|
RequirePydanticForExternalJson,
|
|
149
152
|
)
|
|
153
|
+
from sarj_python_lint.rules.require_pydantic_for_structured_payload import RequirePydanticForStructuredPayload
|
|
150
154
|
from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
|
|
151
155
|
RequirePydanticOrdinalLowerBound,
|
|
152
156
|
)
|
|
157
|
+
from sarj_python_lint.rules.require_typed_http_test_response import RequireTypedHttpTestResponse
|
|
153
158
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
154
159
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
155
160
|
from sarj_python_lint.rules.store_get_delegates_to_bulk_read import StoreGetDelegatesToBulkRead
|
|
@@ -185,6 +190,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
185
190
|
NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
|
|
186
191
|
PydanticAtBoundaries.id: PydanticAtBoundaries,
|
|
187
192
|
FastapiExplicitOpenapiContract.id: FastapiExplicitOpenapiContract,
|
|
193
|
+
FastapiClassRouterContract.id: FastapiClassRouterContract,
|
|
188
194
|
FakesInSharedLocation.id: FakesInSharedLocation,
|
|
189
195
|
PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,
|
|
190
196
|
NoSecretInLog.id: NoSecretInLog,
|
|
@@ -222,6 +228,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
222
228
|
NoRedundantModuleAliasExports.id: NoRedundantModuleAliasExports,
|
|
223
229
|
NoHiddenConstructorFallback.id: NoHiddenConstructorFallback,
|
|
224
230
|
NoFileLevelEscapeHatchSuppression.id: NoFileLevelEscapeHatchSuppression,
|
|
231
|
+
NoInvalidArgumentNameSuppression.id: NoInvalidArgumentNameSuppression,
|
|
225
232
|
NoFastapiOnEvent.id: NoFastapiOnEvent,
|
|
226
233
|
NoTautologicalExpect.id: NoTautologicalExpect,
|
|
227
234
|
PreferLibraryFake.id: PreferLibraryFake,
|
|
@@ -258,6 +265,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
258
265
|
NoTypedDocSections.id: NoTypedDocSections,
|
|
259
266
|
NoUnnecessaryDocstring.id: NoUnnecessaryDocstring,
|
|
260
267
|
PreferNominalIdTypes.id: PreferNominalIdTypes,
|
|
268
|
+
NoVagueAnnotations.id: NoVagueAnnotations,
|
|
261
269
|
NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
|
|
262
270
|
NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
|
|
263
271
|
ProductionDerivedTestCases.id: ProductionDerivedTestCases,
|
|
@@ -266,6 +274,8 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
266
274
|
NoPositionalPsycopgRowEscape.id: NoPositionalPsycopgRowEscape,
|
|
267
275
|
NoPsycopgExecutionOutsideInjectedOwner.id: NoPsycopgExecutionOutsideInjectedOwner,
|
|
268
276
|
RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
|
|
277
|
+
RequirePydanticForStructuredPayload.id: RequirePydanticForStructuredPayload,
|
|
278
|
+
RequireTypedHttpTestResponse.id: RequireTypedHttpTestResponse,
|
|
269
279
|
RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
|
|
270
280
|
NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
|
|
271
281
|
NoWholeRequestResponsePayloadInLog.id: NoWholeRequestResponsePayloadInLog,
|
|
@@ -181,7 +181,7 @@ class DocstringArgsRestateSignature(Rule):
|
|
|
181
181
|
f"`{node.name}` has an Args section whose entries only repeat matching parameter names or types; "
|
|
182
182
|
"remove that section or document a constraint not evident from the signature."
|
|
183
183
|
),
|
|
184
|
-
severity=Severity.
|
|
184
|
+
severity=Severity.ERROR,
|
|
185
185
|
)
|
|
186
186
|
)
|
|
187
187
|
|
|
@@ -252,7 +252,7 @@ class DocstringReturnsRestateSignature(Rule):
|
|
|
252
252
|
f"`{node.name}` {section.name} section only repeats its callable name or annotated result type; "
|
|
253
253
|
"remove that section or document result semantics not expressed by the signature."
|
|
254
254
|
),
|
|
255
|
-
severity=Severity.
|
|
255
|
+
severity=Severity.ERROR,
|
|
256
256
|
)
|
|
257
257
|
)
|
|
258
258
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
parse_or_none,
|
|
17
|
+
)
|
|
18
|
+
from sarj_python_lint.rules._fastapi import FastapiIndex, Route, flat_name
|
|
19
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_NO_BODY = frozenset({204, 304})
|
|
27
|
+
_NON_JSON_RESPONSES = frozenset(
|
|
28
|
+
{"FileResponse", "HTMLResponse", "PlainTextResponse", "RedirectResponse", "StreamingResponse"}
|
|
29
|
+
)
|
|
30
|
+
_TOP_LEVEL_COLLECTIONS = frozenset(
|
|
31
|
+
{"dict", "Dict", "list", "List", "Mapping", "MutableMapping", "set", "Set", "tuple", "Tuple", "Sequence"}
|
|
32
|
+
)
|
|
33
|
+
_SCALARS = frozenset({"bool", "bytes", "float", "int", "str"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
class FastapiClassRouterContract(Rule):
|
|
38
|
+
id = "fastapi-class-router-contract"
|
|
39
|
+
code = "SARJ451"
|
|
40
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
41
|
+
summary="FastAPI routers use injected `*Router.build()` owners and explicit named object response models.",
|
|
42
|
+
rationale=(
|
|
43
|
+
"Class-owned construction gives dependencies one reviewable composition boundary, while explicit object models "
|
|
44
|
+
"keep success envelopes stable and generated clients precise."
|
|
45
|
+
),
|
|
46
|
+
remediation=(
|
|
47
|
+
"Inject dependencies through `*Router.__init__`, create and return a local `APIRouter` from `build()`, and declare "
|
|
48
|
+
"`response_model=NamedResponse` with a matching named return annotation."
|
|
49
|
+
),
|
|
50
|
+
category=RuleCategory.ARCHITECTURE,
|
|
51
|
+
autofix=AutofixPolicy.NONE,
|
|
52
|
+
limitations=(
|
|
53
|
+
"Tests, generated code, hidden routes, 204/304 responses, and explicit stream/file/HTML/text/redirect responses are excluded.",
|
|
54
|
+
),
|
|
55
|
+
examples=(
|
|
56
|
+
RuleExample(
|
|
57
|
+
example_id="module-router",
|
|
58
|
+
title="A module owns an unscoped router",
|
|
59
|
+
outcome=ExampleOutcome.MATCH,
|
|
60
|
+
files=(ExampleFile.python("app/routes.py", "from fastapi import APIRouter\nrouter = APIRouter()\n"),),
|
|
61
|
+
focus_path=PurePosixPath("app/routes.py"),
|
|
62
|
+
expected_count=1,
|
|
63
|
+
public=True,
|
|
64
|
+
),
|
|
65
|
+
RuleExample(
|
|
66
|
+
example_id="class-router",
|
|
67
|
+
title="A class builds and returns its router",
|
|
68
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
69
|
+
files=(
|
|
70
|
+
ExampleFile.python(
|
|
71
|
+
"app/routes.py",
|
|
72
|
+
"from fastapi import APIRouter\nclass ItemRouter:\n def __init__(self, service: Service) -> None:\n self._service = service\n def build(self) -> APIRouter:\n router = APIRouter()\n return router\n",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
focus_path=PurePosixPath("app/routes.py"),
|
|
76
|
+
expected_count=0,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
description = documentation.summary
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
85
|
+
if is_test_path(path) or is_generated(path, source):
|
|
86
|
+
return []
|
|
87
|
+
tree = parse_or_none(path, source)
|
|
88
|
+
if tree is None:
|
|
89
|
+
return []
|
|
90
|
+
index = FastapiIndex(tree, path=path)
|
|
91
|
+
parents = _parents(tree)
|
|
92
|
+
findings = _router_construction_findings(path, tree, index, parents)
|
|
93
|
+
for function in (node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))):
|
|
94
|
+
routes = tuple(route for route in index.routes(function) if not route.is_hidden)
|
|
95
|
+
for route in routes:
|
|
96
|
+
problem = _response_problem(function, route, index)
|
|
97
|
+
if problem is not None:
|
|
98
|
+
findings.append(Diagnostic(path, function.lineno, function.col_offset + 1, self.code, problem))
|
|
99
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col, finding.message))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _router_construction_findings(
|
|
103
|
+
path: Path, tree: ast.Module, index: FastapiIndex, parents: dict[int, ast.AST]
|
|
104
|
+
) -> list[Diagnostic]:
|
|
105
|
+
findings: list[Diagnostic] = []
|
|
106
|
+
for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
|
|
107
|
+
if index.canonical(call.func) != "APIRouter":
|
|
108
|
+
continue
|
|
109
|
+
function = _ancestor(call, parents, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
110
|
+
cls = _ancestor(call, parents, ast.ClassDef)
|
|
111
|
+
if (
|
|
112
|
+
isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
113
|
+
and isinstance(cls, ast.ClassDef)
|
|
114
|
+
and function.name == "build"
|
|
115
|
+
and cls.name.endswith("Router")
|
|
116
|
+
and _returns_local_router(function, call)
|
|
117
|
+
):
|
|
118
|
+
continue
|
|
119
|
+
findings.append(
|
|
120
|
+
Diagnostic(
|
|
121
|
+
path,
|
|
122
|
+
call.lineno,
|
|
123
|
+
call.col_offset + 1,
|
|
124
|
+
FastapiClassRouterContract.code,
|
|
125
|
+
"construct APIRouter only inside `*Router.build()` and return that local router",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return findings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _returns_local_router(function: ast.FunctionDef | ast.AsyncFunctionDef, call: ast.Call) -> bool:
|
|
132
|
+
names = {
|
|
133
|
+
target.id
|
|
134
|
+
for node in function.body
|
|
135
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign))
|
|
136
|
+
for target in _assignment_targets(node)
|
|
137
|
+
if isinstance(target, ast.Name) and getattr(node, "value", None) is call
|
|
138
|
+
}
|
|
139
|
+
return bool(names) and any(
|
|
140
|
+
isinstance(node, ast.Return) and isinstance(node.value, ast.Name) and node.value.id in names
|
|
141
|
+
for node in function.body
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _assignment_targets(node: ast.Assign | ast.AnnAssign) -> list[ast.expr]:
|
|
146
|
+
return list(node.targets) if isinstance(node, ast.Assign) else [node.target]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _response_problem(
|
|
150
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef, route: Route, index: FastapiIndex
|
|
151
|
+
) -> str | None:
|
|
152
|
+
keywords = route.keywords
|
|
153
|
+
status = keywords.get("status_code")
|
|
154
|
+
if isinstance(status, ast.Constant) and status.value in _NO_BODY:
|
|
155
|
+
return None
|
|
156
|
+
response_class = keywords.get("response_class") or route.inherited_response_class
|
|
157
|
+
if response_class is not None and flat_name(response_class) in _NON_JSON_RESPONSES:
|
|
158
|
+
return None
|
|
159
|
+
response_model = keywords.get("response_model")
|
|
160
|
+
if response_model is None or (isinstance(response_model, ast.Constant) and response_model.value is None):
|
|
161
|
+
return "JSON operation requires explicit `response_model=NamedResponse`"
|
|
162
|
+
annotation = index.resolve_annotation(function.returns)
|
|
163
|
+
if annotation is None:
|
|
164
|
+
return "JSON operation requires a named response return annotation matching response_model"
|
|
165
|
+
if _is_collection_or_scalar(annotation):
|
|
166
|
+
return (
|
|
167
|
+
"successful JSON responses must use a named object envelope, not a top-level collection, mapping, or scalar"
|
|
168
|
+
)
|
|
169
|
+
if flat_name(annotation) != flat_name(response_model):
|
|
170
|
+
return "response_model must match the named response return annotation"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_collection_or_scalar(annotation: ast.expr) -> bool:
|
|
175
|
+
target = annotation.value if isinstance(annotation, ast.Subscript) else annotation
|
|
176
|
+
return flat_name(target) in _TOP_LEVEL_COLLECTIONS | _SCALARS
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parents(tree: ast.Module) -> dict[int, ast.AST]:
|
|
180
|
+
return {id(child): parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ancestor(
|
|
184
|
+
node: ast.AST, parents: dict[int, ast.AST], kind: type[ast.AST] | tuple[type[ast.AST], ...]
|
|
185
|
+
) -> ast.AST | None:
|
|
186
|
+
current = parents.get(id(node))
|
|
187
|
+
while current is not None:
|
|
188
|
+
if isinstance(current, kind):
|
|
189
|
+
return current
|
|
190
|
+
current = parents.get(id(current))
|
|
191
|
+
return None
|
|
@@ -158,7 +158,7 @@ class NoCopiedInheritedDocstring(Rule):
|
|
|
158
158
|
f"Docstring has the same cleaned content as {parent.name}.{name}'s. Remove it only if no "
|
|
159
159
|
"consumer requires the override's direct `__doc__`; otherwise retain it with a narrow rationale."
|
|
160
160
|
),
|
|
161
|
-
severity=Severity.
|
|
161
|
+
severity=Severity.ERROR,
|
|
162
162
|
)
|
|
163
163
|
)
|
|
164
164
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
ColumnEncoding,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
)
|
|
17
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
18
|
+
from sarj_python_lint.rules._suppression_comments import scan_comments_or_none
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_FORBIDDEN = re.compile(r"(?:^|[,:\s\[])\s*(?:N803|invalid-argument-name)(?=$|[,\]\s])", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class NoInvalidArgumentNameSuppression(Rule):
|
|
30
|
+
id = "no-invalid-argument-name-suppression"
|
|
31
|
+
code = "SARJ448"
|
|
32
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
33
|
+
summary="External parameter spelling must use framework aliases instead of disabling snake_case naming.",
|
|
34
|
+
rationale="Naming suppressions leak an external wire vocabulary into Python APIs and normalize future exceptions.",
|
|
35
|
+
remediation=(
|
|
36
|
+
"Keep the Python parameter snake_case and preserve the external spelling with FastAPI `Query`/`Path` aliases "
|
|
37
|
+
"or Pydantic `Field` aliases."
|
|
38
|
+
),
|
|
39
|
+
category=RuleCategory.MAINTAINABILITY,
|
|
40
|
+
limitations=("Generated source is excluded.",),
|
|
41
|
+
examples=(
|
|
42
|
+
RuleExample(
|
|
43
|
+
example_id="camel-case-parameter-suppression",
|
|
44
|
+
title="A wire parameter disables Python naming",
|
|
45
|
+
outcome=ExampleOutcome.MATCH,
|
|
46
|
+
files=(
|
|
47
|
+
ExampleFile.python(
|
|
48
|
+
"app/routes.py",
|
|
49
|
+
"def route(businessFunction: str): # ruff: ignore[invalid-argument-name]\n ...\n",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
focus_path=PurePosixPath("app/routes.py"),
|
|
53
|
+
expected_count=1,
|
|
54
|
+
public=True,
|
|
55
|
+
),
|
|
56
|
+
RuleExample(
|
|
57
|
+
example_id="snake-case-wire-alias",
|
|
58
|
+
title="A framework alias preserves external spelling",
|
|
59
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
60
|
+
files=(
|
|
61
|
+
ExampleFile.python(
|
|
62
|
+
"app/routes.py",
|
|
63
|
+
"from fastapi import Query\ndef route(business_function: str = Query(alias='businessFunction')): ...\n",
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
focus_path=PurePosixPath("app/routes.py"),
|
|
67
|
+
expected_count=0,
|
|
68
|
+
public=True,
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
description = documentation.summary
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
76
|
+
if is_generated(path, source):
|
|
77
|
+
return []
|
|
78
|
+
comments = scan_comments_or_none(source)
|
|
79
|
+
if comments is None:
|
|
80
|
+
return []
|
|
81
|
+
return [
|
|
82
|
+
Diagnostic(
|
|
83
|
+
path=path,
|
|
84
|
+
line=comment.line,
|
|
85
|
+
col=comment.col,
|
|
86
|
+
code=self.code,
|
|
87
|
+
message=(
|
|
88
|
+
"do not suppress Python argument naming; use a snake_case parameter and an explicit wire alias"
|
|
89
|
+
),
|
|
90
|
+
column_encoding=ColumnEncoding.CODEPOINTS,
|
|
91
|
+
)
|
|
92
|
+
for comment in comments
|
|
93
|
+
if _FORBIDDEN.search(comment.body)
|
|
94
|
+
]
|
|
@@ -187,7 +187,7 @@ class NoRepeatedStringLiteral(Rule):
|
|
|
187
187
|
f"structured string literal {_preview(value)} duplicates module constant "
|
|
188
188
|
f"`{constant_name}` — reuse the canonical constant so the copies cannot drift."
|
|
189
189
|
),
|
|
190
|
-
severity=Severity.
|
|
190
|
+
severity=Severity.ERROR,
|
|
191
191
|
)
|
|
192
192
|
for node in function_nodes
|
|
193
193
|
)
|
|
@@ -209,7 +209,7 @@ class NoRepeatedStringLiteral(Rule):
|
|
|
209
209
|
f"callable scopes (first use at line {first.lineno}) — reuse a named binding "
|
|
210
210
|
"when they share ownership, or suppress with an independent-ownership rationale."
|
|
211
211
|
),
|
|
212
|
-
severity=Severity.
|
|
212
|
+
severity=Severity.ERROR,
|
|
213
213
|
)
|
|
214
214
|
for node in repeats
|
|
215
215
|
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
is_suppressed,
|
|
17
|
+
parse_or_none,
|
|
18
|
+
)
|
|
19
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
20
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
|
|
28
|
+
_MAPPING_SOURCES = frozenset({"collections.abc", "typing"})
|
|
29
|
+
_MAPPING_NAMES = frozenset({"dict", "Dict", "Mapping", "MutableMapping"})
|
|
30
|
+
_MAPPING_ARGUMENT_COUNT = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
class NoVagueAnnotations(Rule):
|
|
35
|
+
id = "no-vague-annotations"
|
|
36
|
+
code = "SARJ447"
|
|
37
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
38
|
+
summary="Annotations must not erase domain shape with `object` or `dict[str, Any]`.",
|
|
39
|
+
rationale=(
|
|
40
|
+
"Vague annotations move schema mistakes from type checking to runtime and conceal the fields a caller may use."
|
|
41
|
+
),
|
|
42
|
+
remediation=(
|
|
43
|
+
"Use a named Pydantic model, dataclass, TypedDict, Protocol, TypeVar, domain type, or an explicit recursive "
|
|
44
|
+
"JSON value type at a serialization boundary."
|
|
45
|
+
),
|
|
46
|
+
category=RuleCategory.CORRECTNESS,
|
|
47
|
+
autofix=AutofixPolicy.NONE,
|
|
48
|
+
limitations=("Generated and vendored sources are excluded; exact local suppressions remain auditable.",),
|
|
49
|
+
examples=(
|
|
50
|
+
RuleExample(
|
|
51
|
+
example_id="vague-mapping-annotation",
|
|
52
|
+
title="An open mapping erases the record schema",
|
|
53
|
+
outcome=ExampleOutcome.MATCH,
|
|
54
|
+
files=(
|
|
55
|
+
ExampleFile.python(
|
|
56
|
+
"app/offers.py", "from typing import Any\ndef details() -> dict[str, Any]: ...\n"
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
focus_path=PurePosixPath("app/offers.py"),
|
|
60
|
+
expected_count=1,
|
|
61
|
+
public=True,
|
|
62
|
+
),
|
|
63
|
+
RuleExample(
|
|
64
|
+
example_id="named-model-annotation",
|
|
65
|
+
title="A named model preserves the record schema",
|
|
66
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
67
|
+
files=(ExampleFile.python("app/offers.py", "def details() -> OfferDetails: ...\n"),),
|
|
68
|
+
focus_path=PurePosixPath("app/offers.py"),
|
|
69
|
+
expected_count=0,
|
|
70
|
+
public=True,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
description = documentation.summary
|
|
75
|
+
|
|
76
|
+
@override
|
|
77
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
78
|
+
if is_generated(path, source) or _is_vendor_path(path):
|
|
79
|
+
return []
|
|
80
|
+
tree = parse_or_none(path, source)
|
|
81
|
+
if tree is None:
|
|
82
|
+
return []
|
|
83
|
+
imports = ImportIndex.from_tree(tree)
|
|
84
|
+
lines = source.splitlines()
|
|
85
|
+
findings: list[Diagnostic] = []
|
|
86
|
+
for annotation in _annotations(tree):
|
|
87
|
+
problem = _vague_problem(annotation, imports)
|
|
88
|
+
if problem is None or is_suppressed(lines, annotation.lineno, self.code):
|
|
89
|
+
continue
|
|
90
|
+
findings.append(
|
|
91
|
+
Diagnostic(
|
|
92
|
+
path=path,
|
|
93
|
+
line=annotation.lineno,
|
|
94
|
+
col=annotation.col_offset + 1,
|
|
95
|
+
code=self.code,
|
|
96
|
+
message=f"vague annotation `{problem}` erases its domain schema; use a named precise type",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _annotations(tree: ast.Module) -> list[ast.expr]:
|
|
103
|
+
return [annotation for node in ast.walk(tree) if (annotation := _node_annotation(node)) is not None]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _node_annotation(node: ast.AST) -> ast.expr | None:
|
|
107
|
+
match node:
|
|
108
|
+
case (
|
|
109
|
+
ast.arg(annotation=annotation)
|
|
110
|
+
| ast.AnnAssign(annotation=annotation)
|
|
111
|
+
| ast.FunctionDef(returns=annotation)
|
|
112
|
+
| ast.AsyncFunctionDef(returns=annotation)
|
|
113
|
+
):
|
|
114
|
+
return annotation
|
|
115
|
+
case ast.TypeAlias(value=value):
|
|
116
|
+
return value
|
|
117
|
+
case _:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _vague_problem(annotation: ast.expr, imports: ImportIndex) -> str | None:
|
|
122
|
+
annotation = _parse_string_annotation(annotation)
|
|
123
|
+
for node in ast.walk(annotation):
|
|
124
|
+
if _is_object(node, imports):
|
|
125
|
+
return "object"
|
|
126
|
+
if _is_open_mapping(node, imports):
|
|
127
|
+
return ast.unparse(node)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_string_annotation(annotation: ast.expr) -> ast.expr:
|
|
132
|
+
if not isinstance(annotation, ast.Constant) or not isinstance(annotation.value, str):
|
|
133
|
+
return annotation
|
|
134
|
+
try:
|
|
135
|
+
return ast.parse(annotation.value, mode="eval").body
|
|
136
|
+
except SyntaxError:
|
|
137
|
+
return annotation
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_object(node: ast.AST, imports: ImportIndex) -> bool:
|
|
141
|
+
return isinstance(node, ast.expr) and (
|
|
142
|
+
(isinstance(node, ast.Name) and node.id == "object" and imports.builtin_is_unshadowed("object"))
|
|
143
|
+
or imports.resolves(node, sources=frozenset({"builtins"}), symbol="object")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_open_mapping(node: ast.AST, imports: ImportIndex) -> bool:
|
|
148
|
+
if (
|
|
149
|
+
not isinstance(node, ast.Subscript)
|
|
150
|
+
or not isinstance(node.slice, ast.Tuple)
|
|
151
|
+
or len(node.slice.elts) != _MAPPING_ARGUMENT_COUNT
|
|
152
|
+
):
|
|
153
|
+
return False
|
|
154
|
+
target = node.value
|
|
155
|
+
mapping = (isinstance(target, ast.Name) and target.id == "dict" and imports.builtin_is_unshadowed("dict")) or any(
|
|
156
|
+
imports.resolves(target, sources=_MAPPING_SOURCES, symbol=name) for name in _MAPPING_NAMES
|
|
157
|
+
)
|
|
158
|
+
if not mapping:
|
|
159
|
+
return False
|
|
160
|
+
key, value = node.slice.elts
|
|
161
|
+
return _is_builtin_str(key, imports) and _is_any_or_object(value, imports)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_builtin_str(node: ast.expr, imports: ImportIndex) -> bool:
|
|
165
|
+
return isinstance(node, ast.Name) and node.id == "str" and imports.builtin_is_unshadowed("str")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_any_or_object(node: ast.expr, imports: ImportIndex) -> bool:
|
|
169
|
+
return _is_object(node, imports) or imports.resolves(node, sources=_TYPING_SOURCES, symbol="Any")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_vendor_path(path: Path) -> bool:
|
|
173
|
+
return any(part.casefold() in {"vendor", "vendored", "third_party"} for part in path.parts)
|
|
@@ -123,7 +123,7 @@ class NoVagueSuppressionDescription(Rule):
|
|
|
123
123
|
message=(
|
|
124
124
|
f'Suppression reason "{vague_reason}" is generic; name the concrete mismatch or safety invariant.'
|
|
125
125
|
),
|
|
126
|
-
severity=Severity.
|
|
126
|
+
severity=Severity.ERROR,
|
|
127
127
|
column_encoding=ColumnEncoding.CODEPOINTS,
|
|
128
128
|
)
|
|
129
129
|
)
|
|
@@ -112,7 +112,7 @@ class TestPhaseLabelComment(Rule):
|
|
|
112
112
|
f"Bare test phase comment {body.strip()!r} narrates structure; delete it or replace it "
|
|
113
113
|
"with non-obvious rationale."
|
|
114
114
|
),
|
|
115
|
-
severity=Severity.
|
|
115
|
+
severity=Severity.ERROR,
|
|
116
116
|
column_encoding=ColumnEncoding.CODEPOINTS,
|
|
117
117
|
)
|
|
118
118
|
for line, col, body in standalone
|
{sarj_python_lint-0.83.1 → sarj_python_lint-0.84.1}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
@@ -123,7 +123,7 @@ class PreferClassRow(Rule):
|
|
|
123
123
|
line=cursor.factory.lineno,
|
|
124
124
|
col=cursor.factory.col_offset + 1,
|
|
125
125
|
code=self.code,
|
|
126
|
-
severity=Severity.
|
|
126
|
+
severity=Severity.ERROR,
|
|
127
127
|
message=(
|
|
128
128
|
f"this dict row is immediately converted to `{model}`; use `class_row({model})` to "
|
|
129
129
|
"construct it during fetch"
|