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/features.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The feature dataset: what each feature is and when it arrived."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
from .versions import Version
|
|
9
|
+
|
|
10
|
+
DATA_FILE = "features.toml"
|
|
11
|
+
|
|
12
|
+
MATCHER_FIELDS = ("nodes", "builtins", "modules", "attributes")
|
|
13
|
+
|
|
14
|
+
# How a version claim was established. Each method says what a reviewer
|
|
15
|
+
# has to do to check it: the first three are machine-checkable against
|
|
16
|
+
# archived documentation, and `manual` is the one that needs a human.
|
|
17
|
+
#
|
|
18
|
+
# objects.inv the symbol is absent from one release's Sphinx
|
|
19
|
+
# inventory and present in the next
|
|
20
|
+
# archive the same diff for the era before Sphinx, over the
|
|
21
|
+
# module lists and built-in function pages in the
|
|
22
|
+
# archived HTML doc builds
|
|
23
|
+
# annotation the documentation says so itself, in an
|
|
24
|
+
# "Added in version" marker that is quoted here
|
|
25
|
+
# grammar the token is absent from one release's grammar and
|
|
26
|
+
# present in the next, which is what shipped rather than
|
|
27
|
+
# what a PEP intended
|
|
28
|
+
# pep the feature's PEP carries a Python-Version header
|
|
29
|
+
# manual read out of archived docs by hand, with a note saying
|
|
30
|
+
# what was read and why the automated methods do not
|
|
31
|
+
# settle it
|
|
32
|
+
EVIDENCE_METHODS = frozenset(
|
|
33
|
+
{"objects.inv", "archive", "grammar", "annotation", "pep", "manual"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
EVIDENCE_REQUIRED = {
|
|
37
|
+
"objects.inv": ("symbol", "absent_in", "present_in"),
|
|
38
|
+
"archive": ("present_in",),
|
|
39
|
+
"grammar": ("symbol", "absent_in", "present_in"),
|
|
40
|
+
"annotation": ("docs", "quote"),
|
|
41
|
+
"pep": ("pep", "python_version"),
|
|
42
|
+
"manual": ("note",),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Evidence:
|
|
48
|
+
"""Where a feature's version claim came from.
|
|
49
|
+
|
|
50
|
+
Kept as data rather than a comment so that review is reading a
|
|
51
|
+
citation instead of trusting whoever opened the pull request.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
method: str
|
|
55
|
+
checked: str | None = None
|
|
56
|
+
symbol: str | None = None
|
|
57
|
+
absent_in: str | None = None
|
|
58
|
+
present_in: str | None = None
|
|
59
|
+
docs: str | None = None
|
|
60
|
+
quote: str | None = None
|
|
61
|
+
pep: int | None = None
|
|
62
|
+
python_version: str | None = None
|
|
63
|
+
absent_url: str | None = None
|
|
64
|
+
present_url: str | None = None
|
|
65
|
+
note: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Feature:
|
|
70
|
+
"""One detectable feature and the version that introduced it."""
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
name: str
|
|
74
|
+
added: Version
|
|
75
|
+
category: str
|
|
76
|
+
or_earlier: bool = False
|
|
77
|
+
pep: int | None = None
|
|
78
|
+
docs: str | None = None
|
|
79
|
+
nodes: frozenset[str] = frozenset()
|
|
80
|
+
requires: str | None = None
|
|
81
|
+
check: str | None = None
|
|
82
|
+
builtins: frozenset[str] = frozenset()
|
|
83
|
+
modules: frozenset[str] = frozenset()
|
|
84
|
+
attributes: frozenset[str] = frozenset()
|
|
85
|
+
evidence: Evidence | None = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def since(self) -> str:
|
|
89
|
+
"""How to say when this arrived, in one phrase.
|
|
90
|
+
|
|
91
|
+
Some features are older than the oldest surviving documentation.
|
|
92
|
+
`map()` is listed in the Python 1.2 docs, which is as far back as
|
|
93
|
+
the archives go, so 1.2 is the oldest release it can be shown to
|
|
94
|
+
have existed in rather than the release that added it.
|
|
95
|
+
"""
|
|
96
|
+
return f"{self.added} or earlier" if self.or_earlier else str(self.added)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def pep_url(self) -> str | None:
|
|
100
|
+
if self.pep is None:
|
|
101
|
+
return None
|
|
102
|
+
return f"https://peps.python.org/pep-{self.pep:04d}/"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def docs_url(self) -> str | None:
|
|
106
|
+
"""Where to read more, preferring a hand-picked link."""
|
|
107
|
+
return self.docs or self.added.whatsnew_url
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def targets(self) -> frozenset[str]:
|
|
111
|
+
"""Every name this feature matches on, for searching."""
|
|
112
|
+
return frozenset().union(*(getattr(self, field) for field in MATCHER_FIELDS))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DatasetError(Exception):
|
|
116
|
+
"""The feature dataset is malformed."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_evidence(feature_id: str, entry: dict) -> Evidence:
|
|
120
|
+
method = entry.get("method")
|
|
121
|
+
if method not in EVIDENCE_METHODS:
|
|
122
|
+
raise DatasetError(
|
|
123
|
+
f"{feature_id!r} has evidence method {method!r}, "
|
|
124
|
+
f"expected one of {sorted(EVIDENCE_METHODS)}"
|
|
125
|
+
)
|
|
126
|
+
missing = [field for field in EVIDENCE_REQUIRED[method] if not entry.get(field)]
|
|
127
|
+
if missing:
|
|
128
|
+
raise DatasetError(
|
|
129
|
+
f"{feature_id!r} has {method} evidence missing {', '.join(missing)}"
|
|
130
|
+
)
|
|
131
|
+
unknown = set(entry) - {field.name for field in fields(Evidence)}
|
|
132
|
+
if unknown:
|
|
133
|
+
raise DatasetError(
|
|
134
|
+
f"{feature_id!r} has unknown evidence fields: {', '.join(sorted(unknown))}"
|
|
135
|
+
)
|
|
136
|
+
return Evidence(**entry)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build(entry: dict) -> Feature:
|
|
140
|
+
matchers = [field for field in MATCHER_FIELDS if entry.get(field)]
|
|
141
|
+
if len(matchers) != 1:
|
|
142
|
+
raise DatasetError(
|
|
143
|
+
f"{entry.get('id', entry)!r} needs exactly one matcher kind, "
|
|
144
|
+
f"got {matchers or 'none'}"
|
|
145
|
+
)
|
|
146
|
+
if entry.get("requires") and entry.get("check"):
|
|
147
|
+
raise DatasetError(f"{entry['id']!r} sets both `requires` and `check`")
|
|
148
|
+
evidence = entry.get("evidence")
|
|
149
|
+
return Feature(
|
|
150
|
+
id=entry["id"],
|
|
151
|
+
name=entry["name"],
|
|
152
|
+
added=Version.parse(entry["added"]),
|
|
153
|
+
category=entry["category"],
|
|
154
|
+
or_earlier=entry.get("or_earlier", False),
|
|
155
|
+
pep=entry.get("pep"),
|
|
156
|
+
docs=entry.get("docs"),
|
|
157
|
+
nodes=frozenset(entry.get("nodes", ())),
|
|
158
|
+
requires=entry.get("requires"),
|
|
159
|
+
check=entry.get("check"),
|
|
160
|
+
builtins=frozenset(entry.get("builtins", ())),
|
|
161
|
+
modules=frozenset(entry.get("modules", ())),
|
|
162
|
+
attributes=frozenset(entry.get("attributes", ())),
|
|
163
|
+
evidence=_build_evidence(entry["id"], evidence) if evidence else None,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_dataset() -> str:
|
|
168
|
+
"""The raw dataset text, read from the installed package."""
|
|
169
|
+
return files(__package__).joinpath(DATA_FILE).read_text(encoding="utf-8")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_features(entries: list[dict]) -> tuple[Feature, ...]:
|
|
173
|
+
"""Validate raw dataset entries into `Feature` objects.
|
|
174
|
+
|
|
175
|
+
Kept separate from `load_features` so validation can be exercised
|
|
176
|
+
without going through the bundled file.
|
|
177
|
+
"""
|
|
178
|
+
features = tuple(_build(entry) for entry in entries)
|
|
179
|
+
seen = set()
|
|
180
|
+
for feature in features:
|
|
181
|
+
if feature.id in seen:
|
|
182
|
+
raise DatasetError(f"duplicate feature id: {feature.id!r}")
|
|
183
|
+
seen.add(feature.id)
|
|
184
|
+
return features
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@cache
|
|
188
|
+
def load_features() -> tuple[Feature, ...]:
|
|
189
|
+
"""Read and validate the bundled dataset."""
|
|
190
|
+
return build_features(tomllib.loads(read_dataset())["features"])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _matching(query: str) -> list[Feature]:
|
|
194
|
+
matches = [
|
|
195
|
+
feature
|
|
196
|
+
for feature in load_features()
|
|
197
|
+
if query in feature.id.casefold()
|
|
198
|
+
or query in feature.name.casefold()
|
|
199
|
+
or any(query == target.casefold() for target in feature.targets)
|
|
200
|
+
]
|
|
201
|
+
return sorted(matches, key=lambda feature: (feature.added, feature.id))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def enclosing_module(query: str) -> list[Feature]:
|
|
205
|
+
"""The module a dotted name lives in, if the dataset knows it.
|
|
206
|
+
|
|
207
|
+
Not every member of every module is worth an entry of its own, and
|
|
208
|
+
asking about one should not come back empty when the module it
|
|
209
|
+
belongs to has an answer. A member cannot be older than its module,
|
|
210
|
+
so the module's version is a real answer to "how far back can I use
|
|
211
|
+
`platform.system`?", just a less precise one.
|
|
212
|
+
"""
|
|
213
|
+
prefix = query
|
|
214
|
+
while "." in prefix:
|
|
215
|
+
prefix = prefix.rpartition(".")[0]
|
|
216
|
+
found = [
|
|
217
|
+
feature
|
|
218
|
+
for feature in load_features()
|
|
219
|
+
if any(prefix == module.casefold() for module in feature.modules)
|
|
220
|
+
]
|
|
221
|
+
if found:
|
|
222
|
+
return found
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def lookup(query: str) -> list[Feature]:
|
|
227
|
+
"""Find features matching a search term, oldest first.
|
|
228
|
+
|
|
229
|
+
This powers search mode: a query is matched against feature ids,
|
|
230
|
+
human-readable names, and the names each feature detects. A dotted
|
|
231
|
+
name with no entry falls back to the module it belongs to.
|
|
232
|
+
"""
|
|
233
|
+
query = query.casefold().strip()
|
|
234
|
+
return _matching(query) or enclosing_module(query)
|