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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- 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)."""
|