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.
Files changed (188) hide show
  1. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/PKG-INFO +2 -2
  2. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/pyproject.toml +2 -2
  3. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/__init__.py +1 -0
  4. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/__main__.py +1 -1
  5. sarj_python_lint-1.27.1/src/sarj_python_lint/_source.py +18 -0
  6. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/ratchet.py +3 -3
  7. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_fastapi.py +1 -1
  8. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_local_source.py +2 -2
  9. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_nominal_project.py +3 -3
  10. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_project_index.py +1 -1
  11. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_registry.py +4 -0
  12. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +1 -1
  13. sarj_python_lint-1.27.1/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +273 -0
  14. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +2 -2
  15. sarj_python_lint-1.27.1/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +162 -0
  16. sarj_python_lint-1.26.3/src/sarj_python_lint/_source.py +0 -14
  17. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/.gitignore +0 -0
  18. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/LICENSE +0 -0
  19. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/README.md +0 -0
  20. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  21. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_file_context.py +0 -0
  22. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_filesystem.py +0 -0
  23. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_python_target.py +0 -0
  24. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  25. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_secret_names.py +0 -0
  26. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/_version.py +0 -0
  27. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/benchmark.py +0 -0
  28. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/interpreter_argv.py +0 -0
  29. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/json_boundary.py +0 -0
  30. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/py.typed +0 -0
  31. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rule_base.py +0 -0
  32. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  33. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  34. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  35. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  36. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  37. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  38. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  39. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  40. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  41. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  42. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  43. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  44. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  45. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  46. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  47. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  48. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  49. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  50. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  51. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  52. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  53. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  54. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  55. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  56. {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
  57. {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
  58. {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
  59. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  60. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  61. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  62. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  63. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  64. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  65. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  66. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  67. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  68. {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
  69. {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
  70. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  71. {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
  72. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  73. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  74. {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
  75. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  76. {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
  77. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  78. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  79. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  80. {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
  81. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  82. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  83. {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
  84. {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
  85. {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
  86. {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
  87. {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
  88. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  89. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  90. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  91. {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
  92. {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
  93. {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
  94. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  95. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  96. {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
  97. {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
  98. {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
  99. {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
  100. {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
  101. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  102. {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
  103. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  104. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  105. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  106. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  107. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  108. {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
  109. {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
  110. {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
  111. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  112. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  113. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  114. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  115. {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
  116. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  117. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  118. {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
  119. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  120. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  121. {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
  122. {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
  123. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  124. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  125. {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
  126. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  127. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  128. {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
  129. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  130. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  131. {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
  132. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  133. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  134. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  135. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  136. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  137. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  138. {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
  139. {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
  140. {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
  141. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  142. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  143. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  144. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  145. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  146. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  147. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  148. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  149. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  150. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  151. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  152. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  153. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  154. {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
  155. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  156. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  157. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  158. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  159. {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
  160. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  161. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  162. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  163. {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
  164. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  165. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  166. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  167. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  168. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  169. {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
  170. {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
  171. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  172. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  173. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  174. {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
  175. {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
  176. {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
  177. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  178. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  179. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  180. {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
  181. {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
  182. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  183. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  184. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  185. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  186. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  187. {sarj_python_lint-1.26.3 → sarj_python_lint-1.27.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  188. {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.26.3
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.1
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.26.3"
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.1", "packaging==26.3", "ruff>=0.16.10", "sqlglot==30.19.0", "typer==0.27.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"
@@ -1,2 +1,3 @@
1
1
  from sarj_python_lint._ratchet_cli import run_ratchet as run_ratchet
2
+ from sarj_python_lint._source import python_source_encoding as python_source_encoding
2
3
  from sarj_python_lint._version import __version__ as __version__
@@ -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 PYTHON_SOURCE_ENCODING
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.read_text(encoding=PYTHON_SOURCE_ENCODING)
334
- except UnicodeDecodeError, OSError:
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
@@ -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 PYTHON_SOURCE_ENCODING
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(PYTHON_SOURCE_ENCODING)
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
@@ -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 PYTHON_SOURCE_ENCODING
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.read_text(encoding=PYTHON_SOURCE_ENCODING)
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
 
@@ -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")