sarj-python-lint 1.61.1__tar.gz → 1.62.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 (227) hide show
  1. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/pyproject.toml +1 -1
  3. sarj_python_lint-1.62.0/src/sarj_python_lint/cast_access.py +86 -0
  4. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  5. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +2 -4
  6. sarj_python_lint-1.62.0/src/sarj_python_lint/rules/no_dual_shape_model_field_reader.py +389 -0
  7. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/unused_mock_setup.py +3 -3
  8. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/.gitignore +0 -0
  9. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/LICENSE +0 -0
  10. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/README.md +0 -0
  11. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  14. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_file_context.py +0 -0
  15. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_filesystem.py +0 -0
  16. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_python_target.py +0 -0
  17. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  18. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_secret_names.py +0 -0
  19. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_source.py +0 -0
  20. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/_version.py +0 -0
  21. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/benchmark.py +0 -0
  22. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  23. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/json_boundary.py +0 -0
  24. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/ratchet.py +0 -0
  26. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rule_base.py +0 -0
  27. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  28. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  29. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  30. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  31. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_closed_helpers.py +0 -0
  32. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  33. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  34. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  35. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_data_union_discriminator.py +0 -0
  36. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  37. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  38. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_exports.py +0 -0
  39. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  40. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  41. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  42. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  43. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_inert_model_annotation.py +0 -0
  44. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_inherited_contract.py +0 -0
  45. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  46. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  47. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_mapping_shape_callbacks.py +0 -0
  48. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_namespace_access.py +0 -0
  49. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_native_namespace_capability.py +0 -0
  50. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  51. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  52. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  53. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  54. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_pydantic_field_contract.py +0 -0
  55. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  56. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_resolved_operation_signature.py +0 -0
  57. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  58. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_runtime_bindings.py +0 -0
  59. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  60. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  61. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_static_json_alias.py +0 -0
  62. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_store_writes.py +0 -0
  63. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  64. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  65. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  66. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  67. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/_type_guard_symbols.py +0 -0
  68. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  69. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  70. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  71. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  72. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  73. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  74. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +0 -0
  75. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  76. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  77. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  78. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  79. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  80. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  81. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  82. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  83. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  84. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  85. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  86. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_async_function_cache.py +0 -0
  87. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  88. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  89. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  90. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_conflicting_pydantic_alias_validation.py +0 -0
  91. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  92. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_contradictory_type_guard.py +0 -0
  93. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  94. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  95. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  96. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  97. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_discarded_validation_result.py +0 -0
  98. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  99. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  100. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  101. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  102. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  103. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  104. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  105. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
  106. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  107. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  108. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  109. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_impossible_pydantic_field_bounds.py +0 -0
  110. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  111. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  112. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  113. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  114. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_invalid_pydantic_bypass.py +0 -0
  115. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_invalid_pydantic_validator_return.py +0 -0
  116. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_loguru_exc_info.py +0 -0
  117. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_loguru_printf_format.py +0 -0
  118. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  119. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_mutating_container_transform.py +0 -0
  120. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  121. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  122. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  123. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_partial_type_is.py +0 -0
  124. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  125. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  126. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  127. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  128. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  129. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  130. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  131. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  132. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  133. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  134. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  135. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  136. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  137. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  138. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_shallow_record_type_guard.py +0 -0
  139. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  140. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  141. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  142. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  143. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  144. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  145. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unknown_pydantic_validator_field.py +0 -0
  146. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  147. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  148. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  149. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  150. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  151. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  152. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  153. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  154. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  155. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  156. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_bulk_upsert.py +0 -0
  157. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  158. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  159. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  160. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_declarative_batch_validation.py +0 -0
  161. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_discriminated_model_union.py +0 -0
  162. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  163. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  164. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  165. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  166. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  167. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  168. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  169. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  170. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  171. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  172. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  173. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  174. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  175. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  176. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  177. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  178. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_psycopg_executemany.py +0 -0
  179. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  180. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  181. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  182. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  183. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  184. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  185. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  186. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  187. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  188. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  189. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  190. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  191. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  192. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  193. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  194. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  195. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  196. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  197. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +0 -0
  198. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  199. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  200. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  201. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +0 -0
  202. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  203. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  204. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  205. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
  206. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  207. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  208. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  209. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  210. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  211. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  212. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_pydantic_for_mapping_shape.py +0 -0
  213. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  214. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  215. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  216. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  217. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  218. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/store_field_update_reuses_upsert.py +0 -0
  219. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  220. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  221. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  222. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/test_without_explicit_oracle.py +0 -0
  223. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  224. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  225. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  226. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  227. {sarj_python_lint-1.61.1 → sarj_python_lint-1.62.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.61.1
3
+ Version: 1.62.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.61.1"
3
+ version = "1.62.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,86 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from sarj_python_lint._file_context import PythonFileContext
8
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ _TYPING = frozenset({"typing", "typing_extensions"})
16
+ _GETATTR_ARITIES = frozenset({2, 3})
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class CastAccess:
21
+ node: ast.Call | ast.ImportFrom
22
+ qualified_api: str
23
+ kind: Literal["wildcard", "reflection"]
24
+
25
+
26
+ def indirect_typing_cast_accesses(path: Path, source: str) -> tuple[CastAccess, ...]:
27
+ context = PythonFileContext(path, source)
28
+ if "*" not in source and "getattr" not in context.symbol_source:
29
+ return ()
30
+ if context.tree is None:
31
+ return ()
32
+ accesses = [
33
+ CastAccess(node, f"{node.module}.cast", "wildcard")
34
+ for node in context.nodes(ast.ImportFrom)
35
+ if node.level == 0 and node.module in _TYPING and any(alias.name == "*" for alias in node.names)
36
+ ]
37
+ provenance = ResourceProvenance(context, require_static_namespace=True)
38
+ if provenance.wildcard or provenance.dynamic_namespace or not provenance.builtin_namespace_untouched:
39
+ return tuple(accesses)
40
+ for call in context.nodes(ast.Call):
41
+ if not _getattr_call(call, provenance):
42
+ continue
43
+ module = _typing_receiver(call.args[0], provenance)
44
+ if module is not None:
45
+ accesses.append(CastAccess(call, f"{module}.cast", "reflection"))
46
+ return tuple(sorted(accesses, key=lambda access: (access.node.lineno, access.node.col_offset)))
47
+
48
+
49
+ def _getattr_call(call: ast.Call, provenance: ResourceProvenance) -> bool:
50
+ if not (
51
+ isinstance(call.func, ast.Name)
52
+ and call.func.id == "getattr"
53
+ and len(call.args) in _GETATTR_ARITIES
54
+ and not call.keywords
55
+ and isinstance(call.args[1], ast.Constant)
56
+ and call.args[1].value == "cast"
57
+ ):
58
+ return False
59
+ scope = provenance.scope(call.func)
60
+ while scope is not None:
61
+ if "getattr" in provenance.bindings.get(scope, {}):
62
+ return False
63
+ scope = provenance.scope(scope)
64
+ return True
65
+
66
+
67
+ def _typing_receiver(node: ast.expr, provenance: ResourceProvenance) -> str | None:
68
+ if not isinstance(node, ast.Name) or node.id in provenance.mutated | provenance.external_bindings:
69
+ return None
70
+ binding = provenance.binding(node.id, node)
71
+ if not (
72
+ isinstance(binding, ast.alias)
73
+ and isinstance(provenance.context.parents.get(binding), ast.Import)
74
+ and binding.name in _TYPING
75
+ ):
76
+ return None
77
+ for read in provenance.reads.get(node.id, ()):
78
+ if provenance.binding(read.id, read) is not binding:
79
+ continue
80
+ parent = provenance.context.parents.get(read)
81
+ if isinstance(parent, ast.Attribute) and parent.value is read:
82
+ continue
83
+ if isinstance(parent, ast.Call) and _getattr_call(parent, provenance) and parent.args[0] is read:
84
+ continue
85
+ return None
86
+ return binding.name
@@ -44,6 +44,7 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
44
44
  from sarj_python_lint.rules.no_database_programmability import NoDatabaseProgrammability
45
45
  from sarj_python_lint.rules.no_delete_statement import NoDeleteStatement
46
46
  from sarj_python_lint.rules.no_discarded_validation_result import NoDiscardedValidationResult
47
+ from sarj_python_lint.rules.no_dual_shape_model_field_reader import NoDualShapeModelFieldReader
47
48
  from sarj_python_lint.rules.no_dunder_all import NoDunderAll
48
49
  from sarj_python_lint.rules.no_duplicate_dunder_all_entry import NoDuplicateDunderAllEntry
49
50
  from sarj_python_lint.rules.no_excessive_cognitive_complexity import NoExcessiveCognitiveComplexity
@@ -307,6 +308,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
307
308
  RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
308
309
  RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
309
310
  RequirePydanticForMappingShape.id: RequirePydanticForMappingShape,
311
+ NoDualShapeModelFieldReader.id: NoDualShapeModelFieldReader,
310
312
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
311
313
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
312
314
  PreferInjectedDependencyOverMonkeypatch.id: PreferInjectedDependencyOverMonkeypatch,
@@ -3,10 +3,10 @@ from __future__ import annotations
3
3
  import ast
4
4
  import builtins
5
5
  from pathlib import PurePosixPath
6
+ from types import MappingProxyType
6
7
  from typing import (
7
8
  TYPE_CHECKING,
8
9
  ClassVar,
9
- cast, # ruff: ignore[banned-api] -- vars exposes Any; the object view permits isinstance narrowing.
10
10
  final,
11
11
  override,
12
12
  )
@@ -31,14 +31,12 @@ from sarj_python_lint.rules._suppression_comments import scan_comments
31
31
 
32
32
 
33
33
  if TYPE_CHECKING:
34
- from collections.abc import Mapping
35
-
36
34
  from sarj_python_lint._file_context import PythonFileContext
37
35
  from sarj_python_lint.rules._project_index import SourceUnit, SymbolRef
38
36
  from sarj_python_lint.rules._suppression_comments import Comment
39
37
 
40
38
 
41
- _BUILTIN_NAMESPACE = cast("Mapping[str, object]", vars(builtins))
39
+ _BUILTIN_NAMESPACE = MappingProxyType[str, object](vars(builtins))
42
40
  _BUILTIN_EXCEPTIONS = frozenset(
43
41
  name for name, value in _BUILTIN_NAMESPACE.items() if isinstance(value, type) and issubclass(value, Exception)
44
42
  )
@@ -0,0 +1,389 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ is_suppressed,
18
+ )
19
+ from sarj_python_lint.rules._annotation_semantics import AnnotationSemantics
20
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance, import_name_for_binding
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from sarj_python_lint._file_context import PythonFileContext
25
+
26
+
27
+ _TYPING = ("typing", "typing_extensions")
28
+ _MODEL_BASES = ("pydantic.BaseModel", "pydantic.main.BaseModel")
29
+ _MAPPINGS = (
30
+ "collections.abc.Mapping",
31
+ "collections.abc.MutableMapping",
32
+ "typing.Mapping",
33
+ "typing.MutableMapping",
34
+ )
35
+ _PAIR = 2
36
+ _MAX_SELECTOR_ALIAS_DEPTH = 16
37
+ _MODEL = "from pydantic import BaseModel\nclass Route(BaseModel):\n destination: str\n"
38
+ _BAD = (
39
+ _MODEL
40
+ + """
41
+ def _field(route: object, name: str) -> object:
42
+ if isinstance(route, Route):
43
+ return getattr(route, name)
44
+ if isinstance(route, dict):
45
+ return route.get(name)
46
+ return None
47
+ def destination(raw: object) -> object:
48
+ return _field(raw, 'destination')
49
+ """
50
+ )
51
+ _GOOD = (
52
+ _MODEL
53
+ + """
54
+ def destination(raw: object) -> str:
55
+ route = Route.model_validate(raw)
56
+ return route.destination
57
+ """
58
+ )
59
+
60
+
61
+ @final
62
+ class NoDualShapeModelFieldReader(Rule):
63
+ id = "no-dual-shape-model-field-reader"
64
+ code = "SARJ522"
65
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
66
+ default_level=Severity.WARNING,
67
+ summary="Normalize a known model instead of reading its fields through a model-or-dict accessor.",
68
+ rationale=(
69
+ "A string-selected object return discards the model's field types and duplicates the raw and validated "
70
+ "representations at every read. Literal field names at all local callers prove this is a fixed record API."
71
+ ),
72
+ remediation=(
73
+ "Normalize raw input once with the named model's model_validate, then use typed attributes. Preserve "
74
+ "missing-field, coercion, default and validation-error behavior; before validators still own raw input."
75
+ ),
76
+ category=RuleCategory.MAINTAINABILITY,
77
+ autofix=AutofixPolicy.NONE,
78
+ limitations=(
79
+ "Checks stable private module functions with two parameters, an object/Any input and return, and a string field selector in either parameter order. The body must consist of a known local Pydantic model getattr branch, a builtin dict or import-proven standard Mapping get/subscript branch and a None fallback. Import-proven typing casts are transparent.",
80
+ "Every visible use must be a direct call with a declared model field selected by a literal or a bounded chain of dominating single-assignment constants. Dynamic selectors, escaped/public helpers, imported/inherited models, custom attribute hooks, shadowed builtins and ambiguous or rebound bindings abstain. No application modules are imported.",
81
+ "Opaque values, arbitrary dictionaries and generic reflection alone are valid. Raw Pydantic validators are not prohibited, but extracting this closed dual-shape reader does not make its erased field contract typed.",
82
+ "Authored tests are checked; generated/vendor code is excluded. This is a warning without an autofix because normalization can change rejection, coercion, default generation and error timing.",
83
+ ),
84
+ examples=(
85
+ RuleExample(
86
+ example_id="model-or-dict-reader",
87
+ title="A fixed model field is hidden behind a string-selected object return",
88
+ outcome=ExampleOutcome.MATCH,
89
+ files=(ExampleFile.python("app/routes.py", _BAD),),
90
+ focus_path=PurePosixPath("app/routes.py"),
91
+ expected_count=1,
92
+ public=True,
93
+ ),
94
+ RuleExample(
95
+ example_id="normalized-model",
96
+ title="Normalize the input and read the typed field",
97
+ outcome=ExampleOutcome.NO_MATCH,
98
+ files=(ExampleFile.python("app/routes.py", _GOOD),),
99
+ focus_path=PurePosixPath("app/routes.py"),
100
+ expected_count=0,
101
+ public=True,
102
+ ),
103
+ ),
104
+ )
105
+ description = documentation.summary
106
+
107
+ @override
108
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
109
+ if (
110
+ context.tree is None
111
+ or context.generated
112
+ or not {"vendor", "vendored", "third_party"}.isdisjoint(context.path.parts)
113
+ or "getattr" not in context.symbol_source
114
+ ):
115
+ return []
116
+ provenance = ResourceProvenance(context, require_static_namespace=True)
117
+ if provenance.wildcard or provenance.dynamic_namespace or not provenance.builtin_namespace_untouched:
118
+ return []
119
+ findings: list[Diagnostic] = []
120
+ for function in context.tree.body:
121
+ if not isinstance(function, ast.FunctionDef) or not _reader_signature(function, provenance):
122
+ continue
123
+ model = _reader_model(function, context, provenance)
124
+ if model is None or not _fixed_field_calls(function, model, context, provenance):
125
+ continue
126
+ if is_suppressed(context.source_lines, function.lineno, self.code):
127
+ continue
128
+ findings.append(
129
+ Diagnostic(
130
+ context.path,
131
+ function.lineno,
132
+ function.col_offset + 1,
133
+ self.code,
134
+ f"Normalize input once with {model.name}.model_validate(...) and read typed attributes instead "
135
+ "of projecting the same known fields through model getattr and mapping lookup branches.",
136
+ severity=Severity.WARNING,
137
+ )
138
+ )
139
+ return findings
140
+
141
+
142
+ def _reader_signature(function: ast.FunctionDef, provenance: ResourceProvenance) -> bool:
143
+ return (
144
+ function.name.startswith("_")
145
+ and not function.decorator_list
146
+ and not function.type_params
147
+ and _reader_parameters(function, provenance) is not None
148
+ and function.args.vararg is None
149
+ and function.args.kwarg is None
150
+ and not function.args.defaults
151
+ and not any(function.args.kw_defaults)
152
+ and provenance.binding(function.name, function) is function
153
+ and _opaque(function.returns, provenance)
154
+ )
155
+
156
+
157
+ def _reader_parameters(function: ast.FunctionDef, provenance: ResourceProvenance) -> tuple[ast.arg, ast.arg] | None:
158
+ arguments = (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs)
159
+ if len(arguments) != _PAIR:
160
+ return None
161
+ values = [argument for argument in arguments if _opaque(argument.annotation, provenance)]
162
+ keys = [
163
+ argument
164
+ for argument in arguments
165
+ if _builtin(AnnotationSemantics.parse(argument.annotation), "str", provenance)
166
+ ]
167
+ return (values[0], keys[0]) if len(values) == len(keys) == 1 else None
168
+
169
+
170
+ def _opaque(annotation: ast.expr | None, provenance: ResourceProvenance) -> bool:
171
+ node = AnnotationSemantics.parse(annotation)
172
+ return _builtin(node, "object", provenance) or (
173
+ node is not None and any(_imported(node, f"{module}.Any", provenance) for module in _TYPING)
174
+ )
175
+
176
+
177
+ def _builtin(node: ast.expr | None, name: str, provenance: ResourceProvenance) -> bool:
178
+ return (
179
+ isinstance(node, ast.Name)
180
+ and node.id == name
181
+ and provenance.context.module_imports.builtin_is_unshadowed(name)
182
+ and provenance.binding(name, node) is None
183
+ )
184
+
185
+
186
+ def _imported(node: ast.expr, qualified: str, provenance: ResourceProvenance, *, at: ast.AST | None = None) -> bool:
187
+ root = node
188
+ while isinstance(root, ast.Attribute):
189
+ root = root.value
190
+ if not isinstance(root, ast.Name) or root.id in provenance.mutated | provenance.external_bindings:
191
+ return False
192
+ binding = provenance.binding(root.id, node if at is None else at)
193
+ if not isinstance(binding, ast.alias):
194
+ return False
195
+ return import_name_for_binding(node, binding, provenance.context.parents.get(binding)) == qualified
196
+
197
+
198
+ def _reader_model(
199
+ function: ast.FunctionDef, context: PythonFileContext, provenance: ResourceProvenance
200
+ ) -> ast.ClassDef | None:
201
+ parameters = _reader_parameters(function, provenance)
202
+ if parameters is None:
203
+ return None
204
+ value, key = parameters
205
+ arms = _reader_arms(function.body)
206
+ if len(arms) != _PAIR:
207
+ return None
208
+ model: ast.ClassDef | None = None
209
+ mapping = False
210
+ for branch in arms:
211
+ guard = branch.test
212
+ if not (
213
+ isinstance(guard, ast.Call)
214
+ and _builtin(guard.func, "isinstance", provenance)
215
+ and len(guard.args) == _PAIR
216
+ and not guard.keywords
217
+ and _parameter(guard.args[0], value, provenance)
218
+ and len(branch.body) == 1
219
+ and isinstance(branch.body[0], ast.Return)
220
+ and branch.body[0].value is not None
221
+ ):
222
+ return None
223
+ result = _without_cast(branch.body[0].value, provenance)
224
+ target = guard.args[1]
225
+ if _builtin(target, "dict", provenance) or any(_imported(target, kind, provenance) for kind in _MAPPINGS):
226
+ mapping = _mapping_read(result, value, key, provenance)
227
+ elif _attribute_read(result, value, key, provenance):
228
+ model = _local_model(target, context, provenance)
229
+ else:
230
+ return None
231
+ return model if mapping else None
232
+
233
+
234
+ def _reader_arms(body: list[ast.stmt]) -> list[ast.If]:
235
+ statements = list(body)
236
+ if statements and isinstance(statements[0], ast.Expr) and isinstance(statements[0].value, ast.Constant):
237
+ statements = statements[1:]
238
+ branches: list[ast.If] = []
239
+ while statements:
240
+ branch = statements[0]
241
+ if not isinstance(branch, ast.If):
242
+ break
243
+ branches.append(branch)
244
+ statements = [*branch.orelse, *statements[1:]]
245
+ if len(statements) == 1 and isinstance(statements[0], ast.Return) and _none(statements[0].value):
246
+ return branches
247
+ return []
248
+
249
+
250
+ def _none(node: ast.expr | None) -> bool:
251
+ return node is None or (isinstance(node, ast.Constant) and node.value is None)
252
+
253
+
254
+ def _parameter(node: ast.expr, parameter: ast.arg, provenance: ResourceProvenance) -> bool:
255
+ return isinstance(node, ast.Name) and node.id == parameter.arg and provenance.binding(node.id, node) is parameter
256
+
257
+
258
+ def _without_cast(node: ast.expr, provenance: ResourceProvenance) -> ast.expr:
259
+ if (
260
+ isinstance(node, ast.Call)
261
+ and len(node.args) == _PAIR
262
+ and not node.keywords
263
+ and any(_imported(node.func, f"{module}.cast", provenance) for module in _TYPING)
264
+ ):
265
+ return node.args[1]
266
+ return node
267
+
268
+
269
+ def _attribute_read(node: ast.expr, value: ast.arg, key: ast.arg, provenance: ResourceProvenance) -> bool:
270
+ return (
271
+ isinstance(node, ast.Call)
272
+ and _builtin(node.func, "getattr", provenance)
273
+ and len(node.args) in {_PAIR, _PAIR + 1}
274
+ and not node.keywords
275
+ and _parameter(node.args[0], value, provenance)
276
+ and _parameter(node.args[1], key, provenance)
277
+ and (len(node.args) == _PAIR or _none(node.args[_PAIR]))
278
+ )
279
+
280
+
281
+ def _mapping_read(node: ast.expr, value: ast.arg, key: ast.arg, provenance: ResourceProvenance) -> bool:
282
+ if isinstance(node, ast.Subscript):
283
+ return _parameter(_without_cast(node.value, provenance), value, provenance) and _parameter(
284
+ node.slice, key, provenance
285
+ )
286
+ return (
287
+ isinstance(node, ast.Call)
288
+ and isinstance(node.func, ast.Attribute)
289
+ and node.func.attr == "get"
290
+ and _parameter(_without_cast(node.func.value, provenance), value, provenance)
291
+ and len(node.args) in {1, _PAIR}
292
+ and not node.keywords
293
+ and _parameter(node.args[0], key, provenance)
294
+ and (len(node.args) == 1 or _none(node.args[1]))
295
+ )
296
+
297
+
298
+ def _local_model(target: ast.expr, context: PythonFileContext, provenance: ResourceProvenance) -> ast.ClassDef | None:
299
+ if not isinstance(target, ast.Name):
300
+ return None
301
+ model = provenance.binding(target.id, target)
302
+ if not (
303
+ isinstance(model, ast.ClassDef)
304
+ and context.parents.get(model) is context.tree
305
+ and len(model.bases) == 1
306
+ and not model.decorator_list
307
+ and not model.keywords
308
+ and not model.type_params
309
+ and target.id not in provenance.mutated
310
+ and any(_imported(model.bases[0], base, provenance) for base in _MODEL_BASES)
311
+ ):
312
+ return None
313
+ if any(
314
+ isinstance(member, ast.FunctionDef | ast.AsyncFunctionDef)
315
+ and member.name in {"__getattr__", "__getattribute__"}
316
+ for member in model.body
317
+ ):
318
+ return None
319
+ return model
320
+
321
+
322
+ def _fixed_field_calls(
323
+ function: ast.FunctionDef, model: ast.ClassDef, context: PythonFileContext, provenance: ResourceProvenance
324
+ ) -> bool:
325
+ parameters = _reader_parameters(function, provenance)
326
+ if parameters is None:
327
+ return False
328
+ fields = {
329
+ member.target.id
330
+ for member in model.body
331
+ if isinstance(member, ast.AnnAssign)
332
+ and isinstance(member.target, ast.Name)
333
+ and not member.target.id.startswith("_")
334
+ and member.target.id != "model_config"
335
+ and not _class_variable(member.annotation, provenance)
336
+ }
337
+ calls = 0
338
+ for reference in provenance.reads.get(function.name, ()):
339
+ if provenance.binding(reference.id, reference) is not function:
340
+ continue
341
+ call = context.parents.get(reference)
342
+ if not isinstance(call, ast.Call) or call.func is not reference:
343
+ return False
344
+ key = _call_key(call, function, parameters[1].arg)
345
+ literal = _literal_key(key, provenance)
346
+ if literal is None or literal not in fields:
347
+ return False
348
+ calls += 1
349
+ return calls > 0
350
+
351
+
352
+ def _class_variable(annotation: ast.expr, provenance: ResourceProvenance) -> bool:
353
+ parsed = AnnotationSemantics.parse(annotation)
354
+ if parsed is None:
355
+ return True
356
+ return any(
357
+ isinstance(node, ast.Subscript)
358
+ and any(_imported(node.value, f"{module}.ClassVar", provenance, at=annotation) for module in _TYPING)
359
+ for node in ast.walk(parsed)
360
+ )
361
+
362
+
363
+ def _literal_key(node: ast.expr | None, provenance: ResourceProvenance, *, depth: int = 0) -> str | None:
364
+ if depth >= _MAX_SELECTOR_ALIAS_DEPTH:
365
+ return None
366
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
367
+ return node.value
368
+ if not isinstance(node, ast.Name) or node.id in provenance.external_bindings:
369
+ return None
370
+ binding = provenance.binding(node.id, node)
371
+ if not isinstance(binding, ast.Name):
372
+ return None
373
+ value = provenance.assignment_value(binding, node)
374
+ return _literal_key(value, provenance, depth=depth + 1) if value is not None else None
375
+
376
+
377
+ def _call_key(call: ast.Call, function: ast.FunctionDef, key_name: str) -> ast.expr | None:
378
+ positional = (*function.args.posonlyargs, *function.args.args)
379
+ parameters = (*positional, *function.args.kwonlyargs)
380
+ if len(call.args) > len(positional) or any(isinstance(argument, ast.Starred) for argument in call.args):
381
+ return None
382
+ bound = {parameter.arg: argument for parameter, argument in zip(positional, call.args, strict=False)}
383
+ for keyword in call.keywords:
384
+ if keyword.arg is None or keyword.arg in bound or keyword.arg in {arg.arg for arg in function.args.posonlyargs}:
385
+ return None
386
+ bound[keyword.arg] = keyword.value
387
+ if set(bound) != {parameter.arg for parameter in parameters}:
388
+ return None
389
+ return bound.get(key_name)
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  import ast
4
4
  import builtins
5
5
  from pathlib import PurePosixPath
6
+ from types import MappingProxyType
6
7
  from typing import (
7
8
  TYPE_CHECKING,
8
9
  ClassVar,
9
10
  NamedTuple,
10
- cast, # ruff: ignore[banned-api] -- vars exposes Any; the object view permits native exception narrowing.
11
11
  override,
12
12
  )
13
13
 
@@ -29,14 +29,14 @@ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
29
29
 
30
30
 
31
31
  if TYPE_CHECKING:
32
- from collections.abc import Iterator, Mapping
32
+ from collections.abc import Iterator
33
33
 
34
34
  from sarj_python_lint._file_context import PythonFileContext
35
35
 
36
36
 
37
37
  # The two attributes that *configure* what a mock does when called.
38
38
  _CONFIG_ATTRS = frozenset({"return_value", "side_effect"})
39
- _BUILTIN_NAMESPACE = cast("Mapping[str, object]", vars(builtins))
39
+ _BUILTIN_NAMESPACE = MappingProxyType[str, object](vars(builtins))
40
40
  _EXCEPTION_TYPES = frozenset(
41
41
  name for name, value in _BUILTIN_NAMESPACE.items() if isinstance(value, type) and issubclass(value, BaseException)
42
42
  )