sarj-python-lint 1.2.1__tar.gz → 1.2.4__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.2.1 → sarj_python_lint-1.2.4}/PKG-INFO +1 -1
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/pyproject.toml +1 -1
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_analysis_session.py +2 -0
- sarj_python_lint-1.2.4/src/sarj_python_lint/rules/_nominal_project.py +280 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_unused_value_marker.py +25 -55
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +53 -3
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +302 -24
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/.gitignore +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/LICENSE +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/README.md +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.4}/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.2.
|
|
3
|
+
Version: 1.2.4
|
|
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/
|
|
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, final
|
|
|
4
4
|
|
|
5
5
|
from sarj_python_lint._python_target import PythonTargetFacts
|
|
6
6
|
from sarj_python_lint.rules._first_party import FirstPartyFacts
|
|
7
|
+
from sarj_python_lint.rules._nominal_project import NominalProjectFacts
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
@@ -16,3 +17,4 @@ class AnalysisSession:
|
|
|
16
17
|
self.project: ProjectIndexSet | None = None
|
|
17
18
|
self.first_party = FirstPartyFacts()
|
|
18
19
|
self.python_target = PythonTargetFacts()
|
|
20
|
+
self.nominal_project = NominalProjectFacts()
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tomllib
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import TYPE_CHECKING, TypeGuard, final
|
|
10
|
+
|
|
11
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
12
|
+
from packaging.utils import canonicalize_name
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint.rules._first_party import distribution_root
|
|
15
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
16
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from sarj_python_lint.rules._first_party import FirstPartyFacts
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_MAX_FILES = 5_000
|
|
24
|
+
_MAX_DIRECTORIES = 3_000
|
|
25
|
+
_MAX_BYTES = 500_000
|
|
26
|
+
_MAX_TOTAL_BYTES = 25_000_000
|
|
27
|
+
_SKIP = frozenset({"node_modules", "vendor", "vendored", "build", "dist", "__pycache__", "venv"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class NominalSource:
|
|
32
|
+
path: Path
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def tree(self) -> ast.Module | None:
|
|
37
|
+
try:
|
|
38
|
+
tree = ast.parse(self.text, filename=str(self.path))
|
|
39
|
+
except SyntaxError:
|
|
40
|
+
return None
|
|
41
|
+
if any(
|
|
42
|
+
isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names)
|
|
43
|
+
for node in ast.walk(tree)
|
|
44
|
+
):
|
|
45
|
+
return None
|
|
46
|
+
return tree
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def imports(self) -> ImportIndex:
|
|
50
|
+
tree = self.tree
|
|
51
|
+
if tree is None:
|
|
52
|
+
return ImportIndex(MappingProxyType({}), frozenset())
|
|
53
|
+
imports = ImportIndex.from_tree(tree)
|
|
54
|
+
mutated = {
|
|
55
|
+
ast.unparse(node).partition(".")[0]
|
|
56
|
+
for node in ast.walk(tree)
|
|
57
|
+
if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
58
|
+
}
|
|
59
|
+
return ImportIndex(
|
|
60
|
+
MappingProxyType({name: target for name, target in imports.bindings.items() if name not in mutated}),
|
|
61
|
+
imports.shadowed_names | mutated,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class _Workspace:
|
|
67
|
+
root: Path
|
|
68
|
+
members: tuple[str, ...]
|
|
69
|
+
excludes: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
def contains(self, path: Path) -> bool:
|
|
72
|
+
if path == self.root:
|
|
73
|
+
return True
|
|
74
|
+
if not path.is_relative_to(self.root):
|
|
75
|
+
return False
|
|
76
|
+
relative = path.relative_to(self.root)
|
|
77
|
+
return any(relative.full_match(pattern) for pattern in self.members) and not any(
|
|
78
|
+
relative.full_match(pattern) for pattern in self.excludes
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class _Distribution:
|
|
84
|
+
root: Path
|
|
85
|
+
name: str | None
|
|
86
|
+
dependencies: frozenset[str]
|
|
87
|
+
workspace: _Workspace | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@final
|
|
91
|
+
class NominalProjectFacts:
|
|
92
|
+
# One request owns these caches; subsequent requests see source changes.
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
self._distributions: dict[Path, tuple[_Distribution, ...]] = {}
|
|
95
|
+
self._sources: dict[Path, dict[str, NominalSource]] = {}
|
|
96
|
+
self._scopes: dict[Path, dict[str, NominalSource]] = {}
|
|
97
|
+
|
|
98
|
+
def sources(self, path: Path, first_party: FirstPartyFacts) -> dict[str, NominalSource]:
|
|
99
|
+
owner = distribution_root(path, facts=first_party)
|
|
100
|
+
root = first_party.project_root(path)
|
|
101
|
+
if owner is None or root is None:
|
|
102
|
+
return {}
|
|
103
|
+
if owner in self._scopes:
|
|
104
|
+
return self._scopes[owner]
|
|
105
|
+
distributions = self._accessible_distributions(owner, root)
|
|
106
|
+
sources = self._merge_sources(distributions)
|
|
107
|
+
self._scopes[owner] = sources
|
|
108
|
+
return sources
|
|
109
|
+
|
|
110
|
+
def _accessible_distributions(self, owner: Path, root: Path) -> tuple[_Distribution, ...]:
|
|
111
|
+
if root not in self._distributions:
|
|
112
|
+
self._distributions[root] = tuple(
|
|
113
|
+
distribution
|
|
114
|
+
for manifest in _files(root, "pyproject.toml")
|
|
115
|
+
if (distribution := _distribution(manifest)) is not None
|
|
116
|
+
)
|
|
117
|
+
distributions = self._distributions[root]
|
|
118
|
+
own = next(
|
|
119
|
+
(
|
|
120
|
+
distribution
|
|
121
|
+
for distribution in distributions
|
|
122
|
+
if distribution.root == owner and distribution.name is not None
|
|
123
|
+
),
|
|
124
|
+
None,
|
|
125
|
+
)
|
|
126
|
+
if own is None:
|
|
127
|
+
return ()
|
|
128
|
+
distributions = _workspace_distributions(distributions, owner)
|
|
129
|
+
return tuple(
|
|
130
|
+
distribution
|
|
131
|
+
for distribution in distributions
|
|
132
|
+
if distribution == own
|
|
133
|
+
or (
|
|
134
|
+
distribution.name in own.dependencies
|
|
135
|
+
and sum(candidate.name == distribution.name for candidate in distributions) == 1
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _merge_sources(self, distributions: tuple[_Distribution, ...]) -> dict[str, NominalSource]:
|
|
140
|
+
sources: dict[str, NominalSource] = {}
|
|
141
|
+
ambiguous: set[str] = set()
|
|
142
|
+
for distribution in distributions:
|
|
143
|
+
if distribution.root not in self._sources:
|
|
144
|
+
self._sources[distribution.root] = _sources(distribution.root)
|
|
145
|
+
for module, source in self._sources[distribution.root].items():
|
|
146
|
+
if module in sources:
|
|
147
|
+
ambiguous.add(module)
|
|
148
|
+
sources[module] = source
|
|
149
|
+
return {module: source for module, source in sources.items() if module not in ambiguous}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _workspace_distributions(distributions: tuple[_Distribution, ...], owner: Path) -> tuple[_Distribution, ...]:
|
|
153
|
+
workspaces = [
|
|
154
|
+
distribution.workspace
|
|
155
|
+
for distribution in distributions
|
|
156
|
+
if distribution.workspace is not None and distribution.workspace.contains(owner)
|
|
157
|
+
]
|
|
158
|
+
if workspaces:
|
|
159
|
+
workspace = max(workspaces, key=lambda scope: len(scope.root.parts))
|
|
160
|
+
distributions = tuple(distribution for distribution in distributions if workspace.contains(distribution.root))
|
|
161
|
+
return distributions
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _distribution(manifest: Path) -> _Distribution | None:
|
|
165
|
+
try:
|
|
166
|
+
if manifest.is_symlink() or manifest.stat().st_size > _MAX_BYTES:
|
|
167
|
+
return None
|
|
168
|
+
document: object = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
|
169
|
+
except OSError, ValueError:
|
|
170
|
+
return None
|
|
171
|
+
if not _is_mapping(document):
|
|
172
|
+
return None
|
|
173
|
+
workspace = _workspace(manifest.parent, document)
|
|
174
|
+
project = document.get("project")
|
|
175
|
+
if not _is_mapping(project):
|
|
176
|
+
return _Distribution(manifest.parent, None, frozenset(), workspace) if workspace is not None else None
|
|
177
|
+
name = project.get("name")
|
|
178
|
+
dependencies = project.get("dependencies", [])
|
|
179
|
+
if not isinstance(name, str) or not _is_list(dependencies):
|
|
180
|
+
return None
|
|
181
|
+
names: set[str] = set()
|
|
182
|
+
for dependency in dependencies:
|
|
183
|
+
if not isinstance(dependency, str):
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
names.add(canonicalize_name(Requirement(dependency).name))
|
|
187
|
+
except InvalidRequirement:
|
|
188
|
+
continue
|
|
189
|
+
return _Distribution(manifest.parent, canonicalize_name(name), frozenset(names), workspace)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _workspace(root: Path, document: dict[object, object]) -> _Workspace | None:
|
|
193
|
+
value: object = document
|
|
194
|
+
for key in ("tool", "uv", "workspace"):
|
|
195
|
+
if not _is_mapping(value):
|
|
196
|
+
return None
|
|
197
|
+
value = value.get(key)
|
|
198
|
+
if not _is_mapping(value):
|
|
199
|
+
return None
|
|
200
|
+
members = _patterns(value.get("members"))
|
|
201
|
+
excludes = _patterns(value.get("exclude", []))
|
|
202
|
+
if members is None or excludes is None:
|
|
203
|
+
return None
|
|
204
|
+
return _Workspace(root, members, excludes)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _patterns(value: object) -> tuple[str, ...] | None:
|
|
208
|
+
if not _is_list(value) or any(not isinstance(item, str) for item in value):
|
|
209
|
+
return None
|
|
210
|
+
return tuple(item for item in value if isinstance(item, str))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _files(root: Path, pattern: str) -> list[Path]:
|
|
214
|
+
found: list[Path] = []
|
|
215
|
+
try:
|
|
216
|
+
for count, (directory, directories, filenames) in enumerate(root.walk()):
|
|
217
|
+
if count >= _MAX_DIRECTORIES or len(found) >= _MAX_FILES:
|
|
218
|
+
# A partial inventory cannot establish uniqueness.
|
|
219
|
+
return []
|
|
220
|
+
directories[:] = sorted(name for name in directories if not name.startswith(".") and name not in _SKIP)
|
|
221
|
+
found.extend(directory / name for name in sorted(filenames) if Path(name).match(pattern))
|
|
222
|
+
if len(found) > _MAX_FILES:
|
|
223
|
+
return []
|
|
224
|
+
except OSError:
|
|
225
|
+
return []
|
|
226
|
+
return found
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _sources(root: Path) -> dict[str, NominalSource]:
|
|
230
|
+
sources: dict[str, NominalSource] = {}
|
|
231
|
+
ambiguous: set[str] = set()
|
|
232
|
+
total_bytes = 0
|
|
233
|
+
for path in _files(root, "*.py"):
|
|
234
|
+
try:
|
|
235
|
+
total_bytes += path.stat().st_size
|
|
236
|
+
except OSError:
|
|
237
|
+
continue
|
|
238
|
+
if total_bytes > _MAX_TOTAL_BYTES:
|
|
239
|
+
return {}
|
|
240
|
+
module = _module_name(root, path)
|
|
241
|
+
if not module:
|
|
242
|
+
continue
|
|
243
|
+
source = _source(path)
|
|
244
|
+
if source is None:
|
|
245
|
+
continue
|
|
246
|
+
if module in sources:
|
|
247
|
+
ambiguous.add(module)
|
|
248
|
+
sources[module] = source
|
|
249
|
+
return {module: source for module, source in sources.items() if module not in ambiguous}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _module_name(root: Path, path: Path) -> str | None:
|
|
253
|
+
# A nested manifest owns its sources even when it uses the same namespace.
|
|
254
|
+
if any(
|
|
255
|
+
(parent / "pyproject.toml").is_file() for parent in path.parents if parent != root and root in parent.parents
|
|
256
|
+
):
|
|
257
|
+
return None
|
|
258
|
+
relative = path.relative_to(root)
|
|
259
|
+
parts = relative.parts[1:] if relative.parts[0] == "src" else relative.parts
|
|
260
|
+
return ".".join(parts[:-1] if parts[-1] == "__init__.py" else (*parts[:-1], path.stem)) or None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _source(path: Path) -> NominalSource | None:
|
|
264
|
+
try:
|
|
265
|
+
if path.is_symlink() or path.stat().st_size > _MAX_BYTES:
|
|
266
|
+
return None
|
|
267
|
+
text = path.read_text(encoding="utf-8")
|
|
268
|
+
if is_generated(path, text):
|
|
269
|
+
return None
|
|
270
|
+
except OSError, UnicodeError:
|
|
271
|
+
return None
|
|
272
|
+
return NominalSource(path, text)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_mapping(value: object) -> TypeGuard[dict[object, object]]:
|
|
276
|
+
return isinstance(value, dict)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_list(value: object) -> TypeGuard[list[object]]:
|
|
280
|
+
return isinstance(value, list)
|
|
@@ -16,12 +16,9 @@ from sarj_python_lint.rule_base import (
|
|
|
16
16
|
Severity,
|
|
17
17
|
is_suppressed,
|
|
18
18
|
)
|
|
19
|
-
from sarj_python_lint.rules._ast_index import walk as walk_ast
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
if TYPE_CHECKING:
|
|
23
|
-
from collections.abc import Mapping
|
|
24
|
-
|
|
25
22
|
from sarj_python_lint._file_context import PythonFileContext
|
|
26
23
|
|
|
27
24
|
|
|
@@ -34,27 +31,28 @@ class NoUnusedValueMarker(Rule):
|
|
|
34
31
|
summary="Do not use standalone assignments to `_` to discard values.",
|
|
35
32
|
rationale=(
|
|
36
33
|
"A standalone `_ = value` assignment adds a binding without clarifying intent. A side-effecting call can "
|
|
37
|
-
"be invoked directly.
|
|
38
|
-
"
|
|
34
|
+
"be invoked directly. Reading unused parameters only to satisfy a linter hides whether a callback is "
|
|
35
|
+
"intentionally inert or implements an incomplete contract."
|
|
39
36
|
),
|
|
40
37
|
remediation=(
|
|
41
38
|
"Invoke calls directly instead of assigning their result to `_`. Remove or rename locally owned unused "
|
|
42
39
|
"values; when an external interface requires the exact unused parameter, put a narrow reasoned linter "
|
|
43
|
-
"suppression on its declaration."
|
|
40
|
+
"suppression on its declaration. Use an explicit empty body for an intentional stub, `@override` for "
|
|
41
|
+
"a real override, or `NotImplementedError` for an unsupported operation; preserve required keyword names."
|
|
44
42
|
),
|
|
45
43
|
category=RuleCategory.MAINTAINABILITY,
|
|
46
44
|
autofix=AutofixPolicy.NONE,
|
|
47
45
|
limitations=(
|
|
48
46
|
"Plain and annotated assignments whose standalone target is `_` are reported, including chained assignments.",
|
|
49
|
-
"
|
|
47
|
+
"Formal-parameter references are reported too, including positional, keyword-only, and variadic parameters.",
|
|
50
48
|
"Unpacking placeholders, loop targets, wildcard imports, and underscore-prefixed names are excluded.",
|
|
51
49
|
"Generated and vendored files are excluded.",
|
|
52
50
|
),
|
|
53
51
|
examples=(
|
|
54
52
|
RuleExample(
|
|
55
53
|
example_id="callback-parameter-marker",
|
|
56
|
-
title="A callback
|
|
57
|
-
outcome=ExampleOutcome.
|
|
54
|
+
title="A callback disguises unused parameters with a discard binding",
|
|
55
|
+
outcome=ExampleOutcome.MATCH,
|
|
58
56
|
files=(
|
|
59
57
|
ExampleFile.python(
|
|
60
58
|
"app/adapter.py",
|
|
@@ -62,8 +60,24 @@ class NoUnusedValueMarker(Rule):
|
|
|
62
60
|
),
|
|
63
61
|
),
|
|
64
62
|
focus_path=PurePosixPath("app/adapter.py"),
|
|
63
|
+
expected_count=1,
|
|
64
|
+
public=True,
|
|
65
|
+
scenario="callback-parameters",
|
|
66
|
+
),
|
|
67
|
+
RuleExample(
|
|
68
|
+
example_id="intentional-callback-stub",
|
|
69
|
+
title="An intentional stub preserves its callback signature",
|
|
70
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
71
|
+
files=(
|
|
72
|
+
ExampleFile.python(
|
|
73
|
+
"app/adapter.py",
|
|
74
|
+
"def on(event: str, callback: object) -> None:\n pass\n",
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
focus_path=PurePosixPath("app/adapter.py"),
|
|
65
78
|
expected_count=0,
|
|
66
|
-
public=
|
|
79
|
+
public=True,
|
|
80
|
+
scenario="callback-parameters",
|
|
67
81
|
),
|
|
68
82
|
RuleExample(
|
|
69
83
|
example_id="discarded-nonparameter-value",
|
|
@@ -138,11 +152,6 @@ class NoUnusedValueMarker(Rule):
|
|
|
138
152
|
]
|
|
139
153
|
if not markers:
|
|
140
154
|
return []
|
|
141
|
-
parents: Mapping[ast.AST, ast.AST] = (
|
|
142
|
-
context.parents
|
|
143
|
-
if any(isinstance(node, ast.Assign) and isinstance(node.value, (ast.Name, ast.Tuple)) for node in markers)
|
|
144
|
-
else {}
|
|
145
|
-
)
|
|
146
155
|
return [
|
|
147
156
|
Diagnostic(
|
|
148
157
|
path=path,
|
|
@@ -156,7 +165,7 @@ class NoUnusedValueMarker(Rule):
|
|
|
156
165
|
),
|
|
157
166
|
)
|
|
158
167
|
for node in markers
|
|
159
|
-
if not
|
|
168
|
+
if not is_suppressed(source_lines, node.lineno, self.code)
|
|
160
169
|
]
|
|
161
170
|
|
|
162
171
|
|
|
@@ -164,42 +173,3 @@ def _is_unused_value_marker(node: ast.Assign | ast.AnnAssign) -> bool:
|
|
|
164
173
|
if isinstance(node, ast.AnnAssign):
|
|
165
174
|
return node.value is not None and isinstance(node.target, ast.Name) and node.target.id == "_"
|
|
166
175
|
return any(isinstance(target, ast.Name) and target.id == "_" for target in node.targets)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _is_pure_parameter_marker(
|
|
170
|
-
node: ast.Assign | ast.AnnAssign,
|
|
171
|
-
parents: Mapping[ast.AST, ast.AST],
|
|
172
|
-
) -> bool:
|
|
173
|
-
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
174
|
-
return False
|
|
175
|
-
if not isinstance(node.targets[0], ast.Name) or node.targets[0].id != "_":
|
|
176
|
-
return False
|
|
177
|
-
names = _marker_reference_names(node.value)
|
|
178
|
-
if names is None:
|
|
179
|
-
return False
|
|
180
|
-
function = _enclosing_function(node, parents)
|
|
181
|
-
if function is None:
|
|
182
|
-
return False
|
|
183
|
-
parameters = {argument.arg for argument in walk_ast(function.args) if isinstance(argument, ast.arg)}
|
|
184
|
-
return names <= parameters and not any(
|
|
185
|
-
isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store) and child.id in names
|
|
186
|
-
for child in walk_ast(function)
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _marker_reference_names(value: ast.expr) -> set[str] | None:
|
|
191
|
-
values = value.elts if isinstance(value, ast.Tuple) else [value]
|
|
192
|
-
if not values or not all(isinstance(item, ast.Name) for item in values):
|
|
193
|
-
return None
|
|
194
|
-
return {item.id for item in values if isinstance(item, ast.Name)}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _enclosing_function(
|
|
198
|
-
node: ast.AST, parents: Mapping[ast.AST, ast.AST]
|
|
199
|
-
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
|
|
200
|
-
current = node
|
|
201
|
-
while current in parents:
|
|
202
|
-
current = parents[current]
|
|
203
|
-
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
204
|
-
return current
|
|
205
|
-
return None
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
from pathlib import PurePosixPath
|
|
5
|
+
import re
|
|
5
6
|
from typing import TYPE_CHECKING, NamedTuple, final, override
|
|
6
7
|
|
|
7
8
|
from sarj_python_lint.rule_base import (
|
|
@@ -14,8 +15,10 @@ from sarj_python_lint.rule_base import (
|
|
|
14
15
|
RuleDocumentation,
|
|
15
16
|
RuleExample,
|
|
16
17
|
Severity,
|
|
18
|
+
is_suppressed,
|
|
17
19
|
)
|
|
18
20
|
from sarj_python_lint.rules._ast_index import nodes
|
|
21
|
+
from sarj_python_lint.rules._suppression_comments import scan_comments_or_none
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
if TYPE_CHECKING:
|
|
@@ -28,6 +31,8 @@ if TYPE_CHECKING:
|
|
|
28
31
|
_MIN_ARMS = 2
|
|
29
32
|
|
|
30
33
|
_ENUM_BASES = frozenset({"Enum", "StrEnum", "IntEnum", "ReprEnum"})
|
|
34
|
+
_ARGUMENT_IGNORE = re.compile(r"^(?:type|pyright):\s*ignore\b(?:\[([^\]]*)\])?")
|
|
35
|
+
_TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
class _EnumComparison(NamedTuple):
|
|
@@ -58,17 +63,20 @@ class PreferMatchAssertNever(Rule):
|
|
|
58
63
|
documentation = RuleDocumentation(
|
|
59
64
|
summary="Typed enum dispatch must not silently ignore unhandled members.",
|
|
60
65
|
rationale=(
|
|
61
|
-
"A no-op wildcard or else branch hides missing enum members and lets newly added members pass unnoticed."
|
|
66
|
+
"A no-op wildcard or else branch hides missing enum members and lets newly added members pass unnoticed. "
|
|
67
|
+
"Suppressing the argument-type error at assert_never also disables its static exhaustiveness guarantee."
|
|
62
68
|
),
|
|
63
69
|
remediation=(
|
|
64
70
|
"Handle every enum member, bind the catch-all value, and pass it to `typing.assert_never`; "
|
|
65
|
-
"raise an explicit exception when static exhaustiveness is unavailable."
|
|
71
|
+
"raise an explicit exception when static exhaustiveness is unavailable. Narrow the accepted type or "
|
|
72
|
+
"handle missing variants instead of suppressing assert_never's argument check."
|
|
66
73
|
),
|
|
67
74
|
category=RuleCategory.CORRECTNESS,
|
|
68
75
|
autofix=AutofixPolicy.NONE,
|
|
69
76
|
limitations=(
|
|
70
77
|
"Only subjects explicitly annotated as a locally declared Enum, StrEnum, IntEnum, or ReprEnum are checked.",
|
|
71
78
|
"Flags, imported or untyped domains, class-pattern unions, guarded arms, generated files, and dynamic dispatch are excluded.",
|
|
79
|
+
"Independently warns on import-proven assert_never calls with a trailing blanket or argument-type ignore; module-wide disables are not analyzed.",
|
|
72
80
|
),
|
|
73
81
|
examples=(
|
|
74
82
|
RuleExample(
|
|
@@ -115,7 +123,7 @@ class PreferMatchAssertNever(Rule):
|
|
|
115
123
|
imports = context.imports
|
|
116
124
|
local_enums = _local_enum_names(module_classdefs, imports, tree)
|
|
117
125
|
enum_members = _enum_members(module_classdefs, local_enums)
|
|
118
|
-
diags
|
|
126
|
+
diags = _suppressed_exhaustiveness_calls(context, self.code)
|
|
119
127
|
consumed_elifs: set[int] = set()
|
|
120
128
|
for node in context.nodes(ast.Match, ast.If):
|
|
121
129
|
if isinstance(node, ast.Match):
|
|
@@ -157,6 +165,48 @@ class PreferMatchAssertNever(Rule):
|
|
|
157
165
|
return diags
|
|
158
166
|
|
|
159
167
|
|
|
168
|
+
def _suppressed_exhaustiveness_calls(context: PythonFileContext, code: str) -> list[Diagnostic]:
|
|
169
|
+
if "ignore" not in context.source:
|
|
170
|
+
return []
|
|
171
|
+
comments = scan_comments_or_none(context.source)
|
|
172
|
+
if comments is None:
|
|
173
|
+
return []
|
|
174
|
+
ignored_lines = {
|
|
175
|
+
comment.line for comment in comments if not comment.standalone and _ignores_argument_type(comment.body)
|
|
176
|
+
}
|
|
177
|
+
findings: list[Diagnostic] = []
|
|
178
|
+
for call in context.nodes(ast.Call):
|
|
179
|
+
if not context.imports.resolves(call.func, sources=_TYPING_SOURCES, symbol="assert_never"):
|
|
180
|
+
continue
|
|
181
|
+
call_lines = range(call.lineno, (call.end_lineno or call.lineno) + 1)
|
|
182
|
+
if not any(line in ignored_lines for line in call_lines):
|
|
183
|
+
continue
|
|
184
|
+
if any(is_suppressed(context.source_lines, line, code) for line in call_lines):
|
|
185
|
+
continue
|
|
186
|
+
findings.append(
|
|
187
|
+
Diagnostic(
|
|
188
|
+
path=context.path,
|
|
189
|
+
line=call.lineno,
|
|
190
|
+
col=call.col_offset + 1,
|
|
191
|
+
code=code,
|
|
192
|
+
severity=Severity.WARNING,
|
|
193
|
+
message=(
|
|
194
|
+
"assert_never has its argument-type check suppressed; handle the missing variants, narrow "
|
|
195
|
+
"the accepted type, or use an explicit runtime exception instead of claiming exhaustiveness."
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ignores_argument_type(comment: str) -> bool:
|
|
203
|
+
match = _ARGUMENT_IGNORE.match(comment)
|
|
204
|
+
if match is None:
|
|
205
|
+
return False
|
|
206
|
+
codes = match.group(1)
|
|
207
|
+
return codes is None or bool({code.strip() for code in codes.split(",")} & {"reportArgumentType", "arg-type"})
|
|
208
|
+
|
|
209
|
+
|
|
160
210
|
def _module_scope_classdefs(tree: ast.Module) -> list[ast.ClassDef]:
|
|
161
211
|
return [stmt for stmt in tree.body if isinstance(stmt, ast.ClassDef)]
|
|
162
212
|
|