sarj-python-lint 0.31.0__tar.gz → 0.32.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/mock_without_spec.py +127 -3
  4. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/.gitignore +0 -0
  5. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/README.md +0 -0
  6. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/__init__.py +0 -0
  7. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/__main__.py +0 -0
  8. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  9. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_secret_names.py +0 -0
  10. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_version.py +0 -0
  11. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/py.typed +0 -0
  12. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/ratchet.py +0 -0
  13. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rule_base.py +0 -0
  14. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  15. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  16. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  17. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  18. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  19. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  20. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  21. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  22. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  23. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  24. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  25. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  26. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  27. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  28. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  29. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  30. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  31. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  32. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  33. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  34. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  35. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  36. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  37. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  38. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  39. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  40. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
  41. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  42. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  43. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  44. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  45. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  46. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  47. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  48. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  49. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  50. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  51. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  52. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  53. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  54. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  55. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  56. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  57. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  58. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  59. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  60. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  61. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  62. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  63. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  64. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  65. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  66. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  67. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  68. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  69. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  70. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  71. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  72. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  73. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  74. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  75. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  76. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  77. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  78. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  79. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  80. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  81. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  82. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  83. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  84. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  85. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  86. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  87. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  88. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  89. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  90. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  91. {sarj_python_lint-0.31.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.31.0
3
+ Version: 0.32.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.31.0"
3
+ version = "0.32.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -38,7 +38,9 @@ Deliberately NOT flagged:
38
38
  * `create_autospec(...)`, `mock.ANY`, `mock.sentinel`, `mock.call` — specced by
39
39
  construction or not doubles at all,
40
40
  * bare `Mock` referenced without being called (annotations, `isinstance`
41
- checks) — only a construction can carry a spec argument.
41
+ checks) — only a construction can carry a spec argument,
42
+ * a canned callable stub bound to an attribute (`receiver.method =
43
+ AsyncMock(return_value=...)`) — see exemption 4 below.
42
44
 
43
45
  The import-backed name check is the load-bearing false-positive guard. Test
44
46
  suites routinely define their own `Mock`-suffixed fakes (`MockVisionBankClient`,
@@ -92,6 +94,45 @@ of this rule reported 137 hits, of which 38 were false positives:
92
94
  constructions lexically inside an `ImportError`/`ModuleNotFoundError`
93
95
  handler.
94
96
 
97
+ 4. **A canned callable stub bound to an attribute (146 hits, 18.8%).** Measured
98
+ over the two first-party repos (bulbul + noura-be, 777 hits), the single
99
+ largest shape was `receiver.method = Mock(...)` — 283 hits, 36.4%. It is not
100
+ an unspecced *collaborator*; it replaces one callable on a receiver that
101
+ already exists, and the contract this rule protects belongs to that receiver.
102
+ Either the receiver carries `spec=` — in which case production's call through
103
+ a renamed attribute raises `AttributeError` off the specced parent and the
104
+ test fails loudly, which is exactly the rot this rule wants — or the receiver
105
+ is itself flagged here at its own construction, and reporting the leaf as
106
+ well says the same thing twice. Evidence:
107
+ `bulbul/python/agent/tests/conftest.py:167-170`, where
108
+ `backchanneler = mock.Mock(spec=Backchanneler)` is followed by
109
+ `backchanneler.start_audio = mock.AsyncMock()` / `.run = ...` / `.stop = ...`
110
+ — model spec discipline, and three findings; and
111
+ `bulbul/python/bulbul/tests/integrations/test_zoho_notifications_handler.py:86`
112
+ (`crm_service.get_record = mock.AsyncMock(return_value=record)`), where the
113
+ only thing the file reads off `crm_service.get_record` is
114
+ `assert_awaited_once_with`. `AsyncMock` dominates the shape (164 of 283)
115
+ because `Mock(spec=X)` children are not awaitable, so a specced double *must*
116
+ have its async methods stubbed this way to be usable at all.
117
+
118
+ The guard demands POSITIVE evidence of callability — a canned
119
+ `return_value=`/`side_effect=`, a mock-API read off the path
120
+ (`recv.method.assert_called_once_with(...)`), or an invocation — on top of
121
+ the absence of any domain-attribute read. Absence alone is not enough:
122
+ `agent/tests/test_for_call_settings.py:64` (`room.local_participant =
123
+ mock.Mock()`) is an object double this file never happens to walk, and it
124
+ stays flagged, as do the 60 namespace doubles the corpus does walk —
125
+ `test_for_call_settings.py:71,74,76` (`api.room` / `api.sip` / `api.egress`,
126
+ read back as `.delete_room`, `.create_sip_participant`,
127
+ `.start_room_composite_egress`) and `agent/tests/conftest.py:246-247`
128
+ (`job_context.api`, `job_context.api.sip`).
129
+
130
+ What this gives up is arity: `AsyncMock(spec=Store.get)` would reject a call
131
+ whose signature no longer matches, and the exempted stubs will not. That is
132
+ the narrower half of the defect, and it is the half `patch.object(mod,
133
+ "func")` still gets flagged for, since `autospec=True` is a one-word fix
134
+ there and there is no equivalent for a raw attribute assignment.
135
+
95
136
  The 99 survivors are true positives: unspecced `MagicMock()` doubles for real
96
137
  types (`black/tests/test_black.py:2933`, a `MagicMock()` standing in for `Path`
97
138
  and answering `.relative_to`/`.resolve`/`.is_dir`/`.is_file`), and
@@ -175,6 +216,11 @@ _MOCK_API_ATTRS = frozenset(
175
216
  # An import that failed leaves nothing importable to spec against.
176
217
  _IMPORT_FAILURES = frozenset({"ImportError", "ModuleNotFoundError"})
177
218
 
219
+ # Keywords that canned-answer a *call*. You only say what a double returns, or
220
+ # raises, when something is going to invoke it — so their presence is positive
221
+ # evidence that the double stands in for one callable rather than an object.
222
+ _CANNED_RESULT_KEYWORDS = frozenset({"return_value", "side_effect"})
223
+
178
224
 
179
225
  class MockWithoutSpec(Rule):
180
226
  """A `unittest.mock` double built with no `spec=` accepts any attribute."""
@@ -321,6 +367,9 @@ class _FileFacts:
321
367
  self.reads: dict[str, set[str]] = {}
322
368
  self.called: set[str] = set()
323
369
  self.import_fallbacks: set[ast.Call] = set()
370
+ self.attribute_target: dict[ast.Call, str] = {}
371
+ self.path_reads: dict[str, set[str]] = {}
372
+ self.path_calls: set[str] = set()
324
373
 
325
374
  @classmethod
326
375
  def from_tree(cls, tree: ast.Module) -> _FileFacts:
@@ -339,16 +388,39 @@ class _FileFacts:
339
388
  elif isinstance(node, ast.Attribute):
340
389
  if isinstance(node.value, ast.Name):
341
390
  found.reads.setdefault(node.value.id, set()).add(node.attr)
391
+ found._record_path_read(node)
342
392
  elif isinstance(node, ast.Call):
343
393
  if isinstance(node.func, ast.Name):
344
394
  found.called.add(node.func.id)
395
+ found._record_path_call(node)
345
396
  elif _catches_import_failure(node):
346
397
  found.import_fallbacks.update(child for child in walk(node) if isinstance(child, ast.Call))
347
398
  return found
348
399
 
349
400
  def _bind(self, target: ast.expr | None, value: ast.expr | None) -> None:
350
- if isinstance(target, ast.Name) and isinstance(value, ast.Call):
401
+ if not isinstance(value, ast.Call):
402
+ return
403
+ if isinstance(target, ast.Name):
351
404
  self.bound_name[value] = target.id
405
+ elif isinstance(target, ast.Attribute):
406
+ path = _dotted_path(target)
407
+ if path is not None:
408
+ self.attribute_target[value] = path
409
+
410
+ def _record_path_read(self, node: ast.Attribute) -> None:
411
+ # Load context only. `recv.method = Mock()` is a *store* through
412
+ # `recv.method`; counting it would make every stub look like a namespace
413
+ # double read back through its own name.
414
+ if not isinstance(node.ctx, ast.Load):
415
+ return
416
+ path = _dotted_path(node.value)
417
+ if path is not None:
418
+ self.path_reads.setdefault(path, set()).add(node.attr)
419
+
420
+ def _record_path_call(self, node: ast.Call) -> None:
421
+ path = _dotted_path(node.func)
422
+ if path is not None:
423
+ self.path_calls.add(path)
352
424
 
353
425
  def is_call_recorder(self, node: ast.Call) -> bool:
354
426
  """Report whether the double bound by `node` is only ever called and introspected.
@@ -368,6 +440,58 @@ class _FileFacts:
368
440
  return False
369
441
  return self.reads.get(name, set()) <= _MOCK_API_ATTRS
370
442
 
443
+ def is_method_stub(self, node: ast.Call) -> bool:
444
+ """Report whether `node` is a canned stub for one method of some receiver.
445
+
446
+ `receiver.method = AsyncMock(return_value=...)` replaces a single
447
+ callable, not an object. The contract this rule protects belongs to the
448
+ *receiver* — it is the receiver's construction that either carries
449
+ `spec=` or is itself flagged here — and a callable has no attribute
450
+ surface for `spec=` to fence. Two conditions must hold:
451
+
452
+ * nothing but the mock API is read back off the assigned path, so the
453
+ double is not standing in for an object the test walks, and
454
+ * something positively marks it as a callable — a canned
455
+ `return_value=`/`side_effect=`, an `assert_called*`-style read, or an
456
+ invocation of the path.
457
+
458
+ The second condition is what keeps `room.local_participant = Mock()`
459
+ flagged: absence of attribute reads is not evidence of callability, and
460
+ a namespace double that this file happens never to walk is exactly the
461
+ case `spec=` exists for.
462
+
463
+ Returns:
464
+ True when the double is a canned callable stub bound to an attribute.
465
+
466
+ """
467
+ path = self.attribute_target.get(node)
468
+ if path is None:
469
+ return False
470
+ seen = self.path_reads.get(path, set())
471
+ if not seen <= _MOCK_API_ATTRS:
472
+ return False
473
+ canned = any(kw.arg in _CANNED_RESULT_KEYWORDS for kw in node.keywords)
474
+ return canned or bool(seen) or path in self.path_calls
475
+
476
+
477
+ def _dotted_path(expr: ast.expr) -> str | None:
478
+ """Render a pure `name.attr.attr` chain as a dotted string.
479
+
480
+ Returns:
481
+ The dotted path, or None when the chain is rooted in anything other than
482
+ a bare name (a subscript, a call, a literal), where two occurrences of
483
+ the same text need not denote the same object.
484
+
485
+ """
486
+ parts: list[str] = []
487
+ while isinstance(expr, ast.Attribute):
488
+ parts.append(expr.attr)
489
+ expr = expr.value
490
+ if not isinstance(expr, ast.Name):
491
+ return None
492
+ parts.append(expr.id)
493
+ return ".".join(reversed(parts))
494
+
371
495
 
372
496
  def _catches_import_failure(handler: ast.ExceptHandler) -> bool:
373
497
  caught = handler.type
@@ -386,7 +510,7 @@ def _unspecced_calls(tree: ast.Module, names: _MockNames, facts: _FileFacts) ->
386
510
  label = _render_callee(node.func, symbol)
387
511
  if _has_spec_argument(node) or _has_positional_replacement(node, label):
388
512
  continue
389
- if node in facts.import_fallbacks or facts.is_call_recorder(node):
513
+ if node in facts.import_fallbacks or facts.is_call_recorder(node) or facts.is_method_stub(node):
390
514
  continue
391
515
  hits.append((node, label))
392
516
  return hits