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