sarj-python-lint 1.16.3__tar.gz → 1.18.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-1.16.3 → sarj_python_lint-1.18.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/pyproject.toml +1 -1
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/__main__.py +12 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_registry.py +2 -2
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +136 -19
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +110 -21
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +44 -5
- sarj_python_lint-1.18.0/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +367 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +19 -2
- sarj_python_lint-1.16.3/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -171
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/.gitignore +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/LICENSE +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/README.md +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.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: 1.
|
|
3
|
+
Version: 1.18.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/
|
|
@@ -84,7 +84,18 @@ def _python_files(p: Path) -> list[Path]:
|
|
|
84
84
|
return sorted(out)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _canonical_rule_ids(rule_ids: list[str]) -> list[str]:
|
|
88
|
+
aliases = {
|
|
89
|
+
alias: rule_id
|
|
90
|
+
for rule_id, cls in REGISTRY.items()
|
|
91
|
+
if (documentation := cls.documentation) is not None
|
|
92
|
+
for alias in documentation.aliases
|
|
93
|
+
}
|
|
94
|
+
return list(dict.fromkeys(rid if rid in REGISTRY else aliases.get(rid, rid) for rid in rule_ids))
|
|
95
|
+
|
|
96
|
+
|
|
87
97
|
def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
98
|
+
rule_ids = _canonical_rule_ids(rule_ids)
|
|
88
99
|
unknown = [rid for rid in rule_ids if rid not in REGISTRY]
|
|
89
100
|
if unknown:
|
|
90
101
|
sys.stderr.write(f"unknown rule(s): {', '.join(unknown)}\n")
|
|
@@ -155,6 +166,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
|
|
|
155
166
|
# Keep the annotation diagnostics when SARJ094 is absent, including
|
|
156
167
|
# when a concrete response_model makes the route contract complete.
|
|
157
168
|
"SARJ094": frozenset({"SARJ008", "SARJ447"}),
|
|
169
|
+
"SARJ095": frozenset({"SARJ468"}),
|
|
158
170
|
"SARJ099": frozenset({"SARJ420"}),
|
|
159
171
|
# Any-valued mappings are the stronger correctness failure. SARJ008
|
|
160
172
|
# still owns every fixed dictionary return when selected alone, while
|
|
@@ -9,7 +9,6 @@ from sarj_python_lint.rules.complex_postgres_query_requires_architecture_review
|
|
|
9
9
|
ComplexPostgresQueryRequiresArchitectureReview,
|
|
10
10
|
)
|
|
11
11
|
from sarj_python_lint.rules.defect_xfail_requires_explicit_strict import DefectXfailRequiresExplicitStrict
|
|
12
|
-
from sarj_python_lint.rules.discourage_nullable_constructor_parameters import DiscourageNullableConstructorParameters
|
|
13
12
|
from sarj_python_lint.rules.docstring_args_restate_signature import (
|
|
14
13
|
DocstringArgsRestateSignature,
|
|
15
14
|
)
|
|
@@ -136,6 +135,7 @@ from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
|
|
|
136
135
|
from sarj_python_lint.rules.prefer_pydantic_json_value import PreferPydanticJsonValue
|
|
137
136
|
from sarj_python_lint.rules.prefer_pytest_fixture_injection import PreferPytestFixtureInjection
|
|
138
137
|
from sarj_python_lint.rules.prefer_regex_fullmatch import PreferRegexFullmatch
|
|
138
|
+
from sarj_python_lint.rules.prefer_required_constructor_parameters import PreferRequiredConstructorParameters
|
|
139
139
|
from sarj_python_lint.rules.prefer_self_documenting_constant import (
|
|
140
140
|
PreferSelfDocumentingConstant,
|
|
141
141
|
)
|
|
@@ -203,7 +203,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
203
203
|
{
|
|
204
204
|
NoDatabaseProgrammability.id: NoDatabaseProgrammability,
|
|
205
205
|
RequireExplicitContractImplementation.id: RequireExplicitContractImplementation,
|
|
206
|
-
|
|
206
|
+
PreferRequiredConstructorParameters.id: PreferRequiredConstructorParameters,
|
|
207
207
|
RequirePublicDependencyContract.id: RequirePublicDependencyContract,
|
|
208
208
|
RequirePreciseFactorySignature.id: RequirePreciseFactorySignature,
|
|
209
209
|
RequireExplicitServiceContract.id: RequireExplicitServiceContract,
|
|
@@ -20,10 +20,13 @@ from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
|
|
23
25
|
from sarj_python_lint._file_context import PythonFileContext
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
_CONSTANT_NAME = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
|
|
29
|
+
_TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
|
|
27
30
|
_MUTATING_METHODS = frozenset(
|
|
28
31
|
{
|
|
29
32
|
"add",
|
|
@@ -73,21 +76,25 @@ class PreferImmutableModuleConstant(Rule):
|
|
|
73
76
|
code: str = "SARJ096"
|
|
74
77
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
75
78
|
default_level=Severity.WARNING,
|
|
76
|
-
summary=(
|
|
79
|
+
summary=(
|
|
80
|
+
"Module collection constants should use native immutable containers or declare dictionary bindings as Final."
|
|
81
|
+
),
|
|
77
82
|
rationale=(
|
|
78
|
-
"
|
|
79
|
-
"
|
|
83
|
+
"Tuple and frozenset protect collection membership. Final makes a dictionary's constant binding explicit "
|
|
84
|
+
"without changing its runtime type or concrete dictionary API."
|
|
80
85
|
),
|
|
81
86
|
remediation=(
|
|
82
|
-
"When the concrete
|
|
83
|
-
"membership
|
|
87
|
+
"When the concrete list or set API is not part of the contract, use a tuple for ordered values or a frozenset "
|
|
88
|
+
"for membership. Annotate dictionary constants with Final[dict[K, V]]. Final prevents reassignment in type-checked "
|
|
89
|
+
"code but does not freeze dictionary contents. Use MappingProxyType when runtime write protection is required."
|
|
84
90
|
),
|
|
85
91
|
category=RuleCategory.MAINTAINABILITY,
|
|
86
92
|
autofix=AutofixPolicy.NONE,
|
|
87
93
|
limitations=(
|
|
88
94
|
"Empty collections and collections intentionally mutated or passed to unknown calls are not reported.",
|
|
89
|
-
"
|
|
90
|
-
"
|
|
95
|
+
"Direct declarations and declarations in module-level if/try suites are checked; repeated bindings, loops, functions, and class bodies are excluded.",
|
|
96
|
+
"Final has no runtime enforcement and permits item mutation on dictionaries; tuple and frozenset are shallow.",
|
|
97
|
+
"Changing a list or set to an immutable type can affect consumers, serialization, and concrete API methods.",
|
|
91
98
|
"Test, test-support, and generated files are excluded.",
|
|
92
99
|
),
|
|
93
100
|
examples=(
|
|
@@ -122,12 +129,42 @@ class PreferImmutableModuleConstant(Rule):
|
|
|
122
129
|
RuleExample(
|
|
123
130
|
example_id="immutable-module-mapping",
|
|
124
131
|
scenario="keyed-values",
|
|
125
|
-
title="
|
|
132
|
+
title="Final dictionary binding used for keyed values",
|
|
126
133
|
outcome=ExampleOutcome.NO_MATCH,
|
|
127
134
|
files=(
|
|
128
135
|
ExampleFile.python(
|
|
129
136
|
"settings.py",
|
|
130
|
-
'from
|
|
137
|
+
'from typing import Final\n\nROLE_LABELS: Final[dict[str, str]] = {"admin": "Administrator"}\n',
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
focus_path=PurePosixPath("settings.py"),
|
|
141
|
+
expected_count=0,
|
|
142
|
+
public=True,
|
|
143
|
+
),
|
|
144
|
+
RuleExample(
|
|
145
|
+
example_id="conditional-module-mapping",
|
|
146
|
+
scenario="conditional-factory",
|
|
147
|
+
title="Conditional dictionary factory needs constant intent",
|
|
148
|
+
outcome=ExampleOutcome.MATCH,
|
|
149
|
+
files=(
|
|
150
|
+
ExampleFile.python(
|
|
151
|
+
"settings.py",
|
|
152
|
+
'if voice_enabled:\n TOOL_LABELS = dict.fromkeys(("call", "transfer"), "Voice")\n',
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
focus_path=PurePosixPath("settings.py"),
|
|
156
|
+
expected_count=1,
|
|
157
|
+
public=True,
|
|
158
|
+
),
|
|
159
|
+
RuleExample(
|
|
160
|
+
example_id="final-conditional-module-mapping",
|
|
161
|
+
scenario="conditional-factory",
|
|
162
|
+
title="Conditional dictionary factory declares a Final binding",
|
|
163
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
164
|
+
files=(
|
|
165
|
+
ExampleFile.python(
|
|
166
|
+
"settings.py",
|
|
167
|
+
'from typing import Final\n\nif voice_enabled:\n TOOL_LABELS: Final[dict[str, str]] = dict.fromkeys(("call", "transfer"), "Voice")\n',
|
|
131
168
|
),
|
|
132
169
|
),
|
|
133
170
|
focus_path=PurePosixPath("settings.py"),
|
|
@@ -147,13 +184,18 @@ class PreferImmutableModuleConstant(Rule):
|
|
|
147
184
|
if tree is None:
|
|
148
185
|
return []
|
|
149
186
|
mutated = _mutated_names(tree)
|
|
150
|
-
|
|
187
|
+
module_names = _module_bound_names(tree)
|
|
188
|
+
shadowed_builtins = {"dict", "list", "set"} if "*" in module_names else module_names & {"dict", "list", "set"}
|
|
189
|
+
statements = tuple(_module_statements(tree.body))
|
|
190
|
+
final_names = _final_names(statements, context)
|
|
151
191
|
findings: list[Diagnostic] = []
|
|
152
|
-
for statement in
|
|
192
|
+
for statement in statements:
|
|
153
193
|
for name, value in _bindings(statement):
|
|
154
194
|
kind = _mutable_literal_kind(value, shadowed_builtins=shadowed_builtins)
|
|
155
195
|
if kind is None or name in mutated or not _CONSTANT_NAME.fullmatch(name) or name.startswith("__"):
|
|
156
196
|
continue
|
|
197
|
+
if kind == "dict" and name in final_names:
|
|
198
|
+
continue
|
|
157
199
|
findings.append(
|
|
158
200
|
Diagnostic(
|
|
159
201
|
path,
|
|
@@ -167,6 +209,57 @@ class PreferImmutableModuleConstant(Rule):
|
|
|
167
209
|
return findings
|
|
168
210
|
|
|
169
211
|
|
|
212
|
+
def _module_statements(body: list[ast.stmt]) -> Iterator[ast.stmt]:
|
|
213
|
+
for statement in body:
|
|
214
|
+
yield statement
|
|
215
|
+
match statement:
|
|
216
|
+
case ast.If():
|
|
217
|
+
yield from _module_statements(statement.body)
|
|
218
|
+
yield from _module_statements(statement.orelse)
|
|
219
|
+
case ast.Try() | ast.TryStar():
|
|
220
|
+
yield from _module_statements(statement.body)
|
|
221
|
+
for handler in statement.handlers:
|
|
222
|
+
yield from _module_statements(handler.body)
|
|
223
|
+
yield from _module_statements(statement.orelse)
|
|
224
|
+
yield from _module_statements(statement.finalbody)
|
|
225
|
+
case _:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _final_names(statements: tuple[ast.stmt, ...], context: PythonFileContext) -> frozenset[str]:
|
|
230
|
+
annotations = [
|
|
231
|
+
statement
|
|
232
|
+
for statement in statements
|
|
233
|
+
if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)
|
|
234
|
+
]
|
|
235
|
+
tree = context.tree
|
|
236
|
+
if not annotations or tree is None:
|
|
237
|
+
return frozenset()
|
|
238
|
+
non_import_bindings = _scope_bound_names(tree.body, include_imports=False)
|
|
239
|
+
return frozenset(
|
|
240
|
+
statement.target.id
|
|
241
|
+
for statement in annotations
|
|
242
|
+
if isinstance(statement.target, ast.Name)
|
|
243
|
+
and _is_final_annotation(statement.annotation, context, non_import_bindings)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_final_annotation(annotation: ast.expr, context: PythonFileContext, non_import_bindings: set[str]) -> bool:
|
|
248
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
249
|
+
try:
|
|
250
|
+
annotation = ast.parse(annotation.value.strip(), mode="eval").body
|
|
251
|
+
except SyntaxError:
|
|
252
|
+
return False
|
|
253
|
+
if isinstance(annotation, ast.Subscript):
|
|
254
|
+
if isinstance(annotation.slice, ast.Tuple):
|
|
255
|
+
return False
|
|
256
|
+
annotation = annotation.value
|
|
257
|
+
root = _root_name(annotation)
|
|
258
|
+
return root not in non_import_bindings and context.module_imports.resolves(
|
|
259
|
+
annotation, sources=_TYPING_SOURCES, symbol="Final"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
170
263
|
def _bindings(statement: ast.stmt) -> tuple[tuple[str, ast.expr], ...]:
|
|
171
264
|
match statement:
|
|
172
265
|
case ast.Assign(targets=targets, value=value):
|
|
@@ -195,14 +288,30 @@ def _mutable_literal_kind(value: ast.expr, *, shadowed_builtins: set[str]) -> st
|
|
|
195
288
|
return None
|
|
196
289
|
case ast.Call(func=ast.Name(id=kind)) if kind in {"set", "dict", "list"} and kind not in shadowed_builtins:
|
|
197
290
|
return kind
|
|
291
|
+
case ast.Call(func=ast.Attribute(value=ast.Name(id="dict"), attr="fromkeys"), args=args, keywords=[]) if (
|
|
292
|
+
"dict" not in shadowed_builtins
|
|
293
|
+
and len(args) in {1, 2}
|
|
294
|
+
and not any(isinstance(argument, ast.Starred) for argument in args)
|
|
295
|
+
):
|
|
296
|
+
return None if _empty_literal(args[0]) else "dict"
|
|
198
297
|
case _:
|
|
199
298
|
return None
|
|
200
299
|
|
|
201
300
|
|
|
301
|
+
def _empty_literal(value: ast.expr) -> bool:
|
|
302
|
+
match value:
|
|
303
|
+
case (
|
|
304
|
+
ast.List(elts=[]) | ast.Tuple(elts=[]) | ast.Set(elts=[]) | ast.Dict(keys=[]) | ast.Constant(value="" | b"")
|
|
305
|
+
):
|
|
306
|
+
return True
|
|
307
|
+
case _:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
|
|
202
311
|
def _mutated_names(tree: ast.Module) -> frozenset[str]:
|
|
203
312
|
mutated: set[str] = set()
|
|
204
313
|
module_bindings: dict[str, int] = {}
|
|
205
|
-
for statement in tree.body:
|
|
314
|
+
for statement in _module_statements(tree.body):
|
|
206
315
|
if isinstance(statement, ast.Assign) and len(statement.targets) > 1:
|
|
207
316
|
# Skip every chained alias because mutation or escape through one name changes them all.
|
|
208
317
|
mutated.update(target.id for target in statement.targets if isinstance(target, ast.Name))
|
|
@@ -424,8 +533,9 @@ def _scope_global_names(body: list[ast.stmt]) -> set[str]:
|
|
|
424
533
|
|
|
425
534
|
|
|
426
535
|
class _BoundNameCollector(ast.NodeVisitor):
|
|
427
|
-
def __init__(self) -> None:
|
|
536
|
+
def __init__(self, *, include_imports: bool) -> None:
|
|
428
537
|
self.names: set[str] = set()
|
|
538
|
+
self._include_imports: bool = include_imports
|
|
429
539
|
|
|
430
540
|
def _record_target(self, target: ast.expr) -> None:
|
|
431
541
|
match target:
|
|
@@ -503,11 +613,13 @@ class _BoundNameCollector(ast.NodeVisitor):
|
|
|
503
613
|
|
|
504
614
|
@override
|
|
505
615
|
def visit_Import(self, node: ast.Import) -> None:
|
|
506
|
-
self.
|
|
616
|
+
if self._include_imports:
|
|
617
|
+
self.names.update(alias.asname or alias.name.split(".", maxsplit=1)[0] for alias in node.names)
|
|
507
618
|
|
|
508
619
|
@override
|
|
509
620
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
510
|
-
self.
|
|
621
|
+
if self._include_imports:
|
|
622
|
+
self.names.update(alias.asname or alias.name for alias in node.names)
|
|
511
623
|
|
|
512
624
|
@override
|
|
513
625
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
@@ -544,8 +656,8 @@ class _BoundNameCollector(ast.NodeVisitor):
|
|
|
544
656
|
self.generic_visit(node)
|
|
545
657
|
|
|
546
658
|
|
|
547
|
-
def _scope_bound_names(body: list[ast.stmt]) -> set[str]:
|
|
548
|
-
collector = _BoundNameCollector()
|
|
659
|
+
def _scope_bound_names(body: list[ast.stmt], *, include_imports: bool = True) -> set[str]:
|
|
660
|
+
collector = _BoundNameCollector(include_imports=include_imports)
|
|
549
661
|
for statement in body:
|
|
550
662
|
collector.visit(statement)
|
|
551
663
|
return collector.names
|
|
@@ -615,10 +727,15 @@ def _literal_container_root_names(value: ast.expr) -> tuple[str, ...]:
|
|
|
615
727
|
|
|
616
728
|
|
|
617
729
|
def _message(name: str, kind: str) -> str:
|
|
618
|
-
|
|
730
|
+
if kind == "dict":
|
|
731
|
+
return (
|
|
732
|
+
f"uppercase module dictionary `{name}` should declare constant intent with Final[dict[K, V]]; "
|
|
733
|
+
"Final prevents reassignment in type-checked code but does not freeze dictionary contents."
|
|
734
|
+
)
|
|
735
|
+
replacement = {"list": "tuple", "set": "frozenset"}[kind]
|
|
619
736
|
return (
|
|
620
737
|
f"uppercase module collection `{name}` is a mutable {kind} — consider {replacement} to prevent top-level "
|
|
621
|
-
"membership
|
|
738
|
+
"membership changes; preserve concrete APIs and recursively freeze nested values when required."
|
|
622
739
|
)
|
|
623
740
|
|
|
624
741
|
|
|
@@ -101,7 +101,8 @@ class PreferMatchTypeDispatch(Rule):
|
|
|
101
101
|
limitations=(
|
|
102
102
|
"General dispatch requires three or more adjacent, unguarded `isinstance` branches over the same simple name. Two arms are checked only for an exact `ast.Name.id` / `ast.Attribute.attr` projection followed by a None-returning fallback.",
|
|
103
103
|
"A terminating sibling prefix may precede a two-arm if/elif tail whose bodies can fall through. Preserve nested checks inside case bodies rather than moving them into case guards.",
|
|
104
|
-
"The checked types must be unshadowed builtins, undecorated module-local classes without an explicit metaclass, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
|
|
104
|
+
"The checked types must be unshadowed builtins, undecorated module-local classes without an explicit or known locally inherited custom metaclass, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
|
|
105
|
+
"Local ancestry must resolve to stable undeclared-metaclass classes or proven builtin/stdlib ast bases. External bases, runtime base aliases, and parameterized base expressions remain excluded because their metaclass or __class_getitem__ effects are unproven.",
|
|
105
106
|
"Nested attribute-validation guards are excluded: converting them to keyword class patterns can turn attribute errors into match fallthrough, and an imported isinstance operand may be a runtime tuple rather than a class.",
|
|
106
107
|
"A terminal-looking context-manager body does not prove a sibling branch terminates: exceptions can be suppressed. An unconditional return or raise after the context manager remains eligible.",
|
|
107
108
|
"Declared support for Python before 3.10 suppresses this recommendation when proven by the nearest project metadata or exact installed-distribution ownership. Missing or ambiguous target metadata retains advisory behavior; it does not prove a modern target.",
|
|
@@ -239,20 +240,20 @@ class PreferMatchTypeDispatch(Rule):
|
|
|
239
240
|
return []
|
|
240
241
|
imports = context.imports
|
|
241
242
|
all_nodes = tuple(context.nodes(ast.AST))
|
|
242
|
-
unsafe_bindings = _unsafe_local_bound_names(tree, all_nodes) | frozenset(
|
|
243
|
+
unsafe_bindings = _unsafe_local_bound_names(tree, all_nodes, imports) | frozenset(
|
|
243
244
|
qualified
|
|
244
245
|
for node in all_nodes
|
|
245
246
|
if isinstance(node, ast.Attribute)
|
|
246
247
|
and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
247
248
|
and (qualified := imports.resolved_qualified_name(node)) is not None
|
|
248
|
-
and qualified.startswith("ast.")
|
|
249
|
+
and qualified.startswith(("ast.", "builtins."))
|
|
249
250
|
)
|
|
250
251
|
has_wildcard_import = any(
|
|
251
252
|
isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names) for node in all_nodes
|
|
252
253
|
)
|
|
253
254
|
if not imports.builtin_is_unshadowed("isinstance") or "isinstance" in unsafe_bindings or has_wildcard_import:
|
|
254
255
|
return []
|
|
255
|
-
local_classes = _unshadowed_module_classes(tree, all_nodes)
|
|
256
|
+
local_classes = _unshadowed_module_classes(tree, all_nodes, imports, unsafe_bindings)
|
|
256
257
|
findings = _ladder_findings(
|
|
257
258
|
all_nodes,
|
|
258
259
|
path,
|
|
@@ -287,14 +288,10 @@ class PreferMatchTypeDispatch(Rule):
|
|
|
287
288
|
return findings
|
|
288
289
|
|
|
289
290
|
|
|
290
|
-
def _unshadowed_module_classes(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if isinstance(statement, ast.ClassDef)
|
|
295
|
-
and not statement.decorator_list
|
|
296
|
-
and not any(keyword.arg in {"metaclass", None} for keyword in statement.keywords)
|
|
297
|
-
]
|
|
291
|
+
def _unshadowed_module_classes(
|
|
292
|
+
tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex, unsafe_bindings: frozenset[str]
|
|
293
|
+
) -> frozenset[str]:
|
|
294
|
+
declarations = [statement for statement in tree.body if isinstance(statement, ast.ClassDef)]
|
|
298
295
|
rebound: set[str] = set()
|
|
299
296
|
for node in all_nodes:
|
|
300
297
|
match node:
|
|
@@ -310,17 +307,83 @@ def _unshadowed_module_classes(tree: ast.Module, all_nodes: tuple[ast.AST, ...])
|
|
|
310
307
|
case _:
|
|
311
308
|
pass
|
|
312
309
|
_add_module_import_bindings(tree, rebound)
|
|
313
|
-
|
|
310
|
+
names = [declaration.name for declaration in declarations]
|
|
311
|
+
stable = [
|
|
312
|
+
declaration
|
|
313
|
+
for declaration in declarations
|
|
314
|
+
if names.count(declaration.name) == 1 and declaration.name not in rebound
|
|
315
|
+
]
|
|
316
|
+
nominal = _nominal_class_ancestry(stable, imports, unsafe_bindings)
|
|
317
|
+
return frozenset(
|
|
318
|
+
declaration.name
|
|
319
|
+
for declaration in stable
|
|
320
|
+
if declaration.name in nominal
|
|
321
|
+
and not any(keyword.arg in {"metaclass", None} for keyword in declaration.keywords)
|
|
322
|
+
)
|
|
314
323
|
|
|
315
324
|
|
|
316
|
-
def
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
def _nominal_class_ancestry(
|
|
326
|
+
declarations: list[ast.ClassDef], imports: ImportIndex, unsafe_bindings: frozenset[str]
|
|
327
|
+
) -> frozenset[str]:
|
|
328
|
+
proven: frozenset[str] = frozenset()
|
|
329
|
+
while True:
|
|
330
|
+
nominal = frozenset(
|
|
331
|
+
declaration.name
|
|
332
|
+
for declaration in declarations
|
|
333
|
+
if not declaration.decorator_list
|
|
334
|
+
and not _unproven_metaclass_header(declaration, imports, unsafe_bindings)
|
|
335
|
+
and all(_nominal_base(base, imports, proven, unsafe_bindings) for base in declaration.bases)
|
|
336
|
+
)
|
|
337
|
+
if nominal <= proven:
|
|
338
|
+
return proven
|
|
339
|
+
proven |= nominal
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _nominal_base(
|
|
343
|
+
expression: ast.expr, imports: ImportIndex, local_classes: frozenset[str], unsafe_bindings: frozenset[str]
|
|
344
|
+
) -> bool:
|
|
345
|
+
if isinstance(expression, ast.Subscript):
|
|
346
|
+
return False
|
|
347
|
+
return _type_reference(expression, imports, local_classes, unsafe_bindings) is not None or (
|
|
348
|
+
_builtin_class_reference(expression, imports, unsafe_bindings) is not None
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _unproven_metaclass_header(
|
|
353
|
+
declaration: ast.ClassDef, imports: ImportIndex, unsafe_bindings: frozenset[str]
|
|
354
|
+
) -> bool:
|
|
355
|
+
for keyword in declaration.keywords:
|
|
356
|
+
if keyword.arg is None:
|
|
357
|
+
return True
|
|
358
|
+
if keyword.arg != "metaclass":
|
|
359
|
+
continue
|
|
360
|
+
if _builtin_class_reference(keyword.value, imports, unsafe_bindings) == "type":
|
|
361
|
+
continue
|
|
362
|
+
return True
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _builtin_class_reference(expression: ast.expr, imports: ImportIndex, unsafe_bindings: frozenset[str]) -> str | None:
|
|
367
|
+
root = expression
|
|
368
|
+
while isinstance(root, ast.Attribute):
|
|
369
|
+
root = root.value
|
|
370
|
+
if not isinstance(root, ast.Name) or root.id in unsafe_bindings:
|
|
371
|
+
return None
|
|
372
|
+
if (
|
|
373
|
+
isinstance(expression, ast.Name)
|
|
374
|
+
and expression.id in _BUILTIN_TYPES
|
|
375
|
+
and imports.builtin_is_unshadowed(expression.id)
|
|
376
|
+
and f"builtins.{expression.id}" not in unsafe_bindings
|
|
377
|
+
):
|
|
378
|
+
return expression.id
|
|
379
|
+
symbol = imports.resolved_symbol(expression, sources=frozenset({"builtins"}))
|
|
380
|
+
if symbol in _BUILTIN_TYPES and f"builtins.{symbol}" not in unsafe_bindings:
|
|
381
|
+
return symbol
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _unsafe_local_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
|
|
386
|
+
names = _unsafe_import_bound_names(tree, all_nodes, imports)
|
|
324
387
|
names.update(
|
|
325
388
|
candidate.name
|
|
326
389
|
for candidate in all_nodes
|
|
@@ -334,6 +397,31 @@ def _unsafe_local_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...])
|
|
|
334
397
|
return frozenset(names)
|
|
335
398
|
|
|
336
399
|
|
|
400
|
+
def _unsafe_import_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex) -> set[str]:
|
|
401
|
+
module_imports = {id(statement) for statement in tree.body if isinstance(statement, (ast.Import, ast.ImportFrom))}
|
|
402
|
+
names: set[str] = set()
|
|
403
|
+
for statement in all_nodes:
|
|
404
|
+
if not isinstance(statement, (ast.Import, ast.ImportFrom)):
|
|
405
|
+
continue
|
|
406
|
+
relative = isinstance(statement, ast.ImportFrom) and statement.level > 0
|
|
407
|
+
if id(statement) in module_imports and not relative:
|
|
408
|
+
continue
|
|
409
|
+
for alias in statement.names:
|
|
410
|
+
if not _preserves_indexed_import(statement, alias, imports):
|
|
411
|
+
names.add(alias.asname or alias.name.partition(".")[0])
|
|
412
|
+
return names
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _preserves_indexed_import(statement: ast.Import | ast.ImportFrom, alias: ast.alias, imports: ImportIndex) -> bool:
|
|
416
|
+
local = alias.asname or alias.name.partition(".")[0]
|
|
417
|
+
target = imports.bindings.get(local)
|
|
418
|
+
if target is None:
|
|
419
|
+
return False
|
|
420
|
+
if isinstance(statement, ast.ImportFrom):
|
|
421
|
+
return not statement.level and target.module == statement.module and target.symbol == alias.name
|
|
422
|
+
return target.module == alias.name and target.symbol is None
|
|
423
|
+
|
|
424
|
+
|
|
337
425
|
def _ladder_findings(
|
|
338
426
|
all_nodes: tuple[ast.AST, ...],
|
|
339
427
|
path: Path,
|
|
@@ -632,6 +720,7 @@ def _type_reference(
|
|
|
632
720
|
expression.id in _BUILTIN_TYPES
|
|
633
721
|
and imports.builtin_is_unshadowed(expression.id)
|
|
634
722
|
and expression.id not in unsafe_bindings
|
|
723
|
+
and f"builtins.{expression.id}" not in unsafe_bindings
|
|
635
724
|
):
|
|
636
725
|
return expression.id
|
|
637
726
|
if expression.id in local_classes and expression.id not in unsafe_bindings:
|
|
@@ -99,20 +99,23 @@ class PreferModuleLevelConstant(Rule):
|
|
|
99
99
|
code: str = "SARJ039"
|
|
100
100
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
101
101
|
default_level=Severity.WARNING,
|
|
102
|
-
summary="Hoist repeatedly read static values when
|
|
102
|
+
summary="Hoist repeatedly read static values when a module constant preserves behavior.",
|
|
103
103
|
rationale=(
|
|
104
104
|
"Rebuilding a substantial static collection repeats allocation, while calling `re.compile` with a "
|
|
105
|
-
"constant pattern repeats a regex-cache lookup.
|
|
106
|
-
"the static lifetime explicit
|
|
105
|
+
"constant pattern repeats a regex-cache lookup. Native immutable collections or Final dictionary bindings "
|
|
106
|
+
"make the static lifetime explicit for proven read-only use."
|
|
107
107
|
),
|
|
108
108
|
remediation=(
|
|
109
|
-
"Define the value once at module scope as a tuple, frozenset,
|
|
110
|
-
"then reference it from the function.
|
|
109
|
+
"Define the value once at module scope as a tuple, frozenset, Final[dict[K, V]], or compiled pattern, "
|
|
110
|
+
"then reference it from the function. Final prevents reassignment in type-checked code but does not freeze "
|
|
111
|
+
"dictionary contents; use MappingProxyType when runtime write protection is required. Preserve ordering "
|
|
112
|
+
"and concrete-type behavior used by callers."
|
|
111
113
|
),
|
|
112
114
|
category=RuleCategory.PERFORMANCE,
|
|
113
115
|
limitations=(
|
|
114
116
|
"Test, test-support, and generated files are excluded.",
|
|
115
117
|
"Collections require at least eight deeply immutable elements, or three when allocated inside a loop; only representation-insensitive reads are accepted.",
|
|
118
|
+
"Final dictionaries retain mutable contents at runtime; unknown calls, mutation, and escaping references are excluded from hoisting candidates.",
|
|
116
119
|
"Regex findings require a proven stdlib `re.compile` binding and exclude `re.DEBUG`; the rule cannot prove that a function is hot, so findings remain advisory.",
|
|
117
120
|
),
|
|
118
121
|
examples=(
|
|
@@ -144,6 +147,36 @@ class PreferModuleLevelConstant(Rule):
|
|
|
144
147
|
expected_count=0,
|
|
145
148
|
public=True,
|
|
146
149
|
),
|
|
150
|
+
RuleExample(
|
|
151
|
+
example_id="static-lookup-built-per-call",
|
|
152
|
+
scenario="keyed-values",
|
|
153
|
+
title="Static lookup dictionary rebuilt per call",
|
|
154
|
+
outcome=ExampleOutcome.MATCH,
|
|
155
|
+
files=(
|
|
156
|
+
ExampleFile.python(
|
|
157
|
+
"service.py",
|
|
158
|
+
"def handle(key):\n labels = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8}\n return labels.get(key)\n",
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
focus_path=PurePosixPath("service.py"),
|
|
162
|
+
expected_count=1,
|
|
163
|
+
public=True,
|
|
164
|
+
),
|
|
165
|
+
RuleExample(
|
|
166
|
+
example_id="final-module-lookup",
|
|
167
|
+
scenario="keyed-values",
|
|
168
|
+
title="Final lookup dictionary defined once",
|
|
169
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
170
|
+
files=(
|
|
171
|
+
ExampleFile.python(
|
|
172
|
+
"service.py",
|
|
173
|
+
"from typing import Final\n\nLABELS: Final[dict[str, int]] = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8}\n\ndef handle(key):\n return LABELS.get(key)\n",
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
focus_path=PurePosixPath("service.py"),
|
|
177
|
+
expected_count=0,
|
|
178
|
+
public=True,
|
|
179
|
+
),
|
|
147
180
|
),
|
|
148
181
|
)
|
|
149
182
|
description: str = documentation.summary
|
|
@@ -600,6 +633,12 @@ def _is_excluded_test_path(path: Path) -> bool:
|
|
|
600
633
|
def _message(name: str, candidate: _Candidate) -> str:
|
|
601
634
|
if candidate.kind == _REGEX_KIND:
|
|
602
635
|
return f"`{name}` repeats a regex-cache lookup on every call — hoist it to module scope."
|
|
636
|
+
if candidate.kind == "dict":
|
|
637
|
+
return (
|
|
638
|
+
f"`{name}` is a constant-only dictionary rebuilt on every call — hoist it to module scope as "
|
|
639
|
+
"Final[dict[K, V]] for read-only lookup use. Final prevents reassignment in type-checked code but does not "
|
|
640
|
+
"freeze dictionary contents; use MappingProxyType when runtime write protection is required."
|
|
641
|
+
)
|
|
603
642
|
return (
|
|
604
643
|
f"`{name}` is a constant-only {candidate.kind} rebuilt on every call — hoist it "
|
|
605
644
|
"to module scope in immutable form (tuple, frozenset, or an immutable mapping) "
|