sarj-python-lint 1.8.0__tar.gz → 1.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/PKG-INFO +2 -2
  2. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/pyproject.toml +2 -2
  3. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_project_index.py +177 -0
  4. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +124 -74
  5. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +10 -62
  6. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_port_for_service.py +339 -1
  7. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/.gitignore +0 -0
  8. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/LICENSE +0 -0
  9. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/README.md +0 -0
  10. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_file_context.py +0 -0
  14. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_filesystem.py +0 -0
  15. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_python_target.py +0 -0
  16. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/benchmark.py +0 -0
  20. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/json_boundary.py +0 -0
  21. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rule_base.py +0 -0
  24. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/__init__.py +0 -0
  25. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  26. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  28. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_comments.py +0 -0
  29. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  30. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  31. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  32. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  33. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_first_party.py +0 -0
  34. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  35. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_imports.py +0 -0
  36. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_logging.py +0 -0
  37. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  38. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_paths.py +0 -0
  39. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  40. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_pytest.py +0 -0
  41. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_registry.py +0 -0
  42. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  43. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  44. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_sql.py +0 -0
  45. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  46. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  47. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  48. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  49. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  50. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  51. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
  52. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  53. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  54. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  55. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  56. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  57. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  58. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  59. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  60. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  61. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  62. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  63. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  64. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  65. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  66. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  67. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  68. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  69. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  70. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  71. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  72. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  73. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  74. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  75. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  76. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  77. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  78. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  79. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  80. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  81. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  82. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  83. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  84. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  85. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  86. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  87. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  88. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  89. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  90. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  91. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  92. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  93. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  94. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  95. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  96. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  97. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  98. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  99. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  100. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  101. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  102. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  103. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  104. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  105. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  106. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  107. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  108. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  109. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  110. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  111. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  112. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  113. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  114. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  115. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  116. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  117. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  118. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  119. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  120. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  121. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  122. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  123. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  124. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  125. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  126. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  127. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  128. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  129. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  130. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  131. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  132. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  133. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  134. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  135. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  136. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  137. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  138. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  139. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  140. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  141. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  142. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  143. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  144. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  145. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  146. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  147. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  148. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  149. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  150. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  151. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  152. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  153. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  154. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  155. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/stepdown.py +0 -0
  156. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  157. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  158. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  159. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  160. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  161. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  162. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  163. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  164. {sarj_python_lint-1.8.0 → sarj_python_lint-1.8.3}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.8.0
3
+ Version: 1.8.3
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
20
  Requires-Dist: packaging==26.3
21
21
  Requires-Dist: ruff>=0.16.9
22
- Requires-Dist: sarj-rule-contracts==0.1.1
22
+ Requires-Dist: sarj-rule-contracts==0.2.0
23
23
  Requires-Dist: sqlglot==30.19.0
24
24
  Requires-Dist: typer==0.27.2
25
25
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.8.0"
3
+ version = "1.8.3"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.14",
16
16
  "Topic :: Software Development :: Quality Assurance",
17
17
  ]
18
- dependencies = ["sarj-rule-contracts==0.1.1", "packaging==26.3", "ruff>=0.16.9", "sqlglot==30.19.0", "typer==0.27.2"]
18
+ dependencies = ["sarj-rule-contracts==0.2.0", "packaging==26.3", "ruff>=0.16.9", "sqlglot==30.19.0", "typer==0.27.2"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -4,6 +4,7 @@ import ast
4
4
  from collections.abc import Mapping
5
5
  from contextlib import suppress
6
6
  from dataclasses import dataclass
7
+ from functools import cached_property
7
8
  import os
8
9
  from pathlib import Path
9
10
  import re
@@ -12,6 +13,7 @@ from typing import TYPE_CHECKING, final
12
13
 
13
14
  from sarj_python_lint.rules._ast_index import walk as walk_ast
14
15
  from sarj_python_lint.rules._first_party import FirstPartyFacts, project_root
16
+ from sarj_python_lint.rules._paths import is_generated
15
17
 
16
18
 
17
19
  if TYPE_CHECKING:
@@ -179,6 +181,27 @@ class ProjectIndexSet:
179
181
  consumers.update(_unit_constructor_consumers(candidate, candidate.tree, target))
180
182
  return frozenset(consumers)
181
183
 
184
+ def constructor_operations(self, unit: SourceUnit, name: str) -> frozenset[tuple[Path, str]]:
185
+ if unit.module is None:
186
+ return frozenset()
187
+ return self._constructor_operations.get(SymbolRef(unit.module, name), frozenset())
188
+
189
+ @cached_property
190
+ def _constructor_operations(self) -> Mapping[SymbolRef, frozenset[tuple[Path, str]]]:
191
+ module_counts: dict[str, int] = {}
192
+ for unit in self._units.values():
193
+ if unit.module is not None:
194
+ module_counts[unit.module] = module_counts.get(unit.module, 0) + 1
195
+ owned_modules = frozenset(module for module, count in module_counts.items() if count == 1)
196
+ operations: dict[SymbolRef, set[tuple[Path, str]]] = {}
197
+ for unit in self._units.values():
198
+ if unit.tree is None or unit.module not in owned_modules or is_generated(unit.path, unit.source):
199
+ continue
200
+ for symbol, operation in _unit_constructor_operations(unit, unit.tree, owned_modules):
201
+ if symbol.module in owned_modules and symbol in self._classes:
202
+ operations.setdefault(symbol, set()).add((unit.path, operation))
203
+ return MappingProxyType({symbol: frozenset(calls) for symbol, calls in operations.items()})
204
+
182
205
  def direct_subclasses(self, unit: SourceUnit, name: str) -> frozenset[tuple[Path, str]]:
183
206
  if unit.module is None:
184
207
  return frozenset()
@@ -554,6 +577,160 @@ def _unit_constructor_consumers(
554
577
  yield (candidate.path, owner.name)
555
578
 
556
579
 
580
+ def _unit_constructor_operations(
581
+ unit: SourceUnit, tree: ast.Module, owned_modules: frozenset[str]
582
+ ) -> Iterator[tuple[SymbolRef, str]]:
583
+ if any(
584
+ isinstance(item, ast.ImportFrom) and any(alias.name == "*" for alias in item.names) for item in walk_ast(tree)
585
+ ):
586
+ return
587
+ rebound = _module_constructor_rebindings(tree)
588
+ for owner in tree.body:
589
+ self_symbol = SymbolRef(unit.module or "", owner.name) if isinstance(owner, ast.ClassDef) else None
590
+ for method in _direct_functions(owner):
591
+ for symbol, operation in _function_constructor_operations(unit, method, rebound, owned_modules):
592
+ if symbol != self_symbol:
593
+ yield symbol, operation
594
+
595
+
596
+ def _module_constructor_rebindings(tree: ast.Module) -> frozenset[str]:
597
+ names: set[str] = set()
598
+ declarations: dict[str, int] = {}
599
+ for statement in tree.body:
600
+ for name in _module_constructor_declarations(statement):
601
+ declarations[name] = declarations.get(name, 0) + 1
602
+ if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef):
603
+ names.add(statement.name)
604
+ elif not isinstance(statement, ast.ClassDef | ast.Import | ast.ImportFrom):
605
+ names.update(_bound_constructor_names(statement))
606
+ names.update(name for name, count in declarations.items() if count > 1)
607
+ return frozenset(names)
608
+
609
+
610
+ def _module_constructor_declarations(statement: ast.stmt) -> frozenset[str]:
611
+ if isinstance(statement, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
612
+ return frozenset({statement.name})
613
+ return _bound_constructor_names(statement)
614
+
615
+
616
+ def _direct_functions(owner: ast.stmt) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...]:
617
+ if isinstance(owner, ast.FunctionDef | ast.AsyncFunctionDef):
618
+ return (owner,)
619
+ if isinstance(owner, ast.ClassDef):
620
+ return tuple(item for item in owner.body if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef))
621
+ return ()
622
+
623
+
624
+ def _function_constructor_operations(
625
+ unit: SourceUnit,
626
+ method: ast.FunctionDef | ast.AsyncFunctionDef,
627
+ rebound: frozenset[str],
628
+ owned_modules: frozenset[str],
629
+ ) -> Iterator[tuple[SymbolRef, str]]:
630
+ shadowed = _bound_constructor_names(method) | rebound
631
+ for statement in method.body:
632
+ if isinstance(statement, ast.Expr | ast.Assign | ast.AnnAssign | ast.Return):
633
+ operation = _direct_constructor_operation(unit, statement.value, shadowed, owned_modules)
634
+ if operation is not None:
635
+ yield operation
636
+ if isinstance(
637
+ statement,
638
+ ast.Return
639
+ | ast.Raise
640
+ | ast.If
641
+ | ast.For
642
+ | ast.AsyncFor
643
+ | ast.While
644
+ | ast.Try
645
+ | ast.TryStar
646
+ | ast.With
647
+ | ast.AsyncWith
648
+ | ast.Match,
649
+ ):
650
+ break
651
+
652
+
653
+ def _direct_constructor_operation(
654
+ unit: SourceUnit, value: ast.expr | None, shadowed: frozenset[str], owned_modules: frozenset[str]
655
+ ) -> tuple[SymbolRef, str] | None:
656
+ if isinstance(value, ast.Await):
657
+ value = value.value
658
+ if not isinstance(value, ast.Call) or not isinstance(value.func, ast.Attribute):
659
+ return None
660
+ if not isinstance(value.func.value, ast.Call):
661
+ return None
662
+ constructor = value.func.value.func
663
+ root = constructor
664
+ while isinstance(root, ast.Attribute):
665
+ root = root.value
666
+ if not isinstance(root, ast.Name) or root.id in shadowed:
667
+ return None
668
+ symbol = _owned_constructor_symbol(unit, constructor, owned_modules)
669
+ return (symbol, value.func.attr) if symbol is not None else None
670
+
671
+
672
+ def _owned_constructor_symbol(
673
+ unit: SourceUnit, expression: ast.expr, owned_modules: frozenset[str]
674
+ ) -> SymbolRef | None:
675
+ if isinstance(expression, ast.Name):
676
+ return _resolve(unit, expression)
677
+ parts: list[str] = []
678
+ current = expression
679
+ while isinstance(current, ast.Attribute):
680
+ parts.append(current.attr)
681
+ current = current.value
682
+ if not isinstance(current, ast.Name) or not parts:
683
+ return None
684
+ imported = unit.imports.get(current.id)
685
+ if imported is None or imported.name:
686
+ return None
687
+ ordered = list(reversed(parts))
688
+ module = ".".join((imported.module, *ordered[:-1]))
689
+ if len(ordered) > 1 and not _explicit_constructor_module(unit, module):
690
+ return None
691
+ return SymbolRef(module, ordered[-1]) if module in owned_modules else None
692
+
693
+
694
+ def _explicit_constructor_module(unit: SourceUnit, module: str) -> bool:
695
+ if unit.tree is None:
696
+ return False
697
+ return any(
698
+ alias.name == module and alias.asname is None
699
+ for statement in _module_import_statements(unit.tree)
700
+ if isinstance(statement, ast.Import)
701
+ for alias in statement.names
702
+ )
703
+
704
+
705
+ def _bound_constructor_names(node: ast.AST) -> frozenset[str]:
706
+ names: set[str] = set()
707
+ for item in walk_ast(node):
708
+ match item:
709
+ case (
710
+ ast.Name(id=name, ctx=(ast.Store() | ast.Del()))
711
+ | ast.arg(arg=name)
712
+ | ast.ClassDef(name=name)
713
+ | ast.FunctionDef(name=name)
714
+ | ast.AsyncFunctionDef(name=name)
715
+ | ast.ExceptHandler(name=str(name))
716
+ | ast.MatchAs(name=str(name))
717
+ | ast.MatchStar(name=str(name))
718
+ | ast.MatchMapping(rest=str(name))
719
+ ):
720
+ names.add(name)
721
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
722
+ names.update(alias.asname or alias.name.split(".", 1)[0] for alias in aliases)
723
+ case ast.Attribute(ctx=(ast.Store() | ast.Del())):
724
+ root = item.value
725
+ while isinstance(root, ast.Attribute):
726
+ root = root.value
727
+ if isinstance(root, ast.Name):
728
+ names.add(root.id)
729
+ case _:
730
+ pass
731
+ return frozenset(names)
732
+
733
+
557
734
  def _class_has_typed_dependency(unit: SourceUnit, owner: ast.ClassDef, target: SymbolRef) -> bool:
558
735
  for statement in owner.body:
559
736
  if isinstance(statement, ast.AnnAssign) and _annotation_contains_symbol(unit, statement.annotation, target):
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from enum import Enum, auto
4
5
  from pathlib import PurePosixPath
5
6
  from types import MappingProxyType
6
7
  from typing import TYPE_CHECKING, ClassVar, Final, final, override
@@ -65,12 +66,18 @@ type _TestFunction = ast.FunctionDef | ast.AsyncFunctionDef
65
66
  type _Reference = tuple[str, ...]
66
67
 
67
68
 
69
+ class _Evidence(Enum):
70
+ POSITIVE = auto()
71
+ INSUFFICIENT = auto()
72
+ UNRESOLVED = auto()
73
+
74
+
68
75
  @final
69
76
  class AsyncMockCallWithoutAwaitAssertion(Rule):
70
77
  id = "async-mock-call-without-await-assertion"
71
78
  code = "SARJ456"
72
79
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
73
- default_level=Severity.ERROR,
80
+ default_level=Severity.WARNING,
74
81
  summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
75
82
  rationale=(
76
83
  "Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
@@ -87,33 +94,50 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
87
94
  "Only direct statements in collected test functions and methods are inspected; fixtures, helpers, nested blocks, and generated files are excluded.",
88
95
  "Requires a fresh local unittest.mock.AsyncMock constructor with unshadowed imports; aliases, reassignment, reset, and ambiguous configuration are excluded.",
89
96
  "Only the mock itself and unchanged immediate children of unspecced, unwrapped mocks are inferred; spec children, return values, and deeper chains are excluded.",
90
- "Positive call-count and called-state assertions are covered as well as call-assertion methods. Await-state assertions must prove at least one await; argument equality and exact call/await counts are not compared.",
91
- "Boolean conjunctions combine evidence; disjunctions require evidence in every branch. Dynamic counts and call lists are not inferred.",
97
+ "Positive call evidence includes direct assertion methods and literal integer or boolean call-state checks; unresolved call expectations are excluded.",
98
+ "Await evidence excludes provably vacuous checks, including zero or nonnegative counts and empty expected await sequences; unresolved expectations are accepted without inferring their values.",
99
+ "Conjunctions need one accepted await branch; disjunctions need accepted evidence for the same mock in every branch. Argument equality and assertion strength are not compared.",
100
+ "Compound negation and custom expression semantics remain unresolved; no variable values or fixture graphs are inferred.",
92
101
  "Custom assertion helpers and intentional scheduling contracts may require a reasoned local suppression.",
93
102
  ),
94
103
  examples=(
95
104
  RuleExample(
96
- example_id="numeric-call-with-vacuous-await-check",
97
- title="A nonnegative await count does not prove execution",
105
+ example_id="call-only-async-mock",
106
+ title="A call assertion does not prove awaiting",
98
107
  outcome=ExampleOutcome.MATCH,
99
108
  files=(
100
109
  ExampleFile.python(
101
110
  "tests/test_delivery.py",
102
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 2\n assert send.await_count >= 0\n",
111
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n",
103
112
  ),
104
113
  ),
105
114
  focus_path=PurePosixPath("tests/test_delivery.py"),
106
115
  expected_count=1,
107
- public=False,
116
+ public=True,
108
117
  ),
109
118
  RuleExample(
110
- example_id="call-only-async-mock",
111
- title="A call assertion does not prove awaiting",
119
+ example_id="call-and-await-async-mock",
120
+ title="Call and await assertions protect different contracts",
121
+ outcome=ExampleOutcome.NO_MATCH,
122
+ files=(
123
+ ExampleFile.python(
124
+ "tests/test_delivery.py",
125
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n send.assert_awaited_once_with('item')\n",
126
+ ),
127
+ ),
128
+ focus_path=PurePosixPath("tests/test_delivery.py"),
129
+ expected_count=0,
130
+ public=True,
131
+ ),
132
+ RuleExample(
133
+ example_id="vacuous-await-count",
134
+ scenario="await-expectation-boundaries",
135
+ title="A nonnegative await count does not prove execution",
112
136
  outcome=ExampleOutcome.MATCH,
113
137
  files=(
114
138
  ExampleFile.python(
115
139
  "tests/test_delivery.py",
116
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n",
140
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 1\n assert send.await_count >= 0\n",
117
141
  ),
118
142
  ),
119
143
  focus_path=PurePosixPath("tests/test_delivery.py"),
@@ -121,13 +145,14 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
121
145
  public=True,
122
146
  ),
123
147
  RuleExample(
124
- example_id="call-and-await-async-mock",
125
- title="Call and await assertions protect different contracts",
148
+ example_id="dynamic-expected-awaits",
149
+ scenario="await-expectation-boundaries",
150
+ title="Unresolved await expectations retain their acceptance",
126
151
  outcome=ExampleOutcome.NO_MATCH,
127
152
  files=(
128
153
  ExampleFile.python(
129
154
  "tests/test_delivery.py",
130
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n send.assert_awaited_once_with('item')\n",
155
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery(expected):\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once()\n send.assert_has_awaits(expected)\n",
131
156
  ),
132
157
  ),
133
158
  focus_path=PurePosixPath("tests/test_delivery.py"),
@@ -166,7 +191,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
166
191
  line=call.lineno,
167
192
  col=call.col_offset + 1,
168
193
  code=self.code,
169
- severity=Severity.ERROR,
194
+ severity=Severity.WARNING,
170
195
  message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
171
196
  )
172
197
  for test in _collected_tests(tree.body)
@@ -231,7 +256,7 @@ def _call_oracles(statement: ast.stmt) -> Iterator[tuple[_Reference, ast.Call |
231
256
  ):
232
257
  yield _reference(receiver), call
233
258
  case ast.Assert(test=condition):
234
- for reference in sorted(_positive_states(condition, _CALL_STATE)):
259
+ for reference in sorted(_asserted_states(condition, _CALL_STATE)):
235
260
  oracle = next(
236
261
  node
237
262
  for node in walk_ast(condition)
@@ -304,49 +329,60 @@ def _await_oracles(statements: list[ast.stmt]) -> set[_Reference]:
304
329
  case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method)) as call) if (
305
330
  method in _AWAIT_ASSERTIONS
306
331
  ):
307
- if method != "assert_has_awaits" or _has_nonempty_calls_argument(call):
332
+ if method != "assert_has_awaits" or _calls_evidence(call) is not _Evidence.INSUFFICIENT:
308
333
  covered.add(_reference(receiver))
309
334
  case ast.Assert(test=condition):
310
- covered.update(_positive_states(condition, _AWAIT_STATE))
335
+ covered.update(_asserted_states(condition, _AWAIT_STATE))
311
336
  case _:
312
337
  pass
313
338
  return covered
314
339
 
315
340
 
316
- def _has_nonempty_calls_argument(call: ast.Call) -> bool:
341
+ def _calls_evidence(call: ast.Call) -> _Evidence:
317
342
  if call.args:
318
- return _nonempty_sequence(call.args[0])
319
- return any(item.arg == "calls" and _nonempty_sequence(item.value) for item in call.keywords)
320
-
321
-
322
- def _positive_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
323
- if isinstance(condition, ast.UnaryOp) and isinstance(condition.op, ast.Not):
324
- operand = condition.operand
325
- if isinstance(operand, ast.Compare) and len(operand.ops) == 1:
326
- inverse = _negated_operator(operand.ops[0])
327
- if inverse is not None:
328
- return _comparison_states(operand.left, inverse, operand.comparators[0], states) | _comparison_states(
329
- operand.comparators[0], _reversed_operator(inverse), operand.left, states
330
- )
331
- return set()
332
- if isinstance(condition, ast.BoolOp):
333
- branches = [_positive_states(value, states) for value in condition.values]
334
- return (
335
- branches[0].union(*branches[1:])
336
- if isinstance(condition.op, ast.And)
337
- else branches[0].intersection(*branches[1:])
338
- )
339
- if isinstance(condition, ast.Attribute) and condition.attr in states:
340
- return {_reference(condition.value)}
341
- if isinstance(condition, ast.Compare):
342
- result: set[_Reference] = set()
343
- for left, operator, right in zip(
344
- (condition.left, *condition.comparators[:-1]), condition.ops, condition.comparators, strict=True
345
- ):
346
- result.update(_comparison_states(left, operator, right, states))
347
- result.update(_comparison_states(right, _reversed_operator(operator), left, states))
348
- return result
349
- return set()
343
+ return _sequence_evidence(call.args[0])
344
+ for keyword in call.keywords:
345
+ if keyword.arg == "calls":
346
+ return _sequence_evidence(keyword.value)
347
+ return _Evidence.UNRESOLVED
348
+
349
+
350
+ def _sequence_evidence(node: ast.expr) -> _Evidence:
351
+ if isinstance(node, (ast.List, ast.Tuple)):
352
+ if not node.elts:
353
+ return _Evidence.INSUFFICIENT
354
+ if any(not isinstance(item, ast.Starred) for item in node.elts):
355
+ return _Evidence.POSITIVE
356
+ return _Evidence.UNRESOLVED
357
+
358
+
359
+ def _asserted_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
360
+ match condition:
361
+ case ast.UnaryOp(op=ast.Not(), operand=ast.UnaryOp(op=ast.Not(), operand=operand)):
362
+ return _asserted_states(operand, states)
363
+ case ast.UnaryOp(op=ast.Not(), operand=ast.Compare(left=left, ops=[operator], comparators=[right])):
364
+ inverse = _negated_operator(operator)
365
+ return _comparison_states(left, inverse, right, states) if inverse is not None else set()
366
+ case ast.UnaryOp(op=ast.Not(), operand=ast.Attribute(attr=attribute)) if attribute in states:
367
+ return set()
368
+ case ast.BoolOp(op=operator, values=values):
369
+ branches = [_asserted_states(value, states) for value in values]
370
+ return (
371
+ branches[0].union(*branches[1:])
372
+ if isinstance(operator, ast.And)
373
+ else branches[0].intersection(*branches[1:])
374
+ )
375
+ case ast.Attribute(value=receiver, attr=attribute) if attribute in states:
376
+ return {_reference(receiver)}
377
+ case ast.Compare(left=left, ops=operators, comparators=rights):
378
+ result: set[_Reference] = set()
379
+ for first, operator, second in zip((left, *rights[:-1]), operators, rights, strict=True):
380
+ result.update(_comparison_states(first, operator, second, states))
381
+ return result
382
+ case _:
383
+ # Preserve acceptance of unresolved await-state assertions, such as
384
+ # len(mock.await_args_list) == expected, without value propagation.
385
+ return _unresolved_await_states(condition, states)
350
386
 
351
387
 
352
388
  def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
@@ -364,6 +400,18 @@ def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
364
400
  return factory() if factory is not None else None
365
401
 
366
402
 
403
+ def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
404
+ result: set[_Reference] = set()
405
+ for node, comparison, expected in ((left, operator, right), (right, _reversed_operator(operator), left)):
406
+ if isinstance(node, ast.Attribute) and node.attr in states:
407
+ evidence = _state_evidence(node.attr, comparison, expected)
408
+ if evidence is _Evidence.POSITIVE or (states == _AWAIT_STATE and evidence is _Evidence.UNRESOLVED):
409
+ result.add(_reference(node.value))
410
+ else:
411
+ result.update(_unresolved_await_states(node, states))
412
+ return result
413
+
414
+
367
415
  def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
368
416
  match operator:
369
417
  case ast.Lt():
@@ -378,32 +426,38 @@ def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
378
426
  return operator
379
427
 
380
428
 
381
- def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
382
- if not isinstance(left, ast.Attribute):
429
+ def _unresolved_await_states(node: ast.expr, states: frozenset[str]) -> set[_Reference]:
430
+ if states != _AWAIT_STATE:
383
431
  return set()
432
+ return {
433
+ _reference(child.value) for child in walk_ast(node) if isinstance(child, ast.Attribute) and child.attr in states
434
+ }
435
+
436
+
437
+ def _state_evidence(attribute: str, operator: ast.cmpop, expected: ast.expr) -> _Evidence:
438
+ positive: bool
384
439
  if (
385
- states == _AWAIT_STATE
386
- and isinstance(left.value, ast.Attribute)
387
- and left.value.attr == "await_args"
388
- and left.attr in {"args", "kwargs"}
440
+ attribute in {"call_count", "await_count"}
441
+ and isinstance(expected, ast.Constant)
442
+ and isinstance(expected.value, int)
389
443
  ):
390
- return {_reference(left.value.value)}
391
- if left.attr not in states:
392
- return set()
393
- positive = False
394
- if left.attr in {"call_count", "await_count"} and isinstance(right, ast.Constant) and type(right.value) is int:
395
- positive = _proves_positive_count(operator, right.value)
396
- elif left.attr == "called" and isinstance(right, ast.Constant):
397
- positive = (isinstance(operator, (ast.Eq, ast.Is)) and right.value is True) or (
398
- isinstance(operator, (ast.NotEq, ast.IsNot)) and right.value is False
444
+ positive = _proves_positive_count(operator, expected.value)
445
+ elif attribute == "called" and isinstance(expected, ast.Constant) and isinstance(expected.value, bool):
446
+ positive = (isinstance(operator, (ast.Eq, ast.Is)) and expected.value) or (
447
+ isinstance(operator, (ast.NotEq, ast.IsNot)) and not expected.value
399
448
  )
400
- elif left.attr == "await_args" and isinstance(right, ast.Constant) and right.value is None:
449
+ elif attribute == "await_args" and isinstance(expected, ast.Constant) and expected.value is None:
401
450
  positive = isinstance(operator, (ast.NotEq, ast.IsNot))
402
- elif left.attr == "await_args_list":
403
- positive = (isinstance(operator, ast.Eq) and _nonempty_sequence(right)) or (
404
- isinstance(operator, ast.NotEq) and isinstance(right, ast.List) and not right.elts
451
+ elif attribute == "await_args_list" and isinstance(operator, (ast.Eq, ast.NotEq)):
452
+ sequence = _sequence_evidence(expected)
453
+ if sequence is _Evidence.UNRESOLVED:
454
+ return sequence
455
+ positive = (isinstance(operator, ast.Eq) and sequence is _Evidence.POSITIVE) or (
456
+ isinstance(operator, ast.NotEq) and isinstance(expected, ast.List) and sequence is _Evidence.INSUFFICIENT
405
457
  )
406
- return {_reference(left.value)} if positive else set()
458
+ else:
459
+ return _Evidence.UNRESOLVED
460
+ return _Evidence.POSITIVE if positive else _Evidence.INSUFFICIENT
407
461
 
408
462
 
409
463
  def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
@@ -418,10 +472,6 @@ def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
418
472
  return False
419
473
 
420
474
 
421
- def _nonempty_sequence(node: ast.expr) -> bool:
422
- return isinstance(node, (ast.List, ast.Tuple)) and any(not isinstance(item, ast.Starred) for item in node.elts)
423
-
424
-
425
475
  def _invalidates_reference(node: ast.AST, reference: _Reference, binding: ast.Name) -> bool:
426
476
  match node:
427
477
  case ast.Name(id=name, ctx=ast.Store() | ast.Del()):