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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)