sarj-python-lint 0.80.11__tar.gz → 0.80.12__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 (123) hide show
  1. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_registry.py +0 -2
  4. sarj_python_lint-0.80.11/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -466
  5. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/.gitignore +0 -0
  6. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/LICENSE +0 -0
  7. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/README.md +0 -0
  8. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  23. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  25. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  35. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  36. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  39. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  40. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  42. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  43. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  44. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  45. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  46. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  48. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  49. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  50. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  51. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  52. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  53. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  54. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  55. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  56. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  57. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  58. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  60. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  61. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  62. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  63. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  64. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  65. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  66. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  67. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  68. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  69. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  70. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  71. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  72. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  73. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  74. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  75. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  76. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  77. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  78. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  79. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  80. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  81. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  82. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  83. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  84. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  85. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  86. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  87. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  88. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  89. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  90. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  91. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  92. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  93. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  94. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  95. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  96. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  97. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  98. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  99. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  100. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  101. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  102. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  103. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  104. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  105. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  106. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  107. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  108. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  109. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  110. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  111. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  112. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  113. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  114. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  115. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  116. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/stepdown.py +0 -0
  117. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  118. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  119. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  120. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  121. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  122. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  123. {sarj_python_lint-0.80.11 → sarj_python_lint-0.80.12}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.80.11
3
+ Version: 0.80.12
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 = "0.80.11"
3
+ version = "0.80.12"
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" }]
@@ -146,7 +146,6 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
146
146
  )
147
147
  from sarj_python_lint.rules.timestamp_order_requires_tiebreaker import TimestampOrderRequiresTiebreaker
148
148
  from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
149
- from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
150
149
  from sarj_python_lint.rules.typed_error_reasons import TypedErrorReasons
151
150
  from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
152
151
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
@@ -211,7 +210,6 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
211
210
  OverMockedTest.id: OverMockedTest,
212
211
  InvalidPydanticFieldDefault.id: InvalidPydanticFieldDefault,
213
212
  NoFrozenAfterValidatorFieldWrite.id: NoFrozenAfterValidatorFieldWrite,
214
- TriviallyTrueAssertion.id: TriviallyTrueAssertion,
215
213
  NoRepeatedTestBody.id: NoRepeatedTestBody,
216
214
  UnusedMockSetup.id: UnusedMockSetup,
217
215
  PreferFstringOverConcat.id: PreferFstringOverConcat,
@@ -1,466 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- from dataclasses import dataclass
5
- from pathlib import PurePosixPath
6
- from typing import TYPE_CHECKING, ClassVar, NamedTuple, 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
- parse_or_none,
18
- )
19
- from sarj_python_lint.rules._paths import is_test_path
20
-
21
-
22
- if TYPE_CHECKING:
23
- from pathlib import Path
24
-
25
-
26
- _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
27
-
28
- _TEST_PREFIX = "test_"
29
-
30
- # pytest's default `python_files`.
31
- _COLLECTED_SUFFIX = "_test.py"
32
-
33
- # Manual CLI probes carry `test_*.py` names but are never collected.
34
- _UNCOLLECTED_DIR_NAMES = frozenset({"scripts"})
35
-
36
- _ISINSTANCE = "isinstance"
37
-
38
- _ISINSTANCE_ARITY = 2
39
-
40
- _DUNDER_PREFIX = "__"
41
-
42
- # Class-name endings that mark a collaborator rather than a record.
43
- _COLLABORATOR_SUFFIXES = (
44
- "backend",
45
- "client",
46
- "service",
47
- "manager",
48
- "handler",
49
- "server",
50
- "session",
51
- "pool",
52
- "engine",
53
- "runner",
54
- "worker",
55
- "store",
56
- "repository",
57
- "factory",
58
- "builder",
59
- "adapter",
60
- "connection",
61
- "transport",
62
- "receiver",
63
- )
64
-
65
- # Permit identity comparison only for boolean singleton assertions.
66
- _ECHO_OPS = (ast.Eq, ast.Is)
67
-
68
- _UNITTEST_ECHO_ASSERTS = frozenset({"assertEqual", "assertIs"})
69
-
70
- _UNITTEST_ISINSTANCE_ASSERT = "assertIsInstance"
71
-
72
-
73
- @dataclass(frozen=True, slots=True)
74
- class _EchoOperands:
75
- left: ast.expr
76
- right: ast.expr
77
-
78
-
79
- type _Assertion = ast.Assert | ast.Call
80
-
81
-
82
- class _AssertionFinding(NamedTuple):
83
- node: _Assertion
84
- diagnosis: str
85
-
86
-
87
- _KWARG_DIAGNOSIS = (
88
- "this reads back the literal the test just handed the constructor, so it can only fail if attribute "
89
- "assignment stops working"
90
- )
91
-
92
- _ISINSTANCE_DIAGNOSIS = (
93
- "the value was produced by calling this very class a line above, so the `isinstance` check pins the "
94
- "language rather than the code"
95
- )
96
-
97
- # The advice when the test still has an assertion that can fail.
98
- _ADVICE = ". Assert on something the code under test derived, or drop the assertion"
99
-
100
- # The advice when it does not.
101
- _ONLY_ASSERTION_ADVICE = (
102
- ". Every assertion this test makes is like it, so dropping them would leave a test that verifies "
103
- "nothing. Assert the behaviour the test name claims to cover, or delete the test"
104
- )
105
-
106
-
107
- @dataclass(frozen=True, slots=True)
108
- class _KwargEcho:
109
- node: _Assertion
110
- field: tuple[str, str]
111
- echoes: bool
112
-
113
-
114
- @dataclass(slots=True)
115
- class _Scope:
116
- asserts: list[_Assertion]
117
- loads: dict[str, list[ast.Name]]
118
- binds: dict[str, int]
119
- calls: dict[str, ast.Call]
120
- shadowed: set[str]
121
-
122
-
123
- @dataclass(frozen=True, slots=True)
124
- class _Index:
125
- parents: dict[int, ast.AST]
126
- scopes: list[_Scope]
127
- owners: dict[int, _Scope]
128
- imported_names: frozenset[str]
129
-
130
-
131
- class TriviallyTrueAssertion(Rule):
132
- id: str = "trivially-true-assertion"
133
- code: str = "SARJ064"
134
- documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
135
- summary="Assertions should depend on behavior rather than echoing values supplied by the test.",
136
- rationale="Constructor keyword echoes and equivalent tautologies cannot reveal an application defect.",
137
- remediation="Assert a transformation, validation result, or other value produced independently of the fixture literal.",
138
- category=RuleCategory.TESTING,
139
- autofix=AutofixPolicy.NONE,
140
- limitations=(
141
- "Literal-only assertions owned by Ruff or SARJ057 are excluded.",
142
- "Constructor echoes with evidence of field coercion are excluded.",
143
- "Imported constructors are excluded because their assignment, validation, and normalization behavior is not visible.",
144
- ),
145
- examples=(
146
- RuleExample(
147
- example_id="constructor-keyword-echo",
148
- title="Assertion repeats a constructor keyword",
149
- outcome=ExampleOutcome.MATCH,
150
- files=(
151
- ExampleFile.python(
152
- "tests/test_user.py",
153
- 'def test_user():\n user = User(name="Ada")\n assert user.name == "Ada"\n',
154
- ),
155
- ),
156
- focus_path=PurePosixPath("tests/test_user.py"),
157
- expected_count=1,
158
- public=True,
159
- ),
160
- RuleExample(
161
- example_id="derived-value",
162
- title="Assertion checks a derived value",
163
- outcome=ExampleOutcome.NO_MATCH,
164
- files=(
165
- ExampleFile.python(
166
- "tests/test_user.py",
167
- 'def test_user():\n user = User(name="Ada Lovelace")\n assert user.initials == "AL"\n',
168
- ),
169
- ),
170
- focus_path=PurePosixPath("tests/test_user.py"),
171
- expected_count=0,
172
- public=True,
173
- ),
174
- ),
175
- )
176
- description: str = documentation.summary
177
-
178
- @override
179
- def check(self, path: Path, source: str) -> list[Diagnostic]:
180
- if not is_test_path(path) or not _is_collected_module(path):
181
- return []
182
- tree = parse_or_none(path, source)
183
- if tree is None:
184
- return []
185
-
186
- index = _index_module(tree)
187
- findings: dict[int, _AssertionFinding] = {}
188
- for node, diagnosis in _construction_findings(index):
189
- _ = findings.setdefault(id(node), _AssertionFinding(node, diagnosis))
190
-
191
- diags = [
192
- Diagnostic(
193
- path=path,
194
- line=node.lineno,
195
- col=node.col_offset + 1,
196
- code=self.code,
197
- message=diagnosis + _advice(index.owners.get(id(node)), findings),
198
- )
199
- for node, diagnosis in _one_per_test(index, findings)
200
- ]
201
- diags.sort(key=lambda d: (d.line, d.col))
202
- return diags
203
-
204
-
205
- def _one_per_test(index: _Index, findings: dict[int, _AssertionFinding]) -> list[_AssertionFinding]:
206
- anchors: dict[int, _AssertionFinding] = {}
207
- for node, diagnosis in findings.values():
208
- scope = index.owners.get(id(node))
209
- key = id(node) if scope is None else id(scope)
210
- earlier = anchors.get(key)
211
- if earlier is None or (node.lineno, node.col_offset) < (earlier[0].lineno, earlier[0].col_offset):
212
- anchors[key] = _AssertionFinding(node, diagnosis)
213
- return list(anchors.values())
214
-
215
-
216
- def _advice(scope: _Scope | None, findings: dict[int, _AssertionFinding]) -> str:
217
- if scope is None or any(id(node) not in findings for node in scope.asserts):
218
- return _ADVICE
219
- return _ONLY_ASSERTION_ADVICE
220
-
221
-
222
- def _is_collected_module(path: Path) -> bool:
223
- name = path.name
224
- matches_python_files = name.startswith(_TEST_PREFIX) or name.endswith(_COLLECTED_SUFFIX)
225
- return matches_python_files and not any(part in _UNCOLLECTED_DIR_NAMES for part in path.parts)
226
-
227
-
228
- def _index_module(tree: ast.Module) -> _Index:
229
- parents: dict[int, ast.AST] = {}
230
- scopes: list[_Scope] = []
231
- owners: dict[int, _Scope] = {}
232
- stack: list[tuple[ast.AST, _Scope | None]] = [(tree, None)]
233
- while stack:
234
- node, scope = stack.pop()
235
- if scope is None and isinstance(node, _FUNC_NODES):
236
- scope = _Scope(asserts=[], loads={}, binds={}, calls={}, shadowed=set())
237
- scopes.append(scope)
238
- if scope is not None:
239
- if isinstance(node, ast.Assert) or (isinstance(node, ast.Call) and _is_unittest_assertion(node)):
240
- scope.asserts.append(node)
241
- owners[id(node)] = scope
242
- else:
243
- _record_local(node, scope)
244
- for child in ast.iter_child_nodes(node):
245
- parents[id(child)] = node
246
- stack.append((child, scope))
247
- return _Index(
248
- parents=parents,
249
- scopes=scopes,
250
- owners=owners,
251
- imported_names=_imported_names(tree),
252
- )
253
-
254
-
255
- def _imported_names(tree: ast.Module) -> frozenset[str]:
256
- bindings: set[str] = set()
257
- for node in ast.walk(tree):
258
- if isinstance(node, ast.Import):
259
- bindings.update(alias.asname or alias.name.split(".", maxsplit=1)[0] for alias in node.names)
260
- elif isinstance(node, ast.ImportFrom):
261
- bindings.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
262
- return frozenset(bindings)
263
-
264
-
265
- def _record_local(node: ast.AST, scope: _Scope) -> None:
266
- if isinstance(node, ast.Name):
267
- if isinstance(node.ctx, ast.Load):
268
- scope.loads.setdefault(node.id, []).append(node)
269
- else:
270
- scope.binds[node.id] = scope.binds.get(node.id, 0) + 1
271
- elif isinstance(node, ast.arg):
272
- scope.shadowed.add(node.arg)
273
- elif isinstance(node, (ast.Global, ast.Nonlocal)):
274
- scope.shadowed.update(node.names)
275
- elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.value, ast.Call):
276
- target = node.targets[0]
277
- if isinstance(target, ast.Name):
278
- scope.calls[target.id] = node.value
279
-
280
-
281
- # Both shapes: what the test constructed a line earlier. #
282
-
283
-
284
- def _construction_findings(index: _Index) -> list[_AssertionFinding]:
285
- echoes: list[_KwargEcho] = []
286
- hits: list[_AssertionFinding] = []
287
- for scope in index.scopes:
288
- if not scope.asserts or not scope.calls:
289
- continue
290
- constructed = _constructed_locals(scope, index.parents)
291
- if not constructed:
292
- continue
293
- for node in scope.asserts:
294
- echo = _kwarg_echo(node, constructed, index.imported_names)
295
- if echo is not None:
296
- echoes.append(echo)
297
- elif _is_isinstance_echo(node, constructed, scope.asserts):
298
- hits.append(_AssertionFinding(node, _ISINSTANCE_DIAGNOSIS))
299
-
300
- coercing = {echo.field for echo in echoes if not echo.echoes}
301
- hits.extend(
302
- _AssertionFinding(echo.node, _KWARG_DIAGNOSIS) for echo in echoes if echo.echoes and echo.field not in coercing
303
- )
304
- return hits
305
-
306
-
307
- def _constructed_locals(scope: _Scope, parents: dict[int, ast.AST]) -> dict[str, ast.Call]:
308
- return {
309
- name: call
310
- for name, call in scope.calls.items()
311
- if name not in scope.shadowed
312
- and scope.binds.get(name) == 1
313
- and all(_is_assertion_read(load, parents) for load in scope.loads.get(name, []))
314
- }
315
-
316
-
317
- def _is_assertion_read(node: ast.Name, parents: dict[int, ast.AST]) -> bool:
318
- parent = parents.get(id(node))
319
- if isinstance(parent, ast.Attribute):
320
- grandparent = parents.get(id(parent))
321
- if isinstance(grandparent, ast.Call) and grandparent.func is parent:
322
- return False
323
- return _under_assert(parent, parents)
324
- if isinstance(parent, ast.Call) and parent.args and parent.args[0] is node:
325
- if _is_isinstance_call(parent):
326
- return _under_assert(parent, parents)
327
- if (
328
- isinstance(parent.func, ast.Attribute)
329
- and parent.func.attr == _UNITTEST_ISINSTANCE_ASSERT
330
- and _is_unittest_assertion(parent)
331
- ):
332
- return True
333
- return False
334
-
335
-
336
- def _under_assert(node: ast.AST, parents: dict[int, ast.AST]) -> bool:
337
- current: ast.AST | None = node
338
- while current is not None:
339
- if isinstance(current, ast.Assert) or _is_unittest_assertion(current):
340
- return True
341
- current = parents.get(id(current))
342
- return False
343
-
344
-
345
- def _is_isinstance_call(node: ast.Call) -> bool:
346
- return isinstance(node.func, ast.Name) and node.func.id == _ISINSTANCE
347
-
348
-
349
- def _is_unittest_assertion(node: ast.AST) -> bool:
350
- return (
351
- isinstance(node, ast.Call)
352
- and isinstance(node.func, ast.Attribute)
353
- and isinstance(node.func.value, ast.Name)
354
- and node.func.value.id == "self"
355
- and node.func.attr.startswith("assert")
356
- )
357
-
358
-
359
- def _kwarg_echo(
360
- node: _Assertion,
361
- constructed: dict[str, ast.Call],
362
- imported_names: frozenset[str],
363
- ) -> _KwargEcho | None:
364
- operands = _echo_operands(node)
365
- if operands is None:
366
- return None
367
- for attribute, literal in ((operands.left, operands.right), (operands.right, operands.left)):
368
- if not isinstance(attribute, ast.Attribute) or not isinstance(attribute.value, ast.Name):
369
- continue
370
- if attribute.attr.startswith(_DUNDER_PREFIX):
371
- continue
372
- call = constructed.get(attribute.value.id)
373
- if call is None or node.lineno <= call.lineno:
374
- continue
375
- if _uses_imported_binding(call.func, imported_names):
376
- continue
377
- name = _constructor_name(call)
378
- if name is None or not _is_pure_literal(literal):
379
- continue
380
- for keyword in call.keywords:
381
- if keyword.arg == attribute.attr and _is_pure_literal(keyword.value):
382
- echoes = ast.dump(keyword.value) == ast.dump(literal)
383
- return _KwargEcho(node=node, field=(name, attribute.attr), echoes=echoes)
384
- return None
385
-
386
-
387
- def _uses_imported_binding(func: ast.expr, imported_names: frozenset[str]) -> bool:
388
- current = func
389
- while isinstance(current, ast.Attribute):
390
- current = current.value
391
- return isinstance(current, ast.Name) and current.id in imported_names
392
-
393
-
394
- def _echo_operands(node: _Assertion) -> _EchoOperands | None:
395
- if isinstance(node, ast.Assert):
396
- test = node.test
397
- if not isinstance(test, ast.Compare) or len(test.ops) != 1 or not isinstance(test.ops[0], _ECHO_OPS):
398
- return None
399
- return _EchoOperands(test.left, test.comparators[0])
400
- if (
401
- isinstance(node.func, ast.Attribute)
402
- and node.func.attr in _UNITTEST_ECHO_ASSERTS
403
- and len(node.args) >= _ISINSTANCE_ARITY
404
- ):
405
- return _EchoOperands(node.args[0], node.args[1])
406
- return None
407
-
408
-
409
- def _constructor_name(call: ast.Call) -> str | None:
410
- name = _called_name(call.func)
411
- if name is None or not name[:1].isupper():
412
- return None
413
- lowered = name.lower()
414
- return None if lowered.endswith(_COLLABORATOR_SUFFIXES) else name
415
-
416
-
417
- def _called_name(func: ast.expr) -> str | None:
418
- if isinstance(func, ast.Name):
419
- return func.id
420
- return func.attr if isinstance(func, ast.Attribute) else None
421
-
422
-
423
- def _is_pure_literal(node: ast.expr) -> bool:
424
- match node:
425
- case ast.Constant():
426
- return True
427
- case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=operand):
428
- return _is_pure_literal(operand)
429
- case ast.List() | ast.Set() | ast.Tuple():
430
- return all(_is_pure_literal(elt) for elt in node.elts)
431
- case ast.Dict(keys=keys, values=values):
432
- keys_ok = all(key is not None and _is_pure_literal(key) for key in keys)
433
- return keys_ok and all(_is_pure_literal(value) for value in values)
434
- case _:
435
- return False
436
-
437
-
438
- def _is_isinstance_echo(node: _Assertion, constructed: dict[str, ast.Call], asserts: list[_Assertion]) -> bool:
439
- if isinstance(node, ast.Assert):
440
- test = node.test
441
- if not isinstance(test, ast.Call) or not _is_isinstance_call(test) or len(test.args) != _ISINSTANCE_ARITY:
442
- return False
443
- target, cls = test.args
444
- elif (
445
- isinstance(node.func, ast.Attribute)
446
- and node.func.attr == _UNITTEST_ISINSTANCE_ASSERT
447
- and len(node.args) >= _ISINSTANCE_ARITY
448
- ):
449
- target, cls = node.args[:_ISINSTANCE_ARITY]
450
- else:
451
- return False
452
- if not isinstance(target, ast.Name):
453
- return False
454
- call = constructed.get(target.id)
455
- if call is None or node.lineno <= call.lineno or ast.dump(call.func) != ast.dump(cls):
456
- return False
457
- return not _narrows_for_a_later_assertion(node, target.id, asserts)
458
-
459
-
460
- def _narrows_for_a_later_assertion(node: _Assertion, name: str, asserts: list[_Assertion]) -> bool:
461
- for other in asserts:
462
- if other.lineno <= node.lineno:
463
- continue
464
- if any(isinstance(child, ast.Name) and child.id == name for child in ast.walk(other)):
465
- return True
466
- return False