sarj-python-lint 1.1.1__tar.gz → 1.2.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 (148) hide show
  1. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/__main__.py +28 -0
  4. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_registry.py +2 -0
  5. sarj_python_lint-1.2.1/src/sarj_python_lint/rules/_test_composition.py +286 -0
  6. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +2 -2
  7. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/mock_without_spec.py +2 -2
  8. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
  9. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +1 -1
  10. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +157 -33
  11. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +2 -2
  12. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +4 -4
  13. sarj_python_lint-1.2.1/src/sarj_python_lint/rules/repeated_test_composition.py +286 -0
  14. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/.gitignore +0 -0
  15. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/LICENSE +0 -0
  16. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/README.md +0 -0
  17. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/__init__.py +0 -0
  18. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  19. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_file_context.py +0 -0
  20. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_filesystem.py +0 -0
  21. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_python_target.py +0 -0
  22. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  23. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_secret_names.py +0 -0
  24. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/_version.py +0 -0
  25. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/benchmark.py +0 -0
  26. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/json_boundary.py +0 -0
  27. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/py.typed +0 -0
  28. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/ratchet.py +0 -0
  29. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rule_base.py +0 -0
  30. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  31. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  32. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  33. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  34. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  35. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  36. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  37. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  38. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  39. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  40. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  41. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  42. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  43. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  44. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  45. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  46. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  47. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  48. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  49. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  50. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  51. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  52. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  53. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  54. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  55. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  56. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  57. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  58. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  59. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  60. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  61. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  62. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  63. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  64. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  65. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  66. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  67. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  68. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  69. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  70. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  71. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  72. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  73. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  74. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  75. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  76. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  77. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  78. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  79. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  80. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  81. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  82. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  83. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  84. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  85. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  86. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  87. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  88. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  89. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  90. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  91. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  92. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  93. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  94. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  95. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  96. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  97. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  98. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  99. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  100. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  101. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  102. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  103. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  104. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  105. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  106. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  107. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  108. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  109. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  110. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  111. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  112. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  113. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  114. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  115. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  116. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  117. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  118. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  119. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  120. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  121. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  122. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  123. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  124. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  125. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  126. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  127. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  128. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  129. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  130. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  131. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  132. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  133. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  134. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  135. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  136. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  137. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  138. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  139. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  140. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  141. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  142. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  143. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  144. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  145. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  146. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  147. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  148. {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.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.1.1
3
+ Version: 1.2.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.1.1"
3
+ version = "1.2.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" }]
@@ -160,6 +160,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
160
160
  # still owns every fixed dictionary return when selected alone, while
161
161
  # an all-rules run emits one actionable diagnostic for this overlap.
162
162
  "SARJ447": frozenset({"SARJ008"}),
163
+ "SARJ457": frozenset({"SARJ045"}),
163
164
  }
164
165
  )
165
166
 
@@ -174,6 +175,7 @@ class _OwnerLocation(NamedTuple):
174
175
 
175
176
 
176
177
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
178
+ diags = _deduplicate_test_composition(diags, source)
177
179
  codes = frozenset(diagnostic.code for diagnostic in diags)
178
180
  needs_docstring_owners = ("SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])) or (
179
181
  "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
@@ -221,6 +223,32 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
221
223
  ]
222
224
 
223
225
 
226
+ def _deduplicate_test_composition(diags: list[Diagnostic], source: str | None) -> list[Diagnostic]:
227
+ codes = {diagnostic.code for diagnostic in diags}
228
+ if source is None or not {"SARJ066", "SARJ457"} <= codes:
229
+ return diags
230
+ try:
231
+ tree = ast.parse(source)
232
+ except SyntaxError:
233
+ return diags
234
+ whole_tests = {
235
+ (diagnostic.path, diagnostic.line): diagnostic.severity for diagnostic in diags if diagnostic.code == "SARJ066"
236
+ }
237
+ functions = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
238
+
239
+ def redundant(diagnostic: Diagnostic) -> bool:
240
+ if diagnostic.code != "SARJ457":
241
+ return False
242
+ owners = [node for node in functions if node.lineno <= diagnostic.line <= (node.end_lineno or node.lineno)]
243
+ if not owners:
244
+ return False
245
+ owner = max(owners, key=lambda node: node.lineno)
246
+ severity = whole_tests.get((diagnostic.path, owner.lineno))
247
+ return severity is not None and (severity is Severity.ERROR or diagnostic.severity is Severity.WARNING)
248
+
249
+ return [diagnostic for diagnostic in diags if not redundant(diagnostic)]
250
+
251
+
224
252
  def _suppressed_diagnostics(
225
253
  present: dict[tuple[Path, int, int], dict[str, set[Severity]]],
226
254
  ) -> set[tuple[tuple[Path, int, int], str, Severity]]:
@@ -145,6 +145,7 @@ from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
145
145
  from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDocstring
146
146
  from sarj_python_lint.rules.repeated_kwarg_heavy_call_in_test import RepeatedKwargHeavyCallInTest
147
147
  from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
148
+ from sarj_python_lint.rules.repeated_test_composition import RepeatedTestComposition
148
149
  from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
149
150
  RequireKeywordOnlySwapProneParams,
150
151
  )
@@ -225,6 +226,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
225
226
  PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
226
227
  StoreGetDelegatesToBulkRead.id: StoreGetDelegatesToBulkRead,
227
228
  RepeatedKwargHeavyCallInTest.id: RepeatedKwargHeavyCallInTest,
229
+ RepeatedTestComposition.id: RepeatedTestComposition,
228
230
  DefectXfailRequiresExplicitStrict.id: DefectXfailRequiresExplicitStrict,
229
231
  NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
230
232
  NoRestatedComment.id: NoRestatedComment,
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections import Counter
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sarj_python_lint.rules._project_index import ProjectIndexSet, SourceUnit, SymbolRef
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ from sarj_python_lint._file_context import PythonFileContext
15
+
16
+
17
+ type Function = ast.FunctionDef | ast.AsyncFunctionDef
18
+ _PORT_BASES = frozenset({"abc.ABC", "typing.Protocol", "typing_extensions.Protocol"})
19
+ _MIN_COLLABORATORS = 2
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Constructor:
24
+ symbol: SymbolRef
25
+ positional: tuple[str, ...]
26
+ collaborators: frozenset[str]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class WiringScope:
31
+ parameters: frozenset[str]
32
+ aliases: dict[str, ast.expr]
33
+ blocked: frozenset[str]
34
+
35
+
36
+ def direct_nodes(node: ast.AST) -> Iterator[ast.AST]:
37
+ for child in ast.iter_child_nodes(node):
38
+ yield child
39
+ if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
40
+ yield from direct_nodes(child)
41
+
42
+
43
+ def _class_owner(
44
+ context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet
45
+ ) -> tuple[SourceUnit, ast.ClassDef] | None:
46
+ qualified = context.module_imports.resolved_qualified_name(expression)
47
+ unit = project.unit(context.path)
48
+ if qualified is not None:
49
+ module, _, name = qualified.rpartition(".")
50
+ unit = project.source_unit(module)
51
+ elif isinstance(expression, ast.Name):
52
+ name = expression.id
53
+ else:
54
+ return None
55
+ if unit is None or unit.tree is None or unit.module is None:
56
+ return None
57
+ owner = next((node for node in unit.tree.body if isinstance(node, ast.ClassDef) and node.name == name), None)
58
+ if owner is None:
59
+ return None
60
+ if any(
61
+ isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, (ast.Store, ast.Del))
62
+ for node in direct_nodes(unit.tree)
63
+ ):
64
+ return None
65
+ return unit, owner
66
+
67
+
68
+ def constructor(context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet) -> Constructor | None:
69
+ resolved = _class_owner(context, expression, project)
70
+ if resolved is None:
71
+ return None
72
+ unit, owner = resolved
73
+ if unit.module is None:
74
+ return None
75
+ initializer = next(
76
+ (node for node in owner.body if isinstance(node, ast.FunctionDef) and node.name == "__init__"), None
77
+ )
78
+ if initializer is None:
79
+ return None
80
+ parameters = (*initializer.args.posonlyargs, *initializer.args.args, *initializer.args.kwonlyargs)
81
+ invoked = _invoked_fields(owner)
82
+ stored = _stored_parameters(initializer)
83
+ collaborators = frozenset(
84
+ parameter.arg
85
+ for parameter in parameters
86
+ if parameter.annotation is not None
87
+ and _is_port(project, unit, parameter.annotation)
88
+ and stored.get(parameter.arg) in invoked
89
+ )
90
+ if len(collaborators) < _MIN_COLLABORATORS:
91
+ return None
92
+ return Constructor(
93
+ SymbolRef(unit.module, owner.name),
94
+ tuple(arg.arg for arg in (*initializer.args.posonlyargs, *initializer.args.args)[1:]),
95
+ collaborators,
96
+ )
97
+
98
+
99
+ def _is_port(project: ProjectIndexSet, unit: SourceUnit, annotation: ast.expr) -> bool:
100
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
101
+ try:
102
+ annotation = ast.parse(annotation.value, mode="eval").body
103
+ except SyntaxError:
104
+ return False
105
+ summary = project.class_for(unit, annotation)
106
+ if summary is None:
107
+ return False
108
+ owner = project.source_unit(summary.symbol.module)
109
+ return owner is not None and project.class_inherits_from(owner, summary.symbol.name, _PORT_BASES)
110
+
111
+
112
+ def _stored_parameters(initializer: Function) -> dict[str, str]:
113
+ stored: dict[str, str] = {}
114
+ writes = Counter(
115
+ node.attr
116
+ for node in direct_nodes(initializer)
117
+ if isinstance(node, ast.Attribute) and _is_self(node.value) and isinstance(node.ctx, (ast.Store, ast.Del))
118
+ )
119
+ for statement in initializer.body:
120
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
121
+ target, value = statement.targets[0], statement.value
122
+ elif isinstance(statement, ast.AnnAssign):
123
+ target, value = statement.target, statement.value
124
+ else:
125
+ continue
126
+ if (
127
+ isinstance(value, ast.Name)
128
+ and isinstance(target, ast.Attribute)
129
+ and _is_self(target.value)
130
+ and writes[target.attr] == 1
131
+ ):
132
+ stored[value.id] = target.attr
133
+ return stored
134
+
135
+
136
+ def _is_self(node: ast.expr) -> bool:
137
+ return isinstance(node, ast.Name) and node.id == "self"
138
+
139
+
140
+ def _invoked_fields(owner: ast.ClassDef) -> frozenset[str]:
141
+ invoked: set[str] = set()
142
+ for method in owner.body:
143
+ if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name != "__init__":
144
+ invoked.update(_method_fields(method))
145
+ return frozenset(invoked)
146
+
147
+
148
+ def _method_fields(method: Function) -> Iterator[str]:
149
+ for node in direct_nodes(method):
150
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
151
+ continue
152
+ target = node.func.value
153
+ if isinstance(target, ast.Attribute) and _is_self(target.value):
154
+ yield target.attr
155
+
156
+
157
+ def bindings(function: Function) -> dict[str, ast.expr]:
158
+ candidates: dict[str, list[ast.expr]] = {}
159
+ for statement in function.body:
160
+ if (
161
+ isinstance(statement, ast.Assign)
162
+ and len(statement.targets) == 1
163
+ and isinstance(statement.targets[0], ast.Name)
164
+ ):
165
+ candidates.setdefault(statement.targets[0].id, []).append(statement.value)
166
+ elif (
167
+ isinstance(statement, ast.AnnAssign)
168
+ and isinstance(statement.target, ast.Name)
169
+ and statement.value is not None
170
+ ):
171
+ candidates.setdefault(statement.target.id, []).append(statement.value)
172
+ writes = [
173
+ node.id
174
+ for node in direct_nodes(function)
175
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del))
176
+ ]
177
+ return {name: values[0] for name, values in candidates.items() if len(values) == 1 and writes.count(name) == 1}
178
+
179
+
180
+ def local_names(function: Function) -> set[str]:
181
+ names: set[str] = set()
182
+ for node in direct_nodes(function):
183
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
184
+ names.add(node.id)
185
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
186
+ names.add(node.name)
187
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
188
+ names.update(alias.asname or alias.name.partition(".")[0] for alias in node.names)
189
+ return names
190
+
191
+
192
+ def changed_before(function: Function, call: ast.Call) -> set[str]:
193
+ changed: set[str] = set()
194
+ for node in direct_nodes(function):
195
+ if not isinstance(node, (ast.Call, ast.Attribute)) or node.lineno >= call.lineno:
196
+ continue
197
+ target = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else None
198
+ if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del)):
199
+ target = node.value
200
+ if isinstance(target, ast.Name):
201
+ changed.add(target.id)
202
+ return changed
203
+
204
+
205
+ def unalias(node: ast.expr, aliases: dict[str, ast.expr]) -> ast.expr:
206
+ seen: set[str] = set()
207
+ while isinstance(node, ast.Name) and node.id in aliases and node.id not in seen:
208
+ seen.add(node.id)
209
+ node = aliases[node.id]
210
+ return node
211
+
212
+
213
+ def shadowed(node: ast.expr, names: frozenset[str]) -> bool:
214
+ while isinstance(node, ast.Attribute):
215
+ node = node.value
216
+ return isinstance(node, ast.Name) and node.id in names
217
+
218
+
219
+ def wiring(
220
+ context: PythonFileContext,
221
+ project: ProjectIndexSet,
222
+ call: ast.Call,
223
+ spec: Constructor,
224
+ scope: WiringScope,
225
+ ) -> tuple[tuple[str, str], ...] | None:
226
+ if any(isinstance(arg, ast.Starred) for arg in call.args) or any(keyword.arg is None for keyword in call.keywords):
227
+ return None
228
+ if len(call.args) > len(spec.positional):
229
+ return None
230
+ values = dict(zip(spec.positional, call.args, strict=False))
231
+ for keyword in call.keywords:
232
+ if keyword.arg is None or keyword.arg in values:
233
+ return None
234
+ values[keyword.arg] = keyword.value
235
+ result: list[tuple[str, str]] = []
236
+ for name in sorted(spec.collaborators):
237
+ value = values.get(name)
238
+ if value is None or not isinstance(unalias(value, scope.aliases), (ast.Name, ast.Call)):
239
+ return None
240
+ identity = _value_key(context, project, value, scope)
241
+ if identity is None:
242
+ return None
243
+ result.append((name, identity))
244
+ return tuple(result)
245
+
246
+
247
+ def _value_key(
248
+ context: PythonFileContext,
249
+ project: ProjectIndexSet,
250
+ value: ast.expr,
251
+ scope: WiringScope,
252
+ seen: frozenset[str] = frozenset(),
253
+ ) -> str | None:
254
+ if isinstance(value, ast.Name):
255
+ if value.id in seen or value.id in scope.blocked:
256
+ return None
257
+ if value.id in scope.aliases:
258
+ return _value_key(
259
+ context,
260
+ project,
261
+ scope.aliases[value.id],
262
+ scope,
263
+ seen=seen | {value.id},
264
+ )
265
+ return f"fixture:{value.id}" if value.id in scope.parameters else None
266
+ if isinstance(value, ast.Constant):
267
+ return ast.dump(value)
268
+ if not isinstance(value, ast.Call):
269
+ return None
270
+ callee = unalias(value.func, scope.aliases)
271
+ if shadowed(callee, scope.parameters | scope.blocked):
272
+ return None
273
+ owner = _class_owner(context, callee, project)
274
+ if owner is None or any(keyword.arg is None for keyword in value.keywords):
275
+ return None
276
+ positional = [_value_key(context, project, argument, scope, seen=seen) for argument in value.args]
277
+ keywords = [
278
+ (
279
+ keyword.arg,
280
+ _value_key(context, project, keyword.value, scope, seen=seen),
281
+ )
282
+ for keyword in value.keywords
283
+ ]
284
+ if any(key is None for key in positional) or any(key is None for _, key in keywords):
285
+ return None
286
+ return f"{owner[0].module}.{owner[1].name}:{positional!r}:{sorted(keywords)!r}"
@@ -69,7 +69,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
69
69
  id = "async-mock-call-without-await-assertion"
70
70
  code = "SARJ456"
71
71
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
72
- default_level=Severity.WARNING,
72
+ default_level=Severity.ERROR,
73
73
  summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
74
74
  rationale=(
75
75
  "Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
@@ -150,7 +150,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
150
150
  line=call.lineno,
151
151
  col=call.col_offset + 1,
152
152
  code=self.code,
153
- severity=Severity.WARNING,
153
+ severity=Severity.ERROR,
154
154
  message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
155
155
  )
156
156
  for test in _collected_tests(tree.body)
@@ -121,7 +121,7 @@ class MockWithoutSpec(Rule):
121
121
  id: str = "mock-without-spec"
122
122
  code: str = "SARJ040"
123
123
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
124
- default_level=Severity.WARNING,
124
+ default_level=Severity.ERROR,
125
125
  summary="Unrestricted mock permits attributes outside the collaborator contract.",
126
126
  rationale=(
127
127
  "A mock without a spec permits removed or misspelled attributes; a callable mock without autospec may "
@@ -234,7 +234,7 @@ class MockWithoutSpec(Rule):
234
234
  col=node.col_offset + 1,
235
235
  code=self.code,
236
236
  message=_diagnostic_message(label),
237
- severity=Severity.WARNING,
237
+ severity=Severity.ERROR,
238
238
  )
239
239
  for node, label in _unspecced_calls(
240
240
  tree, names, pytest_mocker, _FileFacts.from_tree(tree), node_index=context.node_index
@@ -204,7 +204,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
204
204
  id = "negative-only-http-status-assertion"
205
205
  code = "SARJ408"
206
206
  documentation = RuleDocumentation(
207
- default_level=Severity.WARNING,
207
+ default_level=Severity.ERROR,
208
208
  summary="HTTP test assertion only excludes a server error instead of identifying the intended response.",
209
209
  rationale=(
210
210
  "Authentication, routing, validation, and domain failures can all replace the intended response while "
@@ -276,7 +276,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
276
276
  line=node.lineno,
277
277
  col=node.col_offset + 1,
278
278
  code=self.code,
279
- severity=Severity.WARNING,
279
+ severity=Severity.ERROR,
280
280
  message=(
281
281
  "this assertion proves only that the response avoided a server-error outcome. Assert the "
282
282
  "intended status and domain effect, or suppress SARJ408 with a reason when avoiding a server "
@@ -43,7 +43,7 @@ class NoFixedSleepBeforeAssert(Rule):
43
43
  id = "no-fixed-sleep-before-assert"
44
44
  code = "SARJ455"
45
45
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
46
- default_level=Severity.WARNING,
46
+ default_level=Severity.ERROR,
47
47
  summary="Do not sleep for a fixed interval and then assert in a test.",
48
48
  rationale=(
49
49
  "A fixed sleep followed by an assertion guesses how long the code under test needs. On a loaded CI "