sarj-python-lint 1.26.3__tar.gz → 1.27.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/PKG-INFO +2 -2
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/pyproject.toml +2 -2
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/__init__.py +1 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/__main__.py +1 -1
- sarj_python_lint-1.27.1/src/sarj_python_lint/_source.py +18 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/ratchet.py +3 -3
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_fastapi.py +1 -1
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_local_source.py +2 -2
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_nominal_project.py +3 -3
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_project_index.py +1 -1
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_registry.py +4 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +1 -1
- sarj_python_lint-1.27.1/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +273 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +2 -2
- sarj_python_lint-1.27.1/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +162 -0
- sarj_python_lint-1.26.3/src/sarj_python_lint/_source.py +0 -14
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/.gitignore +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/LICENSE +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/README.md +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/interpreter_argv.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.27.1
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
|
|
6
6
|
Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
|
|
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Requires-Dist: packaging==26.3
|
|
21
21
|
Requires-Dist: ruff>=0.16.10
|
|
22
|
-
Requires-Dist: sarj-rule-contracts==0.2.
|
|
22
|
+
Requires-Dist: sarj-rule-contracts==0.2.2
|
|
23
23
|
Requires-Dist: sqlglot==30.19.0
|
|
24
24
|
Requires-Dist: typer==0.27.2
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sarj-python-lint"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.27.1"
|
|
4
4
|
description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "sarj-ai" }]
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Programming Language :: Python :: 3.14",
|
|
16
16
|
"Topic :: Software Development :: Quality Assurance",
|
|
17
17
|
]
|
|
18
|
-
dependencies = ["sarj-rule-contracts==0.2.
|
|
18
|
+
dependencies = ["sarj-rule-contracts==0.2.2", "packaging==26.3", "ruff>=0.16.10", "sqlglot==30.19.0", "typer==0.27.2"]
|
|
19
19
|
|
|
20
20
|
[project.scripts]
|
|
21
21
|
sarj-python-lint = "sarj_python_lint.__main__:main"
|
|
@@ -434,7 +434,7 @@ def _apply_baseline(
|
|
|
434
434
|
def _run_check(rule: list[str], files: list[Path], baseline: Path | None, update_baseline: Path | None) -> int:
|
|
435
435
|
try:
|
|
436
436
|
diags = analyze(rule, files)
|
|
437
|
-
except (OSError, ValueError) as exc:
|
|
437
|
+
except (OSError, ValueError, SyntaxError) as exc:
|
|
438
438
|
sys.stderr.write(f"error: {exc}\n")
|
|
439
439
|
return 2
|
|
440
440
|
if update_baseline is not None:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
import tokenize
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def python_source_encoding(data: bytes) -> str:
|
|
13
|
+
return tokenize.detect_encoding(BytesIO(data).readline)[0]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_python_source(path: Path) -> str:
|
|
17
|
+
with tokenize.open(path) as stream:
|
|
18
|
+
return stream.read()
|
|
@@ -9,7 +9,7 @@ import tokenize
|
|
|
9
9
|
from types import MappingProxyType
|
|
10
10
|
from typing import TYPE_CHECKING, Final, NamedTuple, TypeGuard
|
|
11
11
|
|
|
12
|
-
from sarj_python_lint._source import
|
|
12
|
+
from sarj_python_lint._source import read_python_source
|
|
13
13
|
from sarj_python_lint.json_boundary import is_object_mapping, parse_json
|
|
14
14
|
|
|
15
15
|
|
|
@@ -330,8 +330,8 @@ def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Pa
|
|
|
330
330
|
|
|
331
331
|
def _read(path: Path) -> str:
|
|
332
332
|
try:
|
|
333
|
-
return path
|
|
334
|
-
except
|
|
333
|
+
return read_python_source(path)
|
|
334
|
+
except UnicodeError, OSError, SyntaxError:
|
|
335
335
|
return ""
|
|
336
336
|
|
|
337
337
|
|
|
@@ -841,7 +841,7 @@ def _read_module(path: Path, cache: dict[Path, ast.Module | None]) -> ast.Module
|
|
|
841
841
|
tree = None
|
|
842
842
|
else:
|
|
843
843
|
tree = ast.parse(read_python_source(path), filename=str(path))
|
|
844
|
-
except OSError, SyntaxError:
|
|
844
|
+
except OSError, SyntaxError, UnicodeError:
|
|
845
845
|
tree = None
|
|
846
846
|
cache[path] = tree
|
|
847
847
|
return tree
|
{sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_local_source.py
RENAMED
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import TYPE_CHECKING, final
|
|
7
7
|
|
|
8
|
-
from sarj_python_lint._source import
|
|
8
|
+
from sarj_python_lint._source import python_source_encoding
|
|
9
9
|
from sarj_python_lint.rules._imports import ImportIndex
|
|
10
10
|
|
|
11
11
|
|
|
@@ -114,7 +114,7 @@ def _read_module(path: Path) -> LocalModule | None:
|
|
|
114
114
|
data = stream.read(_MAX_MODULE_BYTES + 1)
|
|
115
115
|
if len(data) > _MAX_MODULE_BYTES:
|
|
116
116
|
return None
|
|
117
|
-
source = data.decode(
|
|
117
|
+
source = data.decode(python_source_encoding(data))
|
|
118
118
|
tree = ast.parse(source, filename=str(path))
|
|
119
119
|
except OSError, UnicodeError, SyntaxError:
|
|
120
120
|
return None
|
{sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_nominal_project.py
RENAMED
|
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, TypeGuard, final
|
|
|
11
11
|
from packaging.requirements import InvalidRequirement, Requirement
|
|
12
12
|
from packaging.utils import canonicalize_name
|
|
13
13
|
|
|
14
|
-
from sarj_python_lint._source import
|
|
14
|
+
from sarj_python_lint._source import read_python_source
|
|
15
15
|
from sarj_python_lint.rules._first_party import distribution_root
|
|
16
16
|
from sarj_python_lint.rules._imports import ImportIndex
|
|
17
17
|
from sarj_python_lint.rules._paths import is_generated
|
|
@@ -265,10 +265,10 @@ def _source(path: Path) -> NominalSource | None:
|
|
|
265
265
|
try:
|
|
266
266
|
if path.is_symlink() or path.stat().st_size > _MAX_BYTES:
|
|
267
267
|
return None
|
|
268
|
-
text = path
|
|
268
|
+
text = read_python_source(path)
|
|
269
269
|
if is_generated(path, text):
|
|
270
270
|
return None
|
|
271
|
-
except OSError, UnicodeError:
|
|
271
|
+
except OSError, UnicodeError, SyntaxError:
|
|
272
272
|
return None
|
|
273
273
|
return NominalSource(path, text)
|
|
274
274
|
|
{sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_project_index.py
RENAMED
|
@@ -534,7 +534,7 @@ def _read_bounded_source(root: Path, path: Path) -> LoadedSource | None:
|
|
|
534
534
|
resolved = path.resolve()
|
|
535
535
|
resolved.relative_to(root.resolve())
|
|
536
536
|
return LoadedSource(resolved, read_python_source(resolved))
|
|
537
|
-
except OSError, ValueError:
|
|
537
|
+
except OSError, ValueError, SyntaxError:
|
|
538
538
|
return None
|
|
539
539
|
|
|
540
540
|
|
|
@@ -53,6 +53,7 @@ from sarj_python_lint.rules.no_first_party_private_import import (
|
|
|
53
53
|
NoFirstPartyPrivateImport,
|
|
54
54
|
)
|
|
55
55
|
from sarj_python_lint.rules.no_fixed_sleep_before_assert import NoFixedSleepBeforeAssert
|
|
56
|
+
from sarj_python_lint.rules.no_fixed_sleep_in_loop import NoFixedSleepInLoop
|
|
56
57
|
from sarj_python_lint.rules.no_frozen_after_validator_field_write import (
|
|
57
58
|
NoFrozenAfterValidatorFieldWrite,
|
|
58
59
|
)
|
|
@@ -169,6 +170,7 @@ from sarj_python_lint.rules.repeated_test_composition import RepeatedTestComposi
|
|
|
169
170
|
from sarj_python_lint.rules.require_explicit_contract_implementation import RequireExplicitContractImplementation
|
|
170
171
|
from sarj_python_lint.rules.require_explicit_psycopg_transaction import RequireExplicitPsycopgTransaction
|
|
171
172
|
from sarj_python_lint.rules.require_explicit_service_contract import RequireExplicitServiceContract
|
|
173
|
+
from sarj_python_lint.rules.require_injectable_retry_sleep import RequireInjectableRetrySleep
|
|
172
174
|
from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
173
175
|
RequireKeywordOnlySwapProneParams,
|
|
174
176
|
)
|
|
@@ -208,6 +210,8 @@ if TYPE_CHECKING:
|
|
|
208
210
|
|
|
209
211
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
210
212
|
{
|
|
213
|
+
RequireInjectableRetrySleep.id: RequireInjectableRetrySleep,
|
|
214
|
+
NoFixedSleepInLoop.id: NoFixedSleepInLoop,
|
|
211
215
|
NoInterpreterSourceArguments.id: NoInterpreterSourceArguments,
|
|
212
216
|
NoUnusedCopyResult.id: NoUnusedCopyResult,
|
|
213
217
|
NoBeforeValidatorInputMutation.id: NoBeforeValidatorInputMutation,
|
|
@@ -690,7 +690,7 @@ def _read_module(path: Path) -> ast.Module | None:
|
|
|
690
690
|
if path.stat().st_size > _MAX_IMPORTED_MODULE_BYTES:
|
|
691
691
|
return None
|
|
692
692
|
tree = ast.parse(read_python_source(path), filename=str(path))
|
|
693
|
-
except OSError, SyntaxError:
|
|
693
|
+
except OSError, SyntaxError, UnicodeError:
|
|
694
694
|
return None
|
|
695
695
|
return tree
|
|
696
696
|
|
|
@@ -0,0 +1,273 @@
|
|
|
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, Final, NamedTuple, final, 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, is_test_support_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ASYNC_SLEEPS: Final = frozenset({"asyncio.sleep", "anyio.sleep", "trio.sleep"})
|
|
31
|
+
_BLOCKING_SLEEP: Final = "time.sleep"
|
|
32
|
+
_DURATION_KEYWORDS: Final = frozenset({"delay", "seconds"})
|
|
33
|
+
_CONSTANT_NAME: Final = re.compile(r"_*[A-Z][A-Z0-9_]*")
|
|
34
|
+
_LOOPS: Final = (ast.For, ast.AsyncFor, ast.While)
|
|
35
|
+
_FUNCTIONS: Final = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
36
|
+
_SCOPES: Final = (*_FUNCTIONS, ast.Lambda, ast.ClassDef)
|
|
37
|
+
_MESSAGE: Final = (
|
|
38
|
+
"fixed sleep inside a loop makes every iteration wait on the wall clock, including under test; take the "
|
|
39
|
+
"interval or the sleeper from an injected dependency"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@final
|
|
44
|
+
class NoFixedSleepInLoop(Rule):
|
|
45
|
+
id = "no-fixed-sleep-in-loop"
|
|
46
|
+
code = "SARJ485"
|
|
47
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
48
|
+
default_level=Severity.WARNING,
|
|
49
|
+
summary="Do not sleep for a fixed literal interval inside a production loop.",
|
|
50
|
+
rationale=(
|
|
51
|
+
"A polling or cleanup loop that sleeps for a literal interval gives tests no seam: every test that "
|
|
52
|
+
"drives the loop pays the whole interval in real time, and replacing the module's sleep is a "
|
|
53
|
+
"monkeypatch that SARJ445 forbids. Suites slow down one hardcoded interval at a time."
|
|
54
|
+
),
|
|
55
|
+
remediation=(
|
|
56
|
+
"Route the interval through a constructor or configuration parameter, or call an injected sleeper or "
|
|
57
|
+
"clock, so tests can pass a zero interval or a recording fake. Keep a literal only behind an exact "
|
|
58
|
+
"SARJ485 suppression that names why the interval must never change under test."
|
|
59
|
+
),
|
|
60
|
+
category=RuleCategory.TESTING,
|
|
61
|
+
autofix=AutofixPolicy.NONE,
|
|
62
|
+
limitations=(
|
|
63
|
+
"Only awaited asyncio.sleep, anyio.sleep, and trio.sleep, and time.sleep in synchronous functions, are inspected; import aliases are followed and shadowed names abstain. time.sleep in an async function is left to Ruff ASYNC251.",
|
|
64
|
+
"A duration is fixed when it folds with +, -, *, /, and // from numeric literals and UPPER_CASE constants bound once at module scope in the same file to such a literal; zero and negative results are yields, not waits.",
|
|
65
|
+
"Imported, reassigned, or shadowed constants, lowercase names, attributes including class constants, subscripts, and calls are treated as configurable and are not reported.",
|
|
66
|
+
"The sleep must sit in the body of a for, async for, or while loop inside the same function; loop else clauses, module-level and class-level loops, and loops in an enclosing function across a nested def, lambda, or class are not reported.",
|
|
67
|
+
"Test modules, test directories, conftest.py, test-support paths, and generated files are excluded.",
|
|
68
|
+
),
|
|
69
|
+
examples=(
|
|
70
|
+
RuleExample(
|
|
71
|
+
example_id="polling-loop-sleeps-literal",
|
|
72
|
+
title="A polling loop sleeps for a hardcoded interval",
|
|
73
|
+
outcome=ExampleOutcome.MATCH,
|
|
74
|
+
files=(
|
|
75
|
+
ExampleFile.python(
|
|
76
|
+
"app/jobs.py",
|
|
77
|
+
"import asyncio\n\n\nasync def wait_until_done(job):\n while not await job.done():\n"
|
|
78
|
+
" await asyncio.sleep(2)\n",
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
focus_path=PurePosixPath("app/jobs.py"),
|
|
82
|
+
expected_count=1,
|
|
83
|
+
public=True,
|
|
84
|
+
),
|
|
85
|
+
RuleExample(
|
|
86
|
+
example_id="polling-loop-sleeps-injected-interval",
|
|
87
|
+
title="A polling loop sleeps for an injected interval",
|
|
88
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
89
|
+
files=(
|
|
90
|
+
ExampleFile.python(
|
|
91
|
+
"app/jobs.py",
|
|
92
|
+
"import asyncio\n\n\nclass JobWaiter:\n def __init__(self, poll_seconds: float) -> None:\n"
|
|
93
|
+
" self._poll_seconds = poll_seconds\n\n async def wait_until_done(self, job):\n"
|
|
94
|
+
" while not await job.done():\n await asyncio.sleep(self._poll_seconds)\n",
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
focus_path=PurePosixPath("app/jobs.py"),
|
|
98
|
+
expected_count=0,
|
|
99
|
+
public=True,
|
|
100
|
+
),
|
|
101
|
+
RuleExample(
|
|
102
|
+
example_id="blocking-loop-sleeps-literal",
|
|
103
|
+
title="A synchronous retry loop blocks for a hardcoded interval",
|
|
104
|
+
outcome=ExampleOutcome.MATCH,
|
|
105
|
+
files=(
|
|
106
|
+
ExampleFile.python(
|
|
107
|
+
"app/health.py",
|
|
108
|
+
"import time\n\n\ndef wait_for_health(probe):\n for _ in range(30):\n"
|
|
109
|
+
" if probe():\n return\n time.sleep(0.5)\n",
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
focus_path=PurePosixPath("app/health.py"),
|
|
113
|
+
expected_count=1,
|
|
114
|
+
scenario="blocking-sleep",
|
|
115
|
+
public=True,
|
|
116
|
+
),
|
|
117
|
+
RuleExample(
|
|
118
|
+
example_id="blocking-loop-calls-injected-sleeper",
|
|
119
|
+
title="A synchronous retry loop calls an injected sleeper",
|
|
120
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
121
|
+
files=(
|
|
122
|
+
ExampleFile.python(
|
|
123
|
+
"app/health.py",
|
|
124
|
+
"from collections.abc import Callable\n\n\n"
|
|
125
|
+
"def wait_for_health(probe, sleep: Callable[[float], None]):\n for _ in range(30):\n"
|
|
126
|
+
" if probe():\n return\n sleep(0.5)\n",
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
focus_path=PurePosixPath("app/health.py"),
|
|
130
|
+
expected_count=0,
|
|
131
|
+
scenario="blocking-sleep",
|
|
132
|
+
public=True,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
description = documentation.summary
|
|
137
|
+
|
|
138
|
+
@override
|
|
139
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
140
|
+
path = context.path
|
|
141
|
+
if (
|
|
142
|
+
"sleep" not in context.source
|
|
143
|
+
or is_test_path(path)
|
|
144
|
+
or is_test_support_path(path)
|
|
145
|
+
or context.generated
|
|
146
|
+
or context.tree is None
|
|
147
|
+
):
|
|
148
|
+
return []
|
|
149
|
+
sleeps = [
|
|
150
|
+
sleep
|
|
151
|
+
for call in context.nodes(ast.Call)
|
|
152
|
+
if (sleep := _sleep(context, call)) is not None and _in_function_loop_body(context, sleep.reported)
|
|
153
|
+
]
|
|
154
|
+
if not sleeps:
|
|
155
|
+
return []
|
|
156
|
+
constants = _module_constants(context)
|
|
157
|
+
findings = [
|
|
158
|
+
Diagnostic(
|
|
159
|
+
path=path,
|
|
160
|
+
line=reported.lineno,
|
|
161
|
+
col=reported.col_offset + 1,
|
|
162
|
+
code=self.code,
|
|
163
|
+
message=_MESSAGE,
|
|
164
|
+
severity=Severity.WARNING,
|
|
165
|
+
)
|
|
166
|
+
for reported, duration in sleeps
|
|
167
|
+
if (seconds := _folded_seconds(duration, constants)) is not None
|
|
168
|
+
and seconds > 0
|
|
169
|
+
and not is_suppressed(context.source_lines, reported.lineno, self.code)
|
|
170
|
+
]
|
|
171
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _Sleep(NamedTuple):
|
|
175
|
+
reported: ast.expr
|
|
176
|
+
duration: ast.expr | None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sleep(context: PythonFileContext, call: ast.Call) -> _Sleep | None:
|
|
180
|
+
name = context.imports.resolved_qualified_name(call.func)
|
|
181
|
+
parent = context.parents.get(call)
|
|
182
|
+
if name in _ASYNC_SLEEPS and isinstance(parent, ast.Await):
|
|
183
|
+
return _Sleep(parent, _duration(call))
|
|
184
|
+
if name == _BLOCKING_SLEEP and isinstance(_owner(context, call), ast.FunctionDef):
|
|
185
|
+
return _Sleep(call, _duration(call))
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _duration(call: ast.Call) -> ast.expr | None:
|
|
190
|
+
if call.args:
|
|
191
|
+
return call.args[0]
|
|
192
|
+
return next((keyword.value for keyword in call.keywords if keyword.arg in _DURATION_KEYWORDS), None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _module_constants(context: PythonFileContext) -> dict[str, float]:
|
|
196
|
+
tree = context.tree
|
|
197
|
+
if tree is None:
|
|
198
|
+
return {}
|
|
199
|
+
assignments: dict[str, ast.expr] = {}
|
|
200
|
+
for statement in tree.body:
|
|
201
|
+
match statement:
|
|
202
|
+
case (
|
|
203
|
+
ast.Assign(targets=[ast.Name(id=name)], value=value)
|
|
204
|
+
| ast.AnnAssign(target=ast.Name(id=name), value=ast.expr() as value)
|
|
205
|
+
) if _CONSTANT_NAME.fullmatch(name):
|
|
206
|
+
assignments[name] = value
|
|
207
|
+
case _:
|
|
208
|
+
pass
|
|
209
|
+
stores = Counter(node.id for node in context.nodes(ast.Name) if not isinstance(node.ctx, ast.Load))
|
|
210
|
+
rebound = {
|
|
211
|
+
*(node.arg for node in context.nodes(ast.arg)),
|
|
212
|
+
*(name for node in context.nodes(ast.Global, ast.Nonlocal) for name in node.names),
|
|
213
|
+
*(node.name for node in context.nodes(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)),
|
|
214
|
+
*context.imports.bindings,
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
name: seconds
|
|
218
|
+
for name, value in assignments.items()
|
|
219
|
+
if stores[name] == 1 and name not in rebound and (seconds := _folded_seconds(value, {})) is not None
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _folded_seconds(node: ast.expr | None, constants: Mapping[str, float]) -> float | None:
|
|
224
|
+
match node:
|
|
225
|
+
case ast.Constant(value=bool()):
|
|
226
|
+
return None
|
|
227
|
+
case ast.Constant(value=int() | float() as value):
|
|
228
|
+
return value
|
|
229
|
+
case ast.Name(id=name):
|
|
230
|
+
return constants.get(name)
|
|
231
|
+
case ast.UnaryOp(op=ast.UAdd(), operand=operand):
|
|
232
|
+
return _folded_seconds(operand, constants)
|
|
233
|
+
case ast.UnaryOp(op=ast.USub(), operand=operand):
|
|
234
|
+
value = _folded_seconds(operand, constants)
|
|
235
|
+
return None if value is None else -value
|
|
236
|
+
case ast.BinOp(left=left, op=op, right=right):
|
|
237
|
+
lhs, rhs = _folded_seconds(left, constants), _folded_seconds(right, constants)
|
|
238
|
+
return None if lhs is None or rhs is None else _arithmetic(op, lhs, rhs)
|
|
239
|
+
case _:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _arithmetic(op: ast.operator, lhs: float, rhs: float) -> float | None:
|
|
244
|
+
match op:
|
|
245
|
+
case ast.Add():
|
|
246
|
+
return lhs + rhs
|
|
247
|
+
case ast.Sub():
|
|
248
|
+
return lhs - rhs
|
|
249
|
+
case ast.Mult():
|
|
250
|
+
return lhs * rhs
|
|
251
|
+
case ast.Div() if rhs:
|
|
252
|
+
return lhs / rhs
|
|
253
|
+
case ast.FloorDiv() if rhs:
|
|
254
|
+
return lhs // rhs
|
|
255
|
+
case _:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _owner(context: PythonFileContext, node: ast.AST) -> ast.AST | None:
|
|
260
|
+
current = context.parents.get(node)
|
|
261
|
+
while current is not None and not isinstance(current, _SCOPES):
|
|
262
|
+
current = context.parents.get(current)
|
|
263
|
+
return current
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _in_function_loop_body(context: PythonFileContext, node: ast.AST) -> bool:
|
|
267
|
+
in_loop = False
|
|
268
|
+
child, parent = node, context.parents.get(node)
|
|
269
|
+
while parent is not None and not isinstance(parent, _SCOPES):
|
|
270
|
+
if isinstance(parent, _LOOPS) and any(statement is child for statement in parent.body):
|
|
271
|
+
in_loop = True
|
|
272
|
+
child, parent = parent, context.parents.get(parent)
|
|
273
|
+
return in_loop and isinstance(parent, _FUNCTIONS)
|
|
@@ -720,7 +720,7 @@ def _read_module(path: Path, facts: RuntimeConfigFacts) -> ast.Module:
|
|
|
720
720
|
return cached
|
|
721
721
|
try:
|
|
722
722
|
tree = ast.parse(read_python_source(path), filename=str(path))
|
|
723
|
-
except OSError, SyntaxError:
|
|
723
|
+
except OSError, SyntaxError, UnicodeError:
|
|
724
724
|
tree = ast.Module(body=[], type_ignores=[])
|
|
725
725
|
facts.modules[path] = tree
|
|
726
726
|
return tree
|
|
@@ -783,7 +783,7 @@ def _candidate_calls_class(
|
|
|
783
783
|
return False
|
|
784
784
|
try:
|
|
785
785
|
source = read_python_source(candidate)
|
|
786
|
-
except OSError:
|
|
786
|
+
except OSError, SyntaxError, UnicodeError:
|
|
787
787
|
return False
|
|
788
788
|
if class_name not in source or is_generated(candidate, source):
|
|
789
789
|
return False
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final, 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
|
+
from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
24
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_POLICIES: Final = frozenset(
|
|
28
|
+
{"tenacity.retry", "tenacity.Retrying", "tenacity.AsyncRetrying", "tenacity.asyncio.AsyncRetrying"}
|
|
29
|
+
)
|
|
30
|
+
_WAIT_MODULES: Final = frozenset({"tenacity", "tenacity.wait"})
|
|
31
|
+
_WALL_CLOCK_SLEEPS: Final = frozenset(
|
|
32
|
+
{"asyncio.sleep", "anyio.sleep", "trio.sleep", "time.sleep", "tenacity.nap.sleep"}
|
|
33
|
+
)
|
|
34
|
+
_MESSAGE: Final = (
|
|
35
|
+
"this tenacity policy waits between attempts on the wall clock without a sleep= seam; pass an injected sleep "
|
|
36
|
+
"so tests exercise retries without real delays"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@final
|
|
41
|
+
class RequireInjectableRetrySleep(Rule):
|
|
42
|
+
id = "require-injectable-retry-sleep"
|
|
43
|
+
code = "SARJ486"
|
|
44
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
45
|
+
default_level=Severity.WARNING,
|
|
46
|
+
summary="Pass an injectable sleep to a tenacity retry policy that waits between attempts.",
|
|
47
|
+
rationale=(
|
|
48
|
+
"A tenacity policy built with a backoff wait and no sleep argument sleeps on the wall clock. Every test "
|
|
49
|
+
"that exercises a transient failure pays the backoff, and exhausting an exponential policy costs the "
|
|
50
|
+
"sum of every interval. Replacing tenacity's sleep from a test is a monkeypatch that SARJ445 forbids."
|
|
51
|
+
),
|
|
52
|
+
remediation=(
|
|
53
|
+
"Build the policy in a factory or constructor that accepts a sleep callable and pass it as sleep=, or "
|
|
54
|
+
"accept the wait strategy itself as a dependency. Tests then inject a no-op or recording sleeper, or "
|
|
55
|
+
"wait_none()."
|
|
56
|
+
),
|
|
57
|
+
category=RuleCategory.TESTING,
|
|
58
|
+
autofix=AutofixPolicy.NONE,
|
|
59
|
+
limitations=(
|
|
60
|
+
"Only calls to tenacity.retry, tenacity.Retrying, and tenacity.AsyncRetrying resolved through imports are inspected; bare @retry, positional arguments, and **kwargs expansion abstain.",
|
|
61
|
+
"A wait is reported only when it is built inline from tenacity wait_* calls, optionally combined with +; wait_none() and wait_fixed(0) do not wait. A wait read from a name, attribute, or other call is treated as injected.",
|
|
62
|
+
"Any sleep= argument other than a wall-clock sleep imported from asyncio, anyio, trio, time, or tenacity.nap counts as a seam; the rule does not prove that tests replace it.",
|
|
63
|
+
"The backoff library has no sleep parameter, so its decorators are not inspected. Test modules, test directories, conftest.py, test-support paths, and generated files are excluded.",
|
|
64
|
+
),
|
|
65
|
+
examples=(
|
|
66
|
+
RuleExample(
|
|
67
|
+
example_id="retry-backoff-without-sleep",
|
|
68
|
+
title="A module-level retry decorator backs off on the wall clock",
|
|
69
|
+
outcome=ExampleOutcome.MATCH,
|
|
70
|
+
files=(
|
|
71
|
+
ExampleFile.python(
|
|
72
|
+
"app/retry.py",
|
|
73
|
+
"from tenacity import retry, stop_after_attempt, wait_exponential\n\n"
|
|
74
|
+
"client_retry = retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=2))\n",
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
focus_path=PurePosixPath("app/retry.py"),
|
|
78
|
+
expected_count=1,
|
|
79
|
+
public=True,
|
|
80
|
+
),
|
|
81
|
+
RuleExample(
|
|
82
|
+
example_id="retry-backoff-with-injected-sleep",
|
|
83
|
+
title="A retry factory passes an injected sleep",
|
|
84
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
85
|
+
files=(
|
|
86
|
+
ExampleFile.python(
|
|
87
|
+
"app/retry.py",
|
|
88
|
+
"from collections.abc import Awaitable, Callable\n\n"
|
|
89
|
+
"from tenacity import AsyncRetrying, stop_after_attempt, wait_exponential\n\n\n"
|
|
90
|
+
"def client_retrying(sleep: Callable[[float], Awaitable[None]]) -> AsyncRetrying:\n"
|
|
91
|
+
" return AsyncRetrying(sleep=sleep, stop=stop_after_attempt(4), "
|
|
92
|
+
"wait=wait_exponential(multiplier=2))\n",
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
focus_path=PurePosixPath("app/retry.py"),
|
|
96
|
+
expected_count=0,
|
|
97
|
+
public=True,
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
description = documentation.summary
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
105
|
+
path = context.path
|
|
106
|
+
if (
|
|
107
|
+
"tenacity" not in context.source
|
|
108
|
+
or is_test_path(path)
|
|
109
|
+
or is_test_support_path(path)
|
|
110
|
+
or context.generated
|
|
111
|
+
or context.tree is None
|
|
112
|
+
):
|
|
113
|
+
return []
|
|
114
|
+
imports = context.imports
|
|
115
|
+
findings = [
|
|
116
|
+
Diagnostic(
|
|
117
|
+
path=path,
|
|
118
|
+
line=call.lineno,
|
|
119
|
+
col=call.col_offset + 1,
|
|
120
|
+
code=self.code,
|
|
121
|
+
message=_MESSAGE,
|
|
122
|
+
severity=Severity.WARNING,
|
|
123
|
+
)
|
|
124
|
+
for call in context.nodes(ast.Call)
|
|
125
|
+
if imports.resolved_qualified_name(call.func) in _POLICIES
|
|
126
|
+
and _waits_without_seam(call, imports)
|
|
127
|
+
and not is_suppressed(context.source_lines, call.lineno, self.code)
|
|
128
|
+
]
|
|
129
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _waits_without_seam(call: ast.Call, imports: ImportIndex) -> bool:
|
|
133
|
+
if call.args or any(keyword.arg is None for keyword in call.keywords):
|
|
134
|
+
return False
|
|
135
|
+
arguments = {keyword.arg: keyword.value for keyword in call.keywords}
|
|
136
|
+
wait = arguments.get("wait")
|
|
137
|
+
if wait is None or _wait_strategy(wait, imports) is not True:
|
|
138
|
+
return False
|
|
139
|
+
sleep = arguments.get("sleep")
|
|
140
|
+
return sleep is None or imports.resolved_qualified_name(sleep) in _WALL_CLOCK_SLEEPS
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _wait_strategy(node: ast.expr, imports: ImportIndex) -> bool | None:
|
|
144
|
+
match node:
|
|
145
|
+
case ast.BinOp(left=left, op=ast.Add(), right=right):
|
|
146
|
+
parts = (_wait_strategy(left, imports), _wait_strategy(right, imports))
|
|
147
|
+
return None if None in parts else any(parts)
|
|
148
|
+
case ast.Call(func=func, args=args, keywords=keywords):
|
|
149
|
+
module, _, strategy = (imports.resolved_qualified_name(func) or "").rpartition(".")
|
|
150
|
+
if module not in _WAIT_MODULES or not strategy.startswith("wait_"):
|
|
151
|
+
return None
|
|
152
|
+
match strategy, args, keywords:
|
|
153
|
+
case "wait_none", [], []:
|
|
154
|
+
return False
|
|
155
|
+
case "wait_fixed", [ast.Constant(value=0)], []:
|
|
156
|
+
return False
|
|
157
|
+
case "wait_fixed", [], [ast.keyword(arg="wait", value=ast.Constant(value=0))]:
|
|
158
|
+
return False
|
|
159
|
+
case _:
|
|
160
|
+
return True
|
|
161
|
+
case _:
|
|
162
|
+
return None
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import TYPE_CHECKING, Final
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if TYPE_CHECKING:
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
PYTHON_SOURCE_ENCODING: Final = "utf-8-sig"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def read_python_source(path: Path) -> str:
|
|
14
|
-
return path.read_text(encoding=PYTHON_SOURCE_ENCODING, errors="replace")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_analysis_session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|