sarj-python-lint 1.3.1__tar.gz → 1.4.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.3.1 → sarj_python_lint-1.4.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/pyproject.toml +1 -1
- sarj_python_lint-1.4.0/src/sarj_python_lint/rules/_dict_key_views.py +105 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- sarj_python_lint-1.4.0/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +233 -0
- sarj_python_lint-1.4.0/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +298 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +19 -4
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +2 -2
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +109 -5
- sarj_python_lint-1.4.0/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +255 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/.gitignore +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/LICENSE +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/README.md +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.3.1 → sarj_python_lint-1.4.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.4.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/
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from sarj_python_lint.rules._ast_index import walk
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
14
|
+
_NESTED = (ast.Lambda, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def proven_dict_key_views(tree: ast.Module, shadowed: frozenset[str]) -> set[ast.Call]:
|
|
18
|
+
parents = {child: parent for parent in walk(tree) for child in ast.iter_child_nodes(parent)}
|
|
19
|
+
result: set[ast.Call] = set()
|
|
20
|
+
scopes: dict[ast.AST, dict[str, ast.AST]] = {}
|
|
21
|
+
for node in walk(tree):
|
|
22
|
+
if not (
|
|
23
|
+
isinstance(node, ast.Call)
|
|
24
|
+
and isinstance(node.func, ast.Attribute)
|
|
25
|
+
and node.func.attr == "keys"
|
|
26
|
+
and not node.args
|
|
27
|
+
and not node.keywords
|
|
28
|
+
):
|
|
29
|
+
continue
|
|
30
|
+
receiver = node.func.value
|
|
31
|
+
if _literal_dict(receiver, shadowed) or (
|
|
32
|
+
isinstance(receiver, ast.Name) and _local_dict(receiver, parents, shadowed, scopes)
|
|
33
|
+
):
|
|
34
|
+
result.add(node)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _literal_dict(node: ast.expr, shadowed: frozenset[str]) -> bool:
|
|
39
|
+
return isinstance(node, ast.Dict | ast.DictComp) or (
|
|
40
|
+
isinstance(node, ast.Call)
|
|
41
|
+
and isinstance(node.func, ast.Name)
|
|
42
|
+
and node.func.id == "dict"
|
|
43
|
+
and "dict" not in shadowed
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _local_dict(
|
|
48
|
+
node: ast.Name,
|
|
49
|
+
parents: Mapping[ast.AST, ast.AST],
|
|
50
|
+
shadowed: frozenset[str],
|
|
51
|
+
scopes: dict[ast.AST, dict[str, ast.AST]],
|
|
52
|
+
) -> bool:
|
|
53
|
+
ancestor: ast.AST = node
|
|
54
|
+
while not isinstance(ancestor, _SCOPES):
|
|
55
|
+
if isinstance(ancestor, _NESTED):
|
|
56
|
+
return False
|
|
57
|
+
ancestor = parents[ancestor]
|
|
58
|
+
if isinstance(ancestor, ast.ClassDef):
|
|
59
|
+
return False
|
|
60
|
+
if ancestor not in scopes:
|
|
61
|
+
scopes[ancestor] = _unique_bindings(ancestor)
|
|
62
|
+
binding = scopes[ancestor].get(node.id)
|
|
63
|
+
if binding is None:
|
|
64
|
+
return False
|
|
65
|
+
statement = parents.get(binding)
|
|
66
|
+
if not isinstance(statement, ast.Assign | ast.AnnAssign) or parents.get(statement) is not ancestor:
|
|
67
|
+
return False
|
|
68
|
+
if isinstance(statement, ast.Assign) and len(statement.targets) != 1:
|
|
69
|
+
return False
|
|
70
|
+
return statement.lineno < node.lineno and statement.value is not None and _literal_dict(statement.value, shadowed)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _unique_bindings(scope: ast.AST) -> dict[str, ast.AST]:
|
|
74
|
+
bindings: dict[str, ast.AST] = {}
|
|
75
|
+
repeated: set[str] = set()
|
|
76
|
+
for node in walk(scope):
|
|
77
|
+
for name in _bound_names(node):
|
|
78
|
+
if name == "*":
|
|
79
|
+
return {}
|
|
80
|
+
if name in bindings:
|
|
81
|
+
repeated.add(name)
|
|
82
|
+
bindings[name] = node
|
|
83
|
+
return {name: node for name, node in bindings.items() if name not in repeated}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _bound_names(node: ast.AST) -> tuple[str, ...]:
|
|
87
|
+
match node:
|
|
88
|
+
case ast.Name(id=name, ctx=ast.Store() | ast.Del()) | ast.arg(arg=name):
|
|
89
|
+
return (name,)
|
|
90
|
+
case ast.alias():
|
|
91
|
+
return (node.asname or node.name.split(".")[0],)
|
|
92
|
+
case (
|
|
93
|
+
ast.ExceptHandler(name=str() as name)
|
|
94
|
+
| ast.MatchAs(name=str() as name)
|
|
95
|
+
| ast.MatchStar(name=str() as name)
|
|
96
|
+
| ast.MatchMapping(rest=str() as name)
|
|
97
|
+
| ast.FunctionDef(name=name)
|
|
98
|
+
| ast.AsyncFunctionDef(name=name)
|
|
99
|
+
| ast.ClassDef(name=name)
|
|
100
|
+
):
|
|
101
|
+
return (name,)
|
|
102
|
+
case ast.Global(names=names) | ast.Nonlocal(names=names):
|
|
103
|
+
return tuple(names)
|
|
104
|
+
case _:
|
|
105
|
+
return ()
|
|
@@ -115,6 +115,7 @@ from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
|
115
115
|
from sarj_python_lint.rules.prefer_monkeypatch_for_process_state_in_test import (
|
|
116
116
|
PreferMonkeypatchForProcessStateInTest,
|
|
117
117
|
)
|
|
118
|
+
from sarj_python_lint.rules.prefer_monotonic_for_elapsed_time import PreferMonotonicForElapsedTime
|
|
118
119
|
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
119
120
|
PreferNamedtupleOverTupleReturn,
|
|
120
121
|
)
|
|
@@ -124,6 +125,7 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
|
|
|
124
125
|
)
|
|
125
126
|
from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
|
|
126
127
|
from sarj_python_lint.rules.prefer_pydantic_json_value import PreferPydanticJsonValue
|
|
128
|
+
from sarj_python_lint.rules.prefer_regex_fullmatch import PreferRegexFullmatch
|
|
127
129
|
from sarj_python_lint.rules.prefer_self_documenting_constant import (
|
|
128
130
|
PreferSelfDocumentingConstant,
|
|
129
131
|
)
|
|
@@ -162,6 +164,7 @@ from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
|
|
|
162
164
|
RequirePydanticOrdinalLowerBound,
|
|
163
165
|
)
|
|
164
166
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
167
|
+
from sarj_python_lint.rules.shared_mutable_pydantic_factory import SharedMutablePydanticFactory
|
|
165
168
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
166
169
|
from sarj_python_lint.rules.store_get_delegates_to_bulk_read import StoreGetDelegatesToBulkRead
|
|
167
170
|
from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
@@ -291,6 +294,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
291
294
|
RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
|
|
292
295
|
NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
|
|
293
296
|
NoWholeRequestResponsePayloadInLog.id: NoWholeRequestResponsePayloadInLog,
|
|
297
|
+
SharedMutablePydanticFactory.id: SharedMutablePydanticFactory,
|
|
298
|
+
PreferRegexFullmatch.id: PreferRegexFullmatch,
|
|
299
|
+
PreferMonotonicForElapsedTime.id: PreferMonotonicForElapsedTime,
|
|
294
300
|
TypedErrorReasons.id: TypedErrorReasons,
|
|
295
301
|
}
|
|
296
302
|
)
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
Severity,
|
|
17
|
+
is_suppressed,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
|
|
24
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
type _Function = ast.FunctionDef | ast.AsyncFunctionDef
|
|
28
|
+
_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
29
|
+
_COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
30
|
+
_REFLECTIVE_CALLS = frozenset({"locals", "globals", "vars", "eval", "exec", "setattr", "delattr"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
class PreferMonotonicForElapsedTime(Rule):
|
|
35
|
+
id = "prefer-monotonic-for-elapsed-time"
|
|
36
|
+
code = "SARJ461"
|
|
37
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
38
|
+
default_level=Severity.WARNING,
|
|
39
|
+
summary="Use a monotonic clock for a local wall-clock value used only to measure elapsed time.",
|
|
40
|
+
rationale=(
|
|
41
|
+
"System-clock corrections can make wall-clock differences negative or unexpectedly large. "
|
|
42
|
+
"A monotonic clock measures elapsed intervals without these jumps."
|
|
43
|
+
),
|
|
44
|
+
remediation=(
|
|
45
|
+
"Use time.monotonic() or time.perf_counter() for both the start reading and its elapsed-time readings. "
|
|
46
|
+
"Keep time.time() when measuring absolute timestamps or deliberate wall-clock adjustments."
|
|
47
|
+
),
|
|
48
|
+
category=RuleCategory.CORRECTNESS,
|
|
49
|
+
autofix=AutofixPolicy.NONE,
|
|
50
|
+
limitations=(
|
|
51
|
+
"Only an unconditional, single-assignment function-local start reading whose every read is the right operand of time.time() - start is reported.",
|
|
52
|
+
"Only unshadowed module-level clock imports are resolved. Reassignment, additional timestamp uses, escapes, nested-scope reads, comprehensions, reflection, and mixed-clock expressions are excluded.",
|
|
53
|
+
"Instance fields, process or persisted deadlines, indirect end-clock readings, and generated files are not inferred. No automatic rewrite is provided.",
|
|
54
|
+
),
|
|
55
|
+
examples=(
|
|
56
|
+
RuleExample(
|
|
57
|
+
example_id="wall-clock-elapsed",
|
|
58
|
+
title="A local wall clock measures duration",
|
|
59
|
+
outcome=ExampleOutcome.MATCH,
|
|
60
|
+
files=(
|
|
61
|
+
ExampleFile.python(
|
|
62
|
+
"app/metrics.py",
|
|
63
|
+
"import time\n\ndef measure():\n started = time.time()\n work()\n return time.time() - started\n",
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
focus_path=PurePosixPath("app/metrics.py"),
|
|
67
|
+
expected_count=1,
|
|
68
|
+
public=True,
|
|
69
|
+
),
|
|
70
|
+
RuleExample(
|
|
71
|
+
example_id="monotonic-elapsed",
|
|
72
|
+
title="A monotonic clock measures duration",
|
|
73
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
74
|
+
files=(
|
|
75
|
+
ExampleFile.python(
|
|
76
|
+
"app/metrics.py",
|
|
77
|
+
"import time\n\ndef measure():\n started = time.monotonic()\n work()\n return time.monotonic() - started\n",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
focus_path=PurePosixPath("app/metrics.py"),
|
|
81
|
+
expected_count=0,
|
|
82
|
+
public=True,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
description = documentation.summary
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
90
|
+
if "time" not in context.source or context.generated or context.tree is None or _clock_is_mutated(context):
|
|
91
|
+
return []
|
|
92
|
+
findings: list[Diagnostic] = []
|
|
93
|
+
for function in context.nodes(ast.FunctionDef, ast.AsyncFunctionDef):
|
|
94
|
+
for start in _elapsed_starts(context, function):
|
|
95
|
+
if is_suppressed(context.source_lines, start.lineno, self.code):
|
|
96
|
+
continue
|
|
97
|
+
findings.append(
|
|
98
|
+
Diagnostic(
|
|
99
|
+
path=context.path,
|
|
100
|
+
line=start.lineno,
|
|
101
|
+
col=start.col_offset + 1,
|
|
102
|
+
code=self.code,
|
|
103
|
+
severity=Severity.WARNING,
|
|
104
|
+
message="this wall-clock reading is used only for elapsed time; use a monotonic clock for both the start and elapsed readings",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _elapsed_starts(context: PythonFileContext, function: _Function) -> Iterator[ast.Call]:
|
|
111
|
+
nodes = tuple(ast.walk(function))
|
|
112
|
+
if any(_reflective_call(node) for node in nodes):
|
|
113
|
+
return
|
|
114
|
+
for statement in function.body:
|
|
115
|
+
candidate = _start_assignment(statement)
|
|
116
|
+
if candidate is None:
|
|
117
|
+
continue
|
|
118
|
+
target, call = candidate
|
|
119
|
+
if not _wall_clock(context, call) or any(_rebinds(node, target) for node in nodes):
|
|
120
|
+
continue
|
|
121
|
+
reads = _reads_of(nodes, target.id)
|
|
122
|
+
if reads and all(_elapsed_read(context, function, call, read) for read in reads):
|
|
123
|
+
yield call
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _start_assignment(statement: ast.stmt) -> tuple[ast.Name, ast.Call] | None:
|
|
127
|
+
match statement:
|
|
128
|
+
case (
|
|
129
|
+
ast.Assign(targets=[ast.Name() as target], value=ast.Call() as call)
|
|
130
|
+
| ast.AnnAssign(target=ast.Name() as target, value=ast.Call() as call)
|
|
131
|
+
):
|
|
132
|
+
return target, call
|
|
133
|
+
case _:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reads_of(nodes: tuple[ast.AST, ...], name: str) -> list[ast.Name]:
|
|
138
|
+
return [node for node in nodes if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load)]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _wall_clock(context: PythonFileContext, call: ast.expr) -> bool:
|
|
142
|
+
return (
|
|
143
|
+
isinstance(call, ast.Call)
|
|
144
|
+
and not call.args
|
|
145
|
+
and not call.keywords
|
|
146
|
+
and context.imports.resolved_qualified_name(call.func) == "time.time"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _elapsed_read(context: PythonFileContext, function: _Function, start: ast.Call, read: ast.Name) -> bool:
|
|
151
|
+
if (read.lineno, read.col_offset) <= (start.lineno, start.col_offset):
|
|
152
|
+
return False
|
|
153
|
+
parent = context.parents.get(read)
|
|
154
|
+
if not isinstance(parent, ast.BinOp) or not isinstance(parent.op, ast.Sub) or parent.right is not read:
|
|
155
|
+
return False
|
|
156
|
+
if not _wall_clock(context, parent.left):
|
|
157
|
+
return False
|
|
158
|
+
owner = context.parents.get(parent)
|
|
159
|
+
while owner is not None and owner is not function:
|
|
160
|
+
if isinstance(owner, (*_SCOPES, *_COMPREHENSIONS)):
|
|
161
|
+
return False
|
|
162
|
+
owner = context.parents.get(owner)
|
|
163
|
+
return owner is function
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rebinds(node: ast.AST, target: ast.Name) -> bool:
|
|
167
|
+
match node:
|
|
168
|
+
case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
|
|
169
|
+
return node is not target and name == target.id
|
|
170
|
+
case ast.arg(arg=name) | ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) | ast.ClassDef(name=name):
|
|
171
|
+
return name == target.id
|
|
172
|
+
case ast.alias(name=name, asname=alias):
|
|
173
|
+
return (alias or name.partition(".")[0]) == target.id
|
|
174
|
+
case ast.Global(names=names) | ast.Nonlocal(names=names):
|
|
175
|
+
return target.id in names
|
|
176
|
+
case (
|
|
177
|
+
ast.ExceptHandler(name=name)
|
|
178
|
+
| ast.MatchAs(name=name)
|
|
179
|
+
| ast.MatchStar(name=name)
|
|
180
|
+
| ast.MatchMapping(rest=name)
|
|
181
|
+
):
|
|
182
|
+
return name == target.id
|
|
183
|
+
case _:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _reflective_call(node: ast.AST) -> bool:
|
|
188
|
+
if not isinstance(node, ast.Call):
|
|
189
|
+
return False
|
|
190
|
+
return (isinstance(node.func, ast.Name) and node.func.id in _REFLECTIVE_CALLS) or (
|
|
191
|
+
isinstance(node.func, ast.Attribute) and node.func.attr in _REFLECTIVE_CALLS
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _clock_is_mutated(context: PythonFileContext) -> bool:
|
|
196
|
+
if any(
|
|
197
|
+
isinstance(node.ctx, (ast.Store, ast.Del)) and context.imports.resolved_qualified_name(node) == "time.time"
|
|
198
|
+
for node in context.nodes(ast.Attribute)
|
|
199
|
+
):
|
|
200
|
+
return True
|
|
201
|
+
clocks = _clock_imports(context)
|
|
202
|
+
if context.tree is None:
|
|
203
|
+
return False
|
|
204
|
+
for node in ast.walk(context.tree):
|
|
205
|
+
if isinstance(node, ast.alias):
|
|
206
|
+
if node.name == "*":
|
|
207
|
+
return True
|
|
208
|
+
statement = context.parents.get(node)
|
|
209
|
+
if _is_direct_clock_import(context, statement, node):
|
|
210
|
+
continue
|
|
211
|
+
if any(_rebinds(node, clock) for clock in clocks):
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _clock_imports(context: PythonFileContext) -> list[ast.Name]:
|
|
217
|
+
return [
|
|
218
|
+
ast.Name(id=name, ctx=ast.Load())
|
|
219
|
+
for name in context.imports.bindings
|
|
220
|
+
if context.imports.resolved_qualified_name(ast.Name(id=name, ctx=ast.Load())) == "time.time"
|
|
221
|
+
or context.imports.resolved_qualified_name(
|
|
222
|
+
ast.Attribute(value=ast.Name(id=name, ctx=ast.Load()), attr="time", ctx=ast.Load())
|
|
223
|
+
)
|
|
224
|
+
== "time.time"
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_direct_clock_import(context: PythonFileContext, statement: ast.AST | None, alias: ast.alias) -> bool:
|
|
229
|
+
if statement is None or context.parents.get(statement) is not context.tree or alias.name != "time":
|
|
230
|
+
return False
|
|
231
|
+
return isinstance(statement, ast.Import) or (
|
|
232
|
+
isinstance(statement, ast.ImportFrom) and statement.module == "time" and statement.level == 0
|
|
233
|
+
)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, override
|
|
8
|
+
|
|
9
|
+
from sarj_python_lint.rule_base import (
|
|
10
|
+
AutofixPolicy,
|
|
11
|
+
Diagnostic,
|
|
12
|
+
ExampleFile,
|
|
13
|
+
ExampleOutcome,
|
|
14
|
+
Rule,
|
|
15
|
+
RuleCategory,
|
|
16
|
+
RuleDocumentation,
|
|
17
|
+
RuleExample,
|
|
18
|
+
Severity,
|
|
19
|
+
is_suppressed,
|
|
20
|
+
)
|
|
21
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_RE = frozenset({"re"})
|
|
29
|
+
_SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
30
|
+
_BAD = "import re\n\ndef validate(value):\n if not re.match(r'^[a-z]+$', value):\n raise ValueError('invalid value')\n"
|
|
31
|
+
_GOOD = _BAD.replace("re.match(r'^[a-z]+$'", "re.fullmatch(r'[a-z]+'")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PreferRegexFullmatch(Rule):
|
|
35
|
+
id: str = "prefer-regex-fullmatch"
|
|
36
|
+
code: str = "SARJ460"
|
|
37
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
38
|
+
default_level=Severity.WARNING,
|
|
39
|
+
summary="An anchored regex match rejects invalid values without requiring a full-string match.",
|
|
40
|
+
rationale="The regex `$` anchor can match before a final newline, so an anchored match can accept a value that was not fully validated.",
|
|
41
|
+
remediation="Use `re.fullmatch` or the compiled pattern's `fullmatch` method for whole-value validation; review whether a terminal newline is intentionally allowed.",
|
|
42
|
+
category=RuleCategory.CORRECTNESS,
|
|
43
|
+
autofix=AutofixPolicy.NONE,
|
|
44
|
+
limitations=(
|
|
45
|
+
"Only direct negative match or `is None` conditions whose sole rejection statement raises are checked; extraction, search, compound conditions, and return-based validators are excluded.",
|
|
46
|
+
"Patterns must be valid string or bytes literals bounded by `^` or `\\A` and an unescaped outer `$`; top-level alternation, inline extension groups other than noncapturing groups, and flags other than literal zero are excluded.",
|
|
47
|
+
"Only imported standard-library re functions and unique, unconditional module or same-function compile bindings are resolved. Rebinding, import shadowing, escaped regex objects or modules, explicit match positions, tests, and generated files are excluded.",
|
|
48
|
+
"Binding checks are deliberately file-wide, so unrelated same-named locals can suppress findings. Dynamic monkeypatching and interprocedural changes are not inferred.",
|
|
49
|
+
),
|
|
50
|
+
examples=(
|
|
51
|
+
RuleExample(
|
|
52
|
+
example_id="anchored-rejection",
|
|
53
|
+
title="An end anchor permits a final newline",
|
|
54
|
+
outcome=ExampleOutcome.MATCH,
|
|
55
|
+
files=(ExampleFile.python("app/validation.py", _BAD),),
|
|
56
|
+
focus_path=PurePosixPath("app/validation.py"),
|
|
57
|
+
expected_count=1,
|
|
58
|
+
public=True,
|
|
59
|
+
),
|
|
60
|
+
RuleExample(
|
|
61
|
+
example_id="whole-value-rejection",
|
|
62
|
+
title="Validate the entire value",
|
|
63
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
64
|
+
files=(ExampleFile.python("app/validation.py", _GOOD),),
|
|
65
|
+
focus_path=PurePosixPath("app/validation.py"),
|
|
66
|
+
expected_count=0,
|
|
67
|
+
public=True,
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
description: str = documentation.summary
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
75
|
+
if "match" not in context.source or context.tree is None or context.generated or is_test_path(context.path):
|
|
76
|
+
return []
|
|
77
|
+
bindings = _binding_counts(context)
|
|
78
|
+
mutated = {
|
|
79
|
+
root
|
|
80
|
+
for node in context.nodes(ast.Attribute)
|
|
81
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)) and (root := _root_name(node)) is not None
|
|
82
|
+
}
|
|
83
|
+
mutated.update(
|
|
84
|
+
node.id for node in context.nodes(ast.Name) if isinstance(node.ctx, ast.Load) and _escapes(context, node)
|
|
85
|
+
)
|
|
86
|
+
compiled = _compiled_patterns(context, bindings, mutated)
|
|
87
|
+
findings: list[Diagnostic] = []
|
|
88
|
+
for guard in context.nodes(ast.If):
|
|
89
|
+
call = _rejection_call(guard)
|
|
90
|
+
if call is None or is_suppressed(context.source_lines, call.lineno, self.code):
|
|
91
|
+
continue
|
|
92
|
+
pattern = _match_pattern(context, call, bindings, mutated, compiled)
|
|
93
|
+
if pattern is None or not _whole_value_pattern(pattern):
|
|
94
|
+
continue
|
|
95
|
+
findings.append(
|
|
96
|
+
Diagnostic(
|
|
97
|
+
path=context.path,
|
|
98
|
+
line=call.lineno,
|
|
99
|
+
col=call.col_offset + 1,
|
|
100
|
+
code=self.code,
|
|
101
|
+
message="anchored match is used to reject invalid values, but `$` can accept a final newline; use fullmatch for whole-value validation and review newline handling.",
|
|
102
|
+
severity=Severity.WARNING,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return sorted(findings, key=lambda item: (item.line, item.col))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rejection_call(guard: ast.If) -> ast.Call | None:
|
|
109
|
+
if len(guard.body) != 1 or not isinstance(guard.body[0], ast.Raise):
|
|
110
|
+
return None
|
|
111
|
+
condition = guard.test
|
|
112
|
+
if isinstance(condition, ast.UnaryOp) and isinstance(condition.op, ast.Not):
|
|
113
|
+
return condition.operand if isinstance(condition.operand, ast.Call) else None
|
|
114
|
+
if not isinstance(condition, ast.Compare) or len(condition.ops) != 1:
|
|
115
|
+
return None
|
|
116
|
+
if (
|
|
117
|
+
isinstance(condition.ops[0], ast.Is)
|
|
118
|
+
and isinstance(condition.left, ast.Call)
|
|
119
|
+
and isinstance(condition.comparators[0], ast.Constant)
|
|
120
|
+
and condition.comparators[0].value is None
|
|
121
|
+
):
|
|
122
|
+
return condition.left
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _binding_counts(context: PythonFileContext) -> Counter[str]:
|
|
127
|
+
result: Counter[str] = Counter()
|
|
128
|
+
for node in context.nodes(ast.AST):
|
|
129
|
+
match node:
|
|
130
|
+
case (
|
|
131
|
+
ast.Name(id=name, ctx=(ast.Store() | ast.Del()))
|
|
132
|
+
| ast.arg(arg=name)
|
|
133
|
+
| ast.FunctionDef(name=name)
|
|
134
|
+
| ast.AsyncFunctionDef(name=name)
|
|
135
|
+
| ast.ClassDef(name=name)
|
|
136
|
+
):
|
|
137
|
+
result[name] += 1
|
|
138
|
+
case ast.alias(name=name, asname=alias):
|
|
139
|
+
result[alias or name.partition(".")[0]] += 1
|
|
140
|
+
case (
|
|
141
|
+
ast.ExceptHandler(name=str(name))
|
|
142
|
+
| ast.MatchAs(name=str(name))
|
|
143
|
+
| ast.MatchStar(name=str(name))
|
|
144
|
+
| ast.MatchMapping(rest=str(name))
|
|
145
|
+
):
|
|
146
|
+
result[name] += 1
|
|
147
|
+
case _:
|
|
148
|
+
pass
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _root_name(node: ast.expr) -> str | None:
|
|
153
|
+
while isinstance(node, ast.Attribute):
|
|
154
|
+
node = node.value
|
|
155
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _escapes(context: PythonFileContext, node: ast.Name) -> bool:
|
|
159
|
+
parent = context.parents.get(node)
|
|
160
|
+
if isinstance(parent, ast.Attribute) and parent.value is node:
|
|
161
|
+
return False
|
|
162
|
+
return not (isinstance(parent, ast.Call) and parent.func is node)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolves(
|
|
166
|
+
context: PythonFileContext, node: ast.expr, symbol: str, bindings: Counter[str], mutated: set[str]
|
|
167
|
+
) -> bool:
|
|
168
|
+
root = _root_name(node)
|
|
169
|
+
return (
|
|
170
|
+
root is not None
|
|
171
|
+
and bindings[root] == 1
|
|
172
|
+
and root not in mutated
|
|
173
|
+
and context.imports.resolves(node, sources=_RE, symbol=symbol)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _literal_pattern(call: ast.Call, *, compile_call: bool) -> str | bytes | None:
|
|
178
|
+
names = ("pattern", "flags") if compile_call else ("pattern", "string", "flags")
|
|
179
|
+
if len(call.args) > len(names) or any(isinstance(argument, ast.Starred) for argument in call.args):
|
|
180
|
+
return None
|
|
181
|
+
arguments: dict[str, ast.expr] = dict(zip(names, call.args, strict=False))
|
|
182
|
+
for keyword in call.keywords:
|
|
183
|
+
if keyword.arg is None or keyword.arg not in names or keyword.arg in arguments:
|
|
184
|
+
return None
|
|
185
|
+
arguments[keyword.arg] = keyword.value
|
|
186
|
+
required = {"pattern"} if compile_call else {"pattern", "string"}
|
|
187
|
+
if not required <= arguments.keys():
|
|
188
|
+
return None
|
|
189
|
+
flags = arguments.get("flags")
|
|
190
|
+
if flags is not None and not (isinstance(flags, ast.Constant) and type(flags.value) is int and flags.value == 0):
|
|
191
|
+
return None
|
|
192
|
+
pattern = arguments["pattern"]
|
|
193
|
+
return pattern.value if isinstance(pattern, ast.Constant) and isinstance(pattern.value, (str, bytes)) else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _compiled_patterns(
|
|
197
|
+
context: PythonFileContext, bindings: Counter[str], mutated: set[str]
|
|
198
|
+
) -> dict[str, tuple[ast.AST, ast.expr, str | bytes]]:
|
|
199
|
+
patterns: dict[str, tuple[ast.AST, ast.expr, str | bytes]] = {}
|
|
200
|
+
for node in context.nodes(ast.Assign, ast.AnnAssign):
|
|
201
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
202
|
+
target, value = node.targets[0], node.value
|
|
203
|
+
elif isinstance(node, ast.AnnAssign):
|
|
204
|
+
target, value = node.target, node.value
|
|
205
|
+
else:
|
|
206
|
+
continue
|
|
207
|
+
owner = context.parents.get(node)
|
|
208
|
+
if not isinstance(owner, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
209
|
+
continue
|
|
210
|
+
if (
|
|
211
|
+
not isinstance(target, ast.Name)
|
|
212
|
+
or bindings[target.id] != 1
|
|
213
|
+
or target.id in mutated
|
|
214
|
+
or not isinstance(value, ast.Call)
|
|
215
|
+
or not _resolves(context, value.func, "compile", bindings, mutated)
|
|
216
|
+
):
|
|
217
|
+
continue
|
|
218
|
+
pattern = _literal_pattern(value, compile_call=True)
|
|
219
|
+
if pattern is not None:
|
|
220
|
+
patterns[target.id] = owner, target, pattern
|
|
221
|
+
return patterns
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _match_pattern(
|
|
225
|
+
context: PythonFileContext,
|
|
226
|
+
call: ast.Call,
|
|
227
|
+
bindings: Counter[str],
|
|
228
|
+
mutated: set[str],
|
|
229
|
+
compiled: dict[str, tuple[ast.AST, ast.expr, str | bytes]],
|
|
230
|
+
) -> str | bytes | None:
|
|
231
|
+
if _resolves(context, call.func, "match", bindings, mutated):
|
|
232
|
+
return _literal_pattern(call, compile_call=False)
|
|
233
|
+
if not isinstance(call.func, ast.Attribute) or call.func.attr != "match" or len(call.args) != 1 or call.keywords:
|
|
234
|
+
return None
|
|
235
|
+
receiver = call.func.value
|
|
236
|
+
if isinstance(receiver, ast.Call) and _resolves(context, receiver.func, "compile", bindings, mutated):
|
|
237
|
+
return _literal_pattern(receiver, compile_call=True)
|
|
238
|
+
if not isinstance(receiver, ast.Name) or receiver.id not in compiled:
|
|
239
|
+
return None
|
|
240
|
+
owner, target, pattern = compiled[receiver.id]
|
|
241
|
+
if target.lineno >= call.lineno or (not isinstance(owner, ast.Module) and owner is not _scope(context, call)):
|
|
242
|
+
return None
|
|
243
|
+
return pattern
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _scope(context: PythonFileContext, node: ast.AST) -> ast.AST | None:
|
|
247
|
+
parent = context.parents.get(node)
|
|
248
|
+
while parent is not None and not isinstance(parent, _SCOPES):
|
|
249
|
+
parent = context.parents.get(parent)
|
|
250
|
+
return parent
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _whole_value_pattern(pattern: str | bytes) -> bool:
|
|
254
|
+
text = pattern.decode("latin1") if isinstance(pattern, bytes) else pattern
|
|
255
|
+
if not text.startswith(("^", r"\A")) or not text.endswith("$"):
|
|
256
|
+
return False
|
|
257
|
+
try:
|
|
258
|
+
re.compile(pattern)
|
|
259
|
+
except re.PatternError, OverflowError, RecursionError:
|
|
260
|
+
return False
|
|
261
|
+
depth = 0
|
|
262
|
+
index = 0
|
|
263
|
+
while index < len(text):
|
|
264
|
+
char = text[index]
|
|
265
|
+
if char == "\\":
|
|
266
|
+
index += 2
|
|
267
|
+
continue
|
|
268
|
+
if char == "[":
|
|
269
|
+
index = _class_end(text, index) + 1
|
|
270
|
+
continue
|
|
271
|
+
if char == "(":
|
|
272
|
+
if text[index : index + 2] == "(?" and text[index : index + 3] != "(?:":
|
|
273
|
+
return False
|
|
274
|
+
depth += 1
|
|
275
|
+
elif char == ")":
|
|
276
|
+
depth -= 1
|
|
277
|
+
elif char == "|" and depth == 0:
|
|
278
|
+
return False
|
|
279
|
+
elif char == "$" and index == len(text) - 1:
|
|
280
|
+
return depth == 0
|
|
281
|
+
index += 1
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _class_end(text: str, start: int) -> int:
|
|
286
|
+
index = start + 1
|
|
287
|
+
if text[index] == "^":
|
|
288
|
+
index += 1
|
|
289
|
+
if text[index] == "]":
|
|
290
|
+
index += 1
|
|
291
|
+
while index < len(text):
|
|
292
|
+
if text[index] == "\\":
|
|
293
|
+
index += 2
|
|
294
|
+
continue
|
|
295
|
+
if text[index] == "]":
|
|
296
|
+
return index
|
|
297
|
+
index += 1
|
|
298
|
+
return len(text)
|