birling 0.1.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.
- birling/__init__.py +6 -0
- birling/__main__.py +7 -0
- birling/analysis.py +489 -0
- birling/cli.py +109 -0
- birling/config.py +273 -0
- birling/measurement.py +240 -0
- birling/py.typed +0 -0
- birling/recognition.py +239 -0
- birling/report.py +136 -0
- birling/runtime.py +48 -0
- birling/syntax.py +114 -0
- birling-0.1.0.dist-info/METADATA +82 -0
- birling-0.1.0.dist-info/RECORD +16 -0
- birling-0.1.0.dist-info/WHEEL +4 -0
- birling-0.1.0.dist-info/entry_points.txt +2 -0
- birling-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
birling/__init__.py
ADDED
birling/__main__.py
ADDED
birling/analysis.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Finding the units of a module, with their branches and log calls."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from itertools import chain
|
|
8
|
+
from typing import NamedTuple
|
|
9
|
+
|
|
10
|
+
from birling.config import BranchKind, Config
|
|
11
|
+
from birling.recognition import (
|
|
12
|
+
LogCall,
|
|
13
|
+
LoggerScope,
|
|
14
|
+
find_loggers,
|
|
15
|
+
find_module_scope,
|
|
16
|
+
recognise,
|
|
17
|
+
)
|
|
18
|
+
from birling.syntax import Layout, parse, to_layout
|
|
19
|
+
|
|
20
|
+
MODULE_UNIT_NAME = "<module>"
|
|
21
|
+
"""The name of the unit that holds a module's own code, as Python names its code object."""
|
|
22
|
+
|
|
23
|
+
# The kinds of branch opened by an `else:`, whose line the syntax tree does not record.
|
|
24
|
+
_ELSE_KINDS = frozenset({BranchKind.ELSE, BranchKind.TRY_ELSE, BranchKind.LOOP_ELSE})
|
|
25
|
+
|
|
26
|
+
# The comment that accepts a branch without a log call, on the line that opens it.
|
|
27
|
+
_IGNORE_COMMENT = re.compile(r"#\s*birling:\s*ignore\b")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Branch:
|
|
32
|
+
"""One of several alternative paths through a unit."""
|
|
33
|
+
|
|
34
|
+
kind: BranchKind
|
|
35
|
+
"""The construct the branch belongs to."""
|
|
36
|
+
|
|
37
|
+
line: int
|
|
38
|
+
"""The line that opens the branch: its `if`, `elif`, `else`, `case` or `except`."""
|
|
39
|
+
|
|
40
|
+
body_line: int
|
|
41
|
+
"""The line of the branch's first statement, the one a coverage tool records as run."""
|
|
42
|
+
|
|
43
|
+
condition: str
|
|
44
|
+
"""The source of what selects the branch: a condition, exception types or a pattern.
|
|
45
|
+
|
|
46
|
+
Empty for an `else` and for a bare `except`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
exceptions: tuple[str, ...]
|
|
50
|
+
"""The exception types an `except` handler catches, as written.
|
|
51
|
+
|
|
52
|
+
Empty for other kinds of branch and for a bare `except`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
always_raises: bool
|
|
56
|
+
"""Whether every path through the branch ends by raising."""
|
|
57
|
+
|
|
58
|
+
log_calls: tuple[LogCall, ...]
|
|
59
|
+
"""The log calls in the branch, those in its nested statements included.
|
|
60
|
+
|
|
61
|
+
Those in lambdas are included only when the configuration says so.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
has_ignore_comment: bool = False
|
|
65
|
+
"""Whether the line that opens the branch carries a `# birling: ignore` comment."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_logged(self) -> bool:
|
|
69
|
+
"""Whether the branch holds a log call."""
|
|
70
|
+
return bool(self.log_calls)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Unit:
|
|
75
|
+
"""A function or method, or a module's own code."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
"""The qualified name, such as `Service.run` or `outer.inner`."""
|
|
79
|
+
|
|
80
|
+
line: int
|
|
81
|
+
"""The line of the `def`, or 1 for a module's own code."""
|
|
82
|
+
|
|
83
|
+
branches: tuple[Branch, ...]
|
|
84
|
+
"""The unit's branches, in the order of their lines."""
|
|
85
|
+
|
|
86
|
+
log_calls: tuple[LogCall, ...]
|
|
87
|
+
"""The log calls in the unit, without those in nested functions and classes.
|
|
88
|
+
|
|
89
|
+
Those in lambdas are included only when the configuration says so.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_units(source: str, config: Config) -> tuple[Unit, ...]:
|
|
94
|
+
"""Return the functions and methods of a module, nested ones included.
|
|
95
|
+
|
|
96
|
+
The module's own code, outside its functions and classes, is a unit named `<module>`
|
|
97
|
+
when the configuration says so.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
source: The module's source.
|
|
101
|
+
config: What counts as a log call, and whether module-level code counts.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The units, each one before those nested in it.
|
|
105
|
+
"""
|
|
106
|
+
module = parse(source)
|
|
107
|
+
scope = find_module_scope(module, config)
|
|
108
|
+
scan = _scan(module.body, config)
|
|
109
|
+
units = _collect(scan.definitions, "", scope, config)
|
|
110
|
+
if config.module_level:
|
|
111
|
+
units = chain([_to_unit(MODULE_UNIT_NAME, 1, scan, scope, config)], units)
|
|
112
|
+
layout = to_layout(source)
|
|
113
|
+
return tuple(_place_branches(unit, layout) for unit in units)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _place_branches(unit: Unit, layout: Layout) -> Unit:
|
|
117
|
+
"""Place a unit's branches on their opening lines, with the comments there, in order.
|
|
118
|
+
|
|
119
|
+
The walk places an `else` branch at its first statement, since only the tokens show
|
|
120
|
+
the line of the `else:` itself, and the comments.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
unit: The unit as the walk built it.
|
|
124
|
+
layout: The layout of the module's lines.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The unit, with its branches at their opening lines, in order.
|
|
128
|
+
"""
|
|
129
|
+
branches = []
|
|
130
|
+
for branch in unit.branches:
|
|
131
|
+
line = branch.line
|
|
132
|
+
if branch.kind in _ELSE_KINDS:
|
|
133
|
+
line = layout.find_else_line(branch.body_line)
|
|
134
|
+
ignored = bool(_IGNORE_COMMENT.search(layout.comments.get(line, "")))
|
|
135
|
+
branches.append(replace(branch, line=line, has_ignore_comment=ignored))
|
|
136
|
+
return replace(unit, branches=tuple(sorted(branches, key=lambda branch: branch.line)))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_Definition = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _Found(NamedTuple):
|
|
143
|
+
"""A branch as the walk finds it, before its log calls are recognised."""
|
|
144
|
+
|
|
145
|
+
kind: BranchKind
|
|
146
|
+
line: int
|
|
147
|
+
condition: str
|
|
148
|
+
exceptions: tuple[str, ...]
|
|
149
|
+
body: list[ast.stmt]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class _Scan:
|
|
154
|
+
"""What a single walk finds in a unit's own code, before any call is recognised."""
|
|
155
|
+
|
|
156
|
+
assignments: list[ast.Assign | ast.AnnAssign] = field(default_factory=list)
|
|
157
|
+
branches: list[_Found] = field(default_factory=list)
|
|
158
|
+
calls: list[tuple[ast.Call, tuple[int, ...]]] = field(default_factory=list)
|
|
159
|
+
"""Each call with the indices of the branches it is in."""
|
|
160
|
+
definitions: list[_Definition] = field(default_factory=list)
|
|
161
|
+
"""The functions and classes defined in the code, which are not walked into."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _scan(statements: list[ast.stmt], config: Config) -> _Scan:
|
|
165
|
+
"""Walk a unit's own code once, gathering what the unit is built from.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
statements: The unit's body.
|
|
169
|
+
config: Whether lambdas are walked into.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The assignments, branches, calls and definitions found.
|
|
173
|
+
"""
|
|
174
|
+
scan = _Scan()
|
|
175
|
+
# The index of the branch each statement directly in a branch's body belongs to.
|
|
176
|
+
branch_of: dict[int, int] = {}
|
|
177
|
+
stack: list[tuple[ast.AST, tuple[int, ...]]] = [(node, ()) for node in reversed(statements)]
|
|
178
|
+
while stack:
|
|
179
|
+
node, enclosing = stack.pop()
|
|
180
|
+
if (index := branch_of.get(id(node))) is not None:
|
|
181
|
+
enclosing = (*enclosing, index)
|
|
182
|
+
match node:
|
|
183
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
|
|
184
|
+
scan.definitions.append(node)
|
|
185
|
+
continue
|
|
186
|
+
case ast.Lambda(body=body):
|
|
187
|
+
# A lambda's body runs when the lambda is called: it counts if configured.
|
|
188
|
+
if config.lambdas:
|
|
189
|
+
stack.append((body, enclosing))
|
|
190
|
+
continue
|
|
191
|
+
case ast.Call():
|
|
192
|
+
scan.calls.append((node, enclosing))
|
|
193
|
+
case ast.Assign() | ast.AnnAssign():
|
|
194
|
+
scan.assignments.append(node)
|
|
195
|
+
for found in _to_branches(node):
|
|
196
|
+
branch_of.update((id(statement), len(scan.branches)) for statement in found.body)
|
|
197
|
+
scan.branches.append(found)
|
|
198
|
+
children = reversed(list(ast.iter_child_nodes(node)))
|
|
199
|
+
stack.extend((child, enclosing) for child in children)
|
|
200
|
+
return scan
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _collect(
|
|
204
|
+
definitions: Iterable[_Definition], prefix: str, scope: LoggerScope, config: Config
|
|
205
|
+
) -> Iterator[Unit]:
|
|
206
|
+
"""Yield the units of functions and classes, and of those nested in them.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
definitions: The functions and classes.
|
|
210
|
+
prefix: The qualified name of the code they are defined in, with a trailing dot.
|
|
211
|
+
scope: The loggers known where they are defined.
|
|
212
|
+
config: What counts as a log call.
|
|
213
|
+
|
|
214
|
+
Yields:
|
|
215
|
+
The units, each one before those nested in it.
|
|
216
|
+
"""
|
|
217
|
+
for definition in definitions:
|
|
218
|
+
if isinstance(definition, ast.ClassDef):
|
|
219
|
+
yield from _collect_class(definition, prefix, scope, config)
|
|
220
|
+
else:
|
|
221
|
+
scan = _scan(definition.body, config)
|
|
222
|
+
yield from _collect_function(definition, scan, prefix, scope, config)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _collect_class(
|
|
226
|
+
definition: ast.ClassDef, prefix: str, scope: LoggerScope, config: Config
|
|
227
|
+
) -> Iterator[Unit]:
|
|
228
|
+
"""Yield the units of a class's methods and nested classes.
|
|
229
|
+
|
|
230
|
+
Every method is scanned before any unit is built, since a logger set on `self` in one
|
|
231
|
+
serves them all.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
definition: The class.
|
|
235
|
+
prefix: The qualified name of the code it is defined in, with a trailing dot.
|
|
236
|
+
scope: The loggers known where it is defined.
|
|
237
|
+
config: What counts as a log call.
|
|
238
|
+
|
|
239
|
+
Yields:
|
|
240
|
+
The units, each one before those nested in it.
|
|
241
|
+
"""
|
|
242
|
+
members = _scan(definition.body, config).definitions
|
|
243
|
+
scans = {
|
|
244
|
+
id(member): _scan(member.body, config)
|
|
245
|
+
for member in members
|
|
246
|
+
if not isinstance(member, ast.ClassDef)
|
|
247
|
+
}
|
|
248
|
+
attributes = {
|
|
249
|
+
logger
|
|
250
|
+
for scan in scans.values()
|
|
251
|
+
for logger in find_loggers(scan.assignments, config)
|
|
252
|
+
if logger.startswith("self.")
|
|
253
|
+
}
|
|
254
|
+
inner = scope.with_loggers(attributes)
|
|
255
|
+
inner_prefix = f"{prefix}{definition.name}."
|
|
256
|
+
for member in members:
|
|
257
|
+
if isinstance(member, ast.ClassDef):
|
|
258
|
+
yield from _collect_class(member, inner_prefix, inner, config)
|
|
259
|
+
else:
|
|
260
|
+
yield from _collect_function(member, scans[id(member)], inner_prefix, inner, config)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _collect_function(
|
|
264
|
+
definition: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
265
|
+
scan: _Scan,
|
|
266
|
+
prefix: str,
|
|
267
|
+
scope: LoggerScope,
|
|
268
|
+
config: Config,
|
|
269
|
+
) -> Iterator[Unit]:
|
|
270
|
+
"""Yield the unit of a function, then the units nested in it.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
definition: The function.
|
|
274
|
+
scan: The function's own code, already scanned.
|
|
275
|
+
prefix: The qualified name of the code it is defined in, with a trailing dot.
|
|
276
|
+
scope: The loggers known where it is defined.
|
|
277
|
+
config: What counts as a log call.
|
|
278
|
+
|
|
279
|
+
Yields:
|
|
280
|
+
The units, each one before those nested in it.
|
|
281
|
+
"""
|
|
282
|
+
name = f"{prefix}{definition.name}"
|
|
283
|
+
inner = scope.with_loggers(find_loggers(scan.assignments, config))
|
|
284
|
+
yield _to_unit(name, definition.lineno, scan, inner, config)
|
|
285
|
+
yield from _collect(scan.definitions, f"{name}.", inner, config)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _to_unit(name: str, line: int, scan: _Scan, scope: LoggerScope, config: Config) -> Unit:
|
|
289
|
+
"""Build a unit from its scanned code, recognising its log calls.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
name: The unit's qualified name.
|
|
293
|
+
line: The unit's first line.
|
|
294
|
+
scan: The unit's own code, scanned.
|
|
295
|
+
scope: The loggers known in the unit.
|
|
296
|
+
config: What counts as a log call.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The unit, with its branches in the order the walk found them.
|
|
300
|
+
"""
|
|
301
|
+
calls = [
|
|
302
|
+
(log_call, within)
|
|
303
|
+
for call, within in scan.calls
|
|
304
|
+
if (log_call := recognise(call, scope, config))
|
|
305
|
+
]
|
|
306
|
+
branches = (
|
|
307
|
+
Branch(
|
|
308
|
+
kind=found.kind,
|
|
309
|
+
line=found.line,
|
|
310
|
+
body_line=found.body[0].lineno,
|
|
311
|
+
condition=found.condition,
|
|
312
|
+
exceptions=found.exceptions,
|
|
313
|
+
always_raises=_always_raises(found.body),
|
|
314
|
+
log_calls=tuple(log_call for log_call, within in calls if index in within),
|
|
315
|
+
)
|
|
316
|
+
for index, found in enumerate(scan.branches)
|
|
317
|
+
)
|
|
318
|
+
return Unit(
|
|
319
|
+
name=name,
|
|
320
|
+
line=line,
|
|
321
|
+
branches=tuple(branches),
|
|
322
|
+
log_calls=tuple(log_call for log_call, _ in calls),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _to_branches(node: ast.AST) -> Iterator[_Found]:
|
|
327
|
+
"""Yield the branches a node opens, without looking into its children.
|
|
328
|
+
|
|
329
|
+
An `else` branch is placed at its first statement, since the syntax tree does not
|
|
330
|
+
record the line of the `else:`.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
node: Any node.
|
|
334
|
+
|
|
335
|
+
Yields:
|
|
336
|
+
Each branch the node opens.
|
|
337
|
+
"""
|
|
338
|
+
match node:
|
|
339
|
+
case ast.If(test=test, body=body, orelse=orelse):
|
|
340
|
+
yield _Found(BranchKind.IF, node.lineno, ast.unparse(test), (), body)
|
|
341
|
+
if orelse and not _is_elif(node):
|
|
342
|
+
yield _Found(BranchKind.ELSE, orelse[0].lineno, "", (), orelse)
|
|
343
|
+
case (
|
|
344
|
+
ast.Try(handlers=handlers, orelse=orelse)
|
|
345
|
+
| ast.TryStar(handlers=handlers, orelse=orelse)
|
|
346
|
+
):
|
|
347
|
+
for handler in handlers:
|
|
348
|
+
caught = ast.unparse(handler.type) if handler.type else ""
|
|
349
|
+
exceptions = _to_exceptions(handler.type)
|
|
350
|
+
yield _Found(
|
|
351
|
+
BranchKind.EXCEPT, handler.lineno, caught, exceptions, handler.body
|
|
352
|
+
)
|
|
353
|
+
if orelse:
|
|
354
|
+
yield _Found(BranchKind.TRY_ELSE, orelse[0].lineno, "", (), orelse)
|
|
355
|
+
case (
|
|
356
|
+
ast.For(orelse=orelse) | ast.AsyncFor(orelse=orelse) | ast.While(orelse=orelse)
|
|
357
|
+
) if orelse:
|
|
358
|
+
yield _Found(BranchKind.LOOP_ELSE, orelse[0].lineno, "", (), orelse)
|
|
359
|
+
case ast.Match(cases=cases):
|
|
360
|
+
for case in cases:
|
|
361
|
+
pattern = ast.unparse(case.pattern)
|
|
362
|
+
guard = f" if {ast.unparse(case.guard)}" if case.guard else ""
|
|
363
|
+
condition = f"{pattern}{guard}"
|
|
364
|
+
yield _Found(BranchKind.CASE, case.pattern.lineno, condition, (), case.body)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _to_exceptions(caught: ast.expr | None) -> tuple[str, ...]:
|
|
368
|
+
"""Return the exception types an `except` names, as written.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
caught: What the `except` names: a type, a tuple of them, or nothing.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
The types; none for a bare `except`.
|
|
375
|
+
"""
|
|
376
|
+
match caught:
|
|
377
|
+
case None:
|
|
378
|
+
return ()
|
|
379
|
+
case ast.Tuple(elts=elements):
|
|
380
|
+
return tuple(ast.unparse(element) for element in elements)
|
|
381
|
+
return (ast.unparse(caught),)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_elif(node: ast.If) -> bool:
|
|
385
|
+
"""Tell whether the `else` of an `if` is an `elif`.
|
|
386
|
+
|
|
387
|
+
An `elif` is an `if` alone in the `else`, starting at the same column as the outer `if`;
|
|
388
|
+
an `if` nested in an `else` is indented further.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
node: An `if` with an `else`.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Whether the `else` is an `elif`.
|
|
395
|
+
"""
|
|
396
|
+
[first, *rest] = node.orelse
|
|
397
|
+
return not rest and isinstance(first, ast.If) and first.col_offset == node.col_offset
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _always_raises(statements: list[ast.stmt]) -> bool:
|
|
401
|
+
"""Tell whether every path through a block of statements ends by raising.
|
|
402
|
+
|
|
403
|
+
The statements are followed in order: the block raises at the first statement that
|
|
404
|
+
always raises, unless an earlier one offers a way out.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
statements: The block.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Whether every path through the block raises.
|
|
411
|
+
"""
|
|
412
|
+
for statement in statements:
|
|
413
|
+
if _escapes(statement):
|
|
414
|
+
return False
|
|
415
|
+
if _raises(statement):
|
|
416
|
+
return True
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _raises(statement: ast.stmt) -> bool:
|
|
421
|
+
"""Tell whether every path through a statement ends by raising.
|
|
422
|
+
|
|
423
|
+
A compound statement raises when all of its paths do; a `match` needs a last case that
|
|
424
|
+
catches everything. A loop may not run at all, so it never counts as raising.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
statement: The statement.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Whether every path through the statement raises.
|
|
431
|
+
"""
|
|
432
|
+
match statement:
|
|
433
|
+
case ast.Raise():
|
|
434
|
+
return True
|
|
435
|
+
case ast.If(body=body, orelse=orelse):
|
|
436
|
+
return _always_raises(body) and _always_raises(orelse)
|
|
437
|
+
case ast.With(body=body) | ast.AsyncWith(body=body):
|
|
438
|
+
return _always_raises(body)
|
|
439
|
+
case (
|
|
440
|
+
ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=finalbody)
|
|
441
|
+
| ast.TryStar(body=body, handlers=handlers, orelse=orelse, finalbody=finalbody)
|
|
442
|
+
):
|
|
443
|
+
handled = all(_always_raises(handler.body) for handler in handlers)
|
|
444
|
+
completed = _always_raises(body) or _always_raises(orelse)
|
|
445
|
+
return _always_raises(finalbody) or (handled and completed)
|
|
446
|
+
case ast.Match(cases=cases):
|
|
447
|
+
last = cases[-1].pattern
|
|
448
|
+
catches_all = isinstance(last, ast.MatchAs) and last.pattern is None
|
|
449
|
+
return (
|
|
450
|
+
catches_all
|
|
451
|
+
and cases[-1].guard is None
|
|
452
|
+
and all(_always_raises(case.body) for case in cases)
|
|
453
|
+
)
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _escapes(statement: ast.stmt) -> bool:
|
|
458
|
+
"""Tell whether a statement holds a way out of its block other than raising.
|
|
459
|
+
|
|
460
|
+
A way out is a `return`, or a `break` or `continue` that leaves the loop around the
|
|
461
|
+
block rather than one within the statement. Nested functions, classes and lambdas are
|
|
462
|
+
not looked into.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
statement: The statement.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Whether the statement holds a way out.
|
|
469
|
+
"""
|
|
470
|
+
stack: list[tuple[ast.AST, bool]] = [(statement, False)]
|
|
471
|
+
while stack:
|
|
472
|
+
node, in_loop = stack.pop()
|
|
473
|
+
match node:
|
|
474
|
+
case ast.Return():
|
|
475
|
+
return True
|
|
476
|
+
case ast.Break() | ast.Continue() if not in_loop:
|
|
477
|
+
return True
|
|
478
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef() | ast.Lambda():
|
|
479
|
+
continue
|
|
480
|
+
case (
|
|
481
|
+
ast.For(body=body, orelse=orelse)
|
|
482
|
+
| ast.AsyncFor(body=body, orelse=orelse)
|
|
483
|
+
| ast.While(body=body, orelse=orelse)
|
|
484
|
+
):
|
|
485
|
+
stack.extend((child, True) for child in body)
|
|
486
|
+
stack.extend((child, in_loop) for child in orelse)
|
|
487
|
+
continue
|
|
488
|
+
stack.extend((child, in_loop) for child in ast.iter_child_nodes(node))
|
|
489
|
+
return False
|
birling/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The `birling` command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from birling import __version__
|
|
10
|
+
from birling.config import Config, ConfigError
|
|
11
|
+
from birling.measurement import Measurement, MeasurementError, measure_paths
|
|
12
|
+
from birling.report import to_listing, to_text
|
|
13
|
+
from birling.runtime import CoverageDataError, read_executed_lines
|
|
14
|
+
|
|
15
|
+
# Read when present and no other file is named.
|
|
16
|
+
_PYPROJECT = Path("pyproject.toml")
|
|
17
|
+
|
|
18
|
+
# The exit status of a run that could not measure, as for a usage error.
|
|
19
|
+
_ERROR_STATUS = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
23
|
+
"""Measure the log coverage of the paths given, print the report and judge the score.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
argv: The arguments, without the program's name; the process's when omitted.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The exit status: 1 when the total score is below the minimum, 2 when the
|
|
30
|
+
configuration, a module or the coverage data cannot be read, and 0 otherwise.
|
|
31
|
+
"""
|
|
32
|
+
arguments = _to_parser().parse_args(argv)
|
|
33
|
+
pyproject = arguments.config or (_PYPROJECT if _PYPROJECT.exists() else None)
|
|
34
|
+
try:
|
|
35
|
+
config = Config.from_pyproject(pyproject) if pyproject else Config()
|
|
36
|
+
except (ConfigError, OSError, tomllib.TOMLDecodeError) as error: # birling: ignore
|
|
37
|
+
return _fail(f"cannot read {pyproject}: {error}")
|
|
38
|
+
try:
|
|
39
|
+
measurements = measure_paths(arguments.paths, config)
|
|
40
|
+
except MeasurementError as error: # birling: ignore
|
|
41
|
+
return _fail(f"cannot measure {error}")
|
|
42
|
+
executed = None
|
|
43
|
+
if arguments.coverage_file:
|
|
44
|
+
try:
|
|
45
|
+
executed = read_executed_lines(arguments.coverage_file)
|
|
46
|
+
except CoverageDataError as error: # birling: ignore
|
|
47
|
+
return _fail(f"cannot read the coverage data in {error}")
|
|
48
|
+
print(to_text(measurements, executed), end="")
|
|
49
|
+
if arguments.show_unlogged and (listing := to_listing(measurements, executed)):
|
|
50
|
+
print(f"\n{listing}", end="")
|
|
51
|
+
fail_under = config.fail_under if arguments.fail_under is None else arguments.fail_under
|
|
52
|
+
score = Measurement.combine(measurements.values()).score
|
|
53
|
+
if score < fail_under:
|
|
54
|
+
message = f"score {score:.2f}% is below the minimum of {fail_under:g}%"
|
|
55
|
+
print(f"birling: {message}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _to_parser() -> argparse.ArgumentParser:
|
|
61
|
+
"""Build the command's argument parser.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The parser.
|
|
65
|
+
"""
|
|
66
|
+
parser = argparse.ArgumentParser(
|
|
67
|
+
prog="birling", description="Measure how well Python code is covered by log calls."
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"paths", nargs="+", type=Path, metavar="PATH", help="modules and directories to measure"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--config",
|
|
74
|
+
type=Path,
|
|
75
|
+
metavar="FILE",
|
|
76
|
+
help="the pyproject.toml to read [tool.birling] from; by default ./pyproject.toml",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--fail-under",
|
|
80
|
+
type=float,
|
|
81
|
+
metavar="SCORE",
|
|
82
|
+
help="fail when the total score is below SCORE, overriding fail-under",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--coverage-file",
|
|
86
|
+
type=Path,
|
|
87
|
+
metavar="FILE",
|
|
88
|
+
help="coverage.py's data from a test run, to mark the unlogged branches that ran",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--show-unlogged",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="list each unlogged branch after the report, as path:line",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
96
|
+
return parser
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fail(message: str) -> int:
|
|
100
|
+
"""Report an error on the standard error stream.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
message: What went wrong.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The exit status of a run that could not measure.
|
|
107
|
+
"""
|
|
108
|
+
print(f"birling: error: {message}", file=sys.stderr)
|
|
109
|
+
return _ERROR_STATUS
|