python-constricter 0.4.1__cp313-cp313-win32.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/__init__.py +55 -0
- constricter/__main__.py +26 -0
- constricter/_version.py +4 -0
- constricter/cli/__init__.py +2 -0
- constricter/cli/baseline.py +107 -0
- constricter/cli/collecting.py +54 -0
- constricter/cli/command.py +547 -0
- constricter/cli/config.py +322 -0
- constricter/cli/explain.py +104 -0
- constricter/cli/lsp/__init__.py +2 -0
- constricter/cli/lsp/connection.py +475 -0
- constricter/cli/lsp/edits.py +188 -0
- constricter/cli/lsp/guard.py +133 -0
- constricter/cli/lsp/hints.py +391 -0
- constricter/cli/lsp/protocol.py +192 -0
- constricter/cli/lsp/remembered.py +81 -0
- constricter/cli/lsp/stored.py +200 -0
- constricter/cli/options.py +799 -0
- constricter/cli/paths.py +151 -0
- constricter/cli/reading.py +36 -0
- constricter/cli/report.py +397 -0
- constricter/cli/rounds.py +328 -0
- constricter/cli/runs.py +90 -0
- constricter/cli/schedule.py +463 -0
- constricter/cli/traced.py +401 -0
- constricter/cli/workers.py +286 -0
- constricter/empty.py +66 -0
- constricter/fix/__init__.cp313-win32.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp313-win32.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp313-win32.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp313-win32.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp313-win32.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp313-win32.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp313-win32.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp313-win32.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp313-win32.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp313-win32.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp313-win32.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp313-win32.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp313-win32.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp313-win32.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp313-win32.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp313-win32.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp313-win32.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp313-win32.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp313-win32.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp313-win32.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp313-win32.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp313-win32.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp313-win32.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp313-win32.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp313-win32.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp313-win32.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp313-win32.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp313-win32.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp313-win32.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp313-win32.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp313-win32.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp313-win32.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp313-win32.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp313-win32.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp313-win32.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp313-win32.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp313-win32.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp313-win32.pyd +0 -0
- constricter/fix/libraries/walks.py +49 -0
- constricter/fix/tables/aliases.json +147 -0
- constricter/fix/tables/attributes.json +700 -0
- constricter/fix/tables/awaited.json +69 -0
- constricter/fix/tables/awaited_overloads.json +14 -0
- constricter/fix/tables/bases.json +888 -0
- constricter/fix/tables/classes.json +1941 -0
- constricter/fix/tables/elements.json +156 -0
- constricter/fix/tables/functions.json +58 -0
- constricter/fix/tables/generic_attributes.json +254 -0
- constricter/fix/tables/method_overloads.json +461 -0
- constricter/fix/tables/method_signatures.json +954 -0
- constricter/fix/tables/methods.json +954 -0
- constricter/fix/tables/overloads.json +540 -0
- constricter/fix/tables/platform.json +37 -0
- constricter/fix/tables/returns.json +1362 -0
- constricter/fix/tables/scalar_members.json +16 -0
- constricter/fix/tables/scalars.json +2707 -0
- constricter/fix/tables/source.json +28 -0
- constricter/fix/tables/subscriptable.json +77 -0
- constricter/fix/tables/type_parameters.json +178 -0
- constricter/fix/tables/variables.json +2962 -0
- constricter/fix/values/__init__.cp313-win32.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp313-win32.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp313-win32.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp313-win32.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp313-win32.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp313-win32.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp313-win32.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp313-win32.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp313-win32.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp313-win32.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp313-win32.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp313-win32.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp313-win32.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp313-win32.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp313-win32.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp313-win32.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp313-win32.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp313-win32.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp313-win32.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp313-win32.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp313-win32.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp313-win32.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp313-win32.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp313-win32.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp313-win32.pyd +0 -0
- constricter/fix/values/unchecked.py +278 -0
- constricter/jsonc.py +50 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +115 -0
- constricter/offences.py +436 -0
- constricter/overrides.py +24 -0
- constricter/plugins/__init__.py +2 -0
- constricter/plugins/flake8.py +133 -0
- constricter/plugins/pylint.py +186 -0
- constricter/py.typed +0 -0
- constricter/recording.py +660 -0
- constricter/rules/__init__.cp313-win32.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp313-win32.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp313-win32.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp313-win32.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp313-win32.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp313-win32.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp313-win32.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp313-win32.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp313-win32.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp313-win32.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp313-win32.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp313-win32.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp313-win32.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp313-win32.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp313-win32.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp313-win32.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp313-win32.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp313-win32.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp313-win32.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp313-win32.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp313-win32.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp313-win32.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp313-win32.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp313-win32.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp313-win32.pyd +0 -0
- python_constricter-0.4.1.dist-info/METADATA +400 -0
- python_constricter-0.4.1.dist-info/RECORD +261 -0
- python_constricter-0.4.1.dist-info/WHEEL +4 -0
- python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
- python_constricter-0.4.1.dist-info/licenses/LICENSE.md +21 -0
|
Binary file
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Value flow over a module's scopes, and its findings (LVA008, LVA009, LVA010) as offences with fixes."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from constricter.offences import NARROW, Edit, Fix, FixPolicy, Offence
|
|
10
|
+
from constricter.rules.annotations import node_name
|
|
11
|
+
from constricter.rules.flow import Finding, Kind
|
|
12
|
+
from constricter.rules.scope import Scope
|
|
13
|
+
from constricter.rules.walked import of_type
|
|
14
|
+
|
|
15
|
+
_TYPE_CHECKING: Final = "TYPE_CHECKING"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def flow_offences(found: list[Finding], policy: FixPolicy) -> list[Offence]:
|
|
19
|
+
"""Report value-flow findings as offences, a finding's kind as its code.
|
|
20
|
+
|
|
21
|
+
Each declaration LVA008 or LVA010 would narrow gets one fix, a guess (`--unsafe-fixes`: a
|
|
22
|
+
declared type can be wider on purpose): the LVA008 finding's narrowed type if there's one,
|
|
23
|
+
since it's already within the members the values use, else the union without its unused
|
|
24
|
+
members, on the first LVA010 finding. Two rewrites of one annotation can't both apply. `policy`
|
|
25
|
+
decides whether it's offered (`narrow`), and whether it's trusted.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The offences.
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
offences: list[Offence] = []
|
|
32
|
+
fixed: set[tuple[int, int]] = set()
|
|
33
|
+
finding: Finding
|
|
34
|
+
narrowed: set[tuple[int, int]] = {(f.line, f.col) for f in found if f.kind is Kind.NARROWABLE}
|
|
35
|
+
for finding in sorted(found, key=lambda f: f.kind is not Kind.NARROWABLE):
|
|
36
|
+
where: tuple[int, int] = (finding.line, finding.col)
|
|
37
|
+
edit: Fix | None = None
|
|
38
|
+
if (
|
|
39
|
+
finding.span
|
|
40
|
+
and finding.rewrite
|
|
41
|
+
and where not in fixed
|
|
42
|
+
and (finding.kind is Kind.NARROWABLE or where not in narrowed)
|
|
43
|
+
):
|
|
44
|
+
fixed.add(where)
|
|
45
|
+
reason: str = (
|
|
46
|
+
"the values it's bound to"
|
|
47
|
+
if finding.kind is Kind.NARROWABLE
|
|
48
|
+
else "the members its values use"
|
|
49
|
+
)
|
|
50
|
+
kinds: frozenset[str] = frozenset({NARROW})
|
|
51
|
+
if policy.allows(kinds):
|
|
52
|
+
edit = Fix(
|
|
53
|
+
finding.rewrite,
|
|
54
|
+
reason,
|
|
55
|
+
unsafe=not policy.trusts(kinds),
|
|
56
|
+
edit=Edit.REPLACE,
|
|
57
|
+
span=finding.span,
|
|
58
|
+
kinds=kinds,
|
|
59
|
+
)
|
|
60
|
+
offences.append(Offence(*where, finding.name, finding.kind.value, edit, detail=finding.detail))
|
|
61
|
+
return offences
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def module_flow(tree: ast.Module, scopes: Sequence[Scope]) -> list[Finding]:
|
|
65
|
+
"""Compare each name's values with its declared type, in each of `scopes`.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Every finding, in source order.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
escaped: frozenset[str]
|
|
72
|
+
checking_only: frozenset[str]
|
|
73
|
+
escaped, checking_only = module_names(tree)
|
|
74
|
+
return sorted(found for scope in scopes for found in scope.value_flow(escaped, checking_only))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=16) # `module_flow` runs once per round of the checker's `_returned`, on the same tree
|
|
78
|
+
def module_names(tree: ast.Module) -> tuple[frozenset[str], frozenset[str]]:
|
|
79
|
+
"""Find the names value flow leaves alone in a module.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Those a `global` or `nonlocal` writes from elsewhere, and those annotated only for type
|
|
83
|
+
checkers (`if TYPE_CHECKING: x: str`), whose runtime values may differ on purpose.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
escaped: set[str] = set()
|
|
87
|
+
checking_only: set[str] = set()
|
|
88
|
+
node: ast.AST
|
|
89
|
+
for node in of_type(tree, ast.Global, ast.Nonlocal, ast.If):
|
|
90
|
+
if isinstance(node, ast.Global | ast.Nonlocal):
|
|
91
|
+
escaped.update(node.names)
|
|
92
|
+
elif isinstance(node, ast.If) and node_name(node.test) == _TYPE_CHECKING: # the only other kind
|
|
93
|
+
checking_only.update(
|
|
94
|
+
stmt.target.id
|
|
95
|
+
for stmt in node.body
|
|
96
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)
|
|
97
|
+
)
|
|
98
|
+
return frozenset(escaped), frozenset(checking_only)
|
|
Binary file
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Parse a module once: the cross-file index and the check share its tree, in the same process.
|
|
3
|
+
|
|
4
|
+
The CLI reads every file to index what it offers other files (`fix.index.project`), then checks each; both
|
|
5
|
+
parse it the same way (`parse`), and the index's tree is kept (`keep`) for the check to take
|
|
6
|
+
(`take`), which keeps it again once it's done: a file may be checked a second time. One `--fix`
|
|
7
|
+
changed is freed (`take`, by the text it had). What's kept is capped (`budget`): a tree takes about
|
|
8
|
+
26 bytes of memory for each byte of source, so past the cap a file is parsed again, as it always
|
|
9
|
+
was. The cap is a quarter of the machine's memory (`sized`), and never under 1 GB of trees.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import tokenize
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Final, TypeAlias
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from constricter.rules.tables import Tables
|
|
25
|
+
|
|
26
|
+
_FLOOR: Final = 40 << 20 # bytes of source whose trees any run may keep (about 1 GB of them)
|
|
27
|
+
_TREE: Final = 26 # bytes of memory a tree takes for each byte of its source
|
|
28
|
+
_SHARE: Final = 4 # the kept trees' part of the machine's memory: a quarter
|
|
29
|
+
# A control group's memory limit (a container's), where there is one: `max` for none.
|
|
30
|
+
_GROUP_LIMIT: Final = Path("/sys/fs/cgroup/memory.max")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def memory() -> int | None:
|
|
34
|
+
"""Read how much memory this process has to use: the machine's, or its control group's limit if lower.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
It, in bytes; `None` where the system doesn't say (Windows has no `sysconf`).
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
# Looked up, not named: mypyc's mypy pass on Windows rejects `os.sysconf`.
|
|
41
|
+
sysconf: Callable[[str], int] | None
|
|
42
|
+
if (sysconf := getattr(os, "sysconf", None)) is None:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
total: int = sysconf("SC_PHYS_PAGES") * sysconf("SC_PAGE_SIZE")
|
|
46
|
+
except (ValueError, OSError):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return min(total, int(_GROUP_LIMIT.read_text(encoding="ascii")))
|
|
50
|
+
except (OSError, ValueError): # no control group, or no limit in it
|
|
51
|
+
return total
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sized(available: int | None) -> int:
|
|
55
|
+
"""Size the kept trees' budget for a machine with `available` bytes of memory (see `memory`).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The bytes of source whose trees a run keeps: a quarter of the memory's worth, `_FLOOR` at
|
|
59
|
+
least, and where the memory isn't known.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
return _FLOOR if available is None else max(_FLOOR, available // _SHARE // _TREE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
BUDGET: Final = sized(memory()) # bytes of source whose trees a run keeps, over all its processes
|
|
66
|
+
# A kept tree, and the module's own tables the index read from it (the check reads the same).
|
|
67
|
+
Kept: TypeAlias = tuple[ast.Module, "Tables"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class _Kept:
|
|
72
|
+
"""The trees kept in this process, by their source's text, and what's left of its share."""
|
|
73
|
+
|
|
74
|
+
trees: dict[str, Kept] = field(default_factory=dict)
|
|
75
|
+
left: int = BUDGET
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_KEPT: Final = _Kept()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def budget(processes: int) -> None:
|
|
82
|
+
"""Share the budget among `processes`: each keeps its part (a worker's initializer, or the CLI's own)."""
|
|
83
|
+
_KEPT.trees.clear()
|
|
84
|
+
_KEPT.left = BUDGET // max(processes, 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse(source: str | bytes, filename: str = "<unknown>") -> ast.Module:
|
|
88
|
+
"""Parse `source` with its `# type:` comments; without them if one is misplaced.
|
|
89
|
+
|
|
90
|
+
Python's warnings about the source (an invalid escape sequence in a string) are left unsaid: they're
|
|
91
|
+
about the code checked, not findings, and it's the checked project's to see them when it runs.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The module. Raises `SyntaxError`.
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
with warnings.catch_warnings():
|
|
98
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
99
|
+
warnings.simplefilter("ignore", DeprecationWarning) # what Python 3.11 warns about them with
|
|
100
|
+
try:
|
|
101
|
+
return ast.parse(source, filename, type_comments=True)
|
|
102
|
+
except SyntaxError: # a misplaced `# type:` comment, or a real error raised again here
|
|
103
|
+
return ast.parse(source, filename)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def text(data: bytes) -> str:
|
|
107
|
+
"""Decode a module's bytes as Python finds their encoding (a PEP 263 declaration, a BOM, else UTF-8).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Its text. Raises `SyntaxError` for an unknown or contradictory encoding, and
|
|
111
|
+
`UnicodeDecodeError` for bytes it doesn't hold.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
return data.decode(tokenize.detect_encoding(io.BytesIO(data).readline)[0])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def keep(source: str, kept: Kept) -> None:
|
|
118
|
+
"""Keep `source`'s tree and tables for the check to take, if this process's share of the budget allows."""
|
|
119
|
+
if len(source) <= _KEPT.left and source not in _KEPT.trees:
|
|
120
|
+
_KEPT.trees[source] = kept
|
|
121
|
+
_KEPT.left -= len(source)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def take(source: str) -> "Kept | None":
|
|
125
|
+
"""Take the tree and tables kept for `source`, freeing them (two files alike in every byte share them).
|
|
126
|
+
|
|
127
|
+
Only for exactly the text they were read from: a file `--fix` has changed since is parsed again.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Them, or `None` if none were kept.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
kept: Kept | None
|
|
134
|
+
if (kept := _KEPT.trees.pop(source, None)) is not None:
|
|
135
|
+
_KEPT.left += len(source)
|
|
136
|
+
return kept
|
|
Binary file
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Quoted annotations: a string annotation as the expression it quotes, and a quoted part as its text."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from constricter.offences import BUILTIN_GENERICS, UNION_SYNTAX
|
|
9
|
+
|
|
10
|
+
_LITERAL: Final = "Literal" # its strings are values, not quoted types
|
|
11
|
+
_ANNOTATED: Final = "Annotated" # its first argument alone is a type
|
|
12
|
+
_QUOTES: Final = frozenset("'\"")
|
|
13
|
+
_QUALIFIERS: Final = frozenset({"ClassVar", "Final"}) # around an attribute's type
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _head(node: ast.expr) -> str:
|
|
17
|
+
"""Read the last name a subscript's head is written with (`Literal`, for `typing.Literal`).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
It.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
return ast.unparse(node).rpartition(".")[2]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=256)
|
|
27
|
+
def parsed(annotation: ast.expr) -> ast.expr:
|
|
28
|
+
"""Unwrap a string annotation.
|
|
29
|
+
|
|
30
|
+
`is_vague` and `depth` both call this on the same annotation; cached so it's parsed once.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Its parsed expression, or the annotation itself if it isn't a string.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
37
|
+
try:
|
|
38
|
+
return ast.parse(annotation.value, mode="eval").body
|
|
39
|
+
except SyntaxError:
|
|
40
|
+
return annotation
|
|
41
|
+
return annotation
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def written(annotation: ast.expr) -> str:
|
|
45
|
+
"""Write an annotation as text `--fix` infers from: a quoted one, or a quoted part, as what it quotes.
|
|
46
|
+
|
|
47
|
+
`"list[Node]"` and `list["Node"]` are both `list[Node]`. Not a `Literal`'s strings, nor an
|
|
48
|
+
`Annotated`'s metadata, which are values; nor a string that isn't an expression.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The text.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
text: str = ast.unparse(annotation)
|
|
55
|
+
return ast.unparse(_unquoted(annotation)) if _QUOTES.intersection(text) else text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unqualified(annotation: ast.expr) -> str:
|
|
59
|
+
"""Write an attribute's annotation (see `written`), less the `ClassVar` or `Final` around its type.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The text; nothing for a bare `Final`, which leaves the type to the value.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
name: str
|
|
66
|
+
inner: ast.expr
|
|
67
|
+
match annotation:
|
|
68
|
+
case ast.Name(id=name) | ast.Attribute(attr=name) if name in _QUALIFIERS:
|
|
69
|
+
return ""
|
|
70
|
+
case ast.Subscript(value=ast.Name(id=name) | ast.Attribute(attr=name), slice=inner) if (
|
|
71
|
+
name in _QUALIFIERS
|
|
72
|
+
):
|
|
73
|
+
return unqualified(inner)
|
|
74
|
+
case _:
|
|
75
|
+
return written(annotation)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unquoted(node: ast.expr) -> ast.expr:
|
|
79
|
+
"""Rebuild an annotation with each quoted type in it read as an expression (see `written`).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The annotation: a new tree wherever it differs (the module's own is shared).
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
head: ast.expr
|
|
86
|
+
inner: ast.expr
|
|
87
|
+
first: ast.expr
|
|
88
|
+
rest: list[ast.expr]
|
|
89
|
+
parts: list[ast.expr]
|
|
90
|
+
left: ast.expr
|
|
91
|
+
right: ast.expr
|
|
92
|
+
found: ast.expr = node
|
|
93
|
+
match node:
|
|
94
|
+
case ast.Constant(value=str()):
|
|
95
|
+
found = parsed(node)
|
|
96
|
+
found = found if found is node else _unquoted(found)
|
|
97
|
+
case ast.Subscript(value=head) if _head(head) == _LITERAL:
|
|
98
|
+
pass
|
|
99
|
+
case ast.Subscript(value=head, slice=ast.Tuple(elts=[first, *rest])) if _head(head) == _ANNOTATED:
|
|
100
|
+
found = ast.Subscript(head, ast.Tuple([_unquoted(first), *rest]))
|
|
101
|
+
case ast.Subscript(value=head, slice=inner):
|
|
102
|
+
found = ast.Subscript(head, _unquoted(inner))
|
|
103
|
+
case ast.Tuple(elts=parts):
|
|
104
|
+
found = ast.Tuple([_unquoted(part) for part in parts])
|
|
105
|
+
case ast.List(elts=parts):
|
|
106
|
+
found = ast.List([_unquoted(part) for part in parts])
|
|
107
|
+
case ast.BinOp(left=left, op=ast.BitOr(), right=right):
|
|
108
|
+
found = ast.BinOp(_unquoted(left), ast.BitOr(), _unquoted(right))
|
|
109
|
+
case _:
|
|
110
|
+
pass
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def quote(annotation: str) -> str:
|
|
115
|
+
"""Quote an annotation: in double quotes, unless it has one or a backslash (a `Literal`'s string).
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
It, as a string literal.
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
plain: bool = not {'"', "\\"} & set(annotation)
|
|
122
|
+
return f'"{annotation}"' if plain else ast.unparse(ast.Constant(annotation))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@lru_cache(maxsize=4096) # a module's fixes are a few types, each asked again and again
|
|
126
|
+
def newer(annotation: str, oldest: tuple[int, int] | None) -> bool:
|
|
127
|
+
"""Check whether `annotation` is written as Pythons from `oldest` on don't all read a type.
|
|
128
|
+
|
|
129
|
+
A union by `|` before 3.10, any subscript before 3.9 (a builtin or standard-library class's).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Whether it is; never where `oldest` isn't known, or the annotation is quoted already.
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
if oldest is None or oldest >= UNION_SYNTAX or annotation.startswith(('"', "'")):
|
|
136
|
+
return False
|
|
137
|
+
return any(
|
|
138
|
+
(isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr))
|
|
139
|
+
or (oldest < BUILTIN_GENERICS and isinstance(node, ast.Subscript))
|
|
140
|
+
for node in ast.walk(ast.parse(annotation, mode="eval"))
|
|
141
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""A first binding's fix, refitted to every value the name is bound to later, as a checker holds it to.
|
|
3
|
+
|
|
4
|
+
`x = 1` then `x = None` declares `x: int | None` before the first binding (annotated there, mypy
|
|
5
|
+
wouldn't narrow it to the `int` it's bound to), and `total = 0` then `total += 0.5` declares a
|
|
6
|
+
`float`; another type that doesn't fit (`x = 1` then `x = "a"`, a class and its base) leaves it
|
|
7
|
+
untyped: a union is better left to the author. A `Self` takes only `Self`. A later binding whose
|
|
8
|
+
type isn't known may be anything, and one whose type is a guess may be wrong: either makes the fix
|
|
9
|
+
a guess, and leaves a type checker's hint (`--infer-with`) no fix at all.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Final, NamedTuple
|
|
13
|
+
|
|
14
|
+
from constricter.fix.core.known import Inference
|
|
15
|
+
from constricter.fix.values.hinted import KINDS as HINTED
|
|
16
|
+
from constricter.offences import UNIONS, Edit, Fix, Offence
|
|
17
|
+
from constricter.rules.flow import Binding, Hierarchy, members
|
|
18
|
+
|
|
19
|
+
_NONE: Final = "None"
|
|
20
|
+
_ONLY_NONE: Final = frozenset({_NONE})
|
|
21
|
+
_OPTIONAL: Final = "optional" # the fix kind of a `None` added to a type
|
|
22
|
+
REBOUND: Final = "rebound" # the fix kind (and guessing mechanism) of a later binding's type
|
|
23
|
+
# The types a later binding may widen a first one's to: the numeric tower's (`int`, then `float`).
|
|
24
|
+
_NUMBERS: Final = frozenset({"bool", "int", "float", "complex"})
|
|
25
|
+
_UNION: Final = 3 # the most types a widening's union names
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Refit(NamedTuple):
|
|
29
|
+
"""A fix refitted: its inference, what it rests on if a guess, whether it is one, and its edit."""
|
|
30
|
+
|
|
31
|
+
found: Inference
|
|
32
|
+
origins: frozenset[str]
|
|
33
|
+
unsafe: bool
|
|
34
|
+
edit: Edit
|
|
35
|
+
span: tuple[int, int]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Later(NamedTuple):
|
|
39
|
+
"""What a name's later bindings say: the types that don't fit its fix, their guesses, any unknown."""
|
|
40
|
+
|
|
41
|
+
misfits: frozenset[str]
|
|
42
|
+
origins: frozenset[str] # what the later bindings' guessed types rest on
|
|
43
|
+
unknown: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def refit(
|
|
47
|
+
o: Offence,
|
|
48
|
+
fix: Fix,
|
|
49
|
+
rest: list[Binding],
|
|
50
|
+
hierarchy: Hierarchy,
|
|
51
|
+
module: tuple[str | None, bool],
|
|
52
|
+
) -> Refit | Fix | None:
|
|
53
|
+
"""Refit `o`'s `fix` to the name's `rest` of bindings (see the module docstring).
|
|
54
|
+
|
|
55
|
+
`module`: how the module spells `Self`, if it imports it; and whether values of two or three
|
|
56
|
+
types that fit no one of them are declared their union, a widening (`fix-widen`'s `unions`).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
`fix` itself, if it takes them all as it is; else its refit, to offer; or `None`, for no fix.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
# `--fix`'s annotations are always readable; one that weren't would be its own one member.
|
|
63
|
+
declared: frozenset[str] = members(fix.annotation) or frozenset({fix.annotation})
|
|
64
|
+
later: _Later = _later(rest, declared, hierarchy)
|
|
65
|
+
# A hint is one value's type alone: it says nothing of a later one's.
|
|
66
|
+
if (later.misfits or later.unknown) and (module[0] in declared or HINTED & fix.kinds):
|
|
67
|
+
return None
|
|
68
|
+
if not (later.misfits or later.unknown or later.origins):
|
|
69
|
+
return fix
|
|
70
|
+
rebound: frozenset[str] = frozenset({REBOUND}) if later.unknown else frozenset()
|
|
71
|
+
kinds: frozenset[str] = fix.kinds | later.origins | rebound
|
|
72
|
+
origins: frozenset[str] = later.origins | rebound | (fix.kinds if fix.unsafe else frozenset[str]())
|
|
73
|
+
unsafe: bool = fix.unsafe or later.unknown or bool(later.origins)
|
|
74
|
+
if not later.misfits:
|
|
75
|
+
return Refit(Inference(fix.annotation, fix.reason, kinds), origins, unsafe, fix.edit, fix.span)
|
|
76
|
+
atoms: frozenset[str] = declared | later.misfits
|
|
77
|
+
if module[0] in atoms: # a `Self` takes only `Self`, and is taken by nothing else
|
|
78
|
+
return None
|
|
79
|
+
joined: tuple[str, frozenset[str]] | None
|
|
80
|
+
# A union of known types: one that's a guess makes it a guess, resting on what that one does, as
|
|
81
|
+
# a second pass would declare it once the first had declared the guess.
|
|
82
|
+
if (joined := _joined(atoms - {_NONE}, hierarchy, unions=module[1] and not later.unknown)) is None:
|
|
83
|
+
return None
|
|
84
|
+
return Refit(
|
|
85
|
+
Inference(
|
|
86
|
+
joined[0] + (" | None" if _NONE in atoms else ""),
|
|
87
|
+
f"{fix.reason}, and every value it's bound to later",
|
|
88
|
+
kinds | {_OPTIONAL if later.misfits == _ONLY_NONE else REBOUND} | joined[1],
|
|
89
|
+
),
|
|
90
|
+
origins | joined[1],
|
|
91
|
+
unsafe or bool(joined[1]),
|
|
92
|
+
Edit.DECLARE,
|
|
93
|
+
fix.span if fix.edit is Edit.DECLARE else (o.line, o.col),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _joined(
|
|
98
|
+
atoms: frozenset[str],
|
|
99
|
+
hierarchy: Hierarchy,
|
|
100
|
+
*,
|
|
101
|
+
unions: bool,
|
|
102
|
+
) -> tuple[str, frozenset[str]] | None:
|
|
103
|
+
"""Write the one type a name bound to values of types `atoms` is declared.
|
|
104
|
+
|
|
105
|
+
The one they all fit (see `_widest`); else, with `unions`, the union of two or three of them: a
|
|
106
|
+
widening, which a checker may hold a later use to.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The type, and the widening's kind if it's one; `None` where there's neither.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
widest: str | None
|
|
113
|
+
if (widest := _widest(atoms, hierarchy)) is not None:
|
|
114
|
+
return widest, frozenset()
|
|
115
|
+
return (" | ".join(sorted(atoms)), frozenset({UNIONS})) if unions and len(atoms) <= _UNION else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _later(rest: list[Binding], declared: frozenset[str], hierarchy: Hierarchy) -> _Later:
|
|
119
|
+
"""Read what the later bindings of a name declared as the union `declared` say.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Their types that don't fit it, what their guesses rest on, and whether any is unknown.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
misfits: set[str] = set()
|
|
126
|
+
origins: frozenset[str] = frozenset()
|
|
127
|
+
unknown: bool = False
|
|
128
|
+
binding: Binding
|
|
129
|
+
for binding in rest:
|
|
130
|
+
text: str | None = binding.value or (None if binding.guess is None else binding.guess[0])
|
|
131
|
+
found: frozenset[str] | None
|
|
132
|
+
if text is None or (found := members(text)) is None:
|
|
133
|
+
unknown = True
|
|
134
|
+
continue
|
|
135
|
+
if binding.value is None and binding.guess is not None:
|
|
136
|
+
origins |= binding.guess[1]
|
|
137
|
+
misfits.update(atom for atom in found if not _fits(atom, declared, hierarchy))
|
|
138
|
+
return _Later(frozenset(misfits), origins, unknown)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fits(atom: str, declared: frozenset[str], hierarchy: Hierarchy) -> bool:
|
|
142
|
+
"""Check whether a value of type `atom` fits a name declared as the union `declared`.
|
|
143
|
+
|
|
144
|
+
As value flow's `Hierarchy.fits` has it, but a generic only fits the same text: an annotation that
|
|
145
|
+
says `list[int]` doesn't take a `list[str]`.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Whether it does.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
return any(atom == member or member in hierarchy.wider(atom) for member in declared)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _widest(atoms: frozenset[str], hierarchy: Hierarchy) -> str | None:
|
|
155
|
+
"""Find the one type all of `atoms` fit: itself, if it's one; else the widest, if they're numbers.
|
|
156
|
+
|
|
157
|
+
Not a class's base (`Base` for `Base` and `Sub`): a checker may see only one of them bound (the
|
|
158
|
+
other, say, under `if not TYPE_CHECKING:`), and narrow to it.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
It, or `None`.
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
if len(atoms) == 1:
|
|
165
|
+
return next(iter(atoms))
|
|
166
|
+
widest: list[str] = [
|
|
167
|
+
atom for atom in atoms if all(_fits(other, frozenset({atom}), hierarchy) for other in atoms)
|
|
168
|
+
]
|
|
169
|
+
return widest[0] if widest and atoms <= _NUMBERS else None
|
|
Binary file
|