sarj-python-lint 1.26.2__tar.gz → 1.27.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.
Files changed (187) hide show
  1. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rule_base.py +19 -6
  4. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  5. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_suppression_comments.py +14 -0
  6. sarj_python_lint-1.27.0/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +273 -0
  7. sarj_python_lint-1.27.0/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +162 -0
  8. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/.gitignore +0 -0
  9. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/LICENSE +0 -0
  10. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/README.md +0 -0
  11. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  14. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_file_context.py +0 -0
  15. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_filesystem.py +0 -0
  16. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_python_target.py +0 -0
  17. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  18. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_secret_names.py +0 -0
  19. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_source.py +0 -0
  20. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/_version.py +0 -0
  21. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/benchmark.py +0 -0
  22. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  23. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/json_boundary.py +0 -0
  24. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/ratchet.py +0 -0
  26. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  27. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  28. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  29. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  30. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  31. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  32. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  33. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  34. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  35. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  36. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  37. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  38. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  39. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  40. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  41. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  42. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  43. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  44. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  45. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  46. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  47. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  48. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  49. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  50. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  51. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  52. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  53. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  54. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  55. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  56. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  57. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  58. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  59. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  60. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  61. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  62. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  63. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  64. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  65. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  66. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  67. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  68. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  69. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  70. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  71. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  72. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  73. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  74. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  75. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  76. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  77. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  78. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  79. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  80. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  81. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  82. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  83. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  84. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  85. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  86. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  87. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  88. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  89. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  90. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  91. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  92. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  93. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  94. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  95. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  96. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  97. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  98. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  99. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  100. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  101. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  102. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  103. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  104. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  105. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  106. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  107. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  108. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  109. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  110. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  111. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  112. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  113. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  114. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  115. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  116. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  117. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  118. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  119. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  120. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  121. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  122. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  123. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  124. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  125. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  126. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  127. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  128. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  129. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  130. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  131. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  132. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  133. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  134. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  135. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  136. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  137. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  138. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  139. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  140. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  141. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  142. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  143. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  144. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  145. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  146. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  147. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  148. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  149. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  150. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  151. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  152. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  153. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  154. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  155. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  156. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  157. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  158. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  159. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  160. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  161. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  162. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  163. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  164. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  165. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  166. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  167. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  168. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  169. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  170. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  171. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  172. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  173. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  174. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  175. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  176. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  177. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  178. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  179. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  180. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  181. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  182. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  183. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  184. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  185. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  186. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  187. {sarj_python_lint-1.26.2 → sarj_python_lint-1.27.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.26.2
3
+ Version: 1.27.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.26.2"
3
+ version = "1.27.0"
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" }]
@@ -37,7 +37,7 @@ TESTS_DIR: Final = "packages/python/tests/rules"
37
37
 
38
38
  # Keep SARJ suppressions separate because Ruff removes unknown `noqa` codes.
39
39
  _SARJ_NOQA_RE = re.compile(
40
- r"#\s*sarj-noqa(?::\s*([A-Za-z0-9_, ]+))?",
40
+ r"#\s*sarj-noqa\b(?::\s*([A-Za-z0-9_, ]+))?",
41
41
  re.IGNORECASE,
42
42
  )
43
43
 
@@ -49,12 +49,25 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
49
49
  m = _SARJ_NOQA_RE.search(text)
50
50
  if not m:
51
51
  return False
52
+ from sarj_python_lint.rules._suppression_comments import comment_lines # ruff: ignore[import-outside-top-level] -- comment scanner depends on rule_base.
53
+
54
+ comment = comment_lines(tuple(source_lines)).get(line, "")
55
+ m = _SARJ_NOQA_RE.search(comment)
56
+ if m is None:
57
+ return False
52
58
  codes_str = m.group(1)
53
- if not codes_str:
54
- # A bare sarj-noqa intentionally suppresses every SARJ code on its line.
55
- return True
56
- codes = {val.upper() for c in codes_str.split(",") if (val := c.strip())}
57
- return code.upper() in codes
59
+ trailing = comment[m.end() :]
60
+ if codes_str is None:
61
+ # A complete bare marker preserves the existing all-codes waiver contract.
62
+ return not (trailing and (not trailing[0].isspace() or trailing.lstrip().startswith(":")))
63
+ reason = trailing.lstrip()
64
+ single_dash_reason = reason.startswith("-") and (codes_str[-1:].isspace() or trailing[:1].isspace())
65
+ if reason and not reason.startswith(("--", "—", "–")) and not single_dash_reason:
66
+ return False
67
+ parts = tuple(part.strip() for part in codes_str.split(","))
68
+ if any(not part for part in parts):
69
+ return False
70
+ return code.upper() in {part.upper() for part in parts}
58
71
 
59
72
 
60
73
  class ColumnEncoding(StrEnum):
@@ -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,
@@ -1,11 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from functools import lru_cache
4
5
  import tokenize
6
+ from types import MappingProxyType
7
+ from typing import TYPE_CHECKING
5
8
 
6
9
  from sarj_python_lint.rules._comments import all_comments
7
10
 
8
11
 
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping
14
+
15
+
9
16
  @dataclass(frozen=True, slots=True)
10
17
  class Comment:
11
18
  line: int
@@ -34,3 +41,10 @@ def scan_comments_or_none(source: str) -> list[Comment] | None:
34
41
  return scan_comments(source)
35
42
  except tokenize.TokenError, SyntaxError:
36
43
  return None
44
+
45
+
46
+ @lru_cache(maxsize=1)
47
+ def comment_lines(source_lines: tuple[str, ...]) -> Mapping[int, str]:
48
+ return MappingProxyType(
49
+ {comment.line: "# " + comment.body for comment in scan_comments_or_none("\n".join(source_lines)) or ()}
50
+ )
@@ -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)
@@ -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