sarj-python-lint 0.80.13__tar.gz → 0.80.14__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 (122) hide show
  1. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/unused_mock_setup.py +202 -58
  4. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/.gitignore +0 -0
  5. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/LICENSE +0 -0
  6. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/README.md +0 -0
  7. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/_analysis_session.py +0 -0
  10. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/_filesystem.py +0 -0
  11. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  12. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/_version.py +0 -0
  14. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/py.typed +0 -0
  15. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/ratchet.py +0 -0
  16. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rule_base.py +0 -0
  17. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  19. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_comments.py +0 -0
  20. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  21. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  22. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  24. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_imports.py +0 -0
  25. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_logging.py +0 -0
  26. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_paths.py +0 -0
  27. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_project_index.py +0 -0
  28. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  29. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_pytest.py +0 -0
  30. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_registry.py +0 -0
  31. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  35. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  36. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  39. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  40. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  42. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  43. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  44. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  45. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  46. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  48. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  49. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  50. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  51. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  52. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  53. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  54. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  55. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  56. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  57. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  58. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  60. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  61. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  62. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  63. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  64. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  65. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  66. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  67. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  68. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  69. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  70. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  71. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  72. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  73. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  74. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  75. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  76. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  77. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  78. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  79. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  80. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  81. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  82. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  83. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  84. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  85. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  86. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  87. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  88. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  89. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  90. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  91. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  92. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  93. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  94. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  95. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  96. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  97. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  98. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  99. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  100. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  101. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  102. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  103. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  104. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  105. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  106. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  107. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  108. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  109. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  110. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  111. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  112. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  113. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  114. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  115. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  116. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/stepdown.py +0 -0
  117. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  118. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  119. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  120. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  121. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  122. {sarj_python_lint-0.80.13 → sarj_python_lint-0.80.14}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.80.13
3
+ Version: 0.80.14
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.13"
3
+ version = "0.80.14"
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" }]
@@ -13,11 +13,12 @@ from sarj_python_lint.rule_base import (
13
13
  RuleCategory,
14
14
  RuleDocumentation,
15
15
  RuleExample,
16
+ Severity,
16
17
  parse_or_none,
17
18
  )
18
19
  from sarj_python_lint.rules._ast_index import children, nodes, walk
19
20
  from sarj_python_lint.rules._imports import ImportIndex
20
- from sarj_python_lint.rules._paths import is_test_path
21
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
21
22
 
22
23
 
23
24
  if TYPE_CHECKING:
@@ -72,18 +73,6 @@ _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
72
73
  # itself processed as a function.
73
74
  _NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
74
75
 
75
- # Expression nodes that can run arbitrary user code, so a statement containing
76
- # one may have observed a mock configuration written above it.
77
- _EFFECTFUL_NODES = (
78
- ast.Attribute,
79
- ast.Await,
80
- ast.Call,
81
- ast.NamedExpr,
82
- ast.Subscript,
83
- ast.Yield,
84
- ast.YieldFrom,
85
- )
86
-
87
76
 
88
77
  class _Finding(NamedTuple):
89
78
  node: ast.stmt
@@ -91,17 +80,23 @@ class _Finding(NamedTuple):
91
80
 
92
81
 
93
82
  class UnusedMockSetup(Rule):
94
- id: str = "unused-mock-setup"
83
+ id: str = "no-provably-dead-mock-configuration"
95
84
  code: str = "SARJ067"
96
85
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
97
- summary="Tests should remove mock configuration that cannot affect execution.",
98
- rationale="Overwritten or contradicted mock setup adds misleading, unreachable test behavior.",
99
- remediation="Delete the unused setup or exercise the mock before replacing or contradicting it.",
86
+ summary="Remove mock behavior configuration proven unable to affect a test.",
87
+ rationale=(
88
+ "A mock behavior value is dead when a same-path write replaces it before observation, or when a "
89
+ "terminal exact-path assertion proves the configured mock was never called."
90
+ ),
91
+ remediation=(
92
+ "Delete the earlier overwritten configuration, or place the intended action before reconfiguration; "
93
+ "delete behavior configuration contradicted by a terminal never-called assertion."
94
+ ),
100
95
  category=RuleCategory.TESTING,
101
96
  autofix=AutofixPolicy.NONE,
102
97
  limitations=(
103
- "Only test paths are analyzed.",
104
- "Potentially effectful statements between assignments prevent a finding.",
98
+ "Only non-generated test paths and mock values proven from unittest.mock or an unshadowed pytest-mock fixture are analyzed.",
99
+ "Only same-block overwrites and exact-path terminal never-called assertions are checked; uncertain evaluation, control flow, reads, and escapes are excluded.",
105
100
  ),
106
101
  examples=(
107
102
  RuleExample(
@@ -111,7 +106,7 @@ class UnusedMockSetup(Rule):
111
106
  files=(
112
107
  ExampleFile.python(
113
108
  "tests/test_billing.py",
114
- "def test_charge():\n gateway.charge.return_value = 1\n gateway.charge.return_value = 2\n assert billing.charge(gateway) == 2\n",
109
+ "from unittest.mock import Mock\n\ndef test_charge():\n charge = Mock()\n charge.return_value = 1\n charge.return_value = 2\n assert charge() == 2\n",
115
110
  ),
116
111
  ),
117
112
  focus_path=PurePosixPath("tests/test_billing.py"),
@@ -125,7 +120,7 @@ class UnusedMockSetup(Rule):
125
120
  files=(
126
121
  ExampleFile.python(
127
122
  "tests/test_billing.py",
128
- "def test_charge():\n gateway.charge.return_value = 1\n assert billing.charge(gateway) == 1\n gateway.charge.return_value = 2\n assert billing.charge(gateway) == 2\n",
123
+ "from unittest.mock import Mock\n\ndef test_charge():\n charge = Mock()\n charge.return_value = 1\n assert charge() == 1\n charge.return_value = 2\n assert charge() == 2\n",
129
124
  ),
130
125
  ),
131
126
  focus_path=PurePosixPath("tests/test_billing.py"),
@@ -133,12 +128,13 @@ class UnusedMockSetup(Rule):
133
128
  public=True,
134
129
  ),
135
130
  ),
131
+ aliases=("unused-mock-setup",),
136
132
  )
137
133
  description: str = documentation.summary
138
134
 
139
135
  @override
140
136
  def check(self, path: Path, source: str) -> list[Diagnostic]:
141
- if not is_test_path(path):
137
+ if not is_test_path(path) or is_generated(path, source):
142
138
  return []
143
139
  tree = parse_or_none(path, source)
144
140
  if tree is None:
@@ -159,6 +155,7 @@ class UnusedMockSetup(Rule):
159
155
  line=position[0],
160
156
  col=position[1],
161
157
  code=self.code,
158
+ severity=Severity.WARNING,
162
159
  message=finding.message,
163
160
  )
164
161
  )
@@ -167,19 +164,26 @@ class UnusedMockSetup(Rule):
167
164
 
168
165
 
169
166
  def _dead_setups(fn: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex) -> Iterator[_Finding]:
170
- yield from _overwritten_before_use(fn, imports)
171
- yield from _asserted_never_called(fn)
167
+ origins = _mock_origins(fn, imports)
168
+ if not origins:
169
+ return
170
+ yield from _overwritten_before_use(fn, imports, origins)
171
+ yield from _asserted_never_called(fn, origins)
172
172
 
173
173
 
174
- def _overwritten_before_use(fn: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex) -> Iterator[_Finding]:
175
- has_mocker_fixture = _has_parameter(fn, _MOCKER)
174
+ def _overwritten_before_use(
175
+ fn: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex, origins: frozenset[str]
176
+ ) -> Iterator[_Finding]:
177
+ has_mocker_fixture = _has_unshadowed_mocker_fixture(fn)
176
178
  for block in _blocks(fn):
177
179
  pending: dict[str, ast.stmt] = {}
178
180
  for stmt in block:
179
- target = _config_target(stmt)
181
+ target = _config_target(stmt, origins)
180
182
  if target is None:
181
183
  initial = _initial_mock_configs(stmt, imports, has_mocker_fixture=has_mocker_fixture)
182
- if initial:
184
+ if initial and initial[0].partition(".")[0] in origins:
185
+ if _constructor_observes_pending(stmt, pending):
186
+ pending.clear()
183
187
  configured_name = initial[0].partition(".")[0]
184
188
  pending = {
185
189
  target: setup
@@ -191,6 +195,8 @@ def _overwritten_before_use(fn: ast.FunctionDef | ast.AsyncFunctionDef, imports:
191
195
  if not _is_inert(stmt):
192
196
  pending.clear()
193
197
  continue
198
+ if _statement_observes_pending(stmt, pending):
199
+ pending.clear()
194
200
  previous = pending.get(target)
195
201
  if previous is not None:
196
202
  yield _Finding(
@@ -204,11 +210,57 @@ def _overwritten_before_use(fn: ast.FunctionDef | ast.AsyncFunctionDef, imports:
204
210
  pending[target] = stmt
205
211
 
206
212
 
213
+ def _has_unshadowed_mocker_fixture(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
214
+ return _has_parameter(fn, _MOCKER) and not any(
215
+ isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)) and node.id == _MOCKER
216
+ for node in _owned_nodes(fn)
217
+ )
218
+
219
+
207
220
  def _has_parameter(fn: ast.FunctionDef | ast.AsyncFunctionDef, name: str) -> bool:
208
221
  args = fn.args
209
222
  return any(arg.arg == name for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs))
210
223
 
211
224
 
225
+ def _owned_nodes(root: ast.AST) -> Iterator[ast.AST]:
226
+ stack = [root]
227
+ while stack:
228
+ node = stack.pop()
229
+ yield node
230
+ if node is not root and isinstance(node, _NESTED_SCOPES):
231
+ continue
232
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
233
+
234
+
235
+ def _mock_origins(fn: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex) -> frozenset[str]:
236
+ has_mocker_fixture = _has_unshadowed_mocker_fixture(fn)
237
+ binding_counts: dict[str, int] = {}
238
+ for argument in (*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs):
239
+ binding_counts[argument.arg] = binding_counts.get(argument.arg, 0) + 1
240
+ for node in _owned_nodes(fn):
241
+ if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, (ast.Store, ast.Del)):
242
+ path = _dotted(node)
243
+ if path is not None:
244
+ binding_counts[path] = binding_counts.get(path, 0) + 1
245
+ origins: set[str] = set()
246
+ for stmt in _scope_statements(fn):
247
+ match stmt:
248
+ case ast.Assign(targets=[target], value=ast.Call() as call):
249
+ pass
250
+ case ast.AnnAssign(target=target, value=ast.Call() as call):
251
+ pass
252
+ case _:
253
+ continue
254
+ path = _dotted(target)
255
+ if (
256
+ path is not None
257
+ and binding_counts.get(path) == 1
258
+ and _is_mock_configurator(call.func, imports, has_mocker_fixture=has_mocker_fixture)
259
+ ):
260
+ origins.add(path)
261
+ return frozenset(origins)
262
+
263
+
212
264
  def _initial_mock_configs(stmt: ast.stmt, imports: ImportIndex, *, has_mocker_fixture: bool) -> tuple[str, ...]:
213
265
  match stmt:
214
266
  case ast.Assign(targets=[ast.Name(id=name)], value=ast.Call() as call):
@@ -237,30 +289,66 @@ def _is_mock_configurator(func: ast.expr, imports: ImportIndex, *, has_mocker_fi
237
289
  return any(imports.resolves(func, sources=_UNITTEST_MOCK, symbol=symbol) for symbol in _MOCK_CONSTRUCTORS)
238
290
 
239
291
 
240
- def _asserted_never_called(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[_Finding]:
241
- not_called = _not_called_assertions(fn)
242
- if not not_called:
292
+ def _asserted_never_called(fn: ast.FunctionDef | ast.AsyncFunctionDef, origins: frozenset[str]) -> Iterator[_Finding]:
293
+ if not fn.name.startswith("test_") or _is_fixture(fn):
243
294
  return
244
295
  introspected = _introspected_paths(fn)
245
- last_effect = _last_effectful_line(fn)
246
- for stmt in _scope_statements(fn):
247
- target = _config_target(stmt)
248
- if target is None:
296
+ loaded_configs = _loaded_config_paths(fn)
297
+ last_effect = _last_effectful_line(fn, origins)
298
+ loop_statements = _loop_statement_ids(fn)
299
+ for block in _blocks(fn):
300
+ not_called = _not_called_assertions(block, origins)
301
+ for stmt in block:
302
+ target = _config_target(stmt, origins)
303
+ if target is None or id(stmt) in loop_statements or target in loaded_configs:
304
+ continue
305
+ configured = target.rsplit(".", 1)[0]
306
+ if any(_touches(configured, other) for other in introspected):
307
+ continue
308
+ for asserted, line in not_called.items():
309
+ if line > stmt.lineno and asserted == configured and last_effect <= line:
310
+ yield _Finding(
311
+ stmt,
312
+ (
313
+ f"`{target}` is configured here, but the test ends by proving `{asserted}` was never "
314
+ "called, so its behavior cannot affect the test. Delete the contradictory configuration"
315
+ ),
316
+ )
317
+ break
318
+
319
+
320
+ def _is_fixture(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
321
+ return any(
322
+ (isinstance(decorator, ast.Name) and decorator.id == "fixture")
323
+ or (isinstance(decorator, ast.Attribute) and decorator.attr == "fixture")
324
+ or (
325
+ isinstance(decorator, ast.Call)
326
+ and isinstance(decorator.func, (ast.Name, ast.Attribute))
327
+ and (decorator.func.id if isinstance(decorator.func, ast.Name) else decorator.func.attr) == "fixture"
328
+ )
329
+ for decorator in fn.decorator_list
330
+ )
331
+
332
+
333
+ def _loop_statement_ids(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> set[int]:
334
+ found: set[int] = set()
335
+ for node in _owned_nodes(fn):
336
+ if not isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
249
337
  continue
250
- configured = target.rsplit(".", 1)[0]
251
- if any(_touches(configured, other) for other in introspected):
338
+ for child in (*node.body, *node.orelse):
339
+ found.update(id(descendant) for descendant in _owned_nodes(child) if isinstance(descendant, ast.stmt))
340
+ return found
341
+
342
+
343
+ def _loaded_config_paths(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
344
+ found: set[str] = set()
345
+ for node in _owned_nodes(fn):
346
+ if not isinstance(node, ast.Attribute) or not isinstance(node.ctx, ast.Load) or node.attr not in _CONFIG_ATTRS:
252
347
  continue
253
- for asserted, line in not_called.items():
254
- if line > stmt.lineno and _is_prefix(asserted, configured) and last_effect <= line:
255
- yield _Finding(
256
- stmt,
257
- (
258
- f"`{target}` is configured here, but the test asserts `{asserted}` was never "
259
- "called and nothing runs after that assertion — the configured value can never "
260
- "be observed. Delete the setup, or assert on the call it was written for"
261
- ),
262
- )
263
- break
348
+ path = _dotted(node)
349
+ if path is not None:
350
+ found.add(path)
351
+ return found
264
352
 
265
353
 
266
354
  def _blocks(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[list[ast.stmt]]:
@@ -296,13 +384,17 @@ def _scope_statements(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[as
296
384
  yield from block
297
385
 
298
386
 
299
- def _config_target(stmt: ast.stmt) -> str | None:
387
+ def _config_target(stmt: ast.stmt, origins: frozenset[str]) -> str | None:
300
388
  if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
301
389
  return None
302
390
  target = stmt.targets[0]
303
391
  if not isinstance(target, ast.Attribute) or target.attr not in _CONFIG_ATTRS:
304
392
  return None
305
- return _dotted(target)
393
+ path = _dotted(target)
394
+ if path is None:
395
+ return None
396
+ configured = path.rsplit(".", 1)[0]
397
+ return path if any(_is_prefix(origin, configured) for origin in origins) else None
306
398
 
307
399
 
308
400
  def _dotted(node: ast.expr) -> str | None:
@@ -327,19 +419,66 @@ def _is_inert(stmt: ast.stmt) -> bool:
327
419
  if not all(isinstance(target, ast.Name) for target in stmt.targets):
328
420
  # `obj.attr = 1` can hit a property setter, `d[k] = 1` a `__setitem__`.
329
421
  return False
330
- return not any(isinstance(node, _EFFECTFUL_NODES) for node in walk(stmt.value))
422
+ return _is_safe_value(stmt.value)
423
+
331
424
 
425
+ def _is_safe_value(node: ast.expr) -> bool:
426
+ match node:
427
+ case ast.Constant() | ast.Name():
428
+ return True
429
+ case ast.List() | ast.Tuple() | ast.Set():
430
+ return all(not isinstance(element, ast.Starred) and _is_safe_value(element) for element in node.elts)
431
+ case ast.Dict():
432
+ return all(key is not None and _is_safe_value(key) for key in node.keys) and all(
433
+ _is_safe_value(value) for value in node.values
434
+ )
435
+ case _:
436
+ return False
332
437
 
333
- def _not_called_assertions(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, int]:
438
+
439
+ def _statement_observes_pending(stmt: ast.stmt, pending: dict[str, ast.stmt]) -> bool:
440
+ if not pending:
441
+ return False
442
+ value = stmt.value if isinstance(stmt, (ast.Assign, ast.AnnAssign)) else None
443
+ if value is None:
444
+ return False
445
+ for node in walk(value):
446
+ if not isinstance(node, (ast.Name, ast.Attribute)) or not isinstance(node.ctx, ast.Load):
447
+ continue
448
+ path = _dotted(node)
449
+ if path is not None and any(_touches(path, target.rsplit(".", 1)[0]) for target in pending):
450
+ return True
451
+ return not _is_safe_value(value)
452
+
453
+
454
+ def _constructor_observes_pending(stmt: ast.stmt, pending: dict[str, ast.stmt]) -> bool:
455
+ value = stmt.value if isinstance(stmt, (ast.Assign, ast.AnnAssign)) else None
456
+ if not isinstance(value, ast.Call):
457
+ return True
458
+ expressions = (*value.args, *(keyword.value for keyword in value.keywords))
459
+ return any(
460
+ not _is_safe_value(expression)
461
+ or any(
462
+ isinstance(node, (ast.Name, ast.Attribute))
463
+ and isinstance(node.ctx, ast.Load)
464
+ and (path := _dotted(node)) is not None
465
+ and any(_touches(path, target.rsplit(".", 1)[0]) for target in pending)
466
+ for node in walk(expression)
467
+ )
468
+ for expression in expressions
469
+ )
470
+
471
+
472
+ def _not_called_assertions(statements: list[ast.stmt], origins: frozenset[str]) -> dict[str, int]:
334
473
  found: dict[str, int] = {}
335
- for stmt in _scope_statements(fn):
474
+ for stmt in statements:
336
475
  for node in _own_expressions(stmt):
337
476
  if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
338
477
  continue
339
478
  if node.func.attr not in _NOT_CALLED_ASSERTIONS:
340
479
  continue
341
480
  asserted = _dotted(node.func.value)
342
- if asserted is not None:
481
+ if asserted is not None and any(_is_prefix(origin, asserted) for origin in origins):
343
482
  found[asserted] = max(found.get(asserted, 0), node.lineno)
344
483
  return found
345
484
 
@@ -368,18 +507,23 @@ def _is_introspection(attr: str) -> bool:
368
507
  return attr in _INTROSPECTION_ATTRS or attr.startswith(_ASSERT_PREFIX)
369
508
 
370
509
 
371
- def _last_effectful_line(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
510
+ def _last_effectful_line(fn: ast.FunctionDef | ast.AsyncFunctionDef, origins: frozenset[str]) -> int:
372
511
  last = 0
373
512
  for node in walk(fn):
374
- if isinstance(node, ast.Call) and not _is_assertion_call(node):
513
+ if isinstance(node, ast.Call) and not _is_assertion_call(node, origins):
375
514
  last = max(last, node.lineno)
376
515
  return last
377
516
 
378
517
 
379
- def _is_assertion_call(node: ast.Call) -> bool:
518
+ def _is_assertion_call(node: ast.Call, origins: frozenset[str]) -> bool:
380
519
  if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith(_ASSERT_PREFIX):
381
520
  return False
382
- return not any(isinstance(child, ast.Call) for arg in (*node.args, *node.keywords) for child in walk(arg))
521
+ asserted = _dotted(node.func.value)
522
+ return (
523
+ asserted is not None
524
+ and any(_is_prefix(origin, asserted) for origin in origins)
525
+ and not any(isinstance(child, ast.Call) for arg in (*node.args, *node.keywords) for child in walk(arg))
526
+ )
383
527
 
384
528
 
385
529
  def _is_prefix(prefix: str, path: str) -> bool: