sarj-python-lint 1.16.0__tar.gz → 1.16.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 (177) hide show
  1. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/pyproject.toml +1 -1
  3. sarj_python_lint-1.16.1/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +171 -0
  4. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_database_programmability.py +2 -2
  5. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_injected_module_loader.py +38 -5
  6. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +1 -1
  7. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +21 -3
  8. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +15 -1
  9. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +22 -14
  10. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +78 -10
  11. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +35 -14
  12. sarj_python_lint-1.16.1/src/sarj_python_lint/rules/require_explicit_service_contract.py +579 -0
  13. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_precise_factory_signature.py +12 -2
  14. sarj_python_lint-1.16.1/src/sarj_python_lint/rules/require_public_dependency_contract.py +248 -0
  15. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/unused_test_factory_option.py +25 -101
  16. sarj_python_lint-1.16.0/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -122
  17. sarj_python_lint-1.16.0/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -338
  18. sarj_python_lint-1.16.0/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -196
  19. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/.gitignore +0 -0
  20. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/LICENSE +0 -0
  21. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/README.md +0 -0
  22. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/__init__.py +0 -0
  23. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/__main__.py +0 -0
  24. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  25. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_file_context.py +0 -0
  26. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_filesystem.py +0 -0
  27. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_python_target.py +0 -0
  28. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  29. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_secret_names.py +0 -0
  30. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/_version.py +0 -0
  31. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/benchmark.py +0 -0
  32. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/json_boundary.py +0 -0
  33. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/py.typed +0 -0
  34. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/ratchet.py +0 -0
  35. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rule_base.py +0 -0
  36. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  37. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  38. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  39. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  40. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  41. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  42. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  43. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  44. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  45. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  46. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  47. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  48. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_local_source.py +0 -0
  49. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  50. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  51. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  52. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  53. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  54. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  55. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  56. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  57. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  58. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  59. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  60. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  61. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  62. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  63. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  64. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  65. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  66. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  67. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  68. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  69. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  70. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  71. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  72. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  73. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  74. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  75. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  76. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  77. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  78. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  79. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  80. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  81. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  82. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  83. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  84. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  85. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  86. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  87. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  88. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  89. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  90. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  91. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  92. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  93. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  94. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  95. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  96. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  97. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  98. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  99. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  100. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  101. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  102. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  103. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  104. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  105. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  106. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  107. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  108. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  109. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  110. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  111. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  112. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  113. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  114. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  115. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  116. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  117. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  118. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  119. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  120. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  121. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  122. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  123. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  124. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  125. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  126. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  127. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  128. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  129. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  130. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  131. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  132. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  133. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  134. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  135. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  136. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  137. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  138. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  139. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  140. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  141. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  142. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  143. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  144. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  145. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  146. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  147. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  148. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  149. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  150. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  151. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  152. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  153. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  154. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  155. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  156. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  157. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  158. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  159. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  160. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  161. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  162. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  163. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  164. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  165. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  166. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  167. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  168. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  169. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  170. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  171. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  172. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  173. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  174. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  175. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  176. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  177. {sarj_python_lint-1.16.0 → sarj_python_lint-1.16.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.16.0
3
+ Version: 1.16.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.16.0"
3
+ version = "1.16.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" }]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ )
18
+ from sarj_python_lint.rules._paths import is_test_path
19
+
20
+
21
+ if TYPE_CHECKING:
22
+ from sarj_python_lint._file_context import PythonFileContext
23
+
24
+
25
+ _REJECTED = (
26
+ "class Service:\n"
27
+ " def __init__(self, store: Store | None = None) -> None:\n"
28
+ " if store is None:\n"
29
+ " raise ValueError('store is required')\n"
30
+ " self.store = store\n"
31
+ )
32
+
33
+
34
+ @final
35
+ class DiscourageNullableConstructorParameters(Rule):
36
+ id = "discourage-nullable-constructor-parameters"
37
+ code = "SARJ468"
38
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
39
+ default_level=Severity.WARNING,
40
+ summary="Make constructor inputs required when their None default is immediately rejected.",
41
+ rationale=(
42
+ "A constructor that immediately rejects its None default advertises omission even though it cannot "
43
+ "construct an instance from that default. Static typing permits the omitted call."
44
+ ),
45
+ remediation=(
46
+ "Make the rejected input required and non-nullable. Keep deliberate optional state nullable; "
47
+ "no wrapper, interface, or alternate constructor is needed."
48
+ ),
49
+ category=RuleCategory.ARCHITECTURE,
50
+ autofix=AutofixPolicy.NONE,
51
+ limitations=(
52
+ "Requires a PEP604 nullable parameter with a literal None default and immediate explicit rejection in an undecorated synchronous __init__ body.",
53
+ "Only a first executable `if parameter is None: raise ...` or `assert parameter is not None` is checked; docstrings and simple assignments preparing a raised error are allowed. Compound guards, later validation, fallbacks, and required nullable inputs are excluded.",
54
+ "An assertion expresses rejection in normal assertions-enabled execution; Python -O can remove it. No autofix changes a public constructor signature or error type.",
55
+ "Tests, generated code, decorated classes, and constructors with decorators are excluded. Nullable state that is stored or handled is valid.",
56
+ ),
57
+ examples=(
58
+ RuleExample(
59
+ example_id="rejected-constructor-default",
60
+ title="The constructor immediately rejects its advertised default",
61
+ outcome=ExampleOutcome.MATCH,
62
+ files=(ExampleFile.python("app/service.py", _REJECTED),),
63
+ focus_path=PurePosixPath("app/service.py"),
64
+ expected_count=1,
65
+ public=True,
66
+ ),
67
+ RuleExample(
68
+ example_id="required-constructor-input",
69
+ title="The signature requires the input that construction needs",
70
+ outcome=ExampleOutcome.NO_MATCH,
71
+ files=(ExampleFile.python("app/service.py", _REJECTED.replace("Store | None = None", "Store")),),
72
+ focus_path=PurePosixPath("app/service.py"),
73
+ expected_count=0,
74
+ public=True,
75
+ ),
76
+ ),
77
+ )
78
+ description = documentation.summary
79
+
80
+ @override
81
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
82
+ if context.generated or context.tree is None or is_test_path(context.path):
83
+ return []
84
+ findings: list[Diagnostic] = []
85
+ for owner in context.nodes(ast.ClassDef):
86
+ if owner.decorator_list:
87
+ continue
88
+ for method in owner.body:
89
+ if not isinstance(method, ast.FunctionDef) or method.name != "__init__" or method.decorator_list:
90
+ continue
91
+ parameter = _rejected_default(method)
92
+ if parameter is None:
93
+ continue
94
+ findings.append(
95
+ Diagnostic(
96
+ path=context.path,
97
+ line=parameter.lineno,
98
+ col=parameter.col_offset + 1,
99
+ code=self.code,
100
+ severity=Severity.WARNING,
101
+ message=f"Constructor input `{parameter.arg}` defaults to None but immediately rejects it; make this input required and non-nullable instead of adding an abstraction.",
102
+ )
103
+ )
104
+ return findings
105
+
106
+
107
+ def _rejected_default(method: ast.FunctionDef) -> ast.arg | None:
108
+ rejected = _immediately_rejected_parameter(method)
109
+ if rejected is None:
110
+ return None
111
+ positional = (*method.args.posonlyargs, *method.args.args)
112
+ defaulted = positional[-len(method.args.defaults) :] if method.args.defaults else ()
113
+ defaults = (
114
+ *zip(defaulted, method.args.defaults, strict=True),
115
+ *zip(method.args.kwonlyargs, method.args.kw_defaults, strict=True),
116
+ )
117
+ for parameter, default in defaults:
118
+ if parameter.arg != rejected or (positional and parameter is positional[0]):
119
+ continue
120
+ if (
121
+ not isinstance(default, ast.Constant)
122
+ or default.value is not None
123
+ or parameter.annotation is None
124
+ or not _is_nullable_union(parameter.annotation)
125
+ ):
126
+ continue
127
+ return parameter
128
+ return None
129
+
130
+
131
+ def _immediately_rejected_parameter(method: ast.FunctionDef) -> str | None:
132
+ body = method.body
133
+ if (
134
+ body
135
+ and isinstance(body[0], ast.Expr)
136
+ and isinstance(body[0].value, ast.Constant)
137
+ and isinstance(body[0].value.value, str)
138
+ ):
139
+ body = body[1:]
140
+ if not body:
141
+ return None
142
+ first = body[0]
143
+ if (
144
+ isinstance(first, ast.If)
145
+ and isinstance(first.body[-1], ast.Raise)
146
+ and all(isinstance(statement, (ast.Assign, ast.AnnAssign)) for statement in first.body[:-1])
147
+ ):
148
+ condition, operator = first.test, ast.Is
149
+ elif isinstance(first, ast.Assert):
150
+ condition, operator = first.test, ast.IsNot
151
+ else:
152
+ return None
153
+ if not isinstance(condition, ast.Compare) or len(condition.ops) != 1 or not isinstance(condition.ops[0], operator):
154
+ return None
155
+ left, right = condition.left, condition.comparators[0]
156
+ for name, absent in ((left, right), (right, left)):
157
+ if isinstance(name, ast.Name) and isinstance(absent, ast.Constant) and absent.value is None:
158
+ return name.id
159
+ return None
160
+
161
+
162
+ def _is_nullable_union(annotation: ast.expr) -> bool:
163
+ if not isinstance(annotation, ast.BinOp) or not isinstance(annotation.op, ast.BitOr):
164
+ return False
165
+ return _is_none_union_member(annotation.left) or _is_none_union_member(annotation.right)
166
+
167
+
168
+ def _is_none_union_member(annotation: ast.expr) -> bool:
169
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
170
+ return _is_none_union_member(annotation.left) or _is_none_union_member(annotation.right)
171
+ return isinstance(annotation, ast.Constant) and annotation.value is None
@@ -41,7 +41,7 @@ class NoDatabaseProgrammability(Rule):
41
41
  default_level=Severity.ERROR,
42
42
  summary="Keep stored SQL functions and triggers out of embedded SQL.",
43
43
  rationale="SQL hidden in Python can bypass migration-only architecture rules. Procedural fault fixtures require extra database objects and cleanup when a declarative constraint suffices.",
44
- remediation="Use declarative constraints or explicit transactional application behavior, including in test fixtures. Use an exact SARJ470 suppression for an approved compatibility exception.",
44
+ remediation="Prefer declarative constraints or explicit transactional application behavior where equivalent, including in test fixtures. Preserve transaction atomicity and database-enforced integrity; use an exact SARJ470 suppression for an approved database-owned invariant or compatibility exception.",
45
45
  category=RuleCategory.ARCHITECTURE,
46
46
  autofix=AutofixPolicy.NONE,
47
47
  limitations=(
@@ -92,7 +92,7 @@ class NoDatabaseProgrammability(Rule):
92
92
  col=node.col_offset + 1,
93
93
  code=self.code,
94
94
  severity=Severity.ERROR,
95
- message="Stored SQL functions and triggers are prohibited by project architecture, including in test fixtures; use a declarative constraint or explicit transactional application code.",
95
+ message="Project architecture prefers declarative constraints or explicit transactional application behavior, including in test fixtures. Preserve atomicity and database-enforced integrity; use an exact SARJ470 suppression for an approved database-owned invariant or compatibility exception.",
96
96
  )
97
97
  )
98
98
  return sorted(diagnostics, key=lambda item: (item.line, item.col))
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from itertools import chain
4
5
  from pathlib import PurePosixPath
5
6
  from typing import TYPE_CHECKING, ClassVar, final, override
6
7
 
@@ -45,13 +46,14 @@ class NoInjectedModuleLoader(Rule):
45
46
  code = "SARJ471"
46
47
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
47
48
  default_level=Severity.WARNING,
48
- summary="Keep Python module loaders out of application parameter defaults.",
49
- rationale="Exposing Python's import machinery as an injectable dependency expands the callable contract and hides the concrete imported API behind a generic loader.",
50
- remediation="Use a normal or lazy import inside the implementation; pass the concrete configuration, object, or domain factory when callers need a real dependency boundary.",
49
+ summary="Avoid a generic module-loader parameter used only to import one fixed module.",
50
+ rationale="When every direct use imports the same literal module, a generic importer can be unnecessary customization rather than a domain dependency. Dynamic plugin/import APIs legitimately need import hooks.",
51
+ remediation="Consider owning the fixed import inside the implementation when import-hook substitution is unnecessary. Preserve intentional import hooks and plugin APIs; do not add wrapper factories or Protocols just to silence this advisory.",
51
52
  category=RuleCategory.ARCHITECTURE,
52
53
  autofix=AutofixPolicy.NONE,
53
54
  limitations=(
54
55
  "Flags defaults resolving to importlib.import_module, importlib.__import__, or builtins.__import__, including stable module-level import and single-assignment aliases and unshadowed __import__.",
56
+ "Every body reference to the parameter must be a direct call with one identical literal module name. Dynamic names, multiple module names, forwarding, returned/captured hooks, rebinding, unpacking and unused defaults are excluded.",
55
57
  "Tests, test support, and generated code are excluded. Required loader parameters, application loaders, wrapper lambdas, alias chains, comprehensions, and imports local to enclosing scopes are not inferred.",
56
58
  "Conservatively skips shadowed or rebound names, wildcard-import files, reassigned loader attributes, conflicting conditional or relative imports, and exception or pattern captures of importer roots, symbols or assignment aliases. Identical repeated importer imports remain valid. A relevant-name capture in an unrelated local scope can cause a false negative because capture checks are file-wide.",
57
59
  "Assignment aliases require one module binding and precede use by line and column. Defaults use their enclosing evaluation scope: earlier statements in the nearest executing class body can shadow loaders; methods and nested classes do not close over class attributes. Enclosing function body bindings remain conservative exclusions, but a function's parameters do not shadow its own defaults. Relevant global declarations and walrus writes exclude sensitive importer names. Compound class statements can cause conservative omissions. Dynamic namespace mutation is not inferred. No autofix: changing arguments or import placement can alter API and initialization behavior.",
@@ -110,7 +112,8 @@ class NoInjectedModuleLoader(Rule):
110
112
  and (aliases[default.id].lineno, aliases[default.id].col_offset)
111
113
  < (function.lineno, function.col_offset)
112
114
  )
113
- if not (direct or aliased) or _shadowed(default, function, context):
115
+ module_name = _fixed_module_name(context, function, argument.arg)
116
+ if not (direct or aliased) or _shadowed(default, function, context) or module_name is None:
114
117
  continue
115
118
  findings.append(
116
119
  Diagnostic(
@@ -118,13 +121,43 @@ class NoInjectedModuleLoader(Rule):
118
121
  line=argument.lineno,
119
122
  col=argument.col_offset + 1,
120
123
  code=self.code,
121
- message=f"Parameter `{argument.arg}` exposes Python's module loader; import directly or pass a concrete domain dependency.",
124
+ message=(
125
+ f"Parameter `{argument.arg}` exposes a generic module loader but only imports "
126
+ f"`{module_name}`; consider an internal import unless this import hook is intentional."
127
+ ),
122
128
  severity=Severity.WARNING,
123
129
  )
124
130
  )
125
131
  return sorted(findings, key=lambda finding: (finding.line, finding.col))
126
132
 
127
133
 
134
+ def _fixed_module_name(
135
+ context: PythonFileContext, function: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda, name: str
136
+ ) -> str | None:
137
+ body = [function.body] if isinstance(function, ast.Lambda) else function.body
138
+ module_names: set[str] = set()
139
+ for reference in chain.from_iterable(ast.walk(statement) for statement in body):
140
+ if not isinstance(reference, ast.Name) or reference.id != name:
141
+ continue
142
+ call = context.parents.get(reference)
143
+ if not isinstance(reference.ctx, ast.Load) or not isinstance(call, ast.Call) or call.func is not reference:
144
+ return None
145
+ if (
146
+ call.keywords
147
+ or len(call.args) != 1
148
+ or not isinstance(call.args[0], ast.Constant)
149
+ or not isinstance(call.args[0].value, str)
150
+ ):
151
+ return None
152
+ parent = context.parents.get(call)
153
+ while parent is not None and parent is not function:
154
+ if isinstance(parent, _LEXICAL_SCOPES):
155
+ return None
156
+ parent = context.parents.get(parent)
157
+ module_names.add(call.args[0].value)
158
+ return next(iter(module_names)) if len(module_names) == 1 else None
159
+
160
+
128
161
  def _ambiguous_loader_bindings(context: PythonFileContext, aliases: Mapping[str, ast.stmt]) -> bool:
129
162
  if any(alias.name == "*" for node in context.nodes(ast.ImportFrom) for alias in node.names):
130
163
  return True
@@ -66,7 +66,7 @@ class NoNullableDependencyFallback(Rule):
66
66
  autofix=AutofixPolicy.NONE,
67
67
  limitations=(
68
68
  "Warns for None-defaulted function parameters with a fallback used as a callable or a proven application-settings fallback; genuine absent state is allowed.",
69
- "Constructors remain owned by SARJ095 and SARJ468. Decorated functions, tests, generated code, nested scope captures, non-callable object fallbacks, and interprocedural forwarding are excluded.",
69
+ "Constructor settings fallbacks remain owned by SARJ095; constructor nullability alone is not a defect. Decorated functions, tests, generated code, nested scope captures, non-callable object fallbacks, and interprocedural forwarding are excluded.",
70
70
  "Callable use through a local assignment, annotation or None guard requires at most one body binding for that name and a call after the binding statement completes. Rebinding, deletion, imports, definitions, pattern or exception captures, global/nonlocal declarations and nested-scope bindings conservatively exclude that inference; direct inline calls remain independent. Settings provenance also excludes enclosing bindings and names written by a function declaring them global, while read-only global declarations remain valid.",
71
71
  "No autofix: removing explicit None acceptance changes the callable contract, and moving a fallback can change initialization timing or error handling.",
72
72
  ),
@@ -39,7 +39,8 @@ class NoUnusedUnderscoredKeywordParameter(Rule):
39
39
  ),
40
40
  remediation=(
41
41
  "Remove the unused keyword-only parameter and update callers together. If an external contract requires "
42
- "the input, preserve its required spelling and use a narrow, reasoned unused-argument suppression."
42
+ "the input, preserve its required spelling and use a narrow, reasoned unused-argument suppression. "
43
+ "Do not erase a precise callback signature with **kwargs or add dummy reads to silence this warning."
43
44
  ),
44
45
  category=RuleCategory.MAINTAINABILITY,
45
46
  autofix=AutofixPolicy.NONE,
@@ -48,7 +49,7 @@ class NoUnusedUnderscoredKeywordParameter(Rule):
48
49
  "Decorated functions other than unshadowed builtin staticmethod/classmethod, inherited methods, and intentional stubs are excluded.",
49
50
  "Any reference within the body counts as use, including nested scopes; shadowing and reassignment can produce false negatives.",
50
51
  "Functions accessing locals, vars, eval, or exec through recognized builtin bindings are excluded.",
51
- "Call sites, lambda parameters, unknown dynamic reflection, and cross-module callback contracts are not resolved.",
52
+ "Callables referenced as values are excluded because their external keyword contract is unknown. Lambda parameters, unknown dynamic reflection, and cross-module callback contracts are not resolved.",
52
53
  "Generated and vendored files are excluded; test source is checked.",
53
54
  ),
54
55
  examples=(
@@ -103,7 +104,12 @@ def _unused_parameters(
103
104
  context: PythonFileContext, function: ast.FunctionDef | ast.AsyncFunctionDef
104
105
  ) -> tuple[ast.arg, ...]:
105
106
  candidates = [argument for argument in function.args.kwonlyargs if argument.arg.startswith("_")]
106
- if not candidates or _has_contract(context, function) or _is_stub(function):
107
+ if (
108
+ not candidates
109
+ or _has_contract(context, function)
110
+ or _is_stub(function)
111
+ or _escapes_as_callback(context, function)
112
+ ):
107
113
  return ()
108
114
  body = list(chain.from_iterable(ast.walk(statement) for statement in function.body))
109
115
  if _reads_dynamic_locals(context, function, body):
@@ -174,3 +180,15 @@ def _reads_dynamic_locals(
174
180
  )
175
181
  for node in body
176
182
  )
183
+
184
+
185
+ def _escapes_as_callback(context: PythonFileContext, function: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
186
+ for reference in context.nodes(ast.Name, ast.Attribute):
187
+ if (isinstance(reference, ast.Name) and reference.id != function.name) or (
188
+ isinstance(reference, ast.Attribute) and reference.attr != function.name
189
+ ):
190
+ continue
191
+ parent = context.parents.get(reference)
192
+ if isinstance(reference.ctx, ast.Load) and not (isinstance(parent, ast.Call) and parent.func is reference):
193
+ return True
194
+ return False
@@ -46,6 +46,7 @@ class NoUnusedValueMarker(Rule):
46
46
  "Plain and annotated assignments whose standalone target is `_` are reported, including chained assignments.",
47
47
  "Formal-parameter references are reported too, including positional, keyword-only, and variadic parameters.",
48
48
  "Unpacking placeholders, loop targets, wildcard imports, and underscore-prefixed names are excluded.",
49
+ "A binding read in the same enclosing scope is not a discard. Reads in nested scopes conservatively preserve it, even when shadowing cannot be resolved.",
49
50
  "Generated and vendored files are excluded.",
50
51
  ),
51
52
  examples=(
@@ -148,7 +149,9 @@ class NoUnusedValueMarker(Rule):
148
149
  markers = [
149
150
  node
150
151
  for node in context.nodes(ast.AST)
151
- if isinstance(node, (ast.Assign, ast.AnnAssign)) and _is_unused_value_marker(node)
152
+ if isinstance(node, (ast.Assign, ast.AnnAssign))
153
+ and _is_unused_value_marker(node)
154
+ and not _scope_reads_marker(context, node)
152
155
  ]
153
156
  if not markers:
154
157
  return []
@@ -173,3 +176,14 @@ def _is_unused_value_marker(node: ast.Assign | ast.AnnAssign) -> bool:
173
176
  if isinstance(node, ast.AnnAssign):
174
177
  return node.value is not None and isinstance(node.target, ast.Name) and node.target.id == "_"
175
178
  return any(isinstance(target, ast.Name) and target.id == "_" for target in node.targets)
179
+
180
+
181
+ def _scope_reads_marker(context: PythonFileContext, marker: ast.Assign | ast.AnnAssign) -> bool:
182
+ scope: ast.AST = marker
183
+ while (parent := context.parents.get(scope)) is not None:
184
+ scope = parent
185
+ if isinstance(scope, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Module):
186
+ break
187
+ return any(
188
+ isinstance(node, ast.Name) and node.id == "_" and isinstance(node.ctx, ast.Load) for node in ast.walk(scope)
189
+ )
@@ -101,7 +101,7 @@ class PreferMatchTypeDispatch(Rule):
101
101
  limitations=(
102
102
  "General dispatch requires three or more adjacent, unguarded `isinstance` branches over the same simple name. Two arms are checked only for an exact `ast.Name.id` / `ast.Attribute.attr` projection followed by a None-returning fallback.",
103
103
  "A terminating sibling prefix may precede a two-arm if/elif tail whose bodies can fall through. Preserve nested checks inside case bodies rather than moving them into case guards.",
104
- "The checked types must be unshadowed builtins, unshadowed module-local classes, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
104
+ "The checked types must be unshadowed builtins, undecorated module-local classes without an explicit metaclass, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
105
105
  "Nested attribute-validation guards are excluded: converting them to keyword class patterns can turn attribute errors into match fallthrough, and an imported isinstance operand may be a runtime tuple rather than a class.",
106
106
  "A terminal-looking context-manager body does not prove a sibling branch terminates: exceptions can be suppressed. An unconditional return or raise after the context manager remains eligible.",
107
107
  "Declared support for Python before 3.10 suppresses this recommendation when proven by the nearest project metadata or exact installed-distribution ownership. Missing or ambiguous target metadata retains advisory behavior; it does not prove a modern target.",
@@ -288,19 +288,27 @@ class PreferMatchTypeDispatch(Rule):
288
288
 
289
289
 
290
290
  def _unshadowed_module_classes(tree: ast.Module, all_nodes: tuple[ast.AST, ...]) -> frozenset[str]:
291
- classes = [statement.name for statement in tree.body if isinstance(statement, ast.ClassDef)]
292
- rebound = {
293
- candidate.id
294
- for candidate in all_nodes
295
- if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, (ast.Store, ast.Del))
296
- }
297
- rebound.update(candidate.arg for candidate in all_nodes if isinstance(candidate, ast.arg))
298
- rebound.update(
299
- candidate.name
300
- for candidate in all_nodes
301
- if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
302
- and (not isinstance(candidate, ast.ClassDef) or candidate not in tree.body)
303
- )
291
+ classes = [
292
+ statement.name
293
+ for statement in tree.body
294
+ if isinstance(statement, ast.ClassDef)
295
+ and not statement.decorator_list
296
+ and not any(keyword.arg in {"metaclass", None} for keyword in statement.keywords)
297
+ ]
298
+ rebound: set[str] = set()
299
+ for node in all_nodes:
300
+ match node:
301
+ case (
302
+ ast.Name(id=name, ctx=ast.Store() | ast.Del())
303
+ | ast.arg(arg=name)
304
+ | ast.FunctionDef(name=name)
305
+ | ast.AsyncFunctionDef(name=name)
306
+ ):
307
+ rebound.add(name)
308
+ case ast.ClassDef(name=name) if node not in tree.body:
309
+ rebound.add(name)
310
+ case _:
311
+ pass
304
312
  _add_module_import_bindings(tree, rebound)
305
313
  return frozenset(name for name in classes if classes.count(name) == 1 and name not in rebound)
306
314
 
@@ -51,21 +51,26 @@ class PreferPytestFixtureInjection(Rule):
51
51
  summary="Request shared database-pool setup through a pytest fixture.",
52
52
  rationale=(
53
53
  "Calling a shared conftest pool helper during initial test setup bypasses an explicit pytest "
54
- "fixture dependency for that setup."
54
+ "fixture dependency, per-test caching, and fixture overrides, even when the helper already "
55
+ "centralizes allocation. This preference does not establish a resource leak."
55
56
  ),
56
57
  remediation=(
57
- "Request one function-scoped fixture visible to the test, preserving initial pool state, creation "
58
- "timing, and cleanup ownership. Use yield teardown when the fixture owns cleanup; keep factories "
59
- "for variants or fresh instances."
58
+ "Request one function-scoped fixture visible to the test. Declare required setup ordering as "
59
+ "fixture dependencies and preserve initial pool state and cleanup ownership. An unopened "
60
+ "allocation fixture should return the pool without opening or closing it when application "
61
+ "lifespan owns that lifecycle. Use yield teardown only when the fixture owns cleanup; keep "
62
+ "factories for variants or fresh instances."
60
63
  ),
61
64
  category=RuleCategory.TESTING,
62
65
  autofix=AutofixPolicy.NONE,
63
66
  limitations=(
64
67
  "Only directly collected module-level test functions in conventional pytest module names are checked.",
65
- "Only unshadowed runtime imports from a uniquely resolved local conftest module are followed.",
68
+ "Only unshadowed runtime imports from a uniquely resolved conftest in the test's ancestor directories are followed under conventional pytest discovery.",
66
69
  "Helpers must be undecorated synchronous functions with no formal inputs and one direct return of an imported psycopg_pool pool constructor.",
70
+ "Async pools require an explicit literal open=False, no unpacked arguments, and no calls inside constructor arguments; opened or dynamically configured async pools need lifecycle-specific fixture design.",
67
71
  "Setup must be the first executable statement and immediately feed a plain call, or appear inline as a direct argument of that first call.",
68
72
  "Class tests, decorated helpers, unknown test decorators, dynamic imports, re-exports, and ambiguous or out-of-bound source paths are excluded.",
73
+ "Command-line conftest discovery overrides and external pytest plugin registrations are not inferred.",
69
74
  ),
70
75
  examples=(
71
76
  RuleExample(
@@ -84,6 +89,50 @@ class PreferPytestFixtureInjection(Rule):
84
89
  expected_count=1,
85
90
  public=True,
86
91
  ),
92
+ RuleExample(
93
+ example_id="manual-pool-with-application-lifespan",
94
+ scenario="application-lifecycle",
95
+ title="Application-owned lifecycle still uses a fixture dependency",
96
+ outcome=ExampleOutcome.MATCH,
97
+ files=(
98
+ ExampleFile.python(
99
+ "tests/conftest.py", _SUPPORT_SOURCE.replace("ConnectionPool", "AsyncConnectionPool")
100
+ ),
101
+ ExampleFile.python(
102
+ "tests/test_app.py",
103
+ "from tests.conftest import pool\n\n"
104
+ "async def test_app():\n resource = pool()\n app = Application(resource)\n"
105
+ " async with lifespan(app):\n assert not resource.closed\n"
106
+ " assert resource.closed\n",
107
+ ),
108
+ ),
109
+ focus_path=PurePosixPath("tests/test_app.py"),
110
+ expected_count=1,
111
+ public=True,
112
+ ),
113
+ RuleExample(
114
+ example_id="allocation-fixture-keeps-application-lifespan",
115
+ scenario="application-lifecycle",
116
+ title="An unopened fixture leaves open and close ownership with the application",
117
+ outcome=ExampleOutcome.NO_MATCH,
118
+ files=(
119
+ ExampleFile.python(
120
+ "tests/conftest.py",
121
+ "import pytest\nfrom psycopg_pool import AsyncConnectionPool\n\n"
122
+ "@pytest.fixture\ndef pool() -> AsyncConnectionPool[object]:\n"
123
+ " return AsyncConnectionPool[object](open=False)\n",
124
+ ),
125
+ ExampleFile.python(
126
+ "tests/test_app.py",
127
+ "async def test_app(pool):\n app = Application(pool)\n assert pool.closed\n"
128
+ " async with lifespan(app):\n assert not pool.closed\n"
129
+ " assert pool.closed\n",
130
+ ),
131
+ ),
132
+ focus_path=PurePosixPath("tests/test_app.py"),
133
+ expected_count=0,
134
+ public=True,
135
+ ),
87
136
  RuleExample(
88
137
  example_id="injected-generic-pool",
89
138
  title="A test requests its shared resource",
@@ -145,8 +194,10 @@ class PreferPytestFixtureInjection(Rule):
145
194
  code=self.code,
146
195
  message=(
147
196
  "test calls a shared conftest pool helper during initial setup; request one visible "
148
- "function-scoped fixture by parameter, preserving initial pool state, creation timing, "
149
- "and cleanup ownership; keep factories for variants or fresh instances."
197
+ "function-scoped fixture by parameter for caching and overrides; declare required "
198
+ "setup dependencies and preserve initial pool state and cleanup ownership; leave "
199
+ "open/close with application lifespan when it owns them; keep factories for variants "
200
+ "or fresh instances."
150
201
  ),
151
202
  severity=Severity.WARNING,
152
203
  )
@@ -171,7 +222,9 @@ def _resource_helpers(context: PythonFileContext) -> frozenset[str]:
171
222
  )
172
223
  module = (
173
224
  context.session.local_source.read_module(target)
174
- if target is not None and target.name == "conftest.py"
225
+ if (
226
+ target is not None and target.name == "conftest.py" and target.parent in context.path.absolute().parents
227
+ )
175
228
  else None
176
229
  )
177
230
  if module is not None and _is_resource_helper(module, reference.symbol):
@@ -214,15 +267,30 @@ def _is_resource_helper(module: LocalModule, symbol: str) -> bool:
214
267
  body = _executable_body(helper)
215
268
  if len(body) != 1 or not isinstance(body[0], ast.Return) or not isinstance(body[0].value, ast.Call):
216
269
  return False
217
- constructor = body[0].value.func
270
+ allocation = body[0].value
271
+ constructor = allocation.func
218
272
  if isinstance(constructor, ast.Subscript):
219
273
  constructor = constructor.value
220
274
  root = _root_name(constructor)
275
+ qualified = module.runtime_imports.resolved_qualified_name(constructor)
221
276
  return (
222
277
  root is not None
223
278
  and _scope_bindings(module.tree)[root] == 1
224
279
  and not _root_mutated(module.tree, root)
225
- and module.runtime_imports.resolved_qualified_name(constructor) in _POOL_CONSTRUCTORS
280
+ and qualified in _POOL_CONSTRUCTORS
281
+ and (qualified != "psycopg_pool.AsyncConnectionPool" or _unopened_async_allocation(allocation))
282
+ )
283
+
284
+
285
+ def _unopened_async_allocation(allocation: ast.Call) -> bool:
286
+ return (
287
+ not any(isinstance(argument, ast.Starred) for argument in allocation.args)
288
+ and all(keyword.arg is not None for keyword in allocation.keywords)
289
+ and any(
290
+ keyword.arg == "open" and isinstance(keyword.value, ast.Constant) and keyword.value.value is False
291
+ for keyword in allocation.keywords
292
+ )
293
+ and not any(isinstance(node, ast.Call) for argument in _arguments(allocation) for node in ast.walk(argument))
226
294
  )
227
295
 
228
296