refsource 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.
- refsource/__init__.py +636 -0
- refsource/__main__.py +213 -0
- refsource/manifest.json +4059 -0
- refsource/py.typed +0 -0
- refsource-0.1.0.dist-info/METADATA +228 -0
- refsource-0.1.0.dist-info/RECORD +10 -0
- refsource-0.1.0.dist-info/WHEEL +5 -0
- refsource-0.1.0.dist-info/entry_points.txt +2 -0
- refsource-0.1.0.dist-info/licenses/LICENSE +21 -0
- refsource-0.1.0.dist-info/top_level.txt +1 -0
refsource/__init__.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""refsource — look up reference data that carries its own source.
|
|
2
|
+
|
|
3
|
+
>>> import refsource
|
|
4
|
+
>>> rows = refsource.lookup("conforming-loan-limits", state="AL",
|
|
5
|
+
... county_name="AUTAUGA COUNTY")
|
|
6
|
+
>>> rows[0]["limit_1_unit"]
|
|
7
|
+
'$832,750'
|
|
8
|
+
>>> rows[0]["limit_1_unit"].source
|
|
9
|
+
'https://www.fhfa.gov/document/d/cll/fullcountyloanlimitlist2026_hera-based_final_flat.csv'
|
|
10
|
+
>>> print(rows[0].cite())
|
|
11
|
+
|
|
12
|
+
Every value comes back with the URL it was read from, a verbatim quote from
|
|
13
|
+
that page, and the date it was last checked. That is the whole point: a value
|
|
14
|
+
you can check is worth more than a value you have to trust, and a language
|
|
15
|
+
model asked the same question invents a plausible answer about a fifth of the
|
|
16
|
+
time.
|
|
17
|
+
|
|
18
|
+
The package ships no records. It ships a manifest of what exists — 191
|
|
19
|
+
datasets, their fields, record counts, verification dates and the SHA-256 of
|
|
20
|
+
each bundle at release time — and fetches a dataset's records from
|
|
21
|
+
referencesource.org the first time you ask for one, then caches them.
|
|
22
|
+
|
|
23
|
+
Full catalogue and method: https://referencesource.org/
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import pathlib
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.request
|
|
37
|
+
import warnings
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
__all__ = [
|
|
41
|
+
"lookup", "search", "get", "dataset", "datasets", "fields", "configure",
|
|
42
|
+
"Record", "Value", "Dataset",
|
|
43
|
+
"RefsourceError", "NoSuchDataset", "NoSuchField", "OfflineError",
|
|
44
|
+
"IntegrityError", "StaleDataWarning", "ChangedUpstreamWarning",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_HERE = pathlib.Path(__file__).resolve().parent
|
|
48
|
+
_MANIFEST = json.loads((_HERE / "manifest.json").read_text(encoding="utf-8"))
|
|
49
|
+
_BY_SLUG = {d["slug"]: d for d in _MANIFEST["datasets"]}
|
|
50
|
+
|
|
51
|
+
USER_AGENT = "refsource-python/{0} (+https://referencesource.org)".format(__version__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------- errors
|
|
55
|
+
|
|
56
|
+
class RefsourceError(Exception):
|
|
57
|
+
"""Base class for everything this package raises."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NoSuchDataset(RefsourceError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NoSuchField(RefsourceError):
|
|
65
|
+
"""A filter named a field the dataset does not have.
|
|
66
|
+
|
|
67
|
+
Raised rather than returning nothing, because an empty result from a typo
|
|
68
|
+
reads exactly like an empty result from a real absence, and one of those
|
|
69
|
+
two is a wrong answer.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OfflineError(RefsourceError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class IntegrityError(RefsourceError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class StaleDataWarning(UserWarning):
|
|
82
|
+
"""The dataset is past the date by which it said it should be re-checked."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ChangedUpstreamWarning(UserWarning):
|
|
86
|
+
"""The bundle fetched is not byte-identical to the one this release pinned.
|
|
87
|
+
|
|
88
|
+
Normal and expected — it means the dataset was re-verified upstream since
|
|
89
|
+
this version of the package was cut. The live copy is the current one; the
|
|
90
|
+
hash is here so a change is visible rather than silent.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------- config
|
|
95
|
+
|
|
96
|
+
class _Config(object):
|
|
97
|
+
def __init__(self):
|
|
98
|
+
self.base_url = os.environ.get("REFSOURCE_BASE_URL", "").rstrip("/")
|
|
99
|
+
self.cache_dir = os.environ.get("REFSOURCE_CACHE") or _default_cache()
|
|
100
|
+
self.cache_ttl = _int_env("REFSOURCE_CACHE_TTL", 86400)
|
|
101
|
+
self.timeout = _int_env("REFSOURCE_TIMEOUT", 30)
|
|
102
|
+
self.offline = _bool_env("REFSOURCE_OFFLINE", False)
|
|
103
|
+
self.strict = _bool_env("REFSOURCE_STRICT", False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _int_env(name, default):
|
|
107
|
+
try:
|
|
108
|
+
return int(os.environ[name])
|
|
109
|
+
except (KeyError, ValueError):
|
|
110
|
+
return default
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _bool_env(name, default):
|
|
114
|
+
v = os.environ.get(name)
|
|
115
|
+
if v is None:
|
|
116
|
+
return default
|
|
117
|
+
return v.strip().lower() in ("1", "true", "yes", "on")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _default_cache():
|
|
121
|
+
if sys.platform == "win32":
|
|
122
|
+
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
123
|
+
elif sys.platform == "darwin":
|
|
124
|
+
base = os.path.expanduser("~/Library/Caches")
|
|
125
|
+
else:
|
|
126
|
+
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
|
127
|
+
return os.path.join(base, "refsource")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_config = _Config()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def configure(base_url=None, cache_dir=None, cache_ttl=None, timeout=None,
|
|
134
|
+
offline=None, strict=None):
|
|
135
|
+
"""Change how records are fetched and cached.
|
|
136
|
+
|
|
137
|
+
These live here rather than as keyword arguments on `lookup()` so that
|
|
138
|
+
`lookup(slug, **filters)` can stay entirely field names — a dataset with a
|
|
139
|
+
column called `timeout` should not fight the library for it.
|
|
140
|
+
|
|
141
|
+
- `base_url`: serve records from somewhere else (a local render, a mirror).
|
|
142
|
+
A `file://` URL works, which is how the tests run without a network.
|
|
143
|
+
- `cache_ttl`: seconds before a cached bundle is re-fetched. 0 = always
|
|
144
|
+
re-fetch, negative = never expire.
|
|
145
|
+
- `offline`: use the cache and never open a connection.
|
|
146
|
+
- `strict`: raise `IntegrityError` when a fetched bundle does not match the
|
|
147
|
+
SHA-256 this release pinned, instead of warning.
|
|
148
|
+
|
|
149
|
+
Every one of these also has an environment variable — REFSOURCE_BASE_URL,
|
|
150
|
+
REFSOURCE_CACHE, REFSOURCE_CACHE_TTL, REFSOURCE_TIMEOUT, REFSOURCE_OFFLINE,
|
|
151
|
+
REFSOURCE_STRICT.
|
|
152
|
+
"""
|
|
153
|
+
if base_url is not None:
|
|
154
|
+
_config.base_url = base_url.rstrip("/")
|
|
155
|
+
if cache_dir is not None:
|
|
156
|
+
_config.cache_dir = cache_dir
|
|
157
|
+
if cache_ttl is not None:
|
|
158
|
+
_config.cache_ttl = int(cache_ttl)
|
|
159
|
+
if timeout is not None:
|
|
160
|
+
_config.timeout = int(timeout)
|
|
161
|
+
if offline is not None:
|
|
162
|
+
_config.offline = bool(offline)
|
|
163
|
+
if strict is not None:
|
|
164
|
+
_config.strict = bool(strict)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------- value
|
|
168
|
+
|
|
169
|
+
class Value(str):
|
|
170
|
+
"""A value that knows where it came from.
|
|
171
|
+
|
|
172
|
+
It behaves as an ordinary string everywhere a string is expected, and
|
|
173
|
+
carries the citation for *this field* — which is not always the record's
|
|
174
|
+
own source. A record whose key was reported by a second publisher keeps
|
|
175
|
+
that publisher's values with that publisher's URL and quote, and this is
|
|
176
|
+
how you get at them without attributing one source's number to another.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
# No __slots__: a str subclass cannot have them (variable-length type).
|
|
180
|
+
|
|
181
|
+
def __new__(cls, text, field="", source="", quote="", as_of="",
|
|
182
|
+
confirmed=False, derived=False, record=None):
|
|
183
|
+
self = str.__new__(cls, "" if text is None else str(text))
|
|
184
|
+
self.field = field
|
|
185
|
+
self.source = source
|
|
186
|
+
self.quote = quote
|
|
187
|
+
self.as_of = as_of
|
|
188
|
+
self.confirmed = confirmed
|
|
189
|
+
self.derived = derived
|
|
190
|
+
self.record = record
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def disagreement(self):
|
|
195
|
+
"""Other readings of this same field — never this one.
|
|
196
|
+
|
|
197
|
+
A list of dicts with `value`, `source`, `source_quote`. Empty for the
|
|
198
|
+
overwhelming majority of values. When it is not empty, the honest thing
|
|
199
|
+
to report is that the sources differ, not one of them.
|
|
200
|
+
|
|
201
|
+
The bundle lists every version including the one in hand, so that is
|
|
202
|
+
dropped here: an entry repeating the value you already have reads as a
|
|
203
|
+
second opinion when it is the same opinion.
|
|
204
|
+
"""
|
|
205
|
+
if self.record is None:
|
|
206
|
+
return []
|
|
207
|
+
return [v for v in self.record.disagreements.get(self.field, [])
|
|
208
|
+
if str(v.get("value", "")) != str(self)]
|
|
209
|
+
|
|
210
|
+
def cite(self):
|
|
211
|
+
"""One line you can paste into whatever you are writing."""
|
|
212
|
+
page = self.record.url if self.record is not None else ""
|
|
213
|
+
parts = ["{0}: {1}".format(self.field, str(self))]
|
|
214
|
+
if self.source:
|
|
215
|
+
parts.append("source: {0}".format(self.source))
|
|
216
|
+
if self.quote:
|
|
217
|
+
parts.append('quoted: "{0}"'.format(_clip(self.quote, 200)))
|
|
218
|
+
if page:
|
|
219
|
+
parts.append(page)
|
|
220
|
+
if self.as_of:
|
|
221
|
+
parts.append("verified {0}".format(self.as_of))
|
|
222
|
+
return " — ".join(parts)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _clip(text, n):
|
|
226
|
+
t = " ".join(str(text).split())
|
|
227
|
+
return t if len(t) <= n else t[: n - 1] + "\u2026"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------- record
|
|
231
|
+
|
|
232
|
+
class Record(object):
|
|
233
|
+
"""One published record: its values, each with its own citation."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, raw, ds):
|
|
236
|
+
self._raw = raw
|
|
237
|
+
self.dataset = ds
|
|
238
|
+
self.id = raw.get("id", "")
|
|
239
|
+
self.url = raw.get("url", "")
|
|
240
|
+
self.source_url = raw.get("source", "")
|
|
241
|
+
self.source_quote = raw.get("source_quote", "")
|
|
242
|
+
self.verified = ds.last_verified
|
|
243
|
+
self.stale_after = ds.stale_after
|
|
244
|
+
|
|
245
|
+
self.disagreements = {}
|
|
246
|
+
for d in raw.get("disagreements") or []:
|
|
247
|
+
if d.get("field"):
|
|
248
|
+
self.disagreements[d["field"]] = d.get("versions") or []
|
|
249
|
+
|
|
250
|
+
confirmed = set(raw.get("verified_fields") or [])
|
|
251
|
+
derived = set(raw.get("derived_fields") or [])
|
|
252
|
+
self.values = {}
|
|
253
|
+
for k, v in raw.items():
|
|
254
|
+
if k in _META_KEYS or isinstance(v, (list, dict)):
|
|
255
|
+
continue
|
|
256
|
+
self.values[k] = Value(
|
|
257
|
+
v, field=k, source=self.source_url, quote=self.source_quote,
|
|
258
|
+
as_of=ds.last_verified, confirmed=k in confirmed,
|
|
259
|
+
derived=k in derived, record=self)
|
|
260
|
+
# A second publisher's values are kept apart in the bundle so nobody
|
|
261
|
+
# reads them as the first publisher's. Same treatment here: they are
|
|
262
|
+
# available by name, and they carry their own source and quote.
|
|
263
|
+
for extra in raw.get("also_reported") or []:
|
|
264
|
+
f = extra.get("field")
|
|
265
|
+
if not f:
|
|
266
|
+
continue
|
|
267
|
+
self.values[f] = Value(
|
|
268
|
+
extra.get("value"), field=f, source=extra.get("source", ""),
|
|
269
|
+
quote=extra.get("source_quote", ""),
|
|
270
|
+
as_of=extra.get("as_of") or ds.last_verified,
|
|
271
|
+
confirmed=True, record=self)
|
|
272
|
+
|
|
273
|
+
# dict-ish access, because that is what people try first
|
|
274
|
+
def __getitem__(self, field):
|
|
275
|
+
try:
|
|
276
|
+
return self.values[field]
|
|
277
|
+
except KeyError:
|
|
278
|
+
raise NoSuchField("{0!r} is not a field of {1}. Fields: {2}".format(
|
|
279
|
+
field, self.dataset.slug, ", ".join(sorted(self.values))))
|
|
280
|
+
|
|
281
|
+
def get(self, field, default=None):
|
|
282
|
+
return self.values.get(field, default)
|
|
283
|
+
|
|
284
|
+
def __contains__(self, field):
|
|
285
|
+
return field in self.values
|
|
286
|
+
|
|
287
|
+
def __iter__(self):
|
|
288
|
+
return iter(self.values)
|
|
289
|
+
|
|
290
|
+
def keys(self):
|
|
291
|
+
return self.values.keys()
|
|
292
|
+
|
|
293
|
+
def items(self):
|
|
294
|
+
return self.values.items()
|
|
295
|
+
|
|
296
|
+
def __repr__(self):
|
|
297
|
+
head = ", ".join("{0}={1!r}".format(k, str(v))
|
|
298
|
+
for k, v in list(self.values.items())[:3])
|
|
299
|
+
return "<Record {0} {1}>".format(self.dataset.slug, head)
|
|
300
|
+
|
|
301
|
+
def cite(self):
|
|
302
|
+
"""A citation for the record as a whole."""
|
|
303
|
+
line = "{0} — {1}".format(self.dataset.title, self.url or self.dataset.url)
|
|
304
|
+
if self.source_url:
|
|
305
|
+
line += "\n source: {0}".format(self.source_url)
|
|
306
|
+
if self.source_quote:
|
|
307
|
+
line += '\n quoted: "{0}"'.format(_clip(self.source_quote, 300))
|
|
308
|
+
line += "\n verified {0}".format(self.verified)
|
|
309
|
+
if self.dataset.stale:
|
|
310
|
+
line += " (past its stale_after of {0} — re-check the source)".format(
|
|
311
|
+
self.stale_after)
|
|
312
|
+
return line
|
|
313
|
+
|
|
314
|
+
def to_dict(self):
|
|
315
|
+
"""Plain data, citations included, for JSON output."""
|
|
316
|
+
return {
|
|
317
|
+
"id": self.id,
|
|
318
|
+
"url": self.url,
|
|
319
|
+
"dataset": self.dataset.slug,
|
|
320
|
+
"verified": self.verified,
|
|
321
|
+
"stale_after": self.stale_after,
|
|
322
|
+
"values": {
|
|
323
|
+
k: {
|
|
324
|
+
"value": str(v), "source": v.source, "source_quote": v.quote,
|
|
325
|
+
"confirmed": v.confirmed, "derived": v.derived,
|
|
326
|
+
"disagreement": v.disagreement,
|
|
327
|
+
}
|
|
328
|
+
for k, v in self.values.items()
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_META_KEYS = frozenset((
|
|
334
|
+
"id", "url", "source", "source_quote",
|
|
335
|
+
"verified_fields", "derived_fields", "also_reported", "disagreements",
|
|
336
|
+
))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# -------------------------------------------------------------------- dataset
|
|
340
|
+
|
|
341
|
+
class Dataset(object):
|
|
342
|
+
"""One published dataset. Metadata is offline; records are fetched."""
|
|
343
|
+
|
|
344
|
+
def __init__(self, entry):
|
|
345
|
+
self._entry = entry
|
|
346
|
+
self.slug = entry["slug"]
|
|
347
|
+
self.title = entry.get("title", "")
|
|
348
|
+
self.summary = entry.get("summary", "")
|
|
349
|
+
self.url = entry.get("url", "")
|
|
350
|
+
self.data_url = entry.get("data", "")
|
|
351
|
+
self.record_count = entry.get("records", 0)
|
|
352
|
+
self.last_verified = entry.get("last_verified", "")
|
|
353
|
+
self.stale_after = entry.get("stale_after", "")
|
|
354
|
+
self.fields = list(entry.get("fields") or [])
|
|
355
|
+
self.bytes = entry.get("bytes", 0)
|
|
356
|
+
self.licence = "" # filled in from the bundle once fetched
|
|
357
|
+
self.checksum = "unknown" # "pinned" | "changed" | "unknown"
|
|
358
|
+
self._records = None
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def stale(self):
|
|
362
|
+
"""Past the date the publisher's own cadence says it should be re-checked."""
|
|
363
|
+
if not self.stale_after:
|
|
364
|
+
return False
|
|
365
|
+
try:
|
|
366
|
+
due = datetime.date(*[int(x) for x in self.stale_after.split("-")])
|
|
367
|
+
except (ValueError, TypeError):
|
|
368
|
+
return False
|
|
369
|
+
return datetime.date.today() > due
|
|
370
|
+
|
|
371
|
+
def records(self):
|
|
372
|
+
"""Every record, fetched on first call and cached thereafter."""
|
|
373
|
+
if self._records is None:
|
|
374
|
+
self._records = [Record(r, self) for r in _fetch_records(self)]
|
|
375
|
+
if self.stale:
|
|
376
|
+
warnings.warn(
|
|
377
|
+
"{0} is past its stale_after date ({1}); the values may have "
|
|
378
|
+
"moved on. Check {2} for the current copy.".format(
|
|
379
|
+
self.slug, self.stale_after, self.url),
|
|
380
|
+
StaleDataWarning, stacklevel=3)
|
|
381
|
+
return self._records
|
|
382
|
+
|
|
383
|
+
def lookup(self, **filters):
|
|
384
|
+
return _filter(self.records(), self, filters)
|
|
385
|
+
|
|
386
|
+
def search(self, text, limit=None):
|
|
387
|
+
needle = _norm(text)
|
|
388
|
+
if not needle:
|
|
389
|
+
return []
|
|
390
|
+
out = []
|
|
391
|
+
for rec in self.records():
|
|
392
|
+
hay = _norm(" ".join(str(v) for v in rec.values.values()))
|
|
393
|
+
if needle in hay:
|
|
394
|
+
out.append(rec)
|
|
395
|
+
if limit and len(out) >= limit:
|
|
396
|
+
break
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
def get(self, record_id):
|
|
400
|
+
for rec in self.records():
|
|
401
|
+
if rec.id == record_id:
|
|
402
|
+
return rec
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
def values_of(self, field, limit=None):
|
|
406
|
+
"""The distinct values a field takes, in first-seen order.
|
|
407
|
+
|
|
408
|
+
The reason this exists: one dataset spells a state "TX" and another
|
|
409
|
+
spells it "Texas", so a filter that looks obviously right can match
|
|
410
|
+
nothing. This is how you find out which it is, rather than concluding
|
|
411
|
+
the data is missing.
|
|
412
|
+
"""
|
|
413
|
+
seen = []
|
|
414
|
+
for rec in self.records():
|
|
415
|
+
v = rec.values.get(field)
|
|
416
|
+
if v is None:
|
|
417
|
+
continue
|
|
418
|
+
s = str(v)
|
|
419
|
+
if s not in seen:
|
|
420
|
+
seen.append(s)
|
|
421
|
+
if limit and len(seen) >= limit:
|
|
422
|
+
break
|
|
423
|
+
return seen
|
|
424
|
+
|
|
425
|
+
def __repr__(self):
|
|
426
|
+
return "<Dataset {0} ({1} records, verified {2})>".format(
|
|
427
|
+
self.slug, self.record_count, self.last_verified)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
_datasets_cache = {}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def dataset(slug):
|
|
434
|
+
"""The Dataset object for a slug. Nothing is fetched until you ask for records."""
|
|
435
|
+
if slug not in _BY_SLUG:
|
|
436
|
+
near = [s for s in _BY_SLUG if slug in s or s in slug][:5]
|
|
437
|
+
hint = (" Did you mean: {0}?".format(", ".join(near)) if near
|
|
438
|
+
else " Call refsource.datasets() for the list.")
|
|
439
|
+
raise NoSuchDataset("No dataset {0!r}.{1}".format(slug, hint))
|
|
440
|
+
if slug not in _datasets_cache:
|
|
441
|
+
_datasets_cache[slug] = Dataset(_BY_SLUG[slug])
|
|
442
|
+
return _datasets_cache[slug]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def datasets(query=None):
|
|
446
|
+
"""Every published dataset, or the ones matching `query`. No network.
|
|
447
|
+
|
|
448
|
+
Matches the slug, title, summary and field names, so
|
|
449
|
+
`datasets("loan limit")` and `datasets("fips")` both find things.
|
|
450
|
+
"""
|
|
451
|
+
out = [dataset(s) for s in _BY_SLUG]
|
|
452
|
+
if not query:
|
|
453
|
+
return out
|
|
454
|
+
terms = [t for t in _norm(query).split() if t]
|
|
455
|
+
hits = []
|
|
456
|
+
for ds in out:
|
|
457
|
+
hay = _norm(" ".join([ds.slug.replace("-", " "), ds.title, ds.summary,
|
|
458
|
+
" ".join(ds.fields)]))
|
|
459
|
+
if all(t in hay for t in terms):
|
|
460
|
+
hits.append(ds)
|
|
461
|
+
return hits
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def fields(slug):
|
|
465
|
+
"""The field names of a dataset, for building a lookup. No network."""
|
|
466
|
+
return dataset(slug).fields
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# --------------------------------------------------------------------- lookup
|
|
470
|
+
|
|
471
|
+
def lookup(slug, **filters):
|
|
472
|
+
"""Records from `slug` whose fields match every filter given.
|
|
473
|
+
|
|
474
|
+
refsource.lookup("conforming-loan-limits", state="AL")
|
|
475
|
+
refsource.lookup("auto-insurance-minimums", state=["TX", "NM"])
|
|
476
|
+
|
|
477
|
+
Matching is case-insensitive and ignores surrounding punctuation and
|
|
478
|
+
repeated spaces, so "Autauga County" finds "AUTAUGA COUNTY". A list matches
|
|
479
|
+
any of its members. With no filters you get every record.
|
|
480
|
+
|
|
481
|
+
A filter naming a field the dataset does not have raises `NoSuchField`
|
|
482
|
+
rather than returning an empty list — silence is indistinguishable from a
|
|
483
|
+
real absence, and one of those is a wrong answer.
|
|
484
|
+
"""
|
|
485
|
+
ds = dataset(slug)
|
|
486
|
+
return ds.lookup(**filters)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def search(slug, text, limit=None):
|
|
490
|
+
"""Records from `slug` where `text` appears anywhere in the values."""
|
|
491
|
+
return dataset(slug).search(text, limit=limit)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def get(slug, record_id):
|
|
495
|
+
"""One record by its id, or None."""
|
|
496
|
+
return dataset(slug).get(record_id)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
_PUNCT = re.compile(r"[^\w\s%.$/+-]+", re.UNICODE)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _norm(text):
|
|
503
|
+
"""Same shape of comparison the site's own verifier uses: loose but not lossy."""
|
|
504
|
+
return " ".join(_PUNCT.sub(" ", str(text).lower()).split())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _filter(records, ds, filters):
|
|
508
|
+
if not filters:
|
|
509
|
+
return list(records)
|
|
510
|
+
known = set(ds.fields)
|
|
511
|
+
for f in filters:
|
|
512
|
+
if known and f not in known:
|
|
513
|
+
raise NoSuchField(
|
|
514
|
+
"{0!r} is not a field of {1}. Fields: {2}".format(
|
|
515
|
+
f, ds.slug, ", ".join(ds.fields)))
|
|
516
|
+
wanted = {}
|
|
517
|
+
for f, v in filters.items():
|
|
518
|
+
vals = v if isinstance(v, (list, tuple, set)) else [v]
|
|
519
|
+
wanted[f] = set(_norm(x) for x in vals)
|
|
520
|
+
out = []
|
|
521
|
+
for rec in records:
|
|
522
|
+
for f, vals in wanted.items():
|
|
523
|
+
got = rec.values.get(f)
|
|
524
|
+
if got is None or _norm(got) not in vals:
|
|
525
|
+
break
|
|
526
|
+
else:
|
|
527
|
+
out.append(rec)
|
|
528
|
+
return out
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ---------------------------------------------------------------------- fetch
|
|
532
|
+
|
|
533
|
+
_warned = set()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _url_for(url):
|
|
537
|
+
"""Rewrite a canonical URL onto a configured base (a local render, a mirror)."""
|
|
538
|
+
if not _config.base_url:
|
|
539
|
+
return url
|
|
540
|
+
tail = url.split("://", 1)[-1].split("/", 1)[-1]
|
|
541
|
+
return "{0}/{1}".format(_config.base_url, tail)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _cache_path(url):
|
|
545
|
+
name = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
546
|
+
return pathlib.Path(_config.cache_dir) / (name + ".json")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _read_cache(path):
|
|
550
|
+
if not path.exists():
|
|
551
|
+
return None
|
|
552
|
+
if _config.cache_ttl == 0:
|
|
553
|
+
return None
|
|
554
|
+
if _config.cache_ttl > 0:
|
|
555
|
+
import time
|
|
556
|
+
if time.time() - path.stat().st_mtime > _config.cache_ttl:
|
|
557
|
+
return None
|
|
558
|
+
return path.read_bytes()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _write_cache(path, blob):
|
|
562
|
+
try:
|
|
563
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
tmp = path.with_suffix(".tmp")
|
|
565
|
+
tmp.write_bytes(blob)
|
|
566
|
+
tmp.replace(path)
|
|
567
|
+
except OSError:
|
|
568
|
+
pass # an unwritable cache is a slow package, not a broken one
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _download(url):
|
|
572
|
+
if url.startswith("file://") or url.startswith("/"):
|
|
573
|
+
path = url[7:] if url.startswith("file://") else url
|
|
574
|
+
return pathlib.Path(path).read_bytes()
|
|
575
|
+
req = urllib.request.Request(url, headers={
|
|
576
|
+
"User-Agent": USER_AGENT,
|
|
577
|
+
"Accept": "application/json",
|
|
578
|
+
})
|
|
579
|
+
try:
|
|
580
|
+
with urllib.request.urlopen(req, timeout=_config.timeout) as resp:
|
|
581
|
+
return resp.read()
|
|
582
|
+
except urllib.error.HTTPError as exc:
|
|
583
|
+
raise RefsourceError("{0} returned HTTP {1}".format(url, exc.code))
|
|
584
|
+
except urllib.error.URLError as exc:
|
|
585
|
+
raise RefsourceError("could not reach {0}: {1}".format(url, exc.reason))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _get_bytes(url, expect_sha=None, label=""):
|
|
589
|
+
target = _url_for(url)
|
|
590
|
+
path = _cache_path(target)
|
|
591
|
+
blob = _read_cache(path)
|
|
592
|
+
if blob is None:
|
|
593
|
+
if _config.offline:
|
|
594
|
+
cached = path.exists()
|
|
595
|
+
raise OfflineError(
|
|
596
|
+
"offline and {0} is {1} in the cache ({2})".format(
|
|
597
|
+
label or url, "expired" if cached else "absent", path))
|
|
598
|
+
blob = _download(target)
|
|
599
|
+
_write_cache(path, blob)
|
|
600
|
+
if expect_sha:
|
|
601
|
+
got = hashlib.sha256(blob).hexdigest()
|
|
602
|
+
if got != expect_sha:
|
|
603
|
+
msg = ("{0} has changed since refsource {1} was released — it was "
|
|
604
|
+
"re-verified upstream. The copy you have is the live one; "
|
|
605
|
+
"the pinned hash is only how you find out."
|
|
606
|
+
.format(label or url, __version__))
|
|
607
|
+
if _config.strict:
|
|
608
|
+
raise IntegrityError(msg)
|
|
609
|
+
if url not in _warned:
|
|
610
|
+
_warned.add(url)
|
|
611
|
+
warnings.warn(msg, ChangedUpstreamWarning, stacklevel=4)
|
|
612
|
+
return blob, "changed"
|
|
613
|
+
return blob, "pinned"
|
|
614
|
+
return blob, "unknown"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _fetch_records(ds):
|
|
618
|
+
entry = ds._entry
|
|
619
|
+
blob, state = _get_bytes(ds.data_url, entry.get("sha256"), ds.slug)
|
|
620
|
+
ds.checksum = state
|
|
621
|
+
body = json.loads(blob.decode("utf-8"))
|
|
622
|
+
ds.licence = body.get("licence", "")
|
|
623
|
+
if isinstance(body.get("records"), list):
|
|
624
|
+
return body["records"]
|
|
625
|
+
# The biggest datasets are published as numbered parts, because no single
|
|
626
|
+
# file may exceed the host's size cap. Take all of them: a library on a
|
|
627
|
+
# laptop can afford what a request handler cannot, and half a register
|
|
628
|
+
# silently returned is a wrong answer.
|
|
629
|
+
pinned = {p["url"]: p.get("sha256") for p in (entry.get("parts") or [])}
|
|
630
|
+
records = []
|
|
631
|
+
for url in body.get("parts") or []:
|
|
632
|
+
part, _ = _get_bytes(url, pinned.get(url), "{0} part".format(ds.slug))
|
|
633
|
+
records.extend(json.loads(part.decode("utf-8")).get("records") or [])
|
|
634
|
+
if not records:
|
|
635
|
+
raise RefsourceError("{0} returned no readable records".format(ds.slug))
|
|
636
|
+
return records
|