python-constricter 0.2.2__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.
constricter/checker.py ADDED
@@ -0,0 +1,651 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The rules: every local variable is typed where it's first bound (see README)."""
3
+
4
+ import ast
5
+ import re
6
+ from collections.abc import Iterator, Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from enum import IntEnum
9
+ from typing import Final, NamedTuple, TypeAlias, cast
10
+
11
+ from constricter.annotations import depth, guessed, inferred, is_vague, returns
12
+
13
+ UNANNOTATED: Final = "LVA001"
14
+ UNTYPED_TARGET: Final = "LVA002"
15
+ COMMENT_TYPED_TARGET: Final = "LVA003"
16
+ UNANNOTATED_MEMBER: Final = "LVA004"
17
+ VAGUE_TYPE: Final = "LVA005"
18
+ NESTED_TYPE: Final = "LVA006"
19
+ MESSAGES: dict[str, str] = {
20
+ UNANNOTATED: "local variable {name} is not annotated where it's first bound",
21
+ UNTYPED_TARGET: "for/match variable {name} is untyped; declare it before the statement",
22
+ COMMENT_TYPED_TARGET: "for variable {name} is typed only by a type comment; declare it before the loop",
23
+ UNANNOTATED_MEMBER: "module or class variable {name} is not annotated where it's first bound",
24
+ VAGUE_TYPE: "the annotation of {name} is vague: Any, object, or a generic without its parameters",
25
+ NESTED_TYPE: "the annotation of {name} nests too deeply; name a part of it with a `type` alias",
26
+ }
27
+ NESTING: Final = 5 # LVA006's default depth
28
+
29
+ _FunctionDef: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
30
+ _FUNCTION_DEFS: tuple[type[ast.FunctionDef], type[ast.AsyncFunctionDef]] = (
31
+ ast.FunctionDef,
32
+ ast.AsyncFunctionDef,
33
+ )
34
+ # The node class of `type X = ...` statements, by name: Python 3.11's `ast` has no `TypeAlias`.
35
+ _TYPE_ALIAS: Final = "TypeAlias"
36
+ _FUTURE: Final = "__future__"
37
+ # `from __future__` features only code that also runs on Python 2 imports: its type comments count.
38
+ _PYTHON2_FUTURES: frozenset[str] = frozenset(
39
+ {
40
+ "nested_scopes",
41
+ "generators",
42
+ "division",
43
+ "absolute_import",
44
+ "with_statement",
45
+ "print_function",
46
+ "unicode_literals",
47
+ },
48
+ )
49
+
50
+
51
+ class Level(IntEnum):
52
+ """How strict: each level makes one more code an error rather than a warning."""
53
+
54
+ RELAXED = 0
55
+ STRICT = 1
56
+ CONSTRICT = 2
57
+ SUFFOCATE = 3
58
+
59
+
60
+ # Each level by name and by number, as the options take it.
61
+ LEVELS: dict[str, Level] = {key: level for level in Level for key in (level.name.lower(), str(level.value))}
62
+ _ERROR_FROM: dict[str, Level] = {
63
+ UNANNOTATED: Level.STRICT,
64
+ UNTYPED_TARGET: Level.CONSTRICT,
65
+ COMMENT_TYPED_TARGET: Level.SUFFOCATE,
66
+ UNANNOTATED_MEMBER: Level.STRICT,
67
+ VAGUE_TYPE: Level.SUFFOCATE,
68
+ NESTED_TYPE: Level.SUFFOCATE,
69
+ }
70
+ # Codes reported only from a level up (the rest are reported at every level).
71
+ _REPORTED_FROM: dict[str, Level] = {VAGUE_TYPE: Level.STRICT, NESTED_TYPE: Level.STRICT}
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _Settings:
76
+ """One module's options, and its source lines (to place a `**rest` capture)."""
77
+
78
+ type_comments: bool
79
+ all_scopes: bool
80
+ nesting: int
81
+ lines: Sequence[str]
82
+ calls: dict[str, str] # each module function's return type, for `--fix`
83
+
84
+
85
+ @dataclass(frozen=True, order=True)
86
+ class Offence:
87
+ """One untyped first binding; `col` is 0-based."""
88
+
89
+ line: int
90
+ col: int
91
+ name: str
92
+ code: str = UNANNOTATED
93
+ # The annotation `--fix` would add, where the value makes it unambiguous.
94
+ fix: str | None = field(default=None, compare=False)
95
+ # In a notebook, the cell (from 1); `line` is then the line in that cell.
96
+ cell: int | None = field(default=None, compare=False)
97
+ # Whether `fix` is a guess, applied only with `--unsafe-fixes`.
98
+ unsafe: bool = field(default=False, compare=False)
99
+
100
+ @property
101
+ def message(self) -> str:
102
+ """The report text."""
103
+ return MESSAGES[self.code].format(name=repr(self.name))
104
+
105
+ def is_error(self, level: Level) -> bool:
106
+ """Check this offence's severity at `level`.
107
+
108
+ Returns:
109
+ Whether it's an error rather than a warning.
110
+
111
+ """
112
+ return level >= _ERROR_FROM[self.code]
113
+
114
+ def is_reported(self, level: Level) -> bool:
115
+ """Check whether `level` reports this offence.
116
+
117
+ Returns:
118
+ Whether it does at all.
119
+
120
+ """
121
+ return level >= _REPORTED_FROM.get(self.code, Level.RELAXED)
122
+
123
+
124
+ class Checks(NamedTuple):
125
+ """What to check, beyond the defaults.
126
+
127
+ With `type_comments`, `x = 1 # type: int` counts as annotated; with `all_scopes`, module and
128
+ class bodies are checked too (LVA004); an annotation nested `nesting` deep is LVA006.
129
+ """
130
+
131
+ type_comments: bool = False
132
+ all_scopes: bool = False
133
+ nesting: int = NESTING
134
+
135
+
136
+ DEFAULT_CHECKS: Final = Checks()
137
+
138
+
139
+ def check_source(
140
+ source: str | bytes,
141
+ filename: str = "<unknown>",
142
+ checks: Checks = DEFAULT_CHECKS,
143
+ *,
144
+ calls: Mapping[str, str] | None = None,
145
+ ) -> list[Offence]:
146
+ """Return the offences in `source`, sorted. Raises `SyntaxError`.
147
+
148
+ `calls` adds the return types of functions other modules define, for `--fix` (see `project.calls`).
149
+
150
+ Returns:
151
+ Every offence; `# noqa` comments are the caller's to apply.
152
+
153
+ """
154
+ tree: ast.Module = _parse(source, filename)
155
+ text: str = source.decode("utf-8") if isinstance(source, bytes) else source
156
+ return check_tree(tree, checks, lines=text.splitlines(), calls=calls)
157
+
158
+
159
+ def _parse(source: str | bytes, filename: str) -> ast.Module:
160
+ """Parse `source` with its `# type:` comments; without them if one is misplaced.
161
+
162
+ Returns:
163
+ The module. Raises `SyntaxError`.
164
+
165
+ """
166
+ try:
167
+ return ast.parse(source, filename, type_comments=True)
168
+ except SyntaxError: # a misplaced `# type:` comment, or a real error raised again here
169
+ return ast.parse(source, filename)
170
+
171
+
172
+ def check_tree(
173
+ tree: ast.Module,
174
+ checks: Checks = DEFAULT_CHECKS,
175
+ *,
176
+ lines: Sequence[str] = (),
177
+ calls: Mapping[str, str] | None = None,
178
+ ) -> list[Offence]:
179
+ """Return the offences in a parsed module, sorted.
180
+
181
+ `# type:` comments are seen only if it was parsed with `type_comments=True`; they count for `=`
182
+ and `with` too in a module written to run on Python 2. With its source `lines`, a `**rest`
183
+ capture is reported at its name rather than at its pattern's start.
184
+
185
+ Returns:
186
+ Every offence, in source order.
187
+
188
+ """
189
+ settings: _Settings = _Settings(
190
+ checks.type_comments or _python2_compatible(tree),
191
+ checks.all_scopes,
192
+ checks.nesting,
193
+ lines,
194
+ {**(calls or {}), **returns(tree)},
195
+ )
196
+ return sorted(o for scope in _scopes(tree, settings) for o in scope.reported())
197
+
198
+
199
+ class Coverage(NamedTuple):
200
+ """How many of a module's typeable first bindings are typed, of how many."""
201
+
202
+ typed: int
203
+ total: int
204
+
205
+ @property
206
+ def percent(self) -> float:
207
+ """The typed share, as a percentage (100 when there's nothing to type)."""
208
+ return 100 * self.typed / self.total if self.total else 100.0
209
+
210
+
211
+ # The codes that mean a binding has no type at all (LVA003's type comment is a type).
212
+ _UNTYPED: Final = frozenset({UNANNOTATED, UNTYPED_TARGET, UNANNOTATED_MEMBER})
213
+
214
+
215
+ def annotation_coverage(source: str, checks: Checks = DEFAULT_CHECKS) -> Coverage:
216
+ """Count the first bindings in `source` the rules cover, and how many are typed.
217
+
218
+ Returns:
219
+ The counts; `# noqa` comments don't make a binding typed. Raises `SyntaxError`.
220
+
221
+ """
222
+ tree: ast.Module = _parse(source, "<unknown>")
223
+ settings: _Settings = _Settings(
224
+ checks.type_comments or _python2_compatible(tree),
225
+ checks.all_scopes,
226
+ checks.nesting,
227
+ source.splitlines(),
228
+ {},
229
+ )
230
+ scopes: list[_Scope] = _scopes(tree, settings)
231
+ total: int = sum(len(scope.bound()) for scope in scopes)
232
+ untyped: int = sum(o.code in _UNTYPED for scope in scopes for o in scope.reported())
233
+ return Coverage(total - untyped, total)
234
+
235
+
236
+ def _scopes(tree: ast.Module, settings: _Settings) -> list["_Scope"]:
237
+ """Collect the scopes to check.
238
+
239
+ Returns:
240
+ Every function's scope, and with `all_scopes` every module and class body's.
241
+
242
+ """
243
+ functions: list[_FunctionDef] = []
244
+ _collect_functions(tree.body, functions)
245
+ scopes: list[_Scope] = _function_scopes(functions, settings)
246
+ if settings.all_scopes:
247
+ scopes += _body_scopes(tree, settings)
248
+ return scopes
249
+
250
+
251
+ def _python2_compatible(tree: ast.Module) -> bool:
252
+ """Check for a `from __future__` import only Python 2 needs.
253
+
254
+ Returns:
255
+ Whether it marks the module as written for Python 2.
256
+
257
+ """
258
+ return any(
259
+ isinstance(stmt, ast.ImportFrom)
260
+ and stmt.module == _FUTURE
261
+ and any(alias.name in _PYTHON2_FUTURES for alias in stmt.names)
262
+ for stmt in tree.body
263
+ )
264
+
265
+
266
+ def _body_scopes(tree: ast.Module, settings: _Settings) -> list["_Scope"]:
267
+ """Collect the module and class bodies (LVA004).
268
+
269
+ Returns:
270
+ Their scopes, but for an enum's.
271
+
272
+ """
273
+ classes: list[list[ast.stmt]] = [
274
+ node.body for node in ast.walk(tree) if isinstance(node, ast.ClassDef) and not _is_enum(node)
275
+ ]
276
+ scopes: list[_Scope] = []
277
+ body: list[ast.stmt]
278
+ for body in (tree.body, *classes):
279
+ # A class body is never fixed: annotating a dataclass's variable makes it a field.
280
+ scope: _Scope = _Scope({"_"}, [], settings, unannotated=UNANNOTATED_MEMBER, fixable=body is tree.body)
281
+ stmt: ast.stmt
282
+ for stmt in body:
283
+ _visit(scope, stmt)
284
+ scopes.append(scope)
285
+ return scopes
286
+
287
+
288
+ def _is_enum(node: ast.ClassDef) -> bool:
289
+ """Check whether a base is an enum: enum members mustn't be annotated.
290
+
291
+ Returns:
292
+ Whether its name ends in `Enum` or `Flag`.
293
+
294
+ """
295
+ base: ast.expr
296
+ name: str
297
+ for base in node.bases:
298
+ match base:
299
+ case ast.Name(id=name) | ast.Attribute(attr=name) if name.endswith(("Enum", "Flag")):
300
+ return True
301
+ case _:
302
+ pass
303
+ return False
304
+
305
+
306
+ def _collect_functions(body: list[ast.stmt], into: list[_FunctionDef]) -> None:
307
+ """Collect functions in a module or class body, through compound statements and classes."""
308
+ stmt: ast.stmt
309
+ for stmt in body:
310
+ if isinstance(stmt, _FUNCTION_DEFS):
311
+ into.append(stmt)
312
+ elif isinstance(stmt, ast.ClassDef):
313
+ _collect_functions(stmt.body, into)
314
+ else:
315
+ _collect_functions(_child_statements(stmt), into)
316
+
317
+
318
+ def _child_statements(stmt: ast.stmt) -> list[ast.stmt]:
319
+ """Collect the statements nested directly in `stmt`.
320
+
321
+ Returns:
322
+ Them, in source order.
323
+
324
+ """
325
+ children: list[ast.stmt] = []
326
+ handler: ast.ExceptHandler
327
+ case: ast.match_case
328
+ match stmt:
329
+ case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
330
+ children += stmt.body + stmt.orelse
331
+ case ast.With() | ast.AsyncWith():
332
+ children += stmt.body
333
+ case ast.Try() | ast.TryStar():
334
+ children += stmt.body
335
+ for handler in stmt.handlers:
336
+ children += handler.body
337
+ children += stmt.orelse + stmt.finalbody
338
+ case ast.Match():
339
+ for case in stmt.cases:
340
+ children += case.body
341
+ case _:
342
+ pass
343
+ return children
344
+
345
+
346
+ def _expressions(stmt: ast.stmt) -> Iterator[ast.AST]:
347
+ """Walk the parts of `stmt` that aren't statements.
348
+
349
+ Yields:
350
+ Each, as where a `:=` can bind.
351
+
352
+ """
353
+ child: ast.AST
354
+ for child in ast.iter_child_nodes(stmt):
355
+ if isinstance(child, ast.match_case | ast.ExceptHandler):
356
+ yield from (part for part in ast.iter_child_nodes(child) if not isinstance(part, ast.stmt))
357
+ elif not isinstance(child, ast.stmt):
358
+ yield child
359
+
360
+
361
+ def _names(target: ast.expr) -> Iterator[ast.Name]:
362
+ """Walk an assignment target.
363
+
364
+ Yields:
365
+ Each plain name it binds.
366
+
367
+ """
368
+ elements: list[ast.expr]
369
+ element: ast.expr
370
+ value: ast.expr
371
+ match target:
372
+ case ast.Name():
373
+ yield target
374
+ case ast.Tuple(elts=elements) | ast.List(elts=elements):
375
+ for element in elements:
376
+ yield from _names(element)
377
+ case ast.Starred(value=value):
378
+ yield from _names(value)
379
+ case _:
380
+ return
381
+
382
+
383
+ def _captures(pattern: ast.pattern, lines: Sequence[str]) -> Iterator[tuple[str, tuple[int, int]]]:
384
+ """Walk a `case` pattern.
385
+
386
+ Yields:
387
+ Each name it captures, with where it's bound.
388
+
389
+ """
390
+ node: ast.AST
391
+ name: str
392
+ for node in ast.walk(pattern):
393
+ match node:
394
+ case ast.MatchAs(name=str() as name) | ast.MatchStar(name=str() as name):
395
+ yield name, _at(node)
396
+ case ast.MatchMapping(rest=str() as name):
397
+ yield name, _rest_at(node, name, lines)
398
+ case _:
399
+ pass
400
+
401
+
402
+ def _at(node: ast.expr | ast.pattern) -> tuple[int, int]:
403
+ return node.lineno, node.col_offset
404
+
405
+
406
+ def _rest_at(node: ast.MatchMapping, name: str, lines: Sequence[str]) -> tuple[int, int]:
407
+ """Find `**name` in a mapping pattern's source.
408
+
409
+ Returns:
410
+ Its position (as `ast` gives it, a byte column), or else the pattern's start.
411
+
412
+ """
413
+ rest: re.Pattern[bytes] = re.compile(rb"\*\*\s*(" + re.escape(name.encode()) + rb")\b")
414
+ number: int
415
+ found: re.Match[bytes] | None
416
+ for number in range(node.lineno, min(node.end_lineno or node.lineno, len(lines)) + 1):
417
+ if found := rest.search(lines[number - 1].encode(), node.col_offset if number == node.lineno else 0):
418
+ return number, found.start(1)
419
+ return _at(node)
420
+
421
+
422
+ class _Scope:
423
+ """One function body: names bound so far and offences found."""
424
+
425
+ def __init__(
426
+ self,
427
+ declared: set[str],
428
+ nested: list[_FunctionDef],
429
+ settings: _Settings,
430
+ *,
431
+ unannotated: str = UNANNOTATED,
432
+ fixable: bool = True,
433
+ ) -> None:
434
+ self.declared: set[str] = declared
435
+ self.nested: list[_FunctionDef] = nested
436
+ self.settings: _Settings = settings
437
+ self.unannotated_code: str = unannotated
438
+ self.fixable: bool = fixable
439
+ self.offences: list[Offence] = []
440
+ self.first: list[str] = [] # each first binding the rules cover, typed or not
441
+
442
+ def bind(
443
+ self,
444
+ name: str,
445
+ at: tuple[int, int],
446
+ code: str | None,
447
+ fix: str | None = None,
448
+ *,
449
+ unsafe: bool = False,
450
+ ) -> None:
451
+ """Bind `name`; unless it's already bound, report `code` at `(line, col)` (`None`: typed)."""
452
+ if name not in self.declared:
453
+ self.declared.add(name)
454
+ self.first.append(name)
455
+ if code is not None:
456
+ self.offences.append(Offence(*at, name, code, fix if self.fixable else None, unsafe=unsafe))
457
+
458
+ def declare(self, name: str) -> None:
459
+ """Bind `name` by an annotation (`name: T`, `name: T = ...`): a typed first binding."""
460
+ if name not in self.declared:
461
+ self.declared.add(name)
462
+ self.first.append(name)
463
+
464
+ def _covered(self, name: str) -> bool:
465
+ """Check whether the rules cover `name` here.
466
+
467
+ Returns:
468
+ Whether they do; a module or class body's dunder names are exempt.
469
+
470
+ """
471
+ return self.unannotated_code != UNANNOTATED_MEMBER or not (
472
+ name.startswith("__") and name.endswith("__")
473
+ )
474
+
475
+ def reported(self) -> list[Offence]:
476
+ """Filter the offences found.
477
+
478
+ Returns:
479
+ All but those for exempt names.
480
+
481
+ """
482
+ return [o for o in self.offences if self._covered(o.name)]
483
+
484
+ def bound(self) -> list[str]:
485
+ """List the first bindings the rules cover.
486
+
487
+ Returns:
488
+ Their names, typed or not.
489
+
490
+ """
491
+ return [name for name in self.first if self._covered(name)]
492
+
493
+ def annotation(self, name: str, annotation: ast.expr) -> None:
494
+ """Report an annotation that's vague (LVA005) or nests too deeply (LVA006)."""
495
+ if is_vague(annotation):
496
+ self.offences.append(Offence(*_at(annotation), name, VAGUE_TYPE))
497
+ if depth(annotation) >= self.settings.nesting:
498
+ self.offences.append(Offence(*_at(annotation), name, NESTED_TYPE))
499
+
500
+ def walrus(self, node: ast.AST) -> None:
501
+ """Bind `:=` targets in an expression, comprehensions included, lambdas excluded."""
502
+ in_lambda: set[int] = {
503
+ id(inner)
504
+ for outer in ast.walk(node)
505
+ if isinstance(outer, ast.Lambda)
506
+ for inner in ast.walk(outer)
507
+ }
508
+ current: ast.AST
509
+ for current in ast.walk(node):
510
+ if isinstance(current, ast.NamedExpr) and id(current) not in in_lambda:
511
+ self.bind(current.target.id, _at(current.target), self.unannotated_code)
512
+
513
+ def unannotated(self, type_comment: str | None) -> str | None:
514
+ """Decide the code for an `=` or `with` binding.
515
+
516
+ Returns:
517
+ The code, or `None` if a counted type comment types it.
518
+
519
+ """
520
+ return None if type_comment is not None and self.settings.type_comments else self.unannotated_code
521
+
522
+
523
+ def _function_scopes(functions: list[_FunctionDef], settings: _Settings) -> list["_Scope"]:
524
+ """Check `functions` and every function defined inside them.
525
+
526
+ Returns:
527
+ Their scopes.
528
+
529
+ """
530
+ scopes: list[_Scope] = []
531
+ func: _FunctionDef
532
+ for func in functions:
533
+ nested: list[_FunctionDef] = []
534
+ scopes.append(_function_scope(func, nested, settings))
535
+ scopes += _function_scopes(nested, settings)
536
+ return scopes
537
+
538
+
539
+ def _function_scope(func: _FunctionDef, functions: list[_FunctionDef], settings: _Settings) -> "_Scope":
540
+ """Check one function; functions defined in it are collected into `functions`.
541
+
542
+ Returns:
543
+ Its scope.
544
+
545
+ """
546
+ args: ast.arguments = func.args
547
+ params: set[str] = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)}
548
+ params.update(extra.arg for extra in (args.vararg, args.kwarg) if extra is not None)
549
+ # `_` is a discard.
550
+ scope: _Scope = _Scope(params | {"_"}, functions, settings)
551
+ stmt: ast.stmt
552
+ for stmt in func.body:
553
+ _visit(scope, stmt)
554
+ return scope
555
+
556
+
557
+ def _visit(scope: _Scope, stmt: ast.stmt) -> None:
558
+ """Bind the names `stmt` binds, as Python would, then visit its nested statements."""
559
+ part: ast.AST
560
+ for part in _expressions(stmt):
561
+ scope.walrus(part)
562
+ _declare(scope, stmt)
563
+ _bind(scope, stmt)
564
+ child: ast.stmt
565
+ for child in _child_statements(stmt):
566
+ _visit(scope, child)
567
+
568
+
569
+ def _declare(scope: _Scope, stmt: ast.stmt) -> None:
570
+ """Bind the names `stmt` binds that need no annotation, or carry their own."""
571
+ aliases: list[ast.alias]
572
+ names: list[str]
573
+ name: str
574
+ annotation: ast.expr
575
+ handlers: list[ast.ExceptHandler]
576
+ match stmt:
577
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
578
+ scope.declared.add(stmt.name)
579
+ scope.nested.append(stmt) # its body is its own scope
580
+ case ast.ClassDef():
581
+ scope.declared.add(stmt.name)
582
+ _collect_functions(stmt.body, scope.nested) # methods of a class defined in a function
583
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
584
+ scope.declared.update((alias.asname or alias.name).split(".")[0] for alias in aliases)
585
+ case ast.Global(names=names) | ast.Nonlocal(names=names):
586
+ scope.declared.update(names)
587
+ case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation):
588
+ scope.declare(name)
589
+ scope.annotation(name, annotation)
590
+ case _ if type(stmt).__name__ == _TYPE_ALIAS:
591
+ alias: ast.expr = cast("ast.expr", next(ast.iter_child_nodes(stmt))) # its first field, the name
592
+ scope.declared.update(name.id for name in _names(alias))
593
+ case ast.Try(handlers=handlers) | ast.TryStar(handlers=handlers):
594
+ scope.declared.update(handler.name for handler in handlers if handler.name)
595
+ case _:
596
+ pass
597
+
598
+
599
+ def _bind(scope: _Scope, stmt: ast.stmt) -> None:
600
+ """Bind the names `stmt` binds that need typing, reporting the untyped ones."""
601
+ targets: list[ast.expr]
602
+ target: ast.expr
603
+ items: list[ast.withitem]
604
+ comment: str | None
605
+ name: str
606
+ single: ast.Name
607
+ value: ast.expr
608
+ cases: list[ast.match_case]
609
+ match stmt:
610
+ case ast.Assign(targets=[ast.Name(id=name) as single], value=value, type_comment=comment):
611
+ calls: dict[str, str] = scope.settings.calls
612
+ scope.bind(
613
+ name,
614
+ _at(single),
615
+ scope.unannotated(comment),
616
+ inferred(value, calls),
617
+ unsafe=guessed(value, calls),
618
+ )
619
+ case ast.Assign(targets=targets, type_comment=comment):
620
+ _bind_targets(scope, targets, scope.unannotated(comment))
621
+ case ast.With(items=items, type_comment=comment) | ast.AsyncWith(items=items, type_comment=comment):
622
+ _bind_targets(
623
+ scope,
624
+ [i.optional_vars for i in items if i.optional_vars],
625
+ scope.unannotated(comment),
626
+ )
627
+ case ast.For(target=target, type_comment=comment) | ast.AsyncFor(target=target, type_comment=comment):
628
+ _bind_targets(scope, [target], UNTYPED_TARGET if comment is None else COMMENT_TYPED_TARGET)
629
+ case ast.Match(cases=cases):
630
+ _bind_captures(scope, cases)
631
+ case _:
632
+ pass
633
+
634
+
635
+ def _bind_targets(scope: _Scope, targets: list[ast.expr], code: str | None) -> None:
636
+ """Bind every name in `targets`, reporting `code` for each first binding."""
637
+ target: ast.expr
638
+ name: ast.Name
639
+ for target in targets:
640
+ for name in _names(target):
641
+ scope.bind(name.id, _at(name), code)
642
+
643
+
644
+ def _bind_captures(scope: _Scope, cases: list[ast.match_case]) -> None:
645
+ """Bind every name the `case` patterns capture: LVA002 unless declared first."""
646
+ case: ast.match_case
647
+ name: str
648
+ at: tuple[int, int]
649
+ for case in cases:
650
+ for name, at in _captures(case.pattern, scope.settings.lines):
651
+ scope.bind(name, at, UNTYPED_TARGET)