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/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)