sarj-python-lint 0.84.2__tar.gz → 0.85.0__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.84.2 → sarj_python_lint-0.85.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/__main__.py +6 -2
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_fastapi.py +3 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_imports.py +6 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_registry.py +10 -6
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +27 -4
- sarj_python_lint-0.84.2/src/sarj_python_lint/rules/pydantic_at_boundaries.py → sarj_python_lint-0.85.0/src/sarj_python_lint/rules/named_record_at_boundaries.py +8 -9
- sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_any_mapping_types.py +257 -0
- sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_delete_statement.py +104 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -1
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +5 -5
- sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_unused_value_marker.py +141 -0
- sarj_python_lint-0.85.0/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +420 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +59 -8
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +65 -3
- sarj_python_lint-0.84.2/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -257
- sarj_python_lint-0.84.2/src/sarj_python_lint/rules/no_vague_annotations.py +0 -173
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/.gitignore +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/LICENSE +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/README.md +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_typed_http_test_response.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/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.85.0
|
|
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/
|
|
@@ -143,6 +143,10 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
|
|
|
143
143
|
"SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
|
|
144
144
|
"SARJ093": frozenset({"SARJ034"}),
|
|
145
145
|
"SARJ099": frozenset({"SARJ420"}),
|
|
146
|
+
# Any-valued mappings are the stronger correctness failure. SARJ008
|
|
147
|
+
# still owns every fixed dictionary return when selected alone, while
|
|
148
|
+
# an all-rules run emits one actionable diagnostic for this overlap.
|
|
149
|
+
"SARJ447": frozenset({"SARJ008"}),
|
|
146
150
|
}
|
|
147
151
|
)
|
|
148
152
|
|
|
@@ -162,13 +166,13 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
|
|
|
162
166
|
"SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
|
|
163
167
|
)
|
|
164
168
|
docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
|
|
165
|
-
needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
|
|
169
|
+
needs_signature_owners = ("SARJ093" in codes and "SARJ034" in codes) or ("SARJ447" in codes and "SARJ008" in codes)
|
|
166
170
|
signature_owners = (
|
|
167
171
|
_function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
|
|
168
172
|
)
|
|
169
173
|
|
|
170
174
|
def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
|
|
171
|
-
if diagnostic.code in {"SARJ034", "SARJ093"}:
|
|
175
|
+
if diagnostic.code in {"SARJ008", "SARJ034", "SARJ093", "SARJ447"}:
|
|
172
176
|
line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
|
|
173
177
|
else:
|
|
174
178
|
line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
|
|
@@ -5,6 +5,8 @@ from collections import Counter
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from typing import TYPE_CHECKING, Literal, NamedTuple
|
|
7
7
|
|
|
8
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
if TYPE_CHECKING:
|
|
10
12
|
from pathlib import Path
|
|
@@ -107,6 +109,7 @@ class FastapiIndex:
|
|
|
107
109
|
def __init__(self, tree: ast.Module, *, path: Path | None = None) -> None:
|
|
108
110
|
self.tree: ast.Module = tree
|
|
109
111
|
self.path: Path | None = path
|
|
112
|
+
self.imports: ImportIndex = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
110
113
|
self._imported_dependency_cache: dict[_ImportedReference, bool] = {}
|
|
111
114
|
self._imported_module_cache: dict[Path, ast.Module | None] = {}
|
|
112
115
|
self.modules: set[str] = set()
|
|
@@ -44,6 +44,12 @@ class ImportIndex:
|
|
|
44
44
|
return None
|
|
45
45
|
return resolved.symbol
|
|
46
46
|
|
|
47
|
+
def resolved_qualified_name(self, node: ast.expr) -> str | None:
|
|
48
|
+
resolved = self._resolve(node)
|
|
49
|
+
if resolved is None or resolved.symbol is None:
|
|
50
|
+
return None
|
|
51
|
+
return f"{resolved.module}.{resolved.symbol}"
|
|
52
|
+
|
|
47
53
|
def builtin_is_unshadowed(self, name: str) -> bool:
|
|
48
54
|
return name not in self.shadowed_names and name not in self.bindings
|
|
49
55
|
|
|
@@ -22,19 +22,21 @@ from sarj_python_lint.rules.invalid_pydantic_field_default import (
|
|
|
22
22
|
InvalidPydanticFieldDefault,
|
|
23
23
|
)
|
|
24
24
|
from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
|
|
25
|
+
from sarj_python_lint.rules.named_record_at_boundaries import NamedRecordAtBoundaries
|
|
25
26
|
from sarj_python_lint.rules.negative_only_http_status_assertion import (
|
|
26
27
|
NegativeOnlyHttpStatusAssertion,
|
|
27
28
|
)
|
|
28
29
|
from sarj_python_lint.rules.no_analytical_aggregation_in_postgres_store import (
|
|
29
30
|
NoAnalyticalAggregationInPostgresStore,
|
|
30
31
|
)
|
|
32
|
+
from sarj_python_lint.rules.no_any_mapping_types import NoAnyMappingTypes
|
|
31
33
|
from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
|
|
32
34
|
from sarj_python_lint.rules.no_conftest_test_module_import import NoConftestTestModuleImport
|
|
33
35
|
from sarj_python_lint.rules.no_copied_inherited_docstring import NoCopiedInheritedDocstring
|
|
34
36
|
from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
35
37
|
NoCorsWildcardWithCredentials,
|
|
36
38
|
)
|
|
37
|
-
from sarj_python_lint.rules.
|
|
39
|
+
from sarj_python_lint.rules.no_delete_statement import NoDeleteStatement
|
|
38
40
|
from sarj_python_lint.rules.no_dunder_all import NoDunderAll
|
|
39
41
|
from sarj_python_lint.rules.no_duplicate_dunder_all_entry import NoDuplicateDunderAllEntry
|
|
40
42
|
from sarj_python_lint.rules.no_excessive_cognitive_complexity import NoExcessiveCognitiveComplexity
|
|
@@ -77,7 +79,7 @@ from sarj_python_lint.rules.no_unique_violation_message_match import (
|
|
|
77
79
|
NoUniqueViolationMessageMatch,
|
|
78
80
|
)
|
|
79
81
|
from sarj_python_lint.rules.no_unnecessary_docstring import NoUnnecessaryDocstring
|
|
80
|
-
from sarj_python_lint.rules.
|
|
82
|
+
from sarj_python_lint.rules.no_unused_value_marker import NoUnusedValueMarker
|
|
81
83
|
from sarj_python_lint.rules.no_vague_suppression_description import (
|
|
82
84
|
NoVagueSuppressionDescription,
|
|
83
85
|
)
|
|
@@ -101,6 +103,7 @@ from sarj_python_lint.rules.prefer_injected_dependency_over_monkeypatch import (
|
|
|
101
103
|
)
|
|
102
104
|
from sarj_python_lint.rules.prefer_library_fake import PreferLibraryFake
|
|
103
105
|
from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
|
|
106
|
+
from sarj_python_lint.rules.prefer_match_exception_dispatch import PreferMatchExceptionDispatch
|
|
104
107
|
from sarj_python_lint.rules.prefer_match_type_dispatch import PreferMatchTypeDispatch
|
|
105
108
|
from sarj_python_lint.rules.prefer_match_value_dispatch import PreferMatchValueDispatch
|
|
106
109
|
from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
@@ -133,7 +136,6 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
|
|
|
133
136
|
from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
|
|
134
137
|
from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
|
|
135
138
|
from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
|
|
136
|
-
from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
137
139
|
from sarj_python_lint.rules.pytest_fixture_returns_bare_tuple import PytestFixtureReturnsBareTuple
|
|
138
140
|
from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
|
|
139
141
|
from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
|
|
@@ -188,7 +190,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
188
190
|
NoExcessiveCognitiveComplexity.id: NoExcessiveCognitiveComplexity,
|
|
189
191
|
PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
|
|
190
192
|
NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
|
|
191
|
-
|
|
193
|
+
NamedRecordAtBoundaries.id: NamedRecordAtBoundaries,
|
|
192
194
|
FastapiExplicitOpenapiContract.id: FastapiExplicitOpenapiContract,
|
|
193
195
|
FastapiClassRouterContract.id: FastapiClassRouterContract,
|
|
194
196
|
FakesInSharedLocation.id: FakesInSharedLocation,
|
|
@@ -242,6 +244,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
242
244
|
PreferOrPattern.id: PreferOrPattern,
|
|
243
245
|
RequirePortForService.id: RequirePortForService,
|
|
244
246
|
PreferNonNullableCollection.id: PreferNonNullableCollection,
|
|
247
|
+
PreferMatchExceptionDispatch.id: PreferMatchExceptionDispatch,
|
|
245
248
|
PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
|
|
246
249
|
PreferMatchValueDispatch.id: PreferMatchValueDispatch,
|
|
247
250
|
PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
|
|
@@ -252,7 +255,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
252
255
|
PreferSelfDocumentingConstant.id: PreferSelfDocumentingConstant,
|
|
253
256
|
NoDuplicateDunderAllEntry.id: NoDuplicateDunderAllEntry,
|
|
254
257
|
NoDunderAll.id: NoDunderAll,
|
|
255
|
-
|
|
258
|
+
NoDeleteStatement.id: NoDeleteStatement,
|
|
256
259
|
NoCopiedInheritedDocstring.id: NoCopiedInheritedDocstring,
|
|
257
260
|
RedundantClassDocstring.id: RedundantClassDocstring,
|
|
258
261
|
RedundantModuleDocstring.id: RedundantModuleDocstring,
|
|
@@ -264,8 +267,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
264
267
|
NoRedundantLiteralDescription.id: NoRedundantLiteralDescription,
|
|
265
268
|
NoTypedDocSections.id: NoTypedDocSections,
|
|
266
269
|
NoUnnecessaryDocstring.id: NoUnnecessaryDocstring,
|
|
270
|
+
NoUnusedValueMarker.id: NoUnusedValueMarker,
|
|
267
271
|
PreferNominalIdTypes.id: PreferNominalIdTypes,
|
|
268
|
-
|
|
272
|
+
NoAnyMappingTypes.id: NoAnyMappingTypes,
|
|
269
273
|
NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
|
|
270
274
|
NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
|
|
271
275
|
ProductionDerivedTestCases.id: ProductionDerivedTestCases,
|
|
@@ -425,18 +425,41 @@ def _schema_erasing(node: ast.expr, index: FastapiIndex) -> bool:
|
|
|
425
425
|
return _schema_erasing(parts[0], index)
|
|
426
426
|
if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.BitOr):
|
|
427
427
|
return _schema_erasing(resolved.left, index) or _schema_erasing(resolved.right, index)
|
|
428
|
-
|
|
429
|
-
if
|
|
428
|
+
target = resolved.value if isinstance(resolved, ast.Subscript) else resolved
|
|
429
|
+
if _is_typing_type(target, index, "Any") or _is_builtin_type(target, index, "object"):
|
|
430
430
|
return True
|
|
431
|
-
if
|
|
431
|
+
if index.imports.resolves(target, sources=frozenset({"pydantic"}), symbol="JsonValue"):
|
|
432
|
+
return True
|
|
433
|
+
if _is_container_type(target, index, _RAW_MAPPINGS | _CONTAINERS):
|
|
432
434
|
if not isinstance(resolved, ast.Subscript):
|
|
433
435
|
return True
|
|
434
436
|
return any(_schema_erasing(item, index) for item in _slice_items(resolved.slice))
|
|
435
|
-
if isinstance(resolved, ast.Subscript) and
|
|
437
|
+
if isinstance(resolved, ast.Subscript) and any(
|
|
438
|
+
_is_typing_type(target, index, wrapper) for wrapper in ("Optional", "Union")
|
|
439
|
+
):
|
|
436
440
|
return any(_schema_erasing(item, index) for item in _slice_items(resolved.slice))
|
|
437
441
|
return False
|
|
438
442
|
|
|
439
443
|
|
|
444
|
+
def _is_builtin_type(node: ast.expr, index: FastapiIndex, name: str) -> bool:
|
|
445
|
+
return (
|
|
446
|
+
isinstance(node, ast.Name) and node.id == name and index.imports.builtin_is_unshadowed(name)
|
|
447
|
+
) or index.imports.resolves(node, sources=frozenset({"builtins"}), symbol=name)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _is_typing_type(node: ast.expr, index: FastapiIndex, name: str) -> bool:
|
|
451
|
+
return index.imports.resolves(node, sources=frozenset({"typing", "typing_extensions"}), symbol=name)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _is_container_type(node: ast.expr, index: FastapiIndex, names: frozenset[str]) -> bool:
|
|
455
|
+
builtin_names = {"dict", "list", "set", "tuple"}
|
|
456
|
+
return any(
|
|
457
|
+
(name in builtin_names and _is_builtin_type(node, index, name))
|
|
458
|
+
or index.imports.resolves(node, sources=frozenset({"collections.abc", "typing"}), symbol=name)
|
|
459
|
+
for name in names
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
440
463
|
def _slice_items(node: ast.expr) -> tuple[ast.expr, ...]:
|
|
441
464
|
return tuple(node.elts) if isinstance(node, ast.Tuple) else (node,)
|
|
442
465
|
|
|
@@ -36,15 +36,15 @@ _DICT_ARG_COUNT = 2
|
|
|
36
36
|
_DOCUMENTATION_DIR_NAMES = frozenset({"docs", "docs_src"})
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
class
|
|
40
|
-
id: str = "
|
|
39
|
+
class NamedRecordAtBoundaries(Rule):
|
|
40
|
+
id: str = "named-record-at-boundaries"
|
|
41
41
|
code: str = "SARJ008"
|
|
42
42
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
43
43
|
summary="Public Python API returns an unnamed fixed-shape record.",
|
|
44
44
|
rationale="A named record makes field types and required keys explicit to callers and static tooling.",
|
|
45
45
|
remediation="Define and return a `TypedDict`, Pydantic model, or frozen dataclass for the fixed record shape.",
|
|
46
46
|
category=RuleCategory.ARCHITECTURE,
|
|
47
|
-
aliases=("named-fixed-record-return"
|
|
47
|
+
aliases=("pydantic-at-boundaries", "named-fixed-record-return"),
|
|
48
48
|
limitations=(
|
|
49
49
|
"Visible FastAPI routes are owned by SARJ094. Private functions and classes, closures, tests, generated files, documentation examples, recognized framework hooks, and dictionary conversion methods are excluded.",
|
|
50
50
|
"Only returned record literals and locally built fixed-shape dictionaries are recognized.",
|
|
@@ -57,7 +57,7 @@ class PydanticAtBoundaries(Rule):
|
|
|
57
57
|
files=(
|
|
58
58
|
ExampleFile.python(
|
|
59
59
|
"service.py",
|
|
60
|
-
"
|
|
60
|
+
"def build_payload(call) -> dict[str, object]:\n return {'id': call.id}\n",
|
|
61
61
|
),
|
|
62
62
|
),
|
|
63
63
|
focus_path=PurePosixPath("service.py"),
|
|
@@ -250,24 +250,23 @@ def _classify_return(node: ast.expr, imports: ImportIndex) -> str | None:
|
|
|
250
250
|
if _is_typing_type(node.value, imports, "Union"):
|
|
251
251
|
return _classify_union_return(node, imports)
|
|
252
252
|
if _is_type(node.value, imports, builtin="list", typing_symbol="List"):
|
|
253
|
-
#
|
|
253
|
+
# Fixed lists of unnamed records need one named element contract.
|
|
254
254
|
inner = _classify_return(node.slice, imports)
|
|
255
255
|
return "dict" if inner == "dict" else None
|
|
256
256
|
if _is_type(node.value, imports, builtin="dict", typing_symbol="Dict"):
|
|
257
|
-
return "dict" if
|
|
257
|
+
return "dict" if _is_named_record_dict_args(node.slice, imports) else None
|
|
258
258
|
# Heterogeneous tuple returns are NOT flagged — multiple return values are
|
|
259
259
|
# idiomatic Python, not a missing data contract.
|
|
260
260
|
return None
|
|
261
261
|
|
|
262
262
|
|
|
263
|
-
def
|
|
263
|
+
def _is_named_record_dict_args(slice_node: ast.expr, imports: ImportIndex) -> bool:
|
|
264
264
|
if not isinstance(slice_node, ast.Tuple) or len(slice_node.elts) != _DICT_ARG_COUNT:
|
|
265
265
|
return False
|
|
266
266
|
key = _resolve_annotation(slice_node.elts[0])
|
|
267
267
|
if key is None or not _is_builtin(key, imports, "str"):
|
|
268
268
|
return False
|
|
269
|
-
|
|
270
|
-
return value is not None and (_is_builtin(value, imports, "object") or _is_typing_type(value, imports, "Any"))
|
|
269
|
+
return _resolve_annotation(slice_node.elts[1]) is not None
|
|
271
270
|
|
|
272
271
|
|
|
273
272
|
def _is_type(node: ast.expr, imports: ImportIndex, *, builtin: str, typing_symbol: str) -> bool:
|
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
)
|
|
18
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
19
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
|
|
27
|
+
_PYDANTIC_SOURCES = frozenset({"pydantic"})
|
|
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 NoAnyMappingTypes(Rule):
|
|
35
|
+
id = "no-any-mapping-types"
|
|
36
|
+
code = "SARJ447"
|
|
37
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
38
|
+
summary="String-keyed mapping types must not erase their values with `Any`.",
|
|
39
|
+
rationale=(
|
|
40
|
+
"An `Any`-valued mapping disables checking transitively and conceals whether the value is a fixed record, "
|
|
41
|
+
"an arbitrary JSON tree, or a genuinely opaque Python object."
|
|
42
|
+
),
|
|
43
|
+
remediation=(
|
|
44
|
+
"Use `TypedDict` (and `Unpack[TypedDict]` for closed keyword arguments) for fixed records, a Pydantic model "
|
|
45
|
+
"for untrusted runtime input, `pydantic.JsonValue` for arbitrary JSON, or `object` plus explicit narrowing "
|
|
46
|
+
"for a genuinely opaque Python value."
|
|
47
|
+
),
|
|
48
|
+
category=RuleCategory.CORRECTNESS,
|
|
49
|
+
autofix=AutofixPolicy.NONE,
|
|
50
|
+
aliases=("no-vague-annotations",),
|
|
51
|
+
limitations=(
|
|
52
|
+
(
|
|
53
|
+
"Only proven built-in or standard-library string-keyed mappings whose value recursively contains "
|
|
54
|
+
"`typing.Any` are reported; `object` is intentionally not treated as `Any`."
|
|
55
|
+
),
|
|
56
|
+
"Generated and vendored sources are excluded; exact local suppressions remain auditable.",
|
|
57
|
+
),
|
|
58
|
+
examples=(
|
|
59
|
+
RuleExample(
|
|
60
|
+
example_id="any-valued-mapping-annotation",
|
|
61
|
+
title="An Any-valued mapping erases the record schema",
|
|
62
|
+
outcome=ExampleOutcome.MATCH,
|
|
63
|
+
files=(
|
|
64
|
+
ExampleFile.python(
|
|
65
|
+
"app/offers.py", "from typing import Any\ndef details() -> dict[str, Any]: ...\n"
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
focus_path=PurePosixPath("app/offers.py"),
|
|
69
|
+
expected_count=1,
|
|
70
|
+
public=True,
|
|
71
|
+
),
|
|
72
|
+
RuleExample(
|
|
73
|
+
example_id="typed-dictionary-annotation",
|
|
74
|
+
title="A TypedDict preserves the record schema",
|
|
75
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
76
|
+
files=(
|
|
77
|
+
ExampleFile.python(
|
|
78
|
+
"app/offers.py",
|
|
79
|
+
"from typing import TypedDict\nclass OfferDetails(TypedDict):\n id: str\ndef details() -> OfferDetails: ...\n",
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
focus_path=PurePosixPath("app/offers.py"),
|
|
83
|
+
expected_count=0,
|
|
84
|
+
public=True,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
description = documentation.summary
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
92
|
+
if is_generated(path, source) or _is_vendor_path(path):
|
|
93
|
+
return []
|
|
94
|
+
try:
|
|
95
|
+
tree = ast.parse(source, filename=str(path), type_comments=True)
|
|
96
|
+
except SyntaxError:
|
|
97
|
+
return []
|
|
98
|
+
imports = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
99
|
+
aliases = _type_aliases(tree, imports)
|
|
100
|
+
lines = source.splitlines()
|
|
101
|
+
findings: list[Diagnostic] = []
|
|
102
|
+
seen: set[tuple[int, int]] = set()
|
|
103
|
+
for expression in _type_expressions(tree, imports):
|
|
104
|
+
for mapping in _any_mappings(expression, imports, aliases):
|
|
105
|
+
location = expression if isinstance(expression, ast.Constant) else mapping
|
|
106
|
+
key = (location.lineno, location.col_offset)
|
|
107
|
+
if key in seen or is_suppressed(lines, location.lineno, self.code):
|
|
108
|
+
continue
|
|
109
|
+
seen.add(key)
|
|
110
|
+
findings.append(
|
|
111
|
+
Diagnostic(
|
|
112
|
+
path=path,
|
|
113
|
+
line=location.lineno,
|
|
114
|
+
col=location.col_offset + 1,
|
|
115
|
+
code=self.code,
|
|
116
|
+
message=(
|
|
117
|
+
f"mapping type `{ast.unparse(mapping)}` contains `Any`; use `TypedDict` for a fixed "
|
|
118
|
+
"record, `pydantic.JsonValue` for arbitrary JSON, or `object` with narrowing for opaque values"
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _type_expressions(tree: ast.Module, imports: ImportIndex) -> list[ast.expr]:
|
|
126
|
+
expressions = [annotation for node in ast.walk(tree) if (annotation := _node_annotation(node, imports)) is not None]
|
|
127
|
+
for node in ast.walk(tree):
|
|
128
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
129
|
+
continue
|
|
130
|
+
if imports.resolves(node.func, sources=_TYPING_SOURCES, symbol="cast") or imports.resolves(
|
|
131
|
+
node.func, sources=_PYDANTIC_SOURCES, symbol="TypeAdapter"
|
|
132
|
+
):
|
|
133
|
+
expressions.append(node.args[0])
|
|
134
|
+
return expressions
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _node_annotation(node: ast.AST, imports: ImportIndex) -> ast.expr | None:
|
|
138
|
+
match node:
|
|
139
|
+
case (
|
|
140
|
+
ast.arg(annotation=annotation)
|
|
141
|
+
| ast.FunctionDef(returns=annotation)
|
|
142
|
+
| ast.AsyncFunctionDef(returns=annotation)
|
|
143
|
+
):
|
|
144
|
+
return annotation
|
|
145
|
+
case ast.AnnAssign(annotation=annotation, value=value) if value is not None:
|
|
146
|
+
if imports.resolves(annotation, sources=_TYPING_SOURCES, symbol="TypeAlias"):
|
|
147
|
+
return value
|
|
148
|
+
return annotation
|
|
149
|
+
case ast.AnnAssign(annotation=annotation):
|
|
150
|
+
return annotation
|
|
151
|
+
case ast.TypeAlias(value=value):
|
|
152
|
+
return value
|
|
153
|
+
case (
|
|
154
|
+
ast.Assign(type_comment=str(comment))
|
|
155
|
+
| ast.For(type_comment=str(comment))
|
|
156
|
+
| ast.AsyncFor(type_comment=str(comment))
|
|
157
|
+
| ast.With(type_comment=str(comment))
|
|
158
|
+
| ast.AsyncWith(type_comment=str(comment))
|
|
159
|
+
):
|
|
160
|
+
return _type_comment(comment, node)
|
|
161
|
+
case _:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _type_comment(comment: str, owner: ast.AST) -> ast.expr | None:
|
|
166
|
+
try:
|
|
167
|
+
expression = ast.parse(comment, mode="eval").body
|
|
168
|
+
except SyntaxError:
|
|
169
|
+
return None
|
|
170
|
+
ast.copy_location(expression, owner)
|
|
171
|
+
return expression
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_string_annotation(annotation: ast.expr) -> ast.expr:
|
|
175
|
+
if not isinstance(annotation, ast.Constant) or not isinstance(annotation.value, str):
|
|
176
|
+
return annotation
|
|
177
|
+
try:
|
|
178
|
+
parsed = ast.parse(annotation.value, mode="eval").body
|
|
179
|
+
except SyntaxError:
|
|
180
|
+
return annotation
|
|
181
|
+
ast.copy_location(parsed, annotation)
|
|
182
|
+
return parsed
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _any_mappings(annotation: ast.expr, imports: ImportIndex, aliases: dict[str, ast.expr]) -> list[ast.Subscript]:
|
|
186
|
+
resolved = _parse_string_annotation(annotation)
|
|
187
|
+
return [
|
|
188
|
+
node
|
|
189
|
+
for node in ast.walk(resolved)
|
|
190
|
+
if isinstance(node, ast.Subscript) and _is_any_mapping(node, imports, aliases)
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_any_mapping(node: ast.Subscript, imports: ImportIndex, aliases: dict[str, ast.expr]) -> bool:
|
|
195
|
+
if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != _MAPPING_ARGUMENT_COUNT:
|
|
196
|
+
return False
|
|
197
|
+
target = node.value
|
|
198
|
+
mapping = (isinstance(target, ast.Name) and target.id == "dict" and imports.builtin_is_unshadowed("dict")) or any(
|
|
199
|
+
imports.resolves(target, sources=_MAPPING_SOURCES, symbol=name) for name in _MAPPING_NAMES
|
|
200
|
+
)
|
|
201
|
+
if not mapping:
|
|
202
|
+
return False
|
|
203
|
+
key, value = node.slice.elts
|
|
204
|
+
return _is_builtin_str(key, imports) and _contains_any(value, imports, aliases, seen=frozenset())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_builtin_str(node: ast.expr, imports: ImportIndex) -> bool:
|
|
208
|
+
return isinstance(node, ast.Name) and node.id == "str" and imports.builtin_is_unshadowed("str")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _contains_any(
|
|
212
|
+
node: ast.expr,
|
|
213
|
+
imports: ImportIndex,
|
|
214
|
+
aliases: dict[str, ast.expr],
|
|
215
|
+
*,
|
|
216
|
+
seen: frozenset[str],
|
|
217
|
+
) -> bool:
|
|
218
|
+
node = _parse_string_annotation(node)
|
|
219
|
+
if imports.resolves(node, sources=_TYPING_SOURCES, symbol="Any"):
|
|
220
|
+
return True
|
|
221
|
+
if isinstance(node, ast.Name) and node.id in aliases and node.id not in seen:
|
|
222
|
+
return _contains_any(aliases[node.id], imports, aliases, seen=seen | {node.id})
|
|
223
|
+
return any(
|
|
224
|
+
_contains_any(child, imports, aliases, seen=seen)
|
|
225
|
+
for child in ast.iter_child_nodes(node)
|
|
226
|
+
if isinstance(child, ast.expr)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _type_aliases(tree: ast.Module, imports: ImportIndex) -> dict[str, ast.expr]:
|
|
231
|
+
aliases: dict[str, ast.expr] = {}
|
|
232
|
+
ambiguous: set[str] = set()
|
|
233
|
+
for statement in tree.body:
|
|
234
|
+
name: str | None = None
|
|
235
|
+
value: ast.expr | None = None
|
|
236
|
+
match statement:
|
|
237
|
+
case ast.TypeAlias(name=ast.Name(id=alias), value=alias_value, type_params=[]):
|
|
238
|
+
name, value = alias, alias_value
|
|
239
|
+
case ast.AnnAssign(target=ast.Name(id=alias), annotation=annotation, value=alias_value) if (
|
|
240
|
+
alias_value is not None and imports.resolves(annotation, sources=_TYPING_SOURCES, symbol="TypeAlias")
|
|
241
|
+
):
|
|
242
|
+
name, value = alias, alias_value
|
|
243
|
+
case _:
|
|
244
|
+
pass
|
|
245
|
+
if name is None or value is None:
|
|
246
|
+
continue
|
|
247
|
+
if name in aliases:
|
|
248
|
+
ambiguous.add(name)
|
|
249
|
+
else:
|
|
250
|
+
aliases[name] = value
|
|
251
|
+
for name in ambiguous:
|
|
252
|
+
aliases.pop(name, None)
|
|
253
|
+
return aliases
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_vendor_path(path: Path) -> bool:
|
|
257
|
+
return any(part.casefold() in {"vendor", "vendored", "third_party"} for part in path.parts)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final
|
|
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
|
+
Severity,
|
|
17
|
+
is_suppressed,
|
|
18
|
+
parse_or_none,
|
|
19
|
+
)
|
|
20
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@final
|
|
28
|
+
class NoDeleteStatement(Rule):
|
|
29
|
+
id = "no-delete-statement"
|
|
30
|
+
code = "SARJ442"
|
|
31
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
32
|
+
summary="Avoid `del` statements; prefer constructing an immutable replacement value.",
|
|
33
|
+
rationale=(
|
|
34
|
+
"Deleting a name, attribute, or collection entry introduces lifetime or in-place mutation semantics "
|
|
35
|
+
"that are harder to follow than explicit value construction."
|
|
36
|
+
),
|
|
37
|
+
remediation=(
|
|
38
|
+
"Remove unused-value deletion markers. Rebuild collections with comprehensions, copies, or immutable "
|
|
39
|
+
"value helpers such as dataclasses.replace. When an external API or deliberate lifetime boundary "
|
|
40
|
+
"requires deletion, add an exact SARJ442 suppression on the statement and explain why."
|
|
41
|
+
),
|
|
42
|
+
category=RuleCategory.MAINTAINABILITY,
|
|
43
|
+
autofix=AutofixPolicy.NONE,
|
|
44
|
+
aliases=("no-deleted-only-override-parameter",),
|
|
45
|
+
limitations=(
|
|
46
|
+
"Every authored Python del statement is reported once, regardless of how many targets it contains.",
|
|
47
|
+
"The rule does not automatically rewrite deletion because the correct immutable replacement depends on ownership and value semantics.",
|
|
48
|
+
"Generated and vendored files are excluded.",
|
|
49
|
+
),
|
|
50
|
+
examples=(
|
|
51
|
+
RuleExample(
|
|
52
|
+
example_id="delete-mapping-entry",
|
|
53
|
+
title="Build a mapping without the removed entry",
|
|
54
|
+
outcome=ExampleOutcome.MATCH,
|
|
55
|
+
files=(
|
|
56
|
+
ExampleFile.python(
|
|
57
|
+
"app/request.py",
|
|
58
|
+
"payload = dict(raw_payload)\ndel payload['secret']\n",
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
focus_path=PurePosixPath("app/request.py"),
|
|
62
|
+
expected_count=1,
|
|
63
|
+
public=True,
|
|
64
|
+
),
|
|
65
|
+
RuleExample(
|
|
66
|
+
example_id="immutable-mapping-filter",
|
|
67
|
+
title="Construct the desired mapping directly",
|
|
68
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
69
|
+
files=(
|
|
70
|
+
ExampleFile.python(
|
|
71
|
+
"app/request.py",
|
|
72
|
+
"payload = {key: value for key, value in raw_payload.items() if key != 'secret'}\n",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
focus_path=PurePosixPath("app/request.py"),
|
|
76
|
+
expected_count=0,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
description = documentation.summary
|
|
82
|
+
|
|
83
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
84
|
+
if "del" not in source or is_generated(path, source):
|
|
85
|
+
return []
|
|
86
|
+
tree = parse_or_none(path, source)
|
|
87
|
+
if tree is None:
|
|
88
|
+
return []
|
|
89
|
+
source_lines = source.splitlines()
|
|
90
|
+
return [
|
|
91
|
+
Diagnostic(
|
|
92
|
+
path=path,
|
|
93
|
+
line=node.lineno,
|
|
94
|
+
col=node.col_offset + 1,
|
|
95
|
+
code=self.code,
|
|
96
|
+
severity=Severity.WARNING,
|
|
97
|
+
message=(
|
|
98
|
+
"`del` introduces mutation or hidden lifetime behavior — construct the desired value "
|
|
99
|
+
"immutably, or add an exact SARJ442 suppression explaining why deletion is required."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
for node in ast.walk(tree)
|
|
103
|
+
if isinstance(node, ast.Delete) and not is_suppressed(source_lines, node.lineno, self.code)
|
|
104
|
+
]
|
|
@@ -404,7 +404,7 @@ def _function_scopes(tree: ast.Module) -> list[_FunctionScope]:
|
|
|
404
404
|
|
|
405
405
|
@override
|
|
406
406
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
407
|
-
|
|
407
|
+
return
|
|
408
408
|
|
|
409
409
|
Collector().visit(tree)
|
|
410
410
|
return scopes
|
|
@@ -623,19 +623,19 @@ class _FunctionAnalyzer:
|
|
|
623
623
|
|
|
624
624
|
@override
|
|
625
625
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
626
|
-
|
|
626
|
+
return
|
|
627
627
|
|
|
628
628
|
@override
|
|
629
629
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
630
|
-
|
|
630
|
+
return
|
|
631
631
|
|
|
632
632
|
@override
|
|
633
633
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
634
|
-
|
|
634
|
+
return
|
|
635
635
|
|
|
636
636
|
@override
|
|
637
637
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
638
|
-
|
|
638
|
+
return
|
|
639
639
|
|
|
640
640
|
Visitor().visit(node)
|
|
641
641
|
|