sarj-python-lint 1.2.1__tar.gz → 1.2.6__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 (150) hide show
  1. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_analysis_session.py +2 -0
  4. sarj_python_lint-1.2.6/src/sarj_python_lint/rules/_nominal_project.py +280 -0
  5. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_registry.py +2 -0
  6. sarj_python_lint-1.2.6/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +227 -0
  7. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_unused_value_marker.py +25 -55
  8. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_match_assert_never.py +53 -3
  9. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +302 -24
  10. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +248 -19
  11. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/.gitignore +0 -0
  12. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/LICENSE +0 -0
  13. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/README.md +0 -0
  14. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/__init__.py +0 -0
  15. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/__main__.py +0 -0
  16. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_file_context.py +0 -0
  17. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_python_target.py +0 -0
  19. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  20. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/benchmark.py +0 -0
  23. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/json_boundary.py +0 -0
  24. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/ratchet.py +0 -0
  26. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rule_base.py +0 -0
  27. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/__init__.py +0 -0
  28. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  29. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  30. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  31. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_comments.py +0 -0
  32. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  33. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  34. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_first_party.py +0 -0
  35. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  36. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_imports.py +0 -0
  37. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_logging.py +0 -0
  38. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_paths.py +0 -0
  39. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_project_index.py +0 -0
  40. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  41. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_pytest.py +0 -0
  42. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_sql.py +0 -0
  43. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  44. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  45. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  46. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  47. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  48. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  49. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  50. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  51. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  52. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  53. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  54. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  55. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  56. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  57. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  58. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  59. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  60. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  61. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  62. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  63. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  64. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  65. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  66. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  67. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  68. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  69. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  70. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  71. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  72. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  73. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  74. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  75. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  76. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  77. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  78. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  79. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  80. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  81. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  82. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  83. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  84. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  85. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  86. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  87. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  88. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  89. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  90. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  91. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  92. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  93. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  94. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  95. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  96. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  97. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  98. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  99. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  100. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  101. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  102. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  103. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  104. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  105. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  106. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  107. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  108. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  109. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  110. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  111. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  112. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  113. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  114. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  115. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  116. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  117. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  118. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  119. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  120. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  121. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  122. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  123. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  124. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  125. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  126. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  127. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  128. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  129. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  130. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  131. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  132. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  133. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  134. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  135. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  136. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  137. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  138. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  139. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  140. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  141. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  142. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/stepdown.py +0 -0
  143. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  144. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  145. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  146. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  147. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  148. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  149. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  150. {sarj_python_lint-1.2.1 → sarj_python_lint-1.2.6}/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.2.1
3
+ Version: 1.2.6
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.2.1"
3
+ version = "1.2.6"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, final
4
4
 
5
5
  from sarj_python_lint._python_target import PythonTargetFacts
6
6
  from sarj_python_lint.rules._first_party import FirstPartyFacts
7
+ from sarj_python_lint.rules._nominal_project import NominalProjectFacts
7
8
 
8
9
 
9
10
  if TYPE_CHECKING:
@@ -16,3 +17,4 @@ class AnalysisSession:
16
17
  self.project: ProjectIndexSet | None = None
17
18
  self.first_party = FirstPartyFacts()
18
19
  self.python_target = PythonTargetFacts()
20
+ self.nominal_project = NominalProjectFacts()
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ import tomllib
8
+ from types import MappingProxyType
9
+ from typing import TYPE_CHECKING, TypeGuard, final
10
+
11
+ from packaging.requirements import InvalidRequirement, Requirement
12
+ from packaging.utils import canonicalize_name
13
+
14
+ from sarj_python_lint.rules._first_party import distribution_root
15
+ from sarj_python_lint.rules._imports import ImportIndex
16
+ from sarj_python_lint.rules._paths import is_generated
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from sarj_python_lint.rules._first_party import FirstPartyFacts
21
+
22
+
23
+ _MAX_FILES = 5_000
24
+ _MAX_DIRECTORIES = 3_000
25
+ _MAX_BYTES = 500_000
26
+ _MAX_TOTAL_BYTES = 25_000_000
27
+ _SKIP = frozenset({"node_modules", "vendor", "vendored", "build", "dist", "__pycache__", "venv"})
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class NominalSource:
32
+ path: Path
33
+ text: str
34
+
35
+ @cached_property
36
+ def tree(self) -> ast.Module | None:
37
+ try:
38
+ tree = ast.parse(self.text, filename=str(self.path))
39
+ except SyntaxError:
40
+ return None
41
+ if any(
42
+ isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names)
43
+ for node in ast.walk(tree)
44
+ ):
45
+ return None
46
+ return tree
47
+
48
+ @cached_property
49
+ def imports(self) -> ImportIndex:
50
+ tree = self.tree
51
+ if tree is None:
52
+ return ImportIndex(MappingProxyType({}), frozenset())
53
+ imports = ImportIndex.from_tree(tree)
54
+ mutated = {
55
+ ast.unparse(node).partition(".")[0]
56
+ for node in ast.walk(tree)
57
+ if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del))
58
+ }
59
+ return ImportIndex(
60
+ MappingProxyType({name: target for name, target in imports.bindings.items() if name not in mutated}),
61
+ imports.shadowed_names | mutated,
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class _Workspace:
67
+ root: Path
68
+ members: tuple[str, ...]
69
+ excludes: tuple[str, ...]
70
+
71
+ def contains(self, path: Path) -> bool:
72
+ if path == self.root:
73
+ return True
74
+ if not path.is_relative_to(self.root):
75
+ return False
76
+ relative = path.relative_to(self.root)
77
+ return any(relative.full_match(pattern) for pattern in self.members) and not any(
78
+ relative.full_match(pattern) for pattern in self.excludes
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class _Distribution:
84
+ root: Path
85
+ name: str | None
86
+ dependencies: frozenset[str]
87
+ workspace: _Workspace | None
88
+
89
+
90
+ @final
91
+ class NominalProjectFacts:
92
+ # One request owns these caches; subsequent requests see source changes.
93
+ def __init__(self) -> None:
94
+ self._distributions: dict[Path, tuple[_Distribution, ...]] = {}
95
+ self._sources: dict[Path, dict[str, NominalSource]] = {}
96
+ self._scopes: dict[Path, dict[str, NominalSource]] = {}
97
+
98
+ def sources(self, path: Path, first_party: FirstPartyFacts) -> dict[str, NominalSource]:
99
+ owner = distribution_root(path, facts=first_party)
100
+ root = first_party.project_root(path)
101
+ if owner is None or root is None:
102
+ return {}
103
+ if owner in self._scopes:
104
+ return self._scopes[owner]
105
+ distributions = self._accessible_distributions(owner, root)
106
+ sources = self._merge_sources(distributions)
107
+ self._scopes[owner] = sources
108
+ return sources
109
+
110
+ def _accessible_distributions(self, owner: Path, root: Path) -> tuple[_Distribution, ...]:
111
+ if root not in self._distributions:
112
+ self._distributions[root] = tuple(
113
+ distribution
114
+ for manifest in _files(root, "pyproject.toml")
115
+ if (distribution := _distribution(manifest)) is not None
116
+ )
117
+ distributions = self._distributions[root]
118
+ own = next(
119
+ (
120
+ distribution
121
+ for distribution in distributions
122
+ if distribution.root == owner and distribution.name is not None
123
+ ),
124
+ None,
125
+ )
126
+ if own is None:
127
+ return ()
128
+ distributions = _workspace_distributions(distributions, owner)
129
+ return tuple(
130
+ distribution
131
+ for distribution in distributions
132
+ if distribution == own
133
+ or (
134
+ distribution.name in own.dependencies
135
+ and sum(candidate.name == distribution.name for candidate in distributions) == 1
136
+ )
137
+ )
138
+
139
+ def _merge_sources(self, distributions: tuple[_Distribution, ...]) -> dict[str, NominalSource]:
140
+ sources: dict[str, NominalSource] = {}
141
+ ambiguous: set[str] = set()
142
+ for distribution in distributions:
143
+ if distribution.root not in self._sources:
144
+ self._sources[distribution.root] = _sources(distribution.root)
145
+ for module, source in self._sources[distribution.root].items():
146
+ if module in sources:
147
+ ambiguous.add(module)
148
+ sources[module] = source
149
+ return {module: source for module, source in sources.items() if module not in ambiguous}
150
+
151
+
152
+ def _workspace_distributions(distributions: tuple[_Distribution, ...], owner: Path) -> tuple[_Distribution, ...]:
153
+ workspaces = [
154
+ distribution.workspace
155
+ for distribution in distributions
156
+ if distribution.workspace is not None and distribution.workspace.contains(owner)
157
+ ]
158
+ if workspaces:
159
+ workspace = max(workspaces, key=lambda scope: len(scope.root.parts))
160
+ distributions = tuple(distribution for distribution in distributions if workspace.contains(distribution.root))
161
+ return distributions
162
+
163
+
164
+ def _distribution(manifest: Path) -> _Distribution | None:
165
+ try:
166
+ if manifest.is_symlink() or manifest.stat().st_size > _MAX_BYTES:
167
+ return None
168
+ document: object = tomllib.loads(manifest.read_text(encoding="utf-8"))
169
+ except OSError, ValueError:
170
+ return None
171
+ if not _is_mapping(document):
172
+ return None
173
+ workspace = _workspace(manifest.parent, document)
174
+ project = document.get("project")
175
+ if not _is_mapping(project):
176
+ return _Distribution(manifest.parent, None, frozenset(), workspace) if workspace is not None else None
177
+ name = project.get("name")
178
+ dependencies = project.get("dependencies", [])
179
+ if not isinstance(name, str) or not _is_list(dependencies):
180
+ return None
181
+ names: set[str] = set()
182
+ for dependency in dependencies:
183
+ if not isinstance(dependency, str):
184
+ continue
185
+ try:
186
+ names.add(canonicalize_name(Requirement(dependency).name))
187
+ except InvalidRequirement:
188
+ continue
189
+ return _Distribution(manifest.parent, canonicalize_name(name), frozenset(names), workspace)
190
+
191
+
192
+ def _workspace(root: Path, document: dict[object, object]) -> _Workspace | None:
193
+ value: object = document
194
+ for key in ("tool", "uv", "workspace"):
195
+ if not _is_mapping(value):
196
+ return None
197
+ value = value.get(key)
198
+ if not _is_mapping(value):
199
+ return None
200
+ members = _patterns(value.get("members"))
201
+ excludes = _patterns(value.get("exclude", []))
202
+ if members is None or excludes is None:
203
+ return None
204
+ return _Workspace(root, members, excludes)
205
+
206
+
207
+ def _patterns(value: object) -> tuple[str, ...] | None:
208
+ if not _is_list(value) or any(not isinstance(item, str) for item in value):
209
+ return None
210
+ return tuple(item for item in value if isinstance(item, str))
211
+
212
+
213
+ def _files(root: Path, pattern: str) -> list[Path]:
214
+ found: list[Path] = []
215
+ try:
216
+ for count, (directory, directories, filenames) in enumerate(root.walk()):
217
+ if count >= _MAX_DIRECTORIES or len(found) >= _MAX_FILES:
218
+ # A partial inventory cannot establish uniqueness.
219
+ return []
220
+ directories[:] = sorted(name for name in directories if not name.startswith(".") and name not in _SKIP)
221
+ found.extend(directory / name for name in sorted(filenames) if Path(name).match(pattern))
222
+ if len(found) > _MAX_FILES:
223
+ return []
224
+ except OSError:
225
+ return []
226
+ return found
227
+
228
+
229
+ def _sources(root: Path) -> dict[str, NominalSource]:
230
+ sources: dict[str, NominalSource] = {}
231
+ ambiguous: set[str] = set()
232
+ total_bytes = 0
233
+ for path in _files(root, "*.py"):
234
+ try:
235
+ total_bytes += path.stat().st_size
236
+ except OSError:
237
+ continue
238
+ if total_bytes > _MAX_TOTAL_BYTES:
239
+ return {}
240
+ module = _module_name(root, path)
241
+ if not module:
242
+ continue
243
+ source = _source(path)
244
+ if source is None:
245
+ continue
246
+ if module in sources:
247
+ ambiguous.add(module)
248
+ sources[module] = source
249
+ return {module: source for module, source in sources.items() if module not in ambiguous}
250
+
251
+
252
+ def _module_name(root: Path, path: Path) -> str | None:
253
+ # A nested manifest owns its sources even when it uses the same namespace.
254
+ if any(
255
+ (parent / "pyproject.toml").is_file() for parent in path.parents if parent != root and root in parent.parents
256
+ ):
257
+ return None
258
+ relative = path.relative_to(root)
259
+ parts = relative.parts[1:] if relative.parts[0] == "src" else relative.parts
260
+ return ".".join(parts[:-1] if parts[-1] == "__init__.py" else (*parts[:-1], path.stem)) or None
261
+
262
+
263
+ def _source(path: Path) -> NominalSource | None:
264
+ try:
265
+ if path.is_symlink() or path.stat().st_size > _MAX_BYTES:
266
+ return None
267
+ text = path.read_text(encoding="utf-8")
268
+ if is_generated(path, text):
269
+ return None
270
+ except OSError, UnicodeError:
271
+ return None
272
+ return NominalSource(path, text)
273
+
274
+
275
+ def _is_mapping(value: object) -> TypeGuard[dict[object, object]]:
276
+ return isinstance(value, dict)
277
+
278
+
279
+ def _is_list(value: object) -> TypeGuard[list[object]]:
280
+ return isinstance(value, list)
@@ -75,6 +75,7 @@ from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
75
75
  from sarj_python_lint.rules.no_select_star import NoSelectStar
76
76
  from sarj_python_lint.rules.no_service_behavior_in_settings import NoServiceBehaviorInSettings
77
77
  from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
78
+ from sarj_python_lint.rules.no_swallowed_asyncio_cancellation import NoSwallowedAsyncioCancellation
78
79
  from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
79
80
  from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
80
81
  from sarj_python_lint.rules.no_unique_violation_message_match import (
@@ -181,6 +182,7 @@ if TYPE_CHECKING:
181
182
 
182
183
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
183
184
  {
185
+ NoSwallowedAsyncioCancellation.id: NoSwallowedAsyncioCancellation,
184
186
  AsyncMockCallWithoutAwaitAssertion.id: AsyncMockCallWithoutAwaitAssertion,
185
187
  ComplexPostgresQueryRequiresArchitectureReview.id: ComplexPostgresQueryRequiresArchitectureReview,
186
188
  ExcessiveCommentary.id: ExcessiveCommentary,
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from itertools import pairwise
5
+ from pathlib import PurePosixPath
6
+ from typing import TYPE_CHECKING, ClassVar, final, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
17
+ Severity,
18
+ is_suppressed,
19
+ )
20
+ from sarj_python_lint.rules._ast_index import nodes
21
+ from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterator
26
+
27
+ from sarj_python_lint._file_context import PythonFileContext
28
+ from sarj_python_lint.rules._imports import ImportIndex
29
+
30
+
31
+ _ASYNCIO = frozenset({"asyncio", "asyncio.exceptions"})
32
+ _SIMPLE_STATEMENTS = (ast.Expr, ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete, ast.Pass)
33
+ _MESSAGE = (
34
+ "CancelledError handler completes normally; re-raise caller cancellation after cleanup, "
35
+ "or guard intentional child cancellation with the current task's cancellation state."
36
+ )
37
+
38
+
39
+ @final
40
+ class NoSwallowedAsyncioCancellation(Rule):
41
+ id = "no-swallowed-asyncio-cancellation"
42
+ code = "SARJ458"
43
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
44
+ default_level=Severity.WARNING,
45
+ summary="Preserve cancellation when an async operation is cancelled.",
46
+ rationale=(
47
+ "Returning or falling through after catching CancelledError can make cancelled work look successful "
48
+ "and interfere with task groups and timeout shutdown. Child cancellation must be distinguished from "
49
+ "cancellation of the caller."
50
+ ),
51
+ remediation=(
52
+ "Re-raise after cleanup. If child cancellation is an expected result, first propagate cancellation "
53
+ "when asyncio.current_task().cancelling() is nonzero. Use an exact suppression for a documented "
54
+ "boundary that deliberately consumes cancellation."
55
+ ),
56
+ category=RuleCategory.CORRECTNESS,
57
+ autofix=AutofixPolicy.NONE,
58
+ limitations=(
59
+ "Only explicit, import-proven asyncio.CancelledError handlers in async functions are inspected; broad handlers, contextlib.suppress, tests, support fixtures, and generated files are excluded.",
60
+ "Only straight-line handlers with a return or normal fallthrough are reported. Compound handlers, re-raising, and translated exceptions are excluded. Any attribute call named uncancel is conservatively excluded without proving its receiver, so unrelated methods can hide a finding.",
61
+ "A single-name task created by asyncio.create_task, immediately cancelled before a try that only awaits it, is intentionally excluded as child cleanup; this does not prove concurrent caller cancellation cannot occur.",
62
+ "Try statements with control-transferring or compound finalizers are excluded, including handlers nested beneath them. Runtime helper effects and interprocedural task ownership are not inferred.",
63
+ ),
64
+ examples=(
65
+ RuleExample(
66
+ example_id="cancelled-operation-returns-success",
67
+ title="Cancellation becomes a successful return",
68
+ outcome=ExampleOutcome.MATCH,
69
+ files=(
70
+ ExampleFile.python(
71
+ "worker.py",
72
+ "import asyncio\n\nasync def worker():\n try:\n await asyncio.sleep(10)\n except asyncio.CancelledError:\n return None\n",
73
+ ),
74
+ ),
75
+ focus_path=PurePosixPath("worker.py"),
76
+ expected_count=1,
77
+ public=True,
78
+ ),
79
+ RuleExample(
80
+ example_id="cancelled-operation-reraises",
81
+ title="Cleanup preserves cancellation",
82
+ outcome=ExampleOutcome.NO_MATCH,
83
+ files=(
84
+ ExampleFile.python(
85
+ "worker.py",
86
+ "import asyncio\n\nasync def worker():\n try:\n await asyncio.sleep(10)\n except asyncio.CancelledError:\n raise\n",
87
+ ),
88
+ ),
89
+ focus_path=PurePosixPath("worker.py"),
90
+ expected_count=0,
91
+ public=True,
92
+ ),
93
+ ),
94
+ )
95
+ description = documentation.summary
96
+
97
+ @override
98
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
99
+ if "CancelledError" not in context.source:
100
+ return []
101
+ if context.generated or is_test_path(context.path) or is_test_support_path(context.path):
102
+ return []
103
+ tree = context.tree
104
+ if tree is None:
105
+ return []
106
+ diagnostics: list[Diagnostic] = []
107
+ for function in nodes(tree, ast.AsyncFunctionDef, index=context.node_index):
108
+ diagnostics.extend(_function_diagnostics(function, context, self.code))
109
+ return sorted(diagnostics, key=lambda item: (item.line, item.col))
110
+
111
+
112
+ def _function_diagnostics(function: ast.AsyncFunctionDef, context: PythonFileContext, code: str) -> list[Diagnostic]:
113
+ local = tuple(_local_nodes(function))
114
+ children = _owned_tasks(local, context.imports)
115
+ previous = _preceding_statements(local)
116
+ diagnostics: list[Diagnostic] = []
117
+ for node in local:
118
+ if not isinstance(node, ast.Try) or _owned_child_cleanup(node, previous.get(id(node)), children):
119
+ continue
120
+ for handler in node.handlers:
121
+ if not _catches_cancellation(handler.type, context.imports) or not _normal_exit(handler.body):
122
+ continue
123
+ if is_suppressed(context.source_lines, handler.lineno, code):
124
+ continue
125
+ diagnostics.append(
126
+ Diagnostic(
127
+ path=context.path,
128
+ line=handler.lineno,
129
+ col=handler.col_offset + 1,
130
+ code=code,
131
+ message=_MESSAGE,
132
+ severity=Severity.WARNING,
133
+ )
134
+ )
135
+ return diagnostics
136
+
137
+
138
+ def _local_nodes(function: ast.AsyncFunctionDef) -> Iterator[ast.AST]:
139
+ stack: list[ast.AST] = list(reversed(function.body))
140
+ yield function
141
+ while stack:
142
+ node = stack.pop()
143
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda):
144
+ continue
145
+ if isinstance(node, ast.Try | ast.TryStar) and any(
146
+ not isinstance(statement, _SIMPLE_STATEMENTS) for statement in node.finalbody
147
+ ):
148
+ continue
149
+ yield node
150
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
151
+
152
+
153
+ def _catches_cancellation(exception: ast.expr | None, imports: ImportIndex) -> bool:
154
+ if isinstance(exception, ast.Tuple):
155
+ return any(_catches_cancellation(item, imports) for item in exception.elts)
156
+ return exception is not None and imports.resolves(exception, sources=_ASYNCIO, symbol="CancelledError")
157
+
158
+
159
+ def _normal_exit(statements: list[ast.stmt]) -> bool:
160
+ for statement in statements:
161
+ if any(
162
+ isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "uncancel"
163
+ for node in ast.walk(statement)
164
+ ):
165
+ return False
166
+ if isinstance(statement, ast.Return):
167
+ return True
168
+ if not isinstance(statement, _SIMPLE_STATEMENTS):
169
+ return False
170
+ return True
171
+
172
+
173
+ def _owned_tasks(local: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
174
+ bindings: dict[str, list[ast.AST]] = {}
175
+ for node in local:
176
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store | ast.Del):
177
+ bindings.setdefault(node.id, []).append(node)
178
+ if (
179
+ isinstance(node, ast.Attribute)
180
+ and isinstance(node.ctx, ast.Store | ast.Del)
181
+ and isinstance(node.value, ast.Name)
182
+ ):
183
+ bindings.setdefault(node.value.id, []).append(node)
184
+ return frozenset(
185
+ node.targets[0].id
186
+ for node in local
187
+ if isinstance(node, ast.Assign)
188
+ and len(node.targets) == 1
189
+ and isinstance(node.targets[0], ast.Name)
190
+ and len(bindings[node.targets[0].id]) == 1
191
+ and isinstance(node.value, ast.Call)
192
+ and imports.resolves(node.value.func, sources=frozenset({"asyncio"}), symbol="create_task")
193
+ )
194
+
195
+
196
+ def _preceding_statements(local: tuple[ast.AST, ...]) -> dict[int, ast.stmt]:
197
+ previous: dict[int, ast.stmt] = {}
198
+ for node in local:
199
+ for suite in _statement_suites(node):
200
+ for first, second in pairwise(suite):
201
+ previous[id(second)] = first
202
+ return previous
203
+
204
+
205
+ def _owned_child_cleanup(node: ast.Try, previous: ast.stmt | None, children: frozenset[str]) -> bool:
206
+ if len(node.body) != 1:
207
+ return False
208
+ match node.body[0], previous:
209
+ case (
210
+ ast.Expr(value=ast.Await(value=ast.Name(id=awaited))),
211
+ ast.Expr(value=ast.Call(func=ast.Attribute(value=ast.Name(id=cancelled), attr="cancel"))),
212
+ ):
213
+ return awaited == cancelled and awaited in children
214
+ case _:
215
+ return False
216
+
217
+
218
+ def _statement_suites(node: ast.AST) -> tuple[list[ast.stmt], ...]:
219
+ match node:
220
+ case ast.Try() | ast.TryStar():
221
+ return node.body, node.orelse, node.finalbody
222
+ case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
223
+ return node.body, node.orelse
224
+ case ast.AsyncFunctionDef() | ast.With() | ast.AsyncWith() | ast.ExceptHandler() | ast.match_case():
225
+ return (node.body,)
226
+ case _:
227
+ return ()