sarj-python-lint 1.14.4__tar.gz → 1.15.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 (173) hide show
  1. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_analysis_session.py +2 -0
  4. sarj_python_lint-1.15.1/src/sarj_python_lint/rules/_local_source.py +123 -0
  5. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_registry.py +2 -0
  6. sarj_python_lint-1.15.1/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +452 -0
  7. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/.gitignore +0 -0
  8. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/LICENSE +0 -0
  9. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/README.md +0 -0
  10. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_file_context.py +0 -0
  13. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_python_target.py +0 -0
  15. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  16. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_secret_names.py +0 -0
  17. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/benchmark.py +0 -0
  19. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/json_boundary.py +0 -0
  20. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/py.typed +0 -0
  21. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/ratchet.py +0 -0
  22. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rule_base.py +0 -0
  23. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  24. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  25. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  26. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  27. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  28. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  29. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  30. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  31. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  32. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  33. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  34. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  35. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  36. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  37. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  38. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  39. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  40. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  41. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  42. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  43. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  44. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  45. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  46. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  47. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  48. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  49. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  50. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  51. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
  52. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  53. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  54. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  55. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  56. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  57. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  58. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  59. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  60. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  61. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  62. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  63. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  64. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  65. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  66. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  67. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  68. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  69. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  70. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  71. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  72. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  73. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  74. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  75. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  76. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  77. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  78. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  79. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  80. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  81. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  82. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  83. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  84. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  85. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  86. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  87. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  88. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  89. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  90. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  91. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  92. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  93. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  94. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  95. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  96. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  97. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  98. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  99. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  100. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  101. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  102. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  103. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  104. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  105. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  106. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  107. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  108. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  109. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  110. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  111. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  112. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  113. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  114. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  115. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  116. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  117. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  118. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  119. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  120. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  121. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  122. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  123. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  124. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  125. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  126. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  127. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  128. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  129. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  130. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  131. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  132. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  133. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  134. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  135. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  136. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  137. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  138. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  139. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  140. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  141. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  142. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  143. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  144. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  145. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  146. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  147. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  148. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  149. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  150. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  151. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  152. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  153. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  154. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  155. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  156. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  157. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  158. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  159. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  160. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  161. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  162. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  163. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  164. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  165. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  166. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  167. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  168. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  169. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  170. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  171. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  172. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  173. {sarj_python_lint-1.14.4 → sarj_python_lint-1.15.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.14.4
3
+ Version: 1.15.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.14.4"
3
+ version = "1.15.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" }]
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, final
4
4
 
5
5
  from sarj_python_lint._python_target import PythonTargetFacts
6
6
  from sarj_python_lint.rules._first_party import FirstPartyFacts
7
+ from sarj_python_lint.rules._local_source import LocalSourceFacts
7
8
  from sarj_python_lint.rules._nominal_project import NominalProjectFacts
8
9
 
9
10
 
@@ -16,5 +17,6 @@ class AnalysisSession:
16
17
  def __init__(self) -> None:
17
18
  self.project: ProjectIndexSet | None = None
18
19
  self.first_party = FirstPartyFacts()
20
+ self.local_source = LocalSourceFacts()
19
21
  self.python_target = PythonTargetFacts()
20
22
  self.nominal_project = NominalProjectFacts()
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ from sarj_python_lint.rules._imports import ImportIndex
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from sarj_python_lint.rules._first_party import FirstPartyFacts
13
+
14
+
15
+ _MAX_ANCESTORS = 24
16
+ _MAX_MODULE_BYTES = 256_000
17
+ _MAX_MODULES = 512
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class LocalModule:
22
+ path: Path
23
+ source: str
24
+ tree: ast.Module
25
+ runtime_imports: ImportIndex
26
+
27
+
28
+ @final
29
+ class LocalSourceFacts:
30
+ def __init__(self) -> None:
31
+ self._resolutions: dict[tuple[Path, str | None, int], Path | None] = {}
32
+ self._modules: dict[Path, LocalModule | None] = {}
33
+
34
+ def resolve_module(self, current: Path, module: str | None, level: int, *, facts: FirstPartyFacts) -> Path | None:
35
+ try:
36
+ current = current.absolute()
37
+ except OSError:
38
+ return None
39
+ key = (current, module, level)
40
+ if key in self._resolutions:
41
+ return self._resolutions[key]
42
+ if len(self._resolutions) >= _MAX_MODULES:
43
+ return None
44
+ result = _resolve_module(current, module, level, facts=facts)
45
+ self._resolutions[key] = result
46
+ return result
47
+
48
+ def read_module(self, path: Path) -> LocalModule | None:
49
+ if path in self._modules:
50
+ return self._modules[path]
51
+ if len(self._modules) >= _MAX_MODULES:
52
+ return None
53
+ module = _read_module(path)
54
+ self._modules[path] = module
55
+ return module
56
+
57
+
58
+ def _resolve_module(current: Path, module: str | None, level: int, *, facts: FirstPartyFacts) -> Path | None:
59
+ if ".." in current.parts or level > _MAX_ANCESTORS:
60
+ return None
61
+ root = facts.project_root(current)
62
+ if root is None or not _safe_path(current, root):
63
+ return None
64
+ parts = module.split(".") if module else []
65
+ if any(not part.isidentifier() for part in parts):
66
+ return None
67
+ candidates: set[Path] = set()
68
+ for base in _import_bases(current, root, level):
69
+ target = base.joinpath(*parts)
70
+ for candidate in (target.with_suffix(".py"), target / "__init__.py"):
71
+ if _safe_path(candidate, root) and candidate.is_file():
72
+ candidates.add(candidate)
73
+ return next(iter(candidates)) if len(candidates) == 1 else None
74
+
75
+
76
+ def _import_bases(current: Path, root: Path, level: int) -> list[Path]:
77
+ if level:
78
+ base = current.parent
79
+ for _ in range(level - 1):
80
+ if base == root:
81
+ return []
82
+ base = base.parent
83
+ return [base]
84
+ bases: list[Path] = []
85
+ for ancestor in list(current.parents)[:_MAX_ANCESTORS]:
86
+ if not ancestor.is_relative_to(root):
87
+ break
88
+ bases.append(ancestor)
89
+ if (ancestor / "pyproject.toml").is_file():
90
+ bases.append(ancestor / "src")
91
+ if ancestor == root:
92
+ break
93
+ return bases
94
+
95
+
96
+ def _safe_path(path: Path, root: Path) -> bool:
97
+ if not path.is_relative_to(root):
98
+ return False
99
+ try:
100
+ for entry in (path, *list(path.parents)[:_MAX_ANCESTORS]):
101
+ if entry == root:
102
+ return True
103
+ if entry.is_symlink() or (entry != path and (entry / ".git").exists()):
104
+ return False
105
+ except OSError:
106
+ return False
107
+ return False
108
+
109
+
110
+ def _read_module(path: Path) -> LocalModule | None:
111
+ try:
112
+ with path.open("rb") as stream:
113
+ data = stream.read(_MAX_MODULE_BYTES + 1)
114
+ if len(data) > _MAX_MODULE_BYTES:
115
+ return None
116
+ source = data.decode("utf-8")
117
+ tree = ast.parse(source, filename=str(path))
118
+ except OSError, UnicodeError, SyntaxError:
119
+ return None
120
+ runtime_imports = ast.Module(
121
+ body=[node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))], type_ignores=[]
122
+ )
123
+ return LocalModule(path, source, tree, ImportIndex.from_tree(runtime_imports))
@@ -133,6 +133,7 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
133
133
  )
134
134
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
135
135
  from sarj_python_lint.rules.prefer_pydantic_json_value import PreferPydanticJsonValue
136
+ from sarj_python_lint.rules.prefer_pytest_fixture_injection import PreferPytestFixtureInjection
136
137
  from sarj_python_lint.rules.prefer_regex_fullmatch import PreferRegexFullmatch
137
138
  from sarj_python_lint.rules.prefer_self_documenting_constant import (
138
139
  PreferSelfDocumentingConstant,
@@ -278,6 +279,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
278
279
  NoRepeatedTestBody.id: NoRepeatedTestBody,
279
280
  UnusedMockSetup.id: UnusedMockSetup,
280
281
  UnusedTestFactoryOption.id: UnusedTestFactoryOption,
282
+ PreferPytestFixtureInjection.id: PreferPytestFixtureInjection,
281
283
  PreferFstringOverConcat.id: PreferFstringOverConcat,
282
284
  PreferOrPattern.id: PreferOrPattern,
283
285
  PreferPydanticJsonValue.id: PreferPydanticJsonValue,
@@ -0,0 +1,452 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections import Counter
5
+ from pathlib import PurePosixPath
6
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
17
+ Severity,
18
+ )
19
+ from sarj_python_lint.rules._paths import is_generated, is_test_support_path
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator
24
+
25
+ from sarj_python_lint._file_context import PythonFileContext
26
+ from sarj_python_lint.rules._local_source import LocalModule
27
+
28
+
29
+ type Function = ast.FunctionDef | ast.AsyncFunctionDef
30
+ _POOL_CONSTRUCTORS = frozenset({"psycopg_pool.ConnectionPool", "psycopg_pool.AsyncConnectionPool"})
31
+ _SETUP_AND_CONSUMER = 2
32
+ _SUPPORT_SOURCE = (
33
+ "from psycopg_pool import ConnectionPool\n\n"
34
+ "def pool() -> ConnectionPool[object]:\n"
35
+ " return ConnectionPool[object](open=False)\n"
36
+ )
37
+
38
+
39
+ class _HelperImport(NamedTuple):
40
+ local: str
41
+ symbol: str
42
+ module: str
43
+ level: int
44
+
45
+
46
+ class PreferPytestFixtureInjection(Rule):
47
+ id: str = "prefer-pytest-fixture-injection"
48
+ code: str = "SARJ476"
49
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
50
+ default_level=Severity.WARNING,
51
+ summary="Request shared database-pool setup through a pytest fixture.",
52
+ rationale=(
53
+ "Calling a shared conftest pool helper during initial test setup bypasses an explicit pytest "
54
+ "fixture dependency for that setup."
55
+ ),
56
+ 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."
60
+ ),
61
+ category=RuleCategory.TESTING,
62
+ autofix=AutofixPolicy.NONE,
63
+ limitations=(
64
+ "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.",
66
+ "Helpers must be undecorated synchronous functions with no formal inputs and one direct return of an imported psycopg_pool pool constructor.",
67
+ "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
+ "Class tests, decorated helpers, unknown test decorators, dynamic imports, re-exports, and ambiguous or out-of-bound source paths are excluded.",
69
+ ),
70
+ examples=(
71
+ RuleExample(
72
+ example_id="manual-shared-generic-pool",
73
+ title="A test calls shared generic pool setup",
74
+ outcome=ExampleOutcome.MATCH,
75
+ files=(
76
+ ExampleFile.python("tests/conftest.py", _SUPPORT_SOURCE),
77
+ ExampleFile.python(
78
+ "tests/test_app.py",
79
+ "from tests.conftest import pool\n\n"
80
+ "def test_app():\n resource = pool()\n app = Application(resource)\n assert app\n",
81
+ ),
82
+ ),
83
+ focus_path=PurePosixPath("tests/test_app.py"),
84
+ expected_count=1,
85
+ public=True,
86
+ ),
87
+ RuleExample(
88
+ example_id="injected-generic-pool",
89
+ title="A test requests its shared resource",
90
+ outcome=ExampleOutcome.NO_MATCH,
91
+ files=(
92
+ ExampleFile.python(
93
+ "tests/conftest.py",
94
+ "import pytest\nfrom psycopg_pool import ConnectionPool\n\n"
95
+ "@pytest.fixture\ndef pool() -> ConnectionPool[object]:\n"
96
+ " return ConnectionPool[object](open=False)\n",
97
+ ),
98
+ ExampleFile.python(
99
+ "tests/test_app.py", "def test_app(pool):\n app = Application(pool)\n assert app\n"
100
+ ),
101
+ ),
102
+ focus_path=PurePosixPath("tests/test_app.py"),
103
+ expected_count=0,
104
+ public=True,
105
+ ),
106
+ ),
107
+ )
108
+ description: str = documentation.summary
109
+
110
+ @override
111
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
112
+ if (
113
+ context.path.suffix != ".py"
114
+ or not (context.path.name.startswith("test_") or context.path.name.endswith("_test.py"))
115
+ or is_test_support_path(context.path)
116
+ or context.generated
117
+ or context.tree is None
118
+ ):
119
+ return []
120
+ tree = context.tree
121
+ helpers = _resource_helpers(context)
122
+ if not helpers:
123
+ return []
124
+ module_bindings = _scope_bindings(tree)
125
+ if "__test__" in module_bindings or any(
126
+ isinstance(node, ast.Attribute) and node.attr == "__test__" and isinstance(node.ctx, (ast.Store, ast.Del))
127
+ for node in ast.walk(tree)
128
+ ):
129
+ return []
130
+ diagnostics: list[Diagnostic] = []
131
+ for statement in tree.body:
132
+ if (
133
+ not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
134
+ or module_bindings[statement.name] != 1
135
+ or _module_reference(tree, statement.name)
136
+ ):
137
+ continue
138
+ call = _setup_call(context, statement, helpers)
139
+ if call is not None:
140
+ diagnostics.append(
141
+ Diagnostic(
142
+ path=context.path,
143
+ line=call.lineno,
144
+ col=call.col_offset + 1,
145
+ code=self.code,
146
+ message=(
147
+ "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."
150
+ ),
151
+ severity=Severity.WARNING,
152
+ )
153
+ )
154
+ return diagnostics
155
+
156
+
157
+ def _resource_helpers(context: PythonFileContext) -> frozenset[str]:
158
+ tree = context.tree
159
+ if tree is None:
160
+ return frozenset()
161
+ references = tuple(_helper_imports(tree))
162
+ if not references:
163
+ return frozenset()
164
+ bindings = _scope_bindings(tree)
165
+ helpers: set[str] = set()
166
+ for reference in references:
167
+ if bindings[reference.local] != 1 or _root_mutated(tree, reference.local):
168
+ continue
169
+ target = context.session.local_source.resolve_module(
170
+ context.path, reference.module, reference.level, facts=context.session.first_party
171
+ )
172
+ module = (
173
+ context.session.local_source.read_module(target)
174
+ if target is not None and target.name == "conftest.py"
175
+ else None
176
+ )
177
+ if module is not None and _is_resource_helper(module, reference.symbol):
178
+ helpers.add(reference.local)
179
+ return frozenset(helpers)
180
+
181
+
182
+ def _helper_imports(tree: ast.Module) -> Iterator[_HelperImport]:
183
+ candidates: list[_HelperImport] = []
184
+ for statement in tree.body:
185
+ if not isinstance(statement, ast.ImportFrom):
186
+ continue
187
+ module = statement.module
188
+ if module is None or module.rsplit(".", 1)[-1] != "conftest":
189
+ continue
190
+ candidates.extend(
191
+ _HelperImport(alias.asname or alias.name, alias.name, module, statement.level) for alias in statement.names
192
+ )
193
+ if not candidates or _has_wildcard(tree):
194
+ return
195
+ yield from candidates
196
+
197
+
198
+ def _is_resource_helper(module: LocalModule, symbol: str) -> bool:
199
+ if (
200
+ is_generated(module.path, module.source)
201
+ or _scope_bindings(module.tree)[symbol] != 1
202
+ or _has_wildcard(module.tree)
203
+ or _root_mutated(module.tree, symbol)
204
+ ):
205
+ return False
206
+ helper = next(
207
+ (node for node in module.tree.body if isinstance(node, ast.FunctionDef) and node.name == symbol), None
208
+ )
209
+ if helper is None or helper.decorator_list:
210
+ return False
211
+ arguments = helper.args
212
+ if arguments.posonlyargs or arguments.args or arguments.kwonlyargs or arguments.vararg or arguments.kwarg:
213
+ return False
214
+ body = _executable_body(helper)
215
+ if len(body) != 1 or not isinstance(body[0], ast.Return) or not isinstance(body[0].value, ast.Call):
216
+ return False
217
+ constructor = body[0].value.func
218
+ if isinstance(constructor, ast.Subscript):
219
+ constructor = constructor.value
220
+ root = _root_name(constructor)
221
+ return (
222
+ root is not None
223
+ and _scope_bindings(module.tree)[root] == 1
224
+ and not _root_mutated(module.tree, root)
225
+ and module.runtime_imports.resolved_qualified_name(constructor) in _POOL_CONSTRUCTORS
226
+ )
227
+
228
+
229
+ def _setup_call(context: PythonFileContext, function: Function, helpers: frozenset[str]) -> ast.Call | None:
230
+ if not function.name.startswith("test_") or any(
231
+ not _pytest_mark(context, decorator) for decorator in function.decorator_list
232
+ ):
233
+ return None
234
+ bindings = _scope_bindings(function)
235
+ available = helpers - bindings.keys()
236
+ # Looking through nested scopes here conservatively excludes captures and callable escapes.
237
+ uses = [node for node in ast.walk(function) if isinstance(node, ast.Name) and node.id in available]
238
+ if len(uses) != 1:
239
+ return None
240
+ body = _executable_body(function)
241
+ if not body:
242
+ return None
243
+ first = body[0]
244
+ setup = _plain_call(first)
245
+ if setup is None:
246
+ return None
247
+ if _helper_call(setup, available):
248
+ return _assigned_dependency_call(context, body, bindings, setup)
249
+ if not _unshadowed_consumer(context, setup, bindings):
250
+ return None
251
+ inline = [arg for arg in _arguments(setup) if isinstance(arg, ast.Call) and _helper_call(arg, available)]
252
+ return inline[0] if len(inline) == 1 else None
253
+
254
+
255
+ def _assigned_dependency_call(
256
+ context: PythonFileContext, body: list[ast.stmt], bindings: Counter[str], setup: ast.Call
257
+ ) -> ast.Call | None:
258
+ resource = _assignment_name(body[0])
259
+ if resource is None or bindings[resource] != 1 or len(body) < _SETUP_AND_CONSUMER:
260
+ return None
261
+ consumer = _plain_call(body[1])
262
+ if consumer is None or not _unshadowed_consumer(context, consumer, bindings):
263
+ return None
264
+ for argument in _arguments(consumer):
265
+ if isinstance(argument, ast.Name) and argument.id == resource:
266
+ return setup
267
+ return None
268
+
269
+
270
+ def _pytest_mark(context: PythonFileContext, decorator: ast.expr) -> bool:
271
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
272
+ qualified = context.module_imports.resolved_qualified_name(target)
273
+ tree = context.tree
274
+ root = _root_name(target)
275
+ if (
276
+ qualified is None
277
+ or not qualified.startswith("pytest.mark.")
278
+ or tree is None
279
+ or root is None
280
+ or _scope_bindings(tree)[root] != 1
281
+ ):
282
+ return False
283
+ for node in ast.walk(tree):
284
+ if not isinstance(node, (ast.Attribute, ast.Subscript)) or not isinstance(node.ctx, (ast.Store, ast.Del)):
285
+ continue
286
+ if isinstance(node, ast.Subscript) and _root_name(node.value) == root:
287
+ return False
288
+ changed = context.module_imports.resolved_qualified_name(node)
289
+ if changed is not None and (qualified == changed or qualified.startswith(f"{changed}.")):
290
+ return False
291
+ return True
292
+
293
+
294
+ def _unshadowed_consumer(context: PythonFileContext, call: ast.Call, bindings: Counter[str]) -> bool:
295
+ root = _root_name(call.func)
296
+ tree = context.tree
297
+ return (
298
+ root is not None
299
+ and root not in bindings
300
+ and tree is not None
301
+ and _scope_bindings(tree)[root] <= 1
302
+ and not _root_mutated(tree, root)
303
+ )
304
+
305
+
306
+ def _module_reference(tree: ast.Module, name: str) -> bool:
307
+ return any(
308
+ isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load)
309
+ for statement in tree.body
310
+ if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
311
+ for node in ast.walk(statement)
312
+ )
313
+
314
+
315
+ def _helper_call(call: ast.Call, helpers: frozenset[str] | set[str]) -> bool:
316
+ return isinstance(call.func, ast.Name) and call.func.id in helpers and not call.args and not call.keywords
317
+
318
+
319
+ def _arguments(call: ast.Call) -> Iterator[ast.expr]:
320
+ yield from call.args
321
+ yield from (keyword.value for keyword in call.keywords if keyword.arg is not None)
322
+
323
+
324
+ def _plain_call(statement: ast.stmt) -> ast.Call | None:
325
+ if (
326
+ isinstance(statement, (ast.Expr, ast.Assign, ast.AnnAssign))
327
+ and isinstance(statement.value, ast.Call)
328
+ and isinstance(statement.value.func, (ast.Name, ast.Attribute))
329
+ ):
330
+ return statement.value
331
+ return None
332
+
333
+
334
+ def _assignment_name(statement: ast.stmt) -> str | None:
335
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
336
+ return statement.targets[0].id
337
+ if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
338
+ return statement.target.id
339
+ return None
340
+
341
+
342
+ def _root_name(expression: ast.expr) -> str | None:
343
+ while isinstance(expression, ast.Attribute):
344
+ expression = expression.value
345
+ return expression.id if isinstance(expression, ast.Name) else None
346
+
347
+
348
+ def _executable_body(function: Function) -> list[ast.stmt]:
349
+ body = function.body
350
+ if (
351
+ body
352
+ and isinstance(body[0], ast.Expr)
353
+ and isinstance(body[0].value, ast.Constant)
354
+ and isinstance(body[0].value.value, str)
355
+ ):
356
+ return body[1:]
357
+ return body
358
+
359
+
360
+ def _scope_bindings(scope: ast.Module | Function) -> Counter[str]:
361
+ names: Counter[str] = Counter()
362
+ if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
363
+ arguments = scope.args
364
+ names.update(arg.arg for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs))
365
+ names.update(arg.arg for arg in (arguments.vararg, arguments.kwarg) if arg is not None)
366
+ for statement in scope.body:
367
+ _collect_bindings(statement, names)
368
+ if isinstance(scope, ast.Module):
369
+ names.update(_global_writes(scope))
370
+ return names
371
+
372
+
373
+ def _global_writes(tree: ast.Module) -> Counter[str]:
374
+ if not any(isinstance(node, ast.Global) for node in ast.walk(tree)):
375
+ return Counter()
376
+ writes: Counter[str] = Counter()
377
+ for node in ast.walk(tree):
378
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
379
+ continue
380
+ names: Counter[str] = Counter()
381
+ declarations: set[str] = set()
382
+ for statement in node.body:
383
+ _collect_bindings(statement, names)
384
+ declarations.update(_global_names(statement))
385
+ writes.update(dict.fromkeys(declarations & names.keys(), 2))
386
+ return writes
387
+
388
+
389
+ def _global_names(node: ast.AST) -> Iterator[str]:
390
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
391
+ return
392
+ if isinstance(node, ast.Global):
393
+ yield from node.names
394
+ for child in ast.iter_child_nodes(node):
395
+ yield from _global_names(child)
396
+
397
+
398
+ def _collect_bindings(node: ast.AST, names: Counter[str]) -> None:
399
+ match node:
400
+ case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) | ast.ClassDef(name=name):
401
+ names[name] += 1
402
+ for expression in _definition_expressions(node):
403
+ _collect_bindings(expression, names)
404
+ return
405
+ case ast.Lambda():
406
+ for expression in _definition_expressions(node):
407
+ _collect_bindings(expression, names)
408
+ return
409
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
410
+ names.update(alias.asname or alias.name.partition(".")[0] for alias in aliases)
411
+ case (
412
+ ast.Name(id=name, ctx=(ast.Store() | ast.Del()))
413
+ | ast.ExceptHandler(name=str(name))
414
+ | ast.MatchAs(name=str(name))
415
+ | ast.MatchStar(name=str(name))
416
+ | ast.MatchMapping(rest=str(name))
417
+ ):
418
+ names[name] += 1
419
+ case ast.comprehension():
420
+ _collect_bindings(node.iter, names)
421
+ for condition in node.ifs:
422
+ _collect_bindings(condition, names)
423
+ return
424
+ case _:
425
+ pass
426
+ for child in ast.iter_child_nodes(node):
427
+ _collect_bindings(child, names)
428
+
429
+
430
+ def _definition_expressions(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda) -> list[ast.AST]:
431
+ if isinstance(node, ast.ClassDef):
432
+ return [*node.decorator_list, *node.bases, *node.keywords]
433
+ defaults: list[ast.AST] = [
434
+ *node.args.defaults,
435
+ *(default for default in node.args.kw_defaults if default is not None),
436
+ ]
437
+ return defaults if isinstance(node, ast.Lambda) else [*node.decorator_list, *defaults]
438
+
439
+
440
+ def _has_wildcard(tree: ast.Module) -> bool:
441
+ return any(
442
+ isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names) for node in ast.walk(tree)
443
+ )
444
+
445
+
446
+ def _root_mutated(tree: ast.Module, root: str) -> bool:
447
+ return any(
448
+ isinstance(node, (ast.Attribute, ast.Subscript))
449
+ and isinstance(node.ctx, (ast.Store, ast.Del))
450
+ and _root_name(node.value) == root
451
+ for node in ast.walk(tree)
452
+ )