sarj-python-lint 1.46.3__tar.gz → 1.47.0__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 (216) hide show
  1. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/pyproject.toml +1 -1
  3. sarj_python_lint-1.47.0/src/sarj_python_lint/rules/_data_union_discriminator.py +404 -0
  4. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_discriminated_model_union.py +51 -8
  5. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/.gitignore +0 -0
  6. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/LICENSE +0 -0
  7. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/README.md +0 -0
  8. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_file_context.py +0 -0
  12. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_source.py +0 -0
  17. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/benchmark.py +0 -0
  19. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  20. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/json_boundary.py +0 -0
  21. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rule_base.py +0 -0
  24. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  25. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  26. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  28. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_closed_helpers.py +0 -0
  29. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  30. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  31. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  32. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  33. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  34. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_exports.py +0 -0
  35. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  36. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  37. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  38. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  39. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_inert_model_annotation.py +0 -0
  40. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_inherited_contract.py +0 -0
  41. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  42. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  43. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_namespace_access.py +0 -0
  44. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_native_namespace_capability.py +0 -0
  45. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  46. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  47. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  48. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  49. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_pydantic_field_contract.py +0 -0
  50. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  51. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  52. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_resolved_operation_signature.py +0 -0
  53. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  54. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  55. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  56. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_static_json_alias.py +0 -0
  57. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  58. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  59. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  60. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  61. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/_type_guard_symbols.py +0 -0
  62. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  63. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  64. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  65. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  66. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  67. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  68. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +0 -0
  69. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +0 -0
  70. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  71. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  72. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  73. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  74. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  75. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  76. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  77. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  78. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  79. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  80. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  81. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_async_function_cache.py +0 -0
  82. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  83. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  84. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  85. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_conflicting_pydantic_alias_validation.py +0 -0
  86. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  87. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_contradictory_type_guard.py +0 -0
  88. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  89. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  90. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  91. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  92. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  93. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  94. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  95. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  96. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  97. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  98. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  99. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
  100. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  101. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  102. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  103. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_impossible_pydantic_field_bounds.py +0 -0
  104. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  105. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  106. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  107. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  108. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_invalid_pydantic_bypass.py +0 -0
  109. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_invalid_pydantic_validator_return.py +0 -0
  110. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_loguru_exc_info.py +0 -0
  111. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_loguru_printf_format.py +0 -0
  112. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  113. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  114. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  115. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  116. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_partial_type_is.py +0 -0
  117. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  118. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  119. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  120. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  121. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  122. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  123. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  124. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  125. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  126. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  127. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  128. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  129. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  130. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  131. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_shallow_record_type_guard.py +0 -0
  132. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  133. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  134. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  135. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  136. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  137. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  138. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unknown_pydantic_validator_field.py +0 -0
  139. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  140. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  141. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  142. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  143. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  144. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  145. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  146. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  147. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  148. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  149. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  150. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  151. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  152. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_declarative_batch_validation.py +0 -0
  153. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  154. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  155. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  156. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  157. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  158. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  159. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  160. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  161. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  162. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  163. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  164. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  165. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  166. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  167. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  168. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  169. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  170. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  171. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  172. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  173. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  174. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  175. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  176. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  177. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  178. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  179. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  180. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  181. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  182. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  183. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  184. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  185. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  186. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  187. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +0 -0
  188. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  189. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  190. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  191. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +0 -0
  192. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  193. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  194. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  195. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
  196. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  197. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  198. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  199. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  200. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  201. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  202. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  203. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  204. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  205. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  206. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  207. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  208. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  209. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  210. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/test_without_explicit_oracle.py +0 -0
  211. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  212. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  213. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  214. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  215. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  216. {sarj_python_lint-1.46.3 → sarj_python_lint-1.47.0}/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.46.3
3
+ Version: 1.47.0
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.46.3"
3
+ version = "1.47.0"
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,404 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
8
+ from sarj_python_lint.rules._imports import TYPING_SOURCES
9
+ from sarj_python_lint.rules._pydantic_field_contract import MODEL_SOURCES, unique_members
10
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from sarj_python_lint._file_context import PythonFileContext
15
+
16
+
17
+ _DATACLASS_SOURCES = frozenset({"dataclasses"})
18
+ _MAX_EXPANSION = 128
19
+ _MIN_VARIANTS = 2
20
+
21
+ type _Tag = str | bytes | int | None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class _Record:
26
+ fields: dict[str, ast.AnnAssign]
27
+ required: bool
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _Domain:
32
+ values: frozenset[_Tag] = frozenset()
33
+ opaque: bool = False
34
+ unresolved: bool = False
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _Expansion:
39
+ node: ast.expr
40
+ aliases: frozenset[str] = frozenset()
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class _Budget:
45
+ remaining: int = _MAX_EXPANSION
46
+
47
+
48
+ def data_union_diagnostics(context: PythonFileContext, code: str) -> list[Diagnostic]:
49
+ if not any(symbol in context.symbol_source for symbol in ("dataclass", "BaseModel", "TypedDict")):
50
+ return []
51
+ tree = context.tree
52
+ if tree is None or context.generated or context.has_wildcard_import:
53
+ return []
54
+ if not any(
55
+ value is not None and _union_expression(value, context)
56
+ for node in tree.body
57
+ for value in (_alias_value(node, context),)
58
+ ):
59
+ return []
60
+ analysis = _DataUnions(context, tree)
61
+ if analysis.provenance.dynamic_namespace:
62
+ return []
63
+ return analysis.diagnostics(code)
64
+
65
+
66
+ @final
67
+ class _DataUnions:
68
+ def __init__(self, context: PythonFileContext, tree: ast.Module) -> None:
69
+ self.context = context
70
+ self.provenance = ResourceProvenance(context, require_static_namespace=True)
71
+ bindings = self.provenance.bindings.get(tree, {})
72
+ self.classes = {
73
+ node.name: node
74
+ for node in tree.body
75
+ if isinstance(node, ast.ClassDef)
76
+ and len(bindings.get(node.name, ())) == 1
77
+ and node.name not in self.provenance.mutated
78
+ }
79
+ self.aliases: dict[str, ast.expr] = {}
80
+ self.declarations: dict[str, ast.stmt] = {}
81
+ for node in tree.body:
82
+ value = _alias_value(node, context)
83
+ name = _alias_name(node)
84
+ if value is not None and name is not None and len(bindings.get(name, ())) == 1:
85
+ self.aliases[name] = value
86
+ self.declarations[name] = node
87
+ self.records: dict[str, _Record | None] = {}
88
+
89
+ def diagnostics(self, code: str) -> list[Diagnostic]:
90
+ findings: list[Diagnostic] = []
91
+ for name, value in self.aliases.items():
92
+ node = self.declarations[name]
93
+ if is_suppressed(self.context.source_lines, node.lineno, code) or not self._untagged_union(value):
94
+ continue
95
+ findings.append(
96
+ Diagnostic(
97
+ path=self.context.path,
98
+ line=node.lineno,
99
+ col=node.col_offset + 1,
100
+ code=code,
101
+ message=f"Data union `{name}` has no shared disjoint Literal discriminator. "
102
+ "Add a common field such as `kind` or `scope` with non-overlapping Literal values "
103
+ "on every variant; preserve constructor and serialized contracts when choosing defaults.",
104
+ severity=Severity.WARNING,
105
+ )
106
+ )
107
+ return findings
108
+
109
+ def _untagged_union(self, value: ast.expr) -> bool:
110
+ if not _union_expression(value, self.context):
111
+ return False
112
+ variants = self._variants(value)
113
+ if variants is None or len(variants) < _MIN_VARIANTS:
114
+ return False
115
+ records: list[_Record] = []
116
+ for variant in sorted(variants):
117
+ record = self._record(variant)
118
+ if record is None:
119
+ return False
120
+ records.append(record)
121
+ return not self._has_discriminator(records, _Budget())
122
+
123
+ def _typing_symbol(self, node: ast.expr) -> str | None:
124
+ if not _untouched(node, self.provenance):
125
+ return None
126
+ return self.context.imports.resolved_symbol(node, sources=TYPING_SOURCES)
127
+
128
+ def _variants(self, expression: ast.expr) -> set[str] | None:
129
+ leaves = self._leaves(expression)
130
+ if leaves is None:
131
+ return None
132
+ variants: set[str] = set()
133
+ for node in leaves:
134
+ if isinstance(node, ast.Constant) and node.value is None:
135
+ continue
136
+ if not isinstance(node, ast.Name) or node.id not in self.classes:
137
+ return None
138
+ variants.add(node.id)
139
+ return variants
140
+
141
+ def _leaves(self, expression: ast.expr) -> list[ast.expr] | None:
142
+ pending = [_Expansion(expression)]
143
+ leaves: list[ast.expr] = []
144
+ for _ in range(_MAX_EXPANSION):
145
+ if not pending:
146
+ return leaves
147
+ item = pending.pop()
148
+ node = _string_expression(item.node)
149
+ if node is None:
150
+ return None
151
+ if isinstance(node, ast.Name) and node.id in self.aliases:
152
+ if node.id in item.aliases:
153
+ return None
154
+ pending.append(_Expansion(self.aliases[node.id], item.aliases | {node.id}))
155
+ else:
156
+ children = self._union_members(node)
157
+ if children is None:
158
+ leaves.append(node)
159
+ else:
160
+ pending.extend(_Expansion(child, item.aliases) for child in children)
161
+ return None
162
+
163
+ def _union_members(self, node: ast.expr) -> tuple[ast.expr, ...] | None:
164
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
165
+ return node.left, node.right
166
+ if not isinstance(node, ast.Subscript):
167
+ return None
168
+ symbol = self._typing_symbol(node.value)
169
+ if symbol == "Union" and isinstance(node.slice, ast.Tuple):
170
+ return tuple(node.slice.elts)
171
+ if symbol == "Optional":
172
+ return node.slice, ast.Constant(value=None)
173
+ if symbol == "Annotated" and isinstance(node.slice, ast.Tuple) and node.slice.elts:
174
+ return (node.slice.elts[0],)
175
+ return None
176
+
177
+ def _record(self, name: str) -> _Record | None:
178
+ if name not in self.records:
179
+ self.records[name] = _record(self.classes[name], self.context, self.provenance)
180
+ return self.records[name]
181
+
182
+ def _has_discriminator(self, records: list[_Record], budget: _Budget) -> bool:
183
+ if budget.remaining <= 0:
184
+ return True
185
+ budget.remaining -= 1
186
+ common = set(records[0].fields).intersection(*(record.fields for record in records[1:]))
187
+ common.difference_update({name for name in common if name.startswith("__")})
188
+ uncertain = False
189
+ for name in sorted(common):
190
+ domains = [self._field_domain(record.fields[name], required=record.required) for record in records]
191
+ uncertain |= _uncertain_domains(domains)
192
+ groups = _tag_groups(records, domains)
193
+ if groups and all(len(group) == 1 or self._has_discriminator(group, budget) for group in groups):
194
+ return True
195
+ return uncertain
196
+
197
+ def _field_domain(self, field: ast.AnnAssign, *, required: bool) -> _Domain:
198
+ node = field.annotation
199
+ for _ in range(_MAX_EXPANSION):
200
+ parsed = _string_expression(node)
201
+ if parsed is None:
202
+ return _Domain(opaque=True)
203
+ node = parsed
204
+ if not isinstance(node, ast.Subscript):
205
+ break
206
+ symbol = self._typing_symbol(node.value)
207
+ if symbol in {"Required", "NotRequired"}:
208
+ required = symbol == "Required"
209
+ node = node.slice
210
+ elif symbol == "ClassVar":
211
+ if field.value is None:
212
+ return _Domain()
213
+ node = node.slice
214
+ elif symbol == "ReadOnly":
215
+ node = node.slice
216
+ elif symbol == "Annotated" and isinstance(node.slice, ast.Tuple) and node.slice.elts:
217
+ node = node.slice.elts[0]
218
+ else:
219
+ break
220
+ return self._literal_domain(node) if required else _Domain()
221
+
222
+ def _literal_domain(self, expression: ast.expr) -> _Domain:
223
+ leaves = self._leaves(expression)
224
+ if leaves is None:
225
+ return _Domain(opaque=True)
226
+ domains = [self._leaf_domain(node) for node in leaves]
227
+ if any(domain.opaque for domain in domains):
228
+ return _Domain(opaque=True)
229
+ if any(domain.unresolved for domain in domains):
230
+ return _Domain(unresolved=True)
231
+ if any(not domain.values for domain in domains):
232
+ return _Domain()
233
+ return _Domain(frozenset(value for domain in domains for value in domain.values))
234
+
235
+ def _leaf_domain(self, node: ast.expr) -> _Domain:
236
+ if isinstance(node, ast.Constant) and node.value is None:
237
+ return _Domain(frozenset({None}))
238
+ if isinstance(node, ast.Name) and (
239
+ node.id in self.classes or self.context.imports.resolved_qualified_name(node) is not None
240
+ ):
241
+ return _Domain(unresolved=True)
242
+ if not isinstance(node, ast.Subscript):
243
+ return _Domain()
244
+ symbol = self._typing_symbol(node.value)
245
+ if symbol == "Literal":
246
+ return _literal_values(node)
247
+ if symbol is not None or (
248
+ isinstance(node.value, ast.Name)
249
+ and node.value.id in {"list", "dict", "tuple", "set", "frozenset", "type"}
250
+ and self.context.imports.builtin_is_unshadowed(node.value.id)
251
+ ):
252
+ return _Domain()
253
+ return _Domain(opaque=True)
254
+
255
+
256
+ def _record(node: ast.ClassDef, context: PythonFileContext, provenance: ResourceProvenance) -> _Record | None:
257
+ required = _record_required(node, context, provenance)
258
+ if required is None:
259
+ return None
260
+ fields: dict[str, ast.AnnAssign] = {}
261
+ unique = unique_members(node)
262
+ for member in node.body:
263
+ if isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name):
264
+ if member.target.id not in unique:
265
+ return None
266
+ fields[member.target.id] = member
267
+ elif isinstance(member, ast.Pass | ast.Expr) and (
268
+ isinstance(member, ast.Pass)
269
+ or (isinstance(member.value, ast.Constant) and isinstance(member.value.value, str))
270
+ ):
271
+ continue
272
+ else:
273
+ return None
274
+ return _Record(fields, required)
275
+
276
+
277
+ def _record_required(node: ast.ClassDef, context: PythonFileContext, provenance: ResourceProvenance) -> bool | None:
278
+ if node.type_params:
279
+ return None
280
+ if any(not _untouched(base, provenance) for base in node.bases):
281
+ return None
282
+ if node.decorator_list:
283
+ return _direct_dataclass(node, context, provenance)
284
+ if len(node.bases) != 1:
285
+ return None
286
+ if context.imports.resolves(node.bases[0], sources=MODEL_SOURCES, symbol="BaseModel"):
287
+ return True if not node.keywords else None
288
+ if not context.imports.resolves(node.bases[0], sources=TYPING_SOURCES, symbol="TypedDict"):
289
+ return None
290
+ required = True
291
+ for keyword in node.keywords:
292
+ if keyword.arg != "total" or not isinstance(keyword.value, ast.Constant):
293
+ return None
294
+ if not isinstance(keyword.value.value, bool):
295
+ return None
296
+ required = keyword.value.value
297
+ return required
298
+
299
+
300
+ def _direct_dataclass(node: ast.ClassDef, context: PythonFileContext, provenance: ResourceProvenance) -> bool | None:
301
+ if node.bases or node.keywords or len(node.decorator_list) != 1:
302
+ return None
303
+ decorator = node.decorator_list[0]
304
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
305
+ if not _untouched(target, provenance) or not context.imports.resolves(
306
+ target, sources=_DATACLASS_SOURCES, symbol="dataclass"
307
+ ):
308
+ return None
309
+ return True
310
+
311
+
312
+ def _literal_values(node: ast.Subscript) -> _Domain:
313
+ values: set[_Tag] = set()
314
+ members = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
315
+ for member in members:
316
+ match member:
317
+ case ast.Constant(value=(str() | bytes() | int()) as value):
318
+ values.add(value)
319
+ case ast.Constant(value=None):
320
+ values.add(None)
321
+ case ast.UnaryOp(op=op, operand=ast.Constant(value=int() as number)) if (
322
+ isinstance(op, ast.USub | ast.UAdd) and type(number) is int
323
+ ):
324
+ values.add(-number if isinstance(op, ast.USub) else number)
325
+ case _:
326
+ return _Domain(opaque=True)
327
+ return _Domain(frozenset(values))
328
+
329
+
330
+ def _tag_groups(records: list[_Record], domains: list[_Domain]) -> list[list[_Record]]:
331
+ if any(not domain.values or domain.opaque for domain in domains):
332
+ return []
333
+ groups: dict[_Tag, list[_Record]] = {}
334
+ for record, domain in zip(records, domains, strict=True):
335
+ for value in sorted(domain.values, key=repr):
336
+ groups.setdefault(value, []).append(record)
337
+ if any(len(group) == len(records) for group in groups.values()):
338
+ return []
339
+ return list(groups.values())
340
+
341
+
342
+ def _uncertain_domains(domains: list[_Domain]) -> bool:
343
+ return any(domain.opaque for domain in domains) or (
344
+ any(domain.values for domain in domains) and any(domain.unresolved for domain in domains)
345
+ )
346
+
347
+
348
+ def _alias_name(node: ast.stmt) -> str | None:
349
+ match node:
350
+ case ast.TypeAlias(name=ast.Name(id=name)) | ast.AnnAssign(target=ast.Name(id=name)):
351
+ return name
352
+ case ast.Assign(targets=[ast.Name(id=name)]):
353
+ return name
354
+ case _:
355
+ return None
356
+
357
+
358
+ def _alias_value(node: ast.stmt, context: PythonFileContext) -> ast.expr | None:
359
+ match node:
360
+ case ast.TypeAlias(type_params=[]):
361
+ return node.value
362
+ case ast.AnnAssign(target=ast.Name(), annotation=annotation, value=value):
363
+ return value if context.imports.resolves(annotation, sources=TYPING_SOURCES, symbol="TypeAlias") else None
364
+ case ast.Assign(targets=[ast.Name()], value=value):
365
+ return None if isinstance(value, ast.Constant) and isinstance(value.value, str) else value
366
+ case _:
367
+ return None
368
+
369
+
370
+ def _string_expression(node: ast.expr) -> ast.expr | None:
371
+ if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
372
+ return node
373
+ try:
374
+ return ast.parse(node.value, mode="eval").body
375
+ except SyntaxError, UnicodeError, RecursionError:
376
+ return None
377
+
378
+
379
+ def _untouched(node: ast.expr, provenance: ResourceProvenance) -> bool:
380
+ while isinstance(node, ast.Attribute):
381
+ node = node.value
382
+ return isinstance(node, ast.Name) and node.id not in provenance.mutated
383
+
384
+
385
+ def _union_expression(node: ast.expr, context: PythonFileContext) -> bool:
386
+ for _ in range(_MAX_EXPANSION):
387
+ parsed = _string_expression(node)
388
+ if parsed is None:
389
+ return False
390
+ node = parsed
391
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
392
+ return True
393
+ if not isinstance(node, ast.Subscript):
394
+ return False
395
+ symbol = context.imports.resolved_symbol(node.value, sources=TYPING_SOURCES)
396
+ if symbol == "Union":
397
+ return True
398
+ if symbol == "Optional":
399
+ node = node.slice
400
+ elif symbol == "Annotated" and isinstance(node.slice, ast.Tuple) and node.slice.elts:
401
+ node = node.slice.elts[0]
402
+ else:
403
+ return False
404
+ return False
@@ -17,6 +17,7 @@ from sarj_python_lint.rule_base import (
17
17
  Severity,
18
18
  is_suppressed,
19
19
  )
20
+ from sarj_python_lint.rules._data_union_discriminator import data_union_diagnostics
20
21
  from sarj_python_lint.rules._paths import is_test_path
21
22
  from sarj_python_lint.rules._pydantic_field_contract import (
22
23
  DECORATOR_SOURCES,
@@ -87,13 +88,16 @@ class PreferDiscriminatedModelUnion(Rule):
87
88
  code = "SARJ510"
88
89
  documentation = RuleDocumentation(
89
90
  default_level=Severity.WARNING,
90
- summary="Represent literal-tag-dependent required fields with discriminated model variants.",
91
+ summary="Give data unions explicit Literal discriminators and express tag-dependent payloads as variants.",
91
92
  rationale=(
93
+ "An explicit shared tag identifies data variants and supports field-based narrowing. "
92
94
  "A nullable field remains statically nullable even when an after validator requires it for a literal "
93
95
  "tag. A union of models with distinct Literal tags carries that dependency in the type and supports "
94
96
  "safe narrowing without repeating runtime guards."
95
97
  ),
96
98
  remediation=(
99
+ "Give every data variant a common field with disjoint Literal values. For dataclasses, "
100
+ "field(default=..., init=False) can preserve existing constructor calls; review serialized contracts. "
97
101
  "Consider a discriminated union of model variants. Make the payload non-nullable only in variants "
98
102
  "proven to reject None, and preserve every other accepted field combination, defaults, aliases and wire shape. "
99
103
  "Independent optional fields can remain optional within each variant."
@@ -101,7 +105,10 @@ class PreferDiscriminatedModelUnion(Rule):
101
105
  category=RuleCategory.ARCHITECTURE,
102
106
  autofix=AutofixPolicy.NONE,
103
107
  limitations=(
104
- "Only stable direct module-level import-proven Pydantic BaseModel subclasses with pure direct after model validators are inspected.",
108
+ "Declared module-level unions of at least two source-visible direct dataclasses, Pydantic BaseModels or TypedDicts require a shared disjoint Literal field. PEP 604, typing Union, Optional, Annotated, forward references and bounded local aliases are supported; a None branch is allowed.",
109
+ "The explicit-tag policy is a modeling preference: nominal variants already support isinstance narrowing. Scalar/container unions, inline annotations, generic or inherited variants, behavior classes, unresolved variants and dynamic or ambiguous namespaces are excluded. Imported tag aliases are not resolved; an explicit literal field paired with an unresolved imported or local class domain abstains, as do opaque explicit Literal domains.",
110
+ "String, bytes, integer, Boolean and None tag values are supported. Boolean/integer equality collisions overlap. A bounded hierarchy of shared literal fields may distinguish nested groups; overlapping tags without a further discriminator still warn. TypedDict tags must be required; dataclass ClassVar tags must have a value. Runtime discriminator configuration and default correctness remain separate contracts. No automatic field addition is safe for every constructor or serializer.",
111
+ "For nullable-field dependencies, only stable direct module-level import-proven Pydantic BaseModel subclasses with pure direct after model validators are inspected.",
105
112
  "Bounded paths may contain finite tag comparisons or literal-container membership, None identity tests, Boolean combinations, nested guards and returns of the same instance. Every rejection region must prove one tag-dependent nullable-field requirement; joint missing-field or independent-tag-axis conditions abstain.",
106
113
  "Tags must use imported Literal domains, optionally composed through direct unions, with at least two distinct homogeneous string, integer or Boolean values, optionally including an explicit None state. Mixed primitive families, bool/int equality collisions, open or computed tags abstain. Payloads must explicitly include None in a union, imported Union or Optional.",
107
114
  "Every non-null payload member must have a source-proven inhabited built-in scalar or container type; opaque aliases, custom types and None-only or bottom-type unions are excluded.",
@@ -146,19 +153,55 @@ class PreferDiscriminatedModelUnion(Rule):
146
153
  expected_count=0,
147
154
  public=True,
148
155
  ),
156
+ RuleExample(
157
+ example_id="untagged-data-variants",
158
+ scenario="explicit-data-discriminator",
159
+ title="Data variants lack a common explicit tag",
160
+ outcome=ExampleOutcome.MATCH,
161
+ files=(
162
+ ExampleFile.python(
163
+ "app/access.py",
164
+ "from dataclasses import dataclass\n"
165
+ "@dataclass(frozen=True, slots=True)\nclass TeamAccess:\n team_id: str\n"
166
+ "@dataclass(frozen=True, slots=True)\nclass GlobalAccess:\n group: str\n"
167
+ "type Access = TeamAccess | GlobalAccess\n",
168
+ ),
169
+ ),
170
+ focus_path=PurePosixPath("app/access.py"),
171
+ expected_count=1,
172
+ public=True,
173
+ ),
174
+ RuleExample(
175
+ example_id="literal-tags-preserve-constructor-calls",
176
+ scenario="explicit-data-discriminator",
177
+ title="Explicit tags preserve positional constructor calls with init=False",
178
+ outcome=ExampleOutcome.NO_MATCH,
179
+ files=(
180
+ ExampleFile.python(
181
+ "app/access.py",
182
+ "from dataclasses import dataclass, field\nfrom typing import Literal\n"
183
+ "@dataclass(frozen=True, slots=True)\nclass TeamAccess:\n"
184
+ " scope: Literal['team'] = field(default='team', init=False)\n team_id: str\n"
185
+ "@dataclass(frozen=True, slots=True)\nclass GlobalAccess:\n"
186
+ " scope: Literal['global'] = field(default='global', init=False)\n group: str\n"
187
+ "type Access = TeamAccess | GlobalAccess\n",
188
+ ),
189
+ ),
190
+ focus_path=PurePosixPath("app/access.py"),
191
+ expected_count=0,
192
+ public=True,
193
+ ),
149
194
  ),
150
195
  )
151
196
  description = documentation.summary
152
197
 
153
198
  @override
154
199
  def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
155
- if (
156
- is_test_path(context.path)
157
- or "None" not in context.symbol_source
158
- or "model_validator" not in context.symbol_source
159
- ):
200
+ if is_test_path(context.path):
160
201
  return []
161
- diagnostics: list[Diagnostic] = []
202
+ diagnostics = data_union_diagnostics(context, self.code)
203
+ if "None" not in context.symbol_source or "model_validator" not in context.symbol_source:
204
+ return diagnostics
162
205
  provenance = ResourceProvenance(context, require_static_namespace=True)
163
206
  for model in direct_models(context, methods=True):
164
207
  if not _stable_model(model, context, provenance):