assertpy 2.35.0__py3-none-any.whl

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 (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/_dangling.py ADDED
@@ -0,0 +1,505 @@
1
+ """Find `assert_that(...)` statements that assert nothing.
2
+
3
+ Two shapes pass every test runner in silence:
4
+
5
+ assert_that(items) # a builder is built and thrown away
6
+ assert_that(items).is_empty # the assertion is looked up and never called
7
+
8
+ Neither can happen with a bare ``assert``, so a fluent API owes its users a way to catch them. The
9
+ second shape is already reported by ruff's B018 (useless attribute access); the first is not reported
10
+ by anything, because a call may have side effects and no linter can know this one does not.
11
+
12
+ The check is static and runs at collection. A runtime version was tried first and rejected: marking
13
+ the builder from a wrapper adds a stack frame, which `snapshot()` / `matches_inline()` then had to
14
+ walk past to find the line they key and rewrite (see `_require_caller` in snapshot.py). Reading the
15
+ source costs nothing at runtime and cannot shift a frame.
16
+
17
+ What it deliberately does not flag:
18
+
19
+ * a builder bound to a name (`b = assert_that(x)`), because whether `b` is used later is not a
20
+ question about this statement, and ruff's F841 answers it when the answer is never;
21
+ * `assert_conforms()`, `fail()`, `soft_fail()`, which assert on their own rather than returning a
22
+ builder, so a bare call to them is correct usage.
23
+
24
+ A chain ending on a pivot or a configurer is the same defect wearing a longer tail:
25
+ ``assert_that(load).raises(ValueError)`` never calls anything, and ``assert_that(rows).extracting("id")``
26
+ throws the extracted value away. Both were left out while the set of operations that reach no verdict
27
+ was only a runtime property; `assertpy/_engine/_operations.py` states it now, and a gate keeps it in
28
+ step with the source, so reading it here cannot rot.
29
+
30
+ An ``assert`` in front reads the chain instead of discarding it, and a builder and a bound method are
31
+ both truthy, so ``assert assert_that(items).is_empty`` is green on every value. Neither B018 nor
32
+ coverage sees that one: the value is consumed rather than dropped, and the line does run. What the
33
+ ``assert`` reads is the whole question, since ``assert assert_that(x).val`` tests that value instead,
34
+ and the register names the members that hand it back.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import ast
40
+ import io
41
+ import tokenize
42
+ from typing import TYPE_CHECKING, Final, NamedTuple
43
+
44
+ from . import _renamed
45
+ from ._engine._operations import (
46
+ ALSO_ASSERTS,
47
+ CONFIGURES,
48
+ DESCRIBES,
49
+ HANDS_THE_SUBJECT_BACK,
50
+ POLLS,
51
+ TRANSFORMS,
52
+ WITHOUT_A_VERDICT,
53
+ )
54
+
55
+ if TYPE_CHECKING:
56
+ import pathlib
57
+
58
+ __tracebackhide__ = True
59
+
60
+ # the builder factories and only those: everything else either asserts by itself or is not an entry point
61
+ _ENTRY: Final = frozenset({"assert_that", "assert_warn"})
62
+ _PACKAGES: Final = frozenset({"assertpy", "assertpy2"})
63
+
64
+ ALLOW_MARKER: Final = "assertpy: allow-dangling"
65
+ """Comment that silences one statement, for the cases where asserting nothing is the point.
66
+
67
+ They exist and they are not mistakes: a benchmark measuring what building a builder costs, a test of
68
+ this library's own machinery, a snippet written to demonstrate the shape. Without a way to say so, the
69
+ only answer to one deliberate line is turning the whole check off, and a check that has to be switched
70
+ off wholesale is a check nobody runs.
71
+ """
72
+ _OLD_MARKER: Final = _renamed.old_name(ALLOW_MARKER)
73
+
74
+ _NO_ASSERTION: Final = "{name}() builds a builder here and asserts nothing"
75
+ """Template rather than a sentence: the message names the call as the reader wrote it.
76
+
77
+ A project that registered its own wrapper reads `check(1)` on the offending line, and being told about
78
+ `assert_that()` there costs a moment of "that is not what my code says" on every finding.
79
+ """
80
+ _NOT_CALLED: Final = "assertion is looked up and never called (missing parentheses)"
81
+ _ENDS_ON: Final = {
82
+ CONFIGURES: "{name}() only sets an expectation here, and nothing calls it",
83
+ TRANSFORMS: "{name}() hands back another value here, and nothing asserts on it",
84
+ DESCRIBES: "{name}() only sets the failure description here, and asserts nothing",
85
+ POLLS: "{name}() starts a polling chain here, and no assertion follows it",
86
+ }
87
+ """What to say about a chain ending on an operation that reaches no verdict, one sentence per kind."""
88
+
89
+ _NO_VERDICT: Final = {name: kind for name, kind in WITHOUT_A_VERDICT.items() if name not in ALSO_ASSERTS}
90
+ _NOT_AWAITED: Final = "eventually() chain is never awaited, so nothing is polled (missing await)"
91
+ _UNDER_ASSERT: Final = "nothing here asserts: assert reads the builder, whose truth says nothing about the value"
92
+ """What to say about a dangling chain an `assert` reads.
93
+
94
+ The wrapper turns every shape below into the same defect and a worse one: a builder, a proxy and a
95
+ bound method are all truthy, so the line is green whatever the value is, and it reads as if it
96
+ asserted. Neither ruff nor coverage sees it, since the value is consumed and the line does run.
97
+
98
+ The sentence stops at "says nothing" rather than promising the line passes, because a few of the
99
+ builder's own fields are falsy by default and turn the line red instead. Green or red, neither
100
+ outcome was decided by the value under test.
101
+ """
102
+
103
+
104
+ class Finding(NamedTuple):
105
+ """One offending statement.
106
+
107
+ ``path`` and ``lineno`` locate it and ``message`` says which shape it is. ``scope`` is the chain of
108
+ names around it, ``("TestOne", "test_same")``, so the report reaches the test that contains the line
109
+ rather than another test of the same name: under ``filterwarnings = ["error"]`` the difference is
110
+ which test goes red, and with two same-named methods it was the difference between a finding being
111
+ reported and being dropped.
112
+
113
+ Empty for a statement at module scope, which belongs to no test at all.
114
+ """
115
+
116
+ path: str
117
+ lineno: int
118
+ message: str
119
+ scope: tuple[str, ...]
120
+
121
+
122
+ class _Bindings(NamedTuple):
123
+ """The names in one module that reach an entry point: bare (`assert_that`) and dotted (`ap.`)."""
124
+
125
+ direct: frozenset[str]
126
+ modules: frozenset[str]
127
+
128
+ def __bool__(self) -> bool:
129
+ return bool(self.direct or self.modules)
130
+
131
+
132
+ class _Survey(NamedTuple):
133
+ """Everything the checks need out of the tree, gathered in one walk.
134
+
135
+ Six separate walks were measured before this: resolving the entry points, the names the module
136
+ rebinds, the same two again for `pytest`, the blocks that demand a raise, and the statements that
137
+ consume a builder. Each is cheap on its own and the tree is walked whole every time, which came to
138
+ 624 ms over this repository's own 97 test modules against 166 ms of parsing.
139
+
140
+ `statements` carries the scope each one sits in, as the chain of names around it. A chain rather
141
+ than one name because a bare name does not identify a test: two classes in one file may each define
142
+ `test_same`, and matching on the name alone handed both findings to whichever ran first.
143
+ """
144
+
145
+ rebound: frozenset[str]
146
+ imports: tuple[ast.Import | ast.ImportFrom, ...]
147
+ blocks: tuple[ast.With | ast.AsyncWith, ...]
148
+ statements: tuple[tuple[ast.Expr | ast.Assert, tuple[str, ...]], ...]
149
+
150
+
151
+ def _survey(tree: ast.Module) -> _Survey:
152
+ """Walk once, keeping the four things the checks ask about.
153
+
154
+ The walk is its own rather than `ast.walk` because the scope chain has to be carried down, and
155
+ `ast.walk` hands back a flat stream with no way to know what encloses what.
156
+
157
+ Two statements consume a builder on the spot. An expression statement discards the value, and
158
+ anywhere else it is bound, passed or returned, where whether *that* asserts is not a question about
159
+ this statement. An `assert` reads it for its truth, which a builder and a bound method answer the
160
+ same way whatever the value is.
161
+ """
162
+ rebound: set[str] = set()
163
+ imports: list[ast.Import | ast.ImportFrom] = []
164
+ blocks: list[ast.With | ast.AsyncWith] = []
165
+ statements: list[tuple[ast.Expr | ast.Assert, tuple[str, ...]]] = []
166
+ stack: list[tuple[ast.AST, tuple[str, ...]]] = [(tree, ())]
167
+ while stack:
168
+ node, scope = stack.pop()
169
+ for child in ast.iter_child_nodes(node):
170
+ inner = scope
171
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
172
+ rebound.add(child.name)
173
+ inner = (*scope, child.name)
174
+ elif isinstance(child, ast.Expr | ast.Assert):
175
+ statements.append((child, scope))
176
+ elif isinstance(child, ast.Name):
177
+ if isinstance(child.ctx, ast.Store):
178
+ rebound.add(child.id)
179
+ elif isinstance(child, ast.arg):
180
+ rebound.add(child.arg)
181
+ # merging the branches is what ruff asks and a checker rejects: only the separate one narrows the name
182
+ elif isinstance(child, ast.ExceptHandler) and child.name:
183
+ rebound.add(child.name)
184
+ elif isinstance(child, (ast.Import, ast.ImportFrom)):
185
+ imports.append(child)
186
+ elif isinstance(child, (ast.With, ast.AsyncWith)):
187
+ blocks.append(child)
188
+ stack.append((child, inner))
189
+ return _Survey(frozenset(rebound), tuple(imports), tuple(blocks), tuple(statements))
190
+
191
+
192
+ def _bindings(survey: _Survey, extra: frozenset[str] = frozenset()) -> _Bindings:
193
+ """Resolve how this module spells the entry points, `as` aliases and star imports included.
194
+
195
+ A module that never imports one is skipped whole: a user function of their own named
196
+ `assert_that` must not be reported.
197
+
198
+ ``extra`` holds the names a project declared for its own wrapper around `assert_that`. Those come
199
+ from the project rather than from here, so they are matched whatever module they are imported from,
200
+ and only through `from ... import`: a wrapper reached as `helpers.check(...)` is not recognised, and
201
+ neither is a local `def check` in a module that never imported one. Requiring the import is the
202
+ whole guard against claiming somebody else's identically named function.
203
+ """
204
+ direct: set[str] = set()
205
+ modules: set[str] = set()
206
+ for node in survey.imports:
207
+ if isinstance(node, ast.ImportFrom):
208
+ ours = (node.module or "").split(".")[0] in _PACKAGES
209
+ for alias in node.names:
210
+ if alias.name == "*" and ours:
211
+ direct |= _ENTRY # the docs' own preamble; a star import binds every entry point
212
+ elif alias.name in extra or (ours and alias.name in _ENTRY):
213
+ direct.add(alias.asname or alias.name)
214
+ else:
215
+ for alias in node.names:
216
+ if alias.name.split(".")[0] in _PACKAGES:
217
+ modules.add(alias.asname or alias.name.split(".")[0])
218
+ shadowed = survey.rebound
219
+ return _Bindings(frozenset(direct) - shadowed, frozenset(modules) - shadowed)
220
+
221
+
222
+ def _entry_call(node: ast.expr, bindings: _Bindings) -> str | None:
223
+ """The call's name as written, for `assert_that(...)` however this module spells it, else ``None``.
224
+
225
+ The name comes back rather than a flag so the report can quote the reader's own spelling, which is
226
+ the only one they can search for.
227
+ """
228
+ if not isinstance(node, ast.Call):
229
+ return None
230
+ func = node.func
231
+ if isinstance(func, ast.Name):
232
+ return func.id if func.id in bindings.direct else None
233
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
234
+ reaches = func.attr in _ENTRY and func.value.id in bindings.modules
235
+ return f"{func.value.id}.{func.attr}" if reaches else None
236
+ return None
237
+
238
+
239
+ def _reaches_entry(node: ast.expr, bindings: _Bindings) -> bool:
240
+ """True when an attribute chain bottoms out in an entry call, however deep it is nested.
241
+
242
+ `assert_that(x).is_equal_to` is one hop, `assert_that(x).not_.is_none` is two, and a chain that
243
+ starts anywhere else (`self.helper.attr`) is none.
244
+ """
245
+ current: ast.expr = node
246
+ while True:
247
+ if _entry_call(current, bindings):
248
+ return True
249
+ if isinstance(current, ast.Call):
250
+ current = current.func
251
+ elif isinstance(current, ast.Attribute):
252
+ current = current.value
253
+ else:
254
+ return False
255
+
256
+
257
+ def _runs(node: ast.expr, name: str) -> bool:
258
+ """True when an attribute chain calls *name* anywhere along it."""
259
+ current: ast.expr = node
260
+ while True:
261
+ if isinstance(current, ast.Call):
262
+ if isinstance(current.func, ast.Attribute) and current.func.attr == name:
263
+ return True
264
+ current = current.func
265
+ elif isinstance(current, ast.Attribute):
266
+ current = current.value
267
+ else:
268
+ return False
269
+
270
+
271
+ def _tail(node: ast.expr) -> str | None:
272
+ """The name a statement ends on when that name reaches no verdict, else ``None``."""
273
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in _NO_VERDICT:
274
+ return node.func.attr
275
+ return None
276
+
277
+
278
+ def _reads_a_verdict_field(node: ast.Attribute) -> bool:
279
+ """Whether the attribute reads a field off a verdict rather than leaving an assertion uncalled.
280
+
281
+ Both sit behind `check()` and only the step before them tells them apart: `check().is_empty().passed`
282
+ reads a field of what the assertion decided, while `check().is_empty` is that assertion with its
283
+ parentheses missing, which is the shape this whole check exists for.
284
+ """
285
+ if not _runs(node, "check") or not isinstance(node.value, ast.Call):
286
+ return False
287
+ called = node.value.func
288
+ return not (isinstance(called, ast.Attribute) and called.attr == "check")
289
+
290
+
291
+ def _reads_a_truthy_chain(node: ast.expr, bindings: _Bindings) -> bool:
292
+ """Whether `assert <node>` reads a chain that decided nothing.
293
+
294
+ An attribute handing the subject back is excluded, since `assert assert_that(x).val` tests that
295
+ value rather than leaving an assertion uncalled, and so is a verdict's own field. Nothing else is:
296
+ reading the builder's state asserts as little as reading a method does, and `logger` is truthy on
297
+ every subject there is.
298
+
299
+ A chain ending on a bare `check()` is left alone deliberately. It is truthy and asserts nothing,
300
+ but calling it a defect means deciding the name is ours, and a project may register an extension
301
+ called `check` that asserts by itself. A read attribute needs no such guess, since nothing was
302
+ called at all. Measured before deciding: no bare `check()` under an `assert` in 524 files across
303
+ this suite, the fixture projects and a consuming framework.
304
+ """
305
+ if not _reaches_entry(node, bindings):
306
+ return False
307
+ if _entry_call(node, bindings) or _tail(node) or (_runs(node, "eventually") and not _closed(node)):
308
+ return True
309
+ if not isinstance(node, ast.Attribute):
310
+ return False
311
+ return node.attr not in HANDS_THE_SUBJECT_BACK and not _reads_a_verdict_field(node)
312
+
313
+
314
+ def _closed(node: ast.expr) -> bool:
315
+ """True when the statement ends in a bare ``close()``, the discard the chain itself stays quiet about."""
316
+ return (
317
+ isinstance(node, ast.Call)
318
+ and isinstance(node.func, ast.Attribute)
319
+ and node.func.attr == "close"
320
+ and not node.args
321
+ and not node.keywords
322
+ )
323
+
324
+
325
+ _DEMANDS_A_RAISE: Final = frozenset({"raises", "RaisesGroup"})
326
+ """The `pytest` blocks that fail when their body reaches the end without raising.
327
+
328
+ `warns` and `deprecated_call` are deliberately not here. They expect their body to finish normally,
329
+ so a chain that asserts nothing inside one is as silent as it would be anywhere else. Measured: a
330
+ `pytest.warns` block whose body warns and then dangles passes.
331
+ """
332
+
333
+
334
+ def _imported_as(survey: _Survey, package: str, wanted: frozenset[str]) -> tuple[frozenset[str], frozenset[str]]:
335
+ """How this module spells names from *package*: bare after an import, and dotted through it.
336
+
337
+ Resolved rather than matched on the bare name, for the reason `_bindings` resolves this library's
338
+ own: a project helper that happens to be called `raises` or `fail` is not this one, and trusting
339
+ the name alone would silence a real finding inside it.
340
+
341
+ Three ways a spelling stops being the package's, all of them dropped:
342
+
343
+ * the module rebinds it, which the survey's `rebound` set answers
344
+ * another import binds the same spelling, where which one wins is the order they are written in
345
+ rather than anything a name can be read for
346
+ * it comes from somewhere else entirely
347
+
348
+ What this cannot answer is a mutation at run time. `pytest.raises = something_else` leaves the
349
+ import reading exactly as it does here, and no static pass sees past that. The boundary is the
350
+ spelling, not the object it will hold.
351
+ """
352
+ direct: set[str] = set()
353
+ modules: set[str] = set()
354
+ elsewhere: set[str] = set()
355
+ for node in survey.imports:
356
+ if isinstance(node, ast.ImportFrom):
357
+ ours = (node.module or "").split(".")[0] == package
358
+ for alias in node.names:
359
+ bound = alias.asname or alias.name
360
+ (direct if ours and alias.name in wanted else elsewhere).add(bound)
361
+ else:
362
+ for alias in node.names:
363
+ bound = alias.asname or alias.name.split(".")[0]
364
+ (modules if alias.name.split(".")[0] == package else elsewhere).add(bound)
365
+ taken = survey.rebound | elsewhere
366
+ return frozenset(direct - taken), frozenset(modules - taken)
367
+
368
+
369
+ def _names_a_call(node: ast.expr, names: tuple[frozenset[str], frozenset[str]], wanted: frozenset[str]) -> bool:
370
+ """Whether this call reaches one of *wanted* through the imports *names* resolved."""
371
+ if not isinstance(node, ast.Call):
372
+ return False
373
+ direct, modules = names
374
+ func = node.func
375
+ if isinstance(func, ast.Attribute):
376
+ return func.attr in wanted and isinstance(func.value, ast.Name) and func.value.id in modules
377
+ return isinstance(func, ast.Name) and func.id in direct
378
+
379
+
380
+ def _demands_a_raise(node: ast.With | ast.AsyncWith, names: tuple[frozenset[str], frozenset[str]]) -> bool:
381
+ """Whether this block fails when its body reaches the end quietly, and nothing else can end it.
382
+
383
+ One item only. A sibling manager gets its `__exit__` run after the body, so
384
+ ``with pytest.raises(TypeError), Boom():`` is satisfied by `Boom` and the body never had to raise.
385
+ """
386
+ return len(node.items) == 1 and _names_a_call(node.items[0].context_expr, names, _DEMANDS_A_RAISE)
387
+
388
+
389
+ def _only_statement_of_a_raising_block(survey: _Survey) -> frozenset[int]:
390
+ """The statements a `pytest.raises` block holds alone, by object identity.
391
+
392
+ The exemption is this narrow because every wider one measured unsound. A body of one statement
393
+ leaves the argument with nothing to hide behind: that statement runs, and either it raises, which
394
+ is what the test asserts, or the block itself raises `DID NOT RAISE`.
395
+
396
+ What the block raises is where the promise stops. An enclosing `try` that catches it, or a
397
+ `finally` that returns, leaves the test green again, and no check reading one statement can see
398
+ that. The guarantee is about the immediate block, not about the test around it.
399
+
400
+ Each condition is here for a shape that passed silently without it. A second statement can supply
401
+ the exception instead (`assert_that(x)` then `raise TypeError`), or make the chain unreachable
402
+ (`raise TypeError` then the chain). A sibling `with` item can raise from its own `__exit__`.
403
+
404
+ Keyed on `id()` rather than on a line, because two statements share a line when they are written
405
+ `assert_that(x); raise TypeError`, and exempting the line would exempt the chain as well.
406
+ """
407
+ names = _imported_as(survey, "pytest", _DEMANDS_A_RAISE)
408
+ return frozenset(
409
+ id(node.body[0]) for node in survey.blocks if len(node.body) == 1 and _demands_a_raise(node, names)
410
+ )
411
+
412
+
413
+ def _marked_lines(source: str) -> frozenset[int]:
414
+ """Line numbers whose *comment* carries the marker.
415
+
416
+ Tokenised rather than searched for as text, which was the first implementation and silenced any
417
+ statement holding the marker's own words in a string:
418
+ ``assert_that("# assertpy: allow-dangling")`` reported nothing. A test asserting on text that
419
+ quotes the marker is exactly the kind of line a reader would never suspect of being exempt.
420
+
421
+ A file that tokenises differently from how it parsed yields nothing rather than raising: the parse
422
+ already succeeded, so the check goes on without the escape hatch instead of taking the run down.
423
+
424
+ The text search in front is only to decide whether to tokenise at all. A file without the marker's
425
+ words anywhere cannot have it in a comment either, and two of this repository's 97 test modules
426
+ carry one, so tokenising all 97 was 278 ms spent to read two.
427
+ """
428
+ if ALLOW_MARKER not in source and _OLD_MARKER not in source:
429
+ return frozenset()
430
+ try:
431
+ tokens = tokenize.generate_tokens(io.StringIO(source).readline)
432
+ comments = [one for one in tokens if one.type == tokenize.COMMENT]
433
+ except (tokenize.TokenError, IndentationError, SyntaxError): # pragma: no cover - parse ran first
434
+ return frozenset()
435
+ return frozenset(one.start[0] for one in comments if ALLOW_MARKER in one.string or _OLD_MARKER in one.string)
436
+
437
+
438
+ def _silenced(statement: ast.stmt, marked: frozenset[int]) -> bool:
439
+ """Whether the statement carries the marker on any of its own lines.
440
+
441
+ Every line the statement spans counts, so the marker can sit at the end of a call broken over
442
+ several lines, which is where a reader would naturally put it.
443
+ """
444
+ last = getattr(statement, "end_lineno", statement.lineno) or statement.lineno
445
+ return any(line in marked for line in range(statement.lineno, last + 1))
446
+
447
+
448
+ def read_module(path: pathlib.Path) -> str:
449
+ """A test module's text as the interpreter decodes it, which is what `findings` reads.
450
+
451
+ The encoding a PEP 263 cookie or a BOM declares, found the way `tokenize` finds it, and UTF-8 where
452
+ none is declared. A byte that still does not decode is replaced rather than refused: 3.10 to 3.13
453
+ import a module whose comment is not UTF-8, and reading it strictly aborted a whole run from the
454
+ collection hook. A comment's text is not something the check reads.
455
+ """
456
+ data = path.read_bytes()
457
+ try:
458
+ encoding = tokenize.detect_encoding(io.BytesIO(data).readline)[0]
459
+ except SyntaxError:
460
+ # a first line that is not UTF-8 and names no encoding, which 3.10 to 3.13 import all the same
461
+ encoding = "utf-8"
462
+ return data.decode(encoding, errors="replace")
463
+
464
+
465
+ def findings(source: str, path: str, extra_entries: frozenset[str] = frozenset()) -> list[Finding]:
466
+ """Report the offending statements in one module's source.
467
+
468
+ A statement carrying `ALLOW_MARKER` in a comment is left out: see that constant for why the
469
+ escape hatch has to exist.
470
+
471
+ ``extra_entries`` names a project's own wrappers around `assert_that`. Only list a wrapper that
472
+ *builds* something to assert on: a helper that asserts inside the call itself is not dangling when
473
+ written as a statement, and listing it would report correct code.
474
+
475
+ Raises:
476
+ SyntaxError: if *source* does not parse; the caller decides whether that is its problem.
477
+ """
478
+ tree = ast.parse(source)
479
+ survey = _survey(tree)
480
+ bindings = _bindings(survey, extra_entries)
481
+ if not bindings:
482
+ return []
483
+ marked = _marked_lines(source)
484
+ alone = _only_statement_of_a_raising_block(survey)
485
+ found: list[Finding] = []
486
+ for statement, scope in survey.statements:
487
+ if _silenced(statement, marked) or id(statement) in alone:
488
+ continue
489
+ if isinstance(statement, ast.Assert):
490
+ if _reads_a_truthy_chain(statement.test, bindings):
491
+ found.append(Finding(path, statement.lineno, _UNDER_ASSERT, scope))
492
+ continue
493
+ value = statement.value
494
+ awaited = isinstance(value, ast.Await)
495
+ while isinstance(value, ast.Await): # `await assert_that(x).eventually()...` unwraps to the chain
496
+ value = value.value
497
+ if name := _entry_call(value, bindings):
498
+ found.append(Finding(path, statement.lineno, _NO_ASSERTION.format(name=name), scope))
499
+ elif isinstance(value, ast.Attribute) and _reaches_entry(value, bindings):
500
+ found.append(Finding(path, statement.lineno, _NOT_CALLED, scope))
501
+ elif not awaited and _runs(value, "eventually") and not _closed(value) and _reaches_entry(value, bindings):
502
+ found.append(Finding(path, statement.lineno, _NOT_AWAITED, scope))
503
+ elif (tail := _tail(value)) and _reaches_entry(value, bindings):
504
+ found.append(Finding(path, statement.lineno, _ENDS_ON[_NO_VERDICT[tail]].format(name=tail), scope))
505
+ return sorted(found, key=lambda finding: finding.lineno)
@@ -0,0 +1 @@
1
+ """Internal comparison, diff, introspection and typing engine (private, no public API)."""