sarj-python-lint 1.8.2__tar.gz → 1.8.3__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 (164) hide show
  1. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_project_index.py +177 -0
  4. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_port_for_service.py +339 -1
  5. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/.gitignore +0 -0
  6. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/LICENSE +0 -0
  7. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/README.md +0 -0
  8. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_file_context.py +0 -0
  12. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/benchmark.py +0 -0
  18. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/json_boundary.py +0 -0
  19. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/ratchet.py +0 -0
  21. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rule_base.py +0 -0
  22. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/__init__.py +0 -0
  23. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  24. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  25. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  26. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_comments.py +0 -0
  27. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  28. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  29. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  30. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  31. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_first_party.py +0 -0
  32. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  33. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_imports.py +0 -0
  34. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_logging.py +0 -0
  35. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  36. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_paths.py +0 -0
  37. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  38. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_pytest.py +0 -0
  39. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_registry.py +0 -0
  40. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  41. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  42. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_sql.py +0 -0
  43. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  44. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  45. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  46. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  47. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  48. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  49. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  50. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
  51. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  52. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  53. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  54. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  55. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  56. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  57. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  58. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  59. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  60. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  61. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  62. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  63. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  64. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  65. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  66. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  67. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  68. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  69. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  70. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  71. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  72. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  73. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  74. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  75. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  76. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  77. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  78. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  79. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  80. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  81. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  82. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  83. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  84. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  85. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  86. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  87. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  88. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  89. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  90. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  91. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  92. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  93. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  94. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  95. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  96. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  97. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  98. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  99. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  100. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  101. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  102. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  103. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  104. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  105. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  106. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  107. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  108. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  109. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  110. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  111. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  112. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  113. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  114. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  115. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  116. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  117. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  118. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  119. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  120. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  121. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  122. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  123. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  124. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  125. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  126. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  127. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  128. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  129. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  130. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  131. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  132. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  133. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  134. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  135. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  136. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  137. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  138. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  139. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  140. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  141. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  142. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  143. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  144. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  145. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  146. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  147. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  148. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  149. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  150. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  151. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  152. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  153. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  154. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  155. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/stepdown.py +0 -0
  156. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  157. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  158. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  159. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  160. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  161. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  162. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  163. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  164. {sarj_python_lint-1.8.2 → sarj_python_lint-1.8.3}/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.8.2
3
+ Version: 1.8.3
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.8.2"
3
+ version = "1.8.3"
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 @@ import ast
4
4
  from collections.abc import Mapping
5
5
  from contextlib import suppress
6
6
  from dataclasses import dataclass
7
+ from functools import cached_property
7
8
  import os
8
9
  from pathlib import Path
9
10
  import re
@@ -12,6 +13,7 @@ from typing import TYPE_CHECKING, final
12
13
 
13
14
  from sarj_python_lint.rules._ast_index import walk as walk_ast
14
15
  from sarj_python_lint.rules._first_party import FirstPartyFacts, project_root
16
+ from sarj_python_lint.rules._paths import is_generated
15
17
 
16
18
 
17
19
  if TYPE_CHECKING:
@@ -179,6 +181,27 @@ class ProjectIndexSet:
179
181
  consumers.update(_unit_constructor_consumers(candidate, candidate.tree, target))
180
182
  return frozenset(consumers)
181
183
 
184
+ def constructor_operations(self, unit: SourceUnit, name: str) -> frozenset[tuple[Path, str]]:
185
+ if unit.module is None:
186
+ return frozenset()
187
+ return self._constructor_operations.get(SymbolRef(unit.module, name), frozenset())
188
+
189
+ @cached_property
190
+ def _constructor_operations(self) -> Mapping[SymbolRef, frozenset[tuple[Path, str]]]:
191
+ module_counts: dict[str, int] = {}
192
+ for unit in self._units.values():
193
+ if unit.module is not None:
194
+ module_counts[unit.module] = module_counts.get(unit.module, 0) + 1
195
+ owned_modules = frozenset(module for module, count in module_counts.items() if count == 1)
196
+ operations: dict[SymbolRef, set[tuple[Path, str]]] = {}
197
+ for unit in self._units.values():
198
+ if unit.tree is None or unit.module not in owned_modules or is_generated(unit.path, unit.source):
199
+ continue
200
+ for symbol, operation in _unit_constructor_operations(unit, unit.tree, owned_modules):
201
+ if symbol.module in owned_modules and symbol in self._classes:
202
+ operations.setdefault(symbol, set()).add((unit.path, operation))
203
+ return MappingProxyType({symbol: frozenset(calls) for symbol, calls in operations.items()})
204
+
182
205
  def direct_subclasses(self, unit: SourceUnit, name: str) -> frozenset[tuple[Path, str]]:
183
206
  if unit.module is None:
184
207
  return frozenset()
@@ -554,6 +577,160 @@ def _unit_constructor_consumers(
554
577
  yield (candidate.path, owner.name)
555
578
 
556
579
 
580
+ def _unit_constructor_operations(
581
+ unit: SourceUnit, tree: ast.Module, owned_modules: frozenset[str]
582
+ ) -> Iterator[tuple[SymbolRef, str]]:
583
+ if any(
584
+ isinstance(item, ast.ImportFrom) and any(alias.name == "*" for alias in item.names) for item in walk_ast(tree)
585
+ ):
586
+ return
587
+ rebound = _module_constructor_rebindings(tree)
588
+ for owner in tree.body:
589
+ self_symbol = SymbolRef(unit.module or "", owner.name) if isinstance(owner, ast.ClassDef) else None
590
+ for method in _direct_functions(owner):
591
+ for symbol, operation in _function_constructor_operations(unit, method, rebound, owned_modules):
592
+ if symbol != self_symbol:
593
+ yield symbol, operation
594
+
595
+
596
+ def _module_constructor_rebindings(tree: ast.Module) -> frozenset[str]:
597
+ names: set[str] = set()
598
+ declarations: dict[str, int] = {}
599
+ for statement in tree.body:
600
+ for name in _module_constructor_declarations(statement):
601
+ declarations[name] = declarations.get(name, 0) + 1
602
+ if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef):
603
+ names.add(statement.name)
604
+ elif not isinstance(statement, ast.ClassDef | ast.Import | ast.ImportFrom):
605
+ names.update(_bound_constructor_names(statement))
606
+ names.update(name for name, count in declarations.items() if count > 1)
607
+ return frozenset(names)
608
+
609
+
610
+ def _module_constructor_declarations(statement: ast.stmt) -> frozenset[str]:
611
+ if isinstance(statement, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
612
+ return frozenset({statement.name})
613
+ return _bound_constructor_names(statement)
614
+
615
+
616
+ def _direct_functions(owner: ast.stmt) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...]:
617
+ if isinstance(owner, ast.FunctionDef | ast.AsyncFunctionDef):
618
+ return (owner,)
619
+ if isinstance(owner, ast.ClassDef):
620
+ return tuple(item for item in owner.body if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef))
621
+ return ()
622
+
623
+
624
+ def _function_constructor_operations(
625
+ unit: SourceUnit,
626
+ method: ast.FunctionDef | ast.AsyncFunctionDef,
627
+ rebound: frozenset[str],
628
+ owned_modules: frozenset[str],
629
+ ) -> Iterator[tuple[SymbolRef, str]]:
630
+ shadowed = _bound_constructor_names(method) | rebound
631
+ for statement in method.body:
632
+ if isinstance(statement, ast.Expr | ast.Assign | ast.AnnAssign | ast.Return):
633
+ operation = _direct_constructor_operation(unit, statement.value, shadowed, owned_modules)
634
+ if operation is not None:
635
+ yield operation
636
+ if isinstance(
637
+ statement,
638
+ ast.Return
639
+ | ast.Raise
640
+ | ast.If
641
+ | ast.For
642
+ | ast.AsyncFor
643
+ | ast.While
644
+ | ast.Try
645
+ | ast.TryStar
646
+ | ast.With
647
+ | ast.AsyncWith
648
+ | ast.Match,
649
+ ):
650
+ break
651
+
652
+
653
+ def _direct_constructor_operation(
654
+ unit: SourceUnit, value: ast.expr | None, shadowed: frozenset[str], owned_modules: frozenset[str]
655
+ ) -> tuple[SymbolRef, str] | None:
656
+ if isinstance(value, ast.Await):
657
+ value = value.value
658
+ if not isinstance(value, ast.Call) or not isinstance(value.func, ast.Attribute):
659
+ return None
660
+ if not isinstance(value.func.value, ast.Call):
661
+ return None
662
+ constructor = value.func.value.func
663
+ root = constructor
664
+ while isinstance(root, ast.Attribute):
665
+ root = root.value
666
+ if not isinstance(root, ast.Name) or root.id in shadowed:
667
+ return None
668
+ symbol = _owned_constructor_symbol(unit, constructor, owned_modules)
669
+ return (symbol, value.func.attr) if symbol is not None else None
670
+
671
+
672
+ def _owned_constructor_symbol(
673
+ unit: SourceUnit, expression: ast.expr, owned_modules: frozenset[str]
674
+ ) -> SymbolRef | None:
675
+ if isinstance(expression, ast.Name):
676
+ return _resolve(unit, expression)
677
+ parts: list[str] = []
678
+ current = expression
679
+ while isinstance(current, ast.Attribute):
680
+ parts.append(current.attr)
681
+ current = current.value
682
+ if not isinstance(current, ast.Name) or not parts:
683
+ return None
684
+ imported = unit.imports.get(current.id)
685
+ if imported is None or imported.name:
686
+ return None
687
+ ordered = list(reversed(parts))
688
+ module = ".".join((imported.module, *ordered[:-1]))
689
+ if len(ordered) > 1 and not _explicit_constructor_module(unit, module):
690
+ return None
691
+ return SymbolRef(module, ordered[-1]) if module in owned_modules else None
692
+
693
+
694
+ def _explicit_constructor_module(unit: SourceUnit, module: str) -> bool:
695
+ if unit.tree is None:
696
+ return False
697
+ return any(
698
+ alias.name == module and alias.asname is None
699
+ for statement in _module_import_statements(unit.tree)
700
+ if isinstance(statement, ast.Import)
701
+ for alias in statement.names
702
+ )
703
+
704
+
705
+ def _bound_constructor_names(node: ast.AST) -> frozenset[str]:
706
+ names: set[str] = set()
707
+ for item in walk_ast(node):
708
+ match item:
709
+ case (
710
+ ast.Name(id=name, ctx=(ast.Store() | ast.Del()))
711
+ | ast.arg(arg=name)
712
+ | ast.ClassDef(name=name)
713
+ | ast.FunctionDef(name=name)
714
+ | ast.AsyncFunctionDef(name=name)
715
+ | ast.ExceptHandler(name=str(name))
716
+ | ast.MatchAs(name=str(name))
717
+ | ast.MatchStar(name=str(name))
718
+ | ast.MatchMapping(rest=str(name))
719
+ ):
720
+ names.add(name)
721
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
722
+ names.update(alias.asname or alias.name.split(".", 1)[0] for alias in aliases)
723
+ case ast.Attribute(ctx=(ast.Store() | ast.Del())):
724
+ root = item.value
725
+ while isinstance(root, ast.Attribute):
726
+ root = root.value
727
+ if isinstance(root, ast.Name):
728
+ names.add(root.id)
729
+ case _:
730
+ pass
731
+ return frozenset(names)
732
+
733
+
557
734
  def _class_has_typed_dependency(unit: SourceUnit, owner: ast.ClassDef, target: SymbolRef) -> bool:
558
735
  for statement in owner.body:
559
736
  if isinstance(statement, ast.AnnAssign) and _annotation_contains_symbol(unit, statement.annotation, target):
@@ -20,6 +20,7 @@ from sarj_python_lint.rule_base import (
20
20
  is_suppressed,
21
21
  )
22
22
  from sarj_python_lint.rules._ast_index import walk as walk_ast
23
+ from sarj_python_lint.rules._imports import ABC_SOURCES, TYPING_SOURCES
23
24
  from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
24
25
 
25
26
 
@@ -45,6 +46,13 @@ class _StoredParameters:
45
46
  fallback_stored: frozenset[str]
46
47
 
47
48
 
49
+ @dataclass(frozen=True, slots=True)
50
+ class _FactoryClassFacts:
51
+ owned_names: frozenset[str]
52
+ excluded_owners: frozenset[str]
53
+ excluded_collaborators: frozenset[str]
54
+
55
+
48
56
  # Classes named as the base of a family are the port being asked for, not a missing one.
49
57
  _BASE_NAME_RE = re.compile(r"^(?:Base|Abstract)[A-Z_]")
50
58
 
@@ -211,9 +219,84 @@ class RequirePortForService(ProjectRule):
211
219
  limitations=(
212
220
  "This advisory uses service-family names, constructor annotations, collaborator calls, and public-method counts as heuristics.",
213
221
  "Only direct module classes are checked; tests, generated code, scripts, framework callbacks, Store/Repository persistence dependencies, and external or interface-like bases are excluded.",
214
- "A one-method, suffixless, or store-backed class is checked only when project analysis proves a concrete production type dependency and a test subclass or typed mock, or at least two production consumers for a multi-method class. A port owned in another module may require an exact suppression on the implementation.",
222
+ "A suffixless class can also qualify when it retains a locally typed factory-created collaborator driving multiple public operations and production code constructs it directly to call at least two distinct operations. Constructor aliases and explicit module imports are resolved within the bounded project index; package-export indirection, wildcard imports, duplicate module identities, variable-held instances, factory indirection, and ambiguous control flow are not inferred. Top-level functions are analyzed in indexed modules; ordinary function-only modules may be excluded by the shared type index's lexical admission.",
223
+ "Otherwise, a one-method, suffixless, or store-backed class requires a concrete production type dependency and a test subclass or typed mock, or at least two production consumers for a multi-method class. A port owned in another module may require an exact suppression on the implementation.",
215
224
  ),
216
225
  examples=(
226
+ RuleExample(
227
+ example_id="factory-created-boundary",
228
+ scenario="factory-created-boundary",
229
+ title="A native collaborator is hidden behind a directly constructed concrete boundary",
230
+ outcome=ExampleOutcome.MATCH,
231
+ files=(
232
+ ExampleFile(
233
+ path=PurePosixPath("pyproject.toml"),
234
+ source='[project]\nname = "boundary-example"\nversion = "0.1.0"\n',
235
+ ),
236
+ ExampleFile.python(
237
+ "app/native.py",
238
+ "from typing import Protocol\n\n"
239
+ "class Backend(Protocol):\n"
240
+ " def read(self) -> str: ...\n"
241
+ " def write(self, value: str) -> None: ...\n\n"
242
+ "class Coordinator:\n"
243
+ " def __init__(self) -> None:\n"
244
+ " self.backend: Backend = native_backend()\n"
245
+ " def read(self) -> str:\n"
246
+ " return self.backend.read()\n"
247
+ " def write(self, value: str) -> None:\n"
248
+ " self.backend.write(value)\n\n"
249
+ "class Consumer:\n"
250
+ " def receive(self) -> str:\n"
251
+ " return Coordinator().read()\n"
252
+ " def send(self, value: str) -> None:\n"
253
+ " Coordinator().write(value)\n",
254
+ ),
255
+ ),
256
+ focus_path=PurePosixPath("app/native.py"),
257
+ expected_count=1,
258
+ public=True,
259
+ ),
260
+ RuleExample(
261
+ example_id="factory-created-public-port",
262
+ scenario="factory-created-boundary",
263
+ title="A typed factory exposes the consumer's public Protocol",
264
+ outcome=ExampleOutcome.NO_MATCH,
265
+ files=(
266
+ ExampleFile(
267
+ path=PurePosixPath("pyproject.toml"),
268
+ source='[project]\nname = "boundary-example"\nversion = "0.1.0"\n',
269
+ ),
270
+ ExampleFile.python(
271
+ "app/native.py",
272
+ "from typing import Protocol, final\n\n"
273
+ "class Backend(Protocol):\n"
274
+ " def read(self) -> str: ...\n"
275
+ " def write(self, value: str) -> None: ...\n\n"
276
+ "class Operations(Protocol):\n"
277
+ " def read(self) -> str: ...\n"
278
+ " def write(self, value: str) -> None: ...\n\n"
279
+ "@final\n"
280
+ "class Coordinator:\n"
281
+ " def __init__(self) -> None:\n"
282
+ " self.backend: Backend = native_backend()\n"
283
+ " def read(self) -> str:\n"
284
+ " return self.backend.read()\n"
285
+ " def write(self, value: str) -> None:\n"
286
+ " self.backend.write(value)\n\n"
287
+ "def coordinator() -> Operations:\n"
288
+ " return Coordinator()\n\n"
289
+ "class Consumer:\n"
290
+ " def receive(self) -> str:\n"
291
+ " return coordinator().read()\n"
292
+ " def send(self, value: str) -> None:\n"
293
+ " coordinator().write(value)\n",
294
+ ),
295
+ ),
296
+ focus_path=PurePosixPath("app/native.py"),
297
+ expected_count=0,
298
+ public=True,
299
+ ),
217
300
  RuleExample(
218
301
  example_id="concrete-service-boundary",
219
302
  title="Concrete service directly exposes an injected collaborator",
@@ -285,6 +368,7 @@ class RequirePortForService(ProjectRule):
285
368
  data_names = {node.name for node in classes if is_data_type(node)}
286
369
  local_class_names = {node.name for node in classes}
287
370
  local_port_names = _local_port_names(classes)
371
+ factory_facts = _factory_class_facts(tree, classes, context.module_imports)
288
372
 
289
373
  diags: list[Diagnostic] = []
290
374
  for node in classes:
@@ -298,6 +382,7 @@ class RequirePortForService(ProjectRule):
298
382
  local_class_names=local_class_names,
299
383
  local_port_names=local_port_names,
300
384
  imports=imports,
385
+ factory_facts=factory_facts,
301
386
  )
302
387
  if diagnostic is not None:
303
388
  diags.append(diagnostic)
@@ -316,6 +401,7 @@ class RequirePortForService(ProjectRule):
316
401
  local_class_names: set[str],
317
402
  local_port_names: set[str],
318
403
  imports: ImportIndex,
404
+ factory_facts: _FactoryClassFacts,
319
405
  ) -> Diagnostic | None:
320
406
  if _class_is_suppressed(node, source_lines, self.code):
321
407
  return None
@@ -329,14 +415,22 @@ class RequirePortForService(ProjectRule):
329
415
  test_subclass_count = self._test_subclass_count(path, node, indexes=indexes) if typed_consumer_count else 0
330
416
  test_mock_count = self._test_mock_count(path, node, indexes=indexes) if typed_consumer_count else 0
331
417
  substituted_boundary = typed_consumer_count >= 1 and (test_subclass_count + test_mock_count) >= 1
418
+ factory_boundary = (
419
+ self._factory_boundary(path, node, local_class_names, local_port_names, factory_facts, indexes=indexes)
420
+ if collaborator is None
421
+ else None
422
+ )
332
423
  if (
333
424
  collaborator is None
334
425
  and (consumer_count < _MIN_PROJECT_CONSUMERS or _public_method_count(node) < _MIN_PUBLIC_METHODS)
335
426
  and not substituted_boundary
427
+ and factory_boundary is None
336
428
  ):
337
429
  return None
338
430
  if collaborator is not None:
339
431
  evidence = f"injects `{collaborator}` and exposes {_public_method_count(node)} public methods"
432
+ elif factory_boundary is not None:
433
+ evidence = f"retains factory-created `{factory_boundary}` and is constructed directly to invoke multiple public operations"
340
434
  elif substituted_boundary:
341
435
  evidence = (
342
436
  f"is typed directly by {typed_consumer_count} production classes and has "
@@ -357,6 +451,49 @@ class RequirePortForService(ProjectRule):
357
451
  ),
358
452
  )
359
453
 
454
+ def _factory_boundary(
455
+ self,
456
+ path: Path,
457
+ node: ast.ClassDef,
458
+ local_class_names: set[str],
459
+ local_port_names: set[str],
460
+ factory_facts: _FactoryClassFacts,
461
+ *,
462
+ indexes: ProjectIndexSet | None,
463
+ ) -> str | None:
464
+ if (
465
+ indexes is None
466
+ or node.name not in factory_facts.owned_names
467
+ or node.name in factory_facts.excluded_owners
468
+ or not _project_boundary_candidate(node, local_class_names, local_port_names)
469
+ ):
470
+ return None
471
+ collaborator = _factory_collaborator(node, factory_facts)
472
+ if collaborator is None:
473
+ return None
474
+ unit = indexes.unit(path)
475
+ if unit is None:
476
+ return None
477
+ public_methods = {
478
+ method.name
479
+ for method in class_methods(node)
480
+ if not method.name.startswith("_")
481
+ and not any(_dotted_tail(decorator) in _NON_METHOD_DECORATORS for decorator in method.decorator_list)
482
+ }
483
+ operations = {
484
+ operation
485
+ for consumer_path, operation in indexes.constructor_operations(unit, node.name)
486
+ if operation in public_methods and self._factory_library_consumer(consumer_path, indexes)
487
+ }
488
+ return collaborator if len(operations) >= _MIN_COLLABORATOR_METHODS else None
489
+
490
+ @staticmethod
491
+ def _factory_library_consumer(path: Path, indexes: ProjectIndexSet) -> bool:
492
+ unit = indexes.unit(path)
493
+ return (
494
+ _is_library_source(path) and unit is not None and unit.tree is not None and not _has_main_guard(unit.tree)
495
+ )
496
+
360
497
  def _concrete_consumer_count(
361
498
  self,
362
499
  path: Path,
@@ -649,6 +786,207 @@ def _injected_collaborator(
649
786
  return _behavioral_collaborator(node, candidates)
650
787
 
651
788
 
789
+ def _factory_class_facts(tree: ast.Module, classes: list[ast.ClassDef], imports: ImportIndex) -> _FactoryClassFacts:
790
+ local_names = {node.name for node in classes}
791
+ declarations: dict[str, int] = {}
792
+ for statement in tree.body:
793
+ for name in _factory_statement_bindings(statement):
794
+ declarations[name] = declarations.get(name, 0) + 1
795
+ owned_names: frozenset[str] = frozenset(name for name in local_names if declarations.get(name) == 1)
796
+ if any(
797
+ isinstance(item, ast.ImportFrom) and any(alias.name == "*" for alias in item.names) for item in walk_ast(tree)
798
+ ):
799
+ owned_names = frozenset()
800
+ interfaces = {node.name for node in classes if _factory_interface(node, imports)}
801
+ values = {node.name for node in classes if _factory_value_class(node, imports, local_names)}
802
+ _inherit_factory_exclusions(classes, interfaces)
803
+ _inherit_factory_exclusions(classes, values)
804
+ return _FactoryClassFacts(owned_names, frozenset(interfaces | values), frozenset(values))
805
+
806
+
807
+ def _factory_interface(node: ast.ClassDef, imports: ImportIndex) -> bool:
808
+ return any(
809
+ imports.resolves(base, sources=TYPING_SOURCES, symbol="Protocol")
810
+ or imports.resolves(base, sources=ABC_SOURCES, symbol="ABC")
811
+ for base in node.bases
812
+ ) or any(
813
+ imports.resolved_symbol(decorator, sources=ABC_SOURCES) in _INTERFACE_DECORATORS
814
+ for method in class_methods(node)
815
+ for decorator in method.decorator_list
816
+ )
817
+
818
+
819
+ def _factory_value_class(node: ast.ClassDef, imports: ImportIndex, local_names: set[str]) -> bool:
820
+ if is_data_type(node):
821
+ return True
822
+ if any(
823
+ imports.resolved_symbol(
824
+ decorator.func if isinstance(decorator, ast.Call) else decorator,
825
+ sources=frozenset({"dataclasses", "attrs", "attr"}),
826
+ )
827
+ in _DATA_DECORATORS
828
+ for decorator in node.decorator_list
829
+ ):
830
+ return True
831
+ return any(_factory_value_base(base, imports, local_names) for base in node.bases)
832
+
833
+
834
+ def _factory_value_base(base: ast.expr, imports: ImportIndex, local_names: set[str]) -> bool:
835
+ if (
836
+ imports.resolved_symbol(
837
+ base, sources=frozenset({"pydantic", "typing", "typing_extensions", "enum", "msgspec", "builtins"})
838
+ )
839
+ in _DATA_BASES
840
+ ):
841
+ return True
842
+ if any(imports.resolves(base, sources=TYPING_SOURCES, symbol=name) for name in ("Protocol", "Generic")):
843
+ return False
844
+ if imports.resolves(base, sources=ABC_SOURCES, symbol="ABC"):
845
+ return False
846
+ return _dotted_tail(base) not in local_names | {"object", "Generic", "Protocol", "ABC", None}
847
+
848
+
849
+ def _inherit_factory_exclusions(classes: list[ast.ClassDef], excluded: set[str]) -> None:
850
+ while grown := {
851
+ node.name
852
+ for node in classes
853
+ if node.name not in excluded and any(_dotted_tail(base) in excluded for base in node.bases)
854
+ }:
855
+ excluded.update(grown)
856
+
857
+
858
+ def _factory_statement_bindings(statement: ast.stmt) -> frozenset[str]:
859
+ names: set[str] = set()
860
+ stack: list[ast.AST] = [statement]
861
+ while stack:
862
+ node = stack.pop()
863
+ match node:
864
+ case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name) | ast.ClassDef(name=name):
865
+ names.add(name)
866
+ continue
867
+ case ast.AnnAssign(value=None):
868
+ continue
869
+ case (
870
+ ast.Name(id=name, ctx=(ast.Store() | ast.Del()))
871
+ | ast.ExceptHandler(name=str(name))
872
+ | ast.MatchAs(name=str(name))
873
+ | ast.MatchStar(name=str(name))
874
+ | ast.MatchMapping(rest=str(name))
875
+ ):
876
+ names.add(name)
877
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
878
+ names.update(alias.asname or alias.name.partition(".")[0] for alias in aliases)
879
+ case _:
880
+ pass
881
+ stack.extend(ast.iter_child_nodes(node))
882
+ return frozenset(names)
883
+
884
+
885
+ def _factory_collaborator(node: ast.ClassDef, facts: _FactoryClassFacts) -> str | None:
886
+ init = next((method for method in class_methods(node) if method.name == "__init__"), None)
887
+ if init is None:
888
+ return None
889
+ origins: dict[str, ast.expr | None] = {}
890
+ candidates: list[tuple[str, frozenset[str]]] = []
891
+ writes = _constructor_field_writes(init)
892
+ available_names = set(facts.owned_names)
893
+ available_names.difference_update(
894
+ parameter.arg for parameter in (*init.args.posonlyargs, *init.args.args, *init.args.kwonlyargs)
895
+ )
896
+ for statement in init.body:
897
+ if isinstance(statement, ast.Raise):
898
+ return None
899
+ if isinstance(statement, ast.Return):
900
+ break
901
+ if not _factory_falls_through(statement):
902
+ return None
903
+ available_names.difference_update(_factory_statement_bindings(statement))
904
+ if not _update_factory_origins(statement, origins):
905
+ return None
906
+ candidate = _factory_field_candidate(statement, origins, writes, facts.excluded_collaborators, available_names)
907
+ if candidate is not None:
908
+ candidates.append(candidate)
909
+ return _behavioral_collaborator(node, candidates)
910
+
911
+
912
+ def _factory_falls_through(statement: ast.stmt) -> bool:
913
+ if isinstance(statement, (ast.Return, ast.Raise, ast.While, *_UNSUPPORTED_COMPOUND_STATEMENTS)):
914
+ return False
915
+ if not isinstance(statement, ast.If):
916
+ return True
917
+ truth = _static_truth(statement.test)
918
+ if truth is not None:
919
+ return _factory_statements_fall_through(statement.body if truth else statement.orelse)
920
+ return _factory_statements_fall_through(statement.body) or _factory_statements_fall_through(statement.orelse)
921
+
922
+
923
+ def _factory_statements_fall_through(statements: list[ast.stmt]) -> bool:
924
+ return all(_factory_falls_through(statement) for statement in statements)
925
+
926
+
927
+ def _constructor_field_writes(init: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, int]:
928
+ writes: dict[str, int] = {}
929
+ for statement in walk_ast(init):
930
+ if (
931
+ isinstance(statement, ast.Attribute)
932
+ and isinstance(statement.ctx, ast.Store | ast.Del)
933
+ and isinstance(statement.value, ast.Name)
934
+ and statement.value.id == "self"
935
+ ):
936
+ writes[statement.attr] = writes.get(statement.attr, 0) + 1
937
+ return writes
938
+
939
+
940
+ def _update_factory_origins(statement: ast.stmt, origins: dict[str, ast.expr | None]) -> bool:
941
+ value = statement.value if isinstance(statement, ast.Assign | ast.AnnAssign) else None
942
+ snapshot = origins.get(value.id) if isinstance(value, ast.Name) else value
943
+ for name in _factory_statement_bindings(statement):
944
+ origins.pop(name, None)
945
+ if isinstance(statement, ast.Assign | ast.AnnAssign):
946
+ if statement.value is None:
947
+ return True
948
+ targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
949
+ for target in targets:
950
+ if not isinstance(target, ast.Name):
951
+ continue
952
+ origins[target.id] = snapshot
953
+ elif isinstance(statement, (ast.If, ast.While, *_UNSUPPORTED_COMPOUND_STATEMENTS)):
954
+ for item in walk_ast(statement):
955
+ if isinstance(item, ast.Return):
956
+ return False
957
+ return True
958
+
959
+
960
+ def _factory_field_candidate(
961
+ statement: ast.stmt,
962
+ origins: dict[str, ast.expr | None],
963
+ writes: dict[str, int],
964
+ data_names: frozenset[str],
965
+ local_class_names: set[str],
966
+ ) -> tuple[str, frozenset[str]] | None:
967
+ if not isinstance(statement, ast.AnnAssign):
968
+ return None
969
+ assignment = _self_field_assignment(statement)
970
+ annotation = annotation_tail(statement.annotation)
971
+ if assignment is None or annotation is None or annotation not in local_class_names:
972
+ return None
973
+ if any(isinstance(item, ast.Attribute) and item.attr == annotation for item in walk_ast(statement.annotation)):
974
+ return None
975
+ if (
976
+ annotation in data_names
977
+ or annotation in _DRIVER_HANDLE_ANNOTATIONS
978
+ or _PERSISTENCE_DEPENDENCY_RE.search(annotation)
979
+ or _WEAK_COLLABORATOR_RE.search(annotation)
980
+ or _annotation_allows_none(statement.annotation)
981
+ ):
982
+ return None
983
+ fields, value = assignment
984
+ origin = value if isinstance(value, ast.Call) else origins.get(value.id) if isinstance(value, ast.Name) else None
985
+ if not isinstance(origin, ast.Call) or any(writes[field] != 1 for field in fields):
986
+ return None
987
+ return annotation, frozenset(fields)
988
+
989
+
652
990
  def self_stored_parameters(
653
991
  init: ast.FunctionDef | ast.AsyncFunctionDef,
654
992
  imports: ImportIndex,