sincewhen 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.
- sincewhen/__init__.py +16 -0
- sincewhen/__main__.py +3 -0
- sincewhen/cli.py +222 -0
- sincewhen/detect.py +392 -0
- sincewhen/features.py +234 -0
- sincewhen/features.toml +23435 -0
- sincewhen/versions.py +91 -0
- sincewhen-0.1.0.dist-info/METADATA +221 -0
- sincewhen-0.1.0.dist-info/RECORD +12 -0
- sincewhen-0.1.0.dist-info/WHEEL +4 -0
- sincewhen-0.1.0.dist-info/entry_points.txt +3 -0
- sincewhen-0.1.0.dist-info/licenses/LICENSE.txt +9 -0
sincewhen/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Find out which Python version added each feature your code uses."""
|
|
2
|
+
|
|
3
|
+
from .detect import Detection, detect, minimum_version
|
|
4
|
+
from .features import DatasetError, Feature, load_features, lookup
|
|
5
|
+
from .versions import Version
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DatasetError",
|
|
9
|
+
"Detection",
|
|
10
|
+
"Feature",
|
|
11
|
+
"Version",
|
|
12
|
+
"detect",
|
|
13
|
+
"load_features",
|
|
14
|
+
"lookup",
|
|
15
|
+
"minimum_version",
|
|
16
|
+
]
|
sincewhen/__main__.py
ADDED
sincewhen/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Command-line interface for sincewhen."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .detect import Detection, detect
|
|
10
|
+
from .features import Feature, enclosing_module, lookup
|
|
11
|
+
from .versions import Version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="sincewhen",
|
|
17
|
+
description="Find out which Python version added each feature your code uses.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"paths",
|
|
21
|
+
nargs="*",
|
|
22
|
+
type=Path,
|
|
23
|
+
help="Python files to analyze (use - for standard input)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-s",
|
|
27
|
+
"--search",
|
|
28
|
+
metavar="TERM",
|
|
29
|
+
help="look a feature up by name instead of analyzing code",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-a",
|
|
33
|
+
"--all",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="report every use, not just the first of each feature",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--since",
|
|
39
|
+
metavar="VERSION",
|
|
40
|
+
type=Version.parse,
|
|
41
|
+
help="hide features older than this, for when the ancient ones are noise",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--json", action="store_true", help="emit JSON")
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--version",
|
|
46
|
+
action="version",
|
|
47
|
+
version=f"%(prog)s {version('sincewhen')}",
|
|
48
|
+
help="show the version of sincewhen itself and exit",
|
|
49
|
+
)
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read(path: Path) -> tuple[str, str]:
|
|
54
|
+
if str(path) == "-":
|
|
55
|
+
return sys.stdin.read(), "<stdin>"
|
|
56
|
+
return path.read_text(encoding="utf-8"), str(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _drop_implied(detections: list[Detection]) -> list[Detection]:
|
|
60
|
+
"""Drop a module line when a member of it says the same thing.
|
|
61
|
+
|
|
62
|
+
`from dataclasses import dataclass` is one import that matches two
|
|
63
|
+
features of the same age. Both are true and printing both is noise,
|
|
64
|
+
so the more specific one speaks for the pair. The module keeps its
|
|
65
|
+
line whenever it is the older of the two, since then it is saying
|
|
66
|
+
something the member does not.
|
|
67
|
+
"""
|
|
68
|
+
members = {
|
|
69
|
+
(found.lineno, name.partition(".")[0], found.added)
|
|
70
|
+
for found in detections
|
|
71
|
+
for name in found.feature.attributes
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
found
|
|
75
|
+
for found in detections
|
|
76
|
+
if not any(
|
|
77
|
+
(found.lineno, module, found.added) in members
|
|
78
|
+
for module in found.feature.modules
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _first_uses(detections: list[Detection]) -> list[Detection]:
|
|
84
|
+
seen = set()
|
|
85
|
+
kept = []
|
|
86
|
+
for found in detections:
|
|
87
|
+
if found.feature.id not in seen:
|
|
88
|
+
seen.add(found.feature.id)
|
|
89
|
+
kept.append(found)
|
|
90
|
+
return kept
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _feature_data(feature: Feature) -> dict:
|
|
94
|
+
return {
|
|
95
|
+
"id": feature.id,
|
|
96
|
+
"name": feature.name,
|
|
97
|
+
"added": str(feature.added),
|
|
98
|
+
"or_earlier": feature.or_earlier,
|
|
99
|
+
"released": released.isoformat()
|
|
100
|
+
if (released := feature.added.released)
|
|
101
|
+
else None,
|
|
102
|
+
"category": feature.category,
|
|
103
|
+
"pep": feature.pep,
|
|
104
|
+
"pep_url": feature.pep_url,
|
|
105
|
+
"docs_url": feature.docs_url,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _released(version: Version) -> str:
|
|
110
|
+
"""When that release shipped, for the reader's sense of scale."""
|
|
111
|
+
released = version.released
|
|
112
|
+
return released.isoformat() if released else ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _report(results: list[tuple[str, list[Detection]]]) -> None:
|
|
116
|
+
rows = [
|
|
117
|
+
(
|
|
118
|
+
f"{name}:{found.lineno}",
|
|
119
|
+
found.feature.name,
|
|
120
|
+
found.feature.since,
|
|
121
|
+
_released(found.added),
|
|
122
|
+
)
|
|
123
|
+
for name, detections in results
|
|
124
|
+
for found in detections
|
|
125
|
+
]
|
|
126
|
+
if not rows:
|
|
127
|
+
print("No dated features detected.")
|
|
128
|
+
return
|
|
129
|
+
location_width = max(len(location) for location, *_ in rows)
|
|
130
|
+
name_width = max(len(name) for _, name, *_ in rows)
|
|
131
|
+
since_width = max(len(since) for *_, since, _ in rows)
|
|
132
|
+
for location, name, since, released in rows:
|
|
133
|
+
line = f"{location:<{location_width}} {name:<{name_width}} {since:<{since_width}}"
|
|
134
|
+
print(f"{line} {released}".rstrip())
|
|
135
|
+
|
|
136
|
+
# Non-empty rows guarantee at least one detection, so there is a floor.
|
|
137
|
+
floor = max(found.added for _, detections in results for found in detections)
|
|
138
|
+
setters = sorted(
|
|
139
|
+
{
|
|
140
|
+
found.feature.name
|
|
141
|
+
for _, detections in results
|
|
142
|
+
for found in detections
|
|
143
|
+
if found.added == floor
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
released = _released(floor)
|
|
147
|
+
since = f", released {released}" if released else ""
|
|
148
|
+
print(f"\nMinimum: Python {floor}{since} (set by {', '.join(setters)})")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _search(term: str, as_json: bool) -> int:
|
|
152
|
+
features = lookup(term)
|
|
153
|
+
if as_json:
|
|
154
|
+
print(json.dumps([_feature_data(f) for f in features], indent=2))
|
|
155
|
+
return 0 if features else 1
|
|
156
|
+
if not features:
|
|
157
|
+
print(f"No feature matches {term!r}.", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
if features == enclosing_module(term.casefold().strip()):
|
|
160
|
+
print(f"No entry for {term}, but it lives in:")
|
|
161
|
+
for feature in features:
|
|
162
|
+
released = _released(feature.added)
|
|
163
|
+
when = f" (released {released})" if released else ""
|
|
164
|
+
print(f"{feature.name} - Python {feature.since}{when}")
|
|
165
|
+
for label, url in (("PEP", feature.pep_url), ("Docs", feature.docs_url)):
|
|
166
|
+
if url:
|
|
167
|
+
print(f" {label}: {url}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> int:
|
|
172
|
+
args = _parser().parse_args(argv)
|
|
173
|
+
|
|
174
|
+
if args.search:
|
|
175
|
+
return _search(args.search, args.json)
|
|
176
|
+
|
|
177
|
+
if not args.paths:
|
|
178
|
+
_parser().error("provide at least one path, or use --search")
|
|
179
|
+
|
|
180
|
+
results = []
|
|
181
|
+
for path in args.paths:
|
|
182
|
+
try:
|
|
183
|
+
source, name = _read(path)
|
|
184
|
+
except OSError as error:
|
|
185
|
+
print(f"sincewhen: {error}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
try:
|
|
188
|
+
detections = detect(source, filename=name)
|
|
189
|
+
except SyntaxError as error:
|
|
190
|
+
print(f"sincewhen: {name}: {error}", file=sys.stderr)
|
|
191
|
+
return 2
|
|
192
|
+
detections = _drop_implied(detections)
|
|
193
|
+
if args.since:
|
|
194
|
+
detections = [f for f in detections if f.added >= args.since]
|
|
195
|
+
if not args.all:
|
|
196
|
+
detections = _first_uses(detections)
|
|
197
|
+
results.append((name, detections))
|
|
198
|
+
|
|
199
|
+
if args.json:
|
|
200
|
+
print(
|
|
201
|
+
json.dumps(
|
|
202
|
+
{
|
|
203
|
+
name: [
|
|
204
|
+
{
|
|
205
|
+
"line": f.lineno,
|
|
206
|
+
"column": f.col_offset,
|
|
207
|
+
**_feature_data(f.feature),
|
|
208
|
+
}
|
|
209
|
+
for f in detections
|
|
210
|
+
]
|
|
211
|
+
for name, detections in results
|
|
212
|
+
},
|
|
213
|
+
indent=2,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
_report(results)
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
raise SystemExit(main())
|
sincewhen/detect.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Detect dated features in Python source code."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cache
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .features import Feature, load_features
|
|
10
|
+
from .versions import Version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Detection:
|
|
15
|
+
"""One use of one feature, at one place in the source."""
|
|
16
|
+
|
|
17
|
+
feature: Feature
|
|
18
|
+
lineno: int
|
|
19
|
+
col_offset: int
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def added(self) -> Version:
|
|
23
|
+
return self.feature.added
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Containers that PEP 585 made subscriptable in 3.9. Subscripting any
|
|
27
|
+
# of these names is either the new generic syntax or a variable that
|
|
28
|
+
# shadows the builtin, which is why the check needs to know what the
|
|
29
|
+
# module binds.
|
|
30
|
+
PEP_585_GENERICS = frozenset(
|
|
31
|
+
{"list", "dict", "set", "frozenset", "tuple", "type"},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _has_none_key(node: ast.Dict, _bound: frozenset[str]) -> bool:
|
|
36
|
+
"""A `None` key means dict unpacking, as in `{**a}`."""
|
|
37
|
+
return any(key is None for key in node.keys)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _has_starred_element(
|
|
41
|
+
node: ast.List | ast.Tuple | ast.Set, _bound: frozenset[str]
|
|
42
|
+
) -> bool:
|
|
43
|
+
"""Unpacking into a literal, as in `[*a]`.
|
|
44
|
+
|
|
45
|
+
Store context is excluded because `a, *b = c` is a different
|
|
46
|
+
feature that arrived in Python 3.0.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(getattr(node, "ctx", ast.Load()), ast.Load):
|
|
49
|
+
return False
|
|
50
|
+
return any(isinstance(element, ast.Starred) for element in node.elts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _has_starred_target(node: ast.List | ast.Tuple, _bound: frozenset[str]) -> bool:
|
|
54
|
+
"""Extended unpacking, as in `a, *rest = values`."""
|
|
55
|
+
if isinstance(node.ctx, ast.Load):
|
|
56
|
+
return False
|
|
57
|
+
return any(isinstance(element, ast.Starred) for element in node.elts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_async_comprehension(
|
|
61
|
+
node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
|
|
62
|
+
_bound: frozenset[str],
|
|
63
|
+
) -> bool:
|
|
64
|
+
return any(generator.is_async for generator in node.generators)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_multiple_unpackings(node: ast.Call, _bound: frozenset[str]) -> bool:
|
|
68
|
+
"""More than one unpacking in a call, as in `f(*a, *b)`.
|
|
69
|
+
|
|
70
|
+
A single `f(*a)` has always been legal, so only the second one
|
|
71
|
+
dates the call to 3.5.
|
|
72
|
+
"""
|
|
73
|
+
starred = sum(isinstance(argument, ast.Starred) for argument in node.args)
|
|
74
|
+
doubled = sum(keyword.arg is None for keyword in node.keywords)
|
|
75
|
+
return starred > 1 or doubled > 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_async_generator(node: ast.AsyncFunctionDef, _bound: frozenset[str]) -> bool:
|
|
79
|
+
"""An `async def` that yields, which needed 3.6.
|
|
80
|
+
|
|
81
|
+
Nested functions are skipped, since a plain generator defined inside
|
|
82
|
+
a coroutine is not itself an async generator.
|
|
83
|
+
"""
|
|
84
|
+
return any(
|
|
85
|
+
isinstance(inner, ast.Yield)
|
|
86
|
+
for inner in _own_body(node)
|
|
87
|
+
if not isinstance(inner, ast.Lambda)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _own_body(node: ast.AST):
|
|
92
|
+
"""Every node inside `node` that is not inside a nested function."""
|
|
93
|
+
for child in ast.iter_child_nodes(node):
|
|
94
|
+
if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
|
95
|
+
continue
|
|
96
|
+
yield child
|
|
97
|
+
yield from _own_body(child)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_builtin_generic(node: ast.Subscript, bound: frozenset[str]) -> bool:
|
|
101
|
+
"""Subscripting a builtin container, as in `list[int]` (PEP 585)."""
|
|
102
|
+
return (
|
|
103
|
+
isinstance(node.value, ast.Name)
|
|
104
|
+
and node.value.id in PEP_585_GENERICS
|
|
105
|
+
and node.value.id not in bound
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_zero_argument_super(node: ast.Call, bound: frozenset[str]) -> bool:
|
|
110
|
+
"""`super()` with no arguments, which needs the 3.0 compiler help."""
|
|
111
|
+
return (
|
|
112
|
+
isinstance(node.func, ast.Name)
|
|
113
|
+
and node.func.id == "super"
|
|
114
|
+
and "super" not in bound
|
|
115
|
+
and not node.args
|
|
116
|
+
and not node.keywords
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_complex_decorator(
|
|
121
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, _bound: frozenset[str]
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""A decorator that the pre-3.9 grammar would have rejected.
|
|
124
|
+
|
|
125
|
+
Until PEP 614 a decorator had to be a dotted name, optionally
|
|
126
|
+
called. Anything else -- a subscript, a comparison, a lambda -- is
|
|
127
|
+
3.9 or newer.
|
|
128
|
+
"""
|
|
129
|
+
return any(
|
|
130
|
+
not _is_dotted_name(
|
|
131
|
+
decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
132
|
+
)
|
|
133
|
+
for decorator in node.decorator_list
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_dotted_name(node: ast.expr) -> bool:
|
|
138
|
+
while isinstance(node, ast.Attribute):
|
|
139
|
+
node = node.value
|
|
140
|
+
return isinstance(node, ast.Name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _has_except_and_finally(node: ast.Try, _bound: frozenset[str]) -> bool:
|
|
144
|
+
"""One statement with both `except` and `finally`, unified in 2.5."""
|
|
145
|
+
return bool(node.handlers and node.finalbody)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _has_several_context_managers(
|
|
149
|
+
node: ast.With | ast.AsyncWith, _bound: frozenset[str]
|
|
150
|
+
) -> bool:
|
|
151
|
+
"""`with a, b:` rather than two nested `with` statements."""
|
|
152
|
+
return len(node.items) > 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _has_metaclass_keyword(node: ast.ClassDef, _bound: frozenset[str]) -> bool:
|
|
156
|
+
"""`class C(metaclass=M)`, which replaced `__metaclass__` in 3.0."""
|
|
157
|
+
return any(keyword.arg == "metaclass" for keyword in node.keywords)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _has_named_handler(node: ast.ExceptHandler, _bound: frozenset[str]) -> bool:
|
|
161
|
+
"""`except E as name`, the spelling that replaced `except E, name`."""
|
|
162
|
+
return node.name is not None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _has_cause(node: ast.Raise, _bound: frozenset[str]) -> bool:
|
|
166
|
+
"""`raise X from Y`, which is exception chaining."""
|
|
167
|
+
return node.cause is not None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_bytes(node: ast.Constant, _bound: frozenset[str]) -> bool:
|
|
171
|
+
"""A `b"..."` literal."""
|
|
172
|
+
return isinstance(node.value, bytes)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _has_starred_subscript(node: ast.Subscript, _bound: frozenset[str]) -> bool:
|
|
176
|
+
"""Unpacking inside a subscript, as in `tuple[*Ts]` (PEP 646).
|
|
177
|
+
|
|
178
|
+
A starred subscript is always parsed into a tuple, even when it is
|
|
179
|
+
the only thing in the brackets, so there is no bare `Starred` slice
|
|
180
|
+
to look for.
|
|
181
|
+
"""
|
|
182
|
+
return isinstance(node.slice, ast.Tuple) and any(
|
|
183
|
+
isinstance(element, ast.Starred) for element in node.slice.elts
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Checks are dispatched by name from the dataset, so the node type is
|
|
188
|
+
# only known at runtime. Each predicate still annotates the node types
|
|
189
|
+
# it actually accepts; `Any` here is the dynamic-dispatch boundary.
|
|
190
|
+
# Every check also receives the names the module binds, so that a
|
|
191
|
+
# feature keyed on a builtin can tell a real use from a shadowed one.
|
|
192
|
+
CHECKS: dict[str, Callable[[Any, frozenset[str]], bool]] = {
|
|
193
|
+
"has_none_key": _has_none_key,
|
|
194
|
+
"has_starred_element": _has_starred_element,
|
|
195
|
+
"has_starred_target": _has_starred_target,
|
|
196
|
+
"has_async_comprehension": _has_async_comprehension,
|
|
197
|
+
"has_multiple_unpackings": _has_multiple_unpackings,
|
|
198
|
+
"is_async_generator": _is_async_generator,
|
|
199
|
+
"is_builtin_generic": _is_builtin_generic,
|
|
200
|
+
"is_zero_argument_super": _is_zero_argument_super,
|
|
201
|
+
"has_complex_decorator": _has_complex_decorator,
|
|
202
|
+
"has_except_and_finally": _has_except_and_finally,
|
|
203
|
+
"has_several_context_managers": _has_several_context_managers,
|
|
204
|
+
"has_metaclass_keyword": _has_metaclass_keyword,
|
|
205
|
+
"has_named_handler": _has_named_handler,
|
|
206
|
+
"has_cause": _has_cause,
|
|
207
|
+
"is_bytes": _is_bytes,
|
|
208
|
+
"has_starred_subscript": _has_starred_subscript,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _Index:
|
|
213
|
+
"""Feature lookups keyed by what they match on."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, features: Iterable[Feature]):
|
|
216
|
+
self.by_node: dict[str, list[Feature]] = {}
|
|
217
|
+
self.by_builtin: dict[str, Feature] = {}
|
|
218
|
+
self.by_module: dict[str, Feature] = {}
|
|
219
|
+
self.by_attribute: dict[str, Feature] = {}
|
|
220
|
+
for feature in features:
|
|
221
|
+
for name in feature.nodes:
|
|
222
|
+
self.by_node.setdefault(name, []).append(feature)
|
|
223
|
+
for name in feature.builtins:
|
|
224
|
+
self.by_builtin[name] = feature
|
|
225
|
+
for name in feature.modules:
|
|
226
|
+
self.by_module[name] = feature
|
|
227
|
+
for name in feature.attributes:
|
|
228
|
+
self.by_attribute[name] = feature
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@cache
|
|
232
|
+
def _index() -> _Index:
|
|
233
|
+
return _Index(load_features())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _bound_names(tree: ast.AST) -> frozenset[str]:
|
|
237
|
+
"""Names the module binds itself.
|
|
238
|
+
|
|
239
|
+
A module that defines its own `sum` is not using the builtin, so
|
|
240
|
+
shadowed names are skipped entirely rather than reported wrongly.
|
|
241
|
+
"""
|
|
242
|
+
names = set()
|
|
243
|
+
for node in ast.walk(tree):
|
|
244
|
+
match node:
|
|
245
|
+
case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
|
|
246
|
+
names.add(name)
|
|
247
|
+
case (
|
|
248
|
+
ast.FunctionDef(name=name)
|
|
249
|
+
| ast.AsyncFunctionDef(name=name)
|
|
250
|
+
| ast.ClassDef(name=name)
|
|
251
|
+
):
|
|
252
|
+
names.add(name)
|
|
253
|
+
case ast.arg(arg=name):
|
|
254
|
+
names.add(name)
|
|
255
|
+
case ast.alias(name=name, asname=asname):
|
|
256
|
+
names.add(asname or name.partition(".")[0])
|
|
257
|
+
case ast.Global(names=found) | ast.Nonlocal(names=found):
|
|
258
|
+
names.update(found)
|
|
259
|
+
case ast.ExceptHandler(name=str() as name):
|
|
260
|
+
names.add(name)
|
|
261
|
+
return frozenset(names)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class _Detector(ast.NodeVisitor):
|
|
265
|
+
def __init__(self, index: _Index, bound_names: frozenset[str]):
|
|
266
|
+
self.index = index
|
|
267
|
+
self.bound_names = bound_names
|
|
268
|
+
self.detections: list[Detection] = []
|
|
269
|
+
self.aliases: dict[str, str] = {}
|
|
270
|
+
self._position = (1, 0)
|
|
271
|
+
|
|
272
|
+
def visit(self, node: ast.AST) -> None:
|
|
273
|
+
# Some matched nodes (`arguments`) carry no position of their
|
|
274
|
+
# own, so the nearest enclosing position is tracked instead.
|
|
275
|
+
lineno = getattr(node, "lineno", None)
|
|
276
|
+
previous = self._position
|
|
277
|
+
if lineno is not None:
|
|
278
|
+
self._position = (lineno, getattr(node, "col_offset", 0))
|
|
279
|
+
self._match_node(node)
|
|
280
|
+
handler = getattr(self, f"visit_{type(node).__name__}", None)
|
|
281
|
+
if handler is None:
|
|
282
|
+
self.generic_visit(node)
|
|
283
|
+
else:
|
|
284
|
+
handler(node)
|
|
285
|
+
self._position = previous
|
|
286
|
+
|
|
287
|
+
def _match_node(self, node: ast.AST) -> None:
|
|
288
|
+
for feature in self.index.by_node.get(type(node).__name__, ()):
|
|
289
|
+
if feature.requires and not getattr(node, feature.requires, None):
|
|
290
|
+
continue
|
|
291
|
+
if feature.check and not CHECKS[feature.check](node, self.bound_names):
|
|
292
|
+
continue
|
|
293
|
+
self._record(feature)
|
|
294
|
+
|
|
295
|
+
def _record(self, feature: Feature) -> None:
|
|
296
|
+
lineno, col_offset = self._position
|
|
297
|
+
self.detections.append(Detection(feature, lineno, col_offset))
|
|
298
|
+
|
|
299
|
+
def _record_module(self, dotted: str) -> None:
|
|
300
|
+
"""Report the most specific module the dataset knows about.
|
|
301
|
+
|
|
302
|
+
`import importlib.resources` needs both `importlib` and
|
|
303
|
+
`importlib.resources`, but only the submodule is worth saying:
|
|
304
|
+
it is the newer of the two, so it sets the floor on its own, and
|
|
305
|
+
naming the parent as well would be noise.
|
|
306
|
+
"""
|
|
307
|
+
parts = dotted.split(".")
|
|
308
|
+
for size in range(len(parts), 0, -1):
|
|
309
|
+
feature = self.index.by_module.get(".".join(parts[:size]))
|
|
310
|
+
if feature is not None:
|
|
311
|
+
self._record(feature)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
def _record_attribute(self, dotted: str) -> None:
|
|
315
|
+
feature = self.index.by_attribute.get(dotted)
|
|
316
|
+
if feature is not None:
|
|
317
|
+
self._record(feature)
|
|
318
|
+
|
|
319
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
320
|
+
if isinstance(node.ctx, ast.Load):
|
|
321
|
+
if node.id not in self.bound_names:
|
|
322
|
+
feature = self.index.by_builtin.get(node.id)
|
|
323
|
+
if feature is not None:
|
|
324
|
+
self._record(feature)
|
|
325
|
+
elif node.id in self.aliases:
|
|
326
|
+
self._record_attribute(self.aliases[node.id])
|
|
327
|
+
self.generic_visit(node)
|
|
328
|
+
|
|
329
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
330
|
+
dotted = self._dotted_name(node)
|
|
331
|
+
if dotted is not None:
|
|
332
|
+
self._record_attribute(dotted)
|
|
333
|
+
self.generic_visit(node)
|
|
334
|
+
|
|
335
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
336
|
+
for alias in node.names:
|
|
337
|
+
self._record_module(alias.name)
|
|
338
|
+
bound = alias.asname or alias.name.partition(".")[0]
|
|
339
|
+
self.aliases[bound] = alias.name if alias.asname else bound
|
|
340
|
+
self.generic_visit(node)
|
|
341
|
+
|
|
342
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
343
|
+
if node.level == 0 and node.module:
|
|
344
|
+
for alias in node.names:
|
|
345
|
+
dotted = f"{node.module}.{alias.name}"
|
|
346
|
+
# `_record_module` walks back up the dotted path, so
|
|
347
|
+
# this covers the package too: `from pathlib import
|
|
348
|
+
# Path` reports `pathlib`, and `from importlib import
|
|
349
|
+
# resources` reports the submodule instead.
|
|
350
|
+
self._record_module(dotted)
|
|
351
|
+
self._record_attribute(dotted)
|
|
352
|
+
self.aliases[alias.asname or alias.name] = dotted
|
|
353
|
+
self.generic_visit(node)
|
|
354
|
+
|
|
355
|
+
def _dotted_name(self, node: ast.Attribute) -> str | None:
|
|
356
|
+
"""Resolve `m.isclose` to `math.isclose`, following aliases."""
|
|
357
|
+
parts = []
|
|
358
|
+
current: ast.expr = node
|
|
359
|
+
while isinstance(current, ast.Attribute):
|
|
360
|
+
parts.append(current.attr)
|
|
361
|
+
current = current.value
|
|
362
|
+
if not isinstance(current, ast.Name):
|
|
363
|
+
return None
|
|
364
|
+
parts.append(self.aliases.get(current.id, current.id))
|
|
365
|
+
return ".".join(reversed(parts))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def detect(source: str | ast.AST, *, filename: str = "<string>") -> list[Detection]:
|
|
369
|
+
"""Find every dated feature used in `source`, in source order.
|
|
370
|
+
|
|
371
|
+
A feature used several times is reported once per use.
|
|
372
|
+
"""
|
|
373
|
+
tree = source if isinstance(source, ast.AST) else ast.parse(source, filename)
|
|
374
|
+
detector = _Detector(_index(), _bound_names(tree))
|
|
375
|
+
detector.visit(tree)
|
|
376
|
+
return sorted(
|
|
377
|
+
detector.detections,
|
|
378
|
+
key=lambda found: (found.lineno, found.col_offset, found.feature.id),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def minimum_version(source: str | ast.AST) -> Version | None:
|
|
383
|
+
"""The oldest Python that can run `source`, as far as we can tell.
|
|
384
|
+
|
|
385
|
+
Returns `None` when nothing in the dataset was detected, which
|
|
386
|
+
means no known feature sets a floor rather than that the code runs
|
|
387
|
+
anywhere.
|
|
388
|
+
"""
|
|
389
|
+
detections = detect(source)
|
|
390
|
+
if not detections:
|
|
391
|
+
return None
|
|
392
|
+
return max(found.added for found in detections)
|