sourceledger 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.
- sourceledger/__init__.py +42 -0
- sourceledger/cli.py +86 -0
- sourceledger/ledger.py +626 -0
- sourceledger/py.typed +0 -0
- sourceledger-0.1.0.dist-info/METADATA +182 -0
- sourceledger-0.1.0.dist-info/RECORD +9 -0
- sourceledger-0.1.0.dist-info/WHEEL +4 -0
- sourceledger-0.1.0.dist-info/entry_points.txt +3 -0
- sourceledger-0.1.0.dist-info/licenses/LICENSE +21 -0
sourceledger/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Fetch, verify and validate the public sources a dataset is built from.
|
|
2
|
+
|
|
3
|
+
Every download keeps a provenance record, and every output row names the bytes it came from.
|
|
4
|
+
|
|
5
|
+
The names below are the public API. Until 1.0, a breaking change to them, to the keys of
|
|
6
|
+
sources.toml, or to the fetch-record format comes only in a minor version, listed in the
|
|
7
|
+
changelog. `sourceledger.ledger` and `sourceledger.cli` may change in any release.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sourceledger.cli import main
|
|
11
|
+
from sourceledger.ledger import (
|
|
12
|
+
USER_AGENT,
|
|
13
|
+
FetchError,
|
|
14
|
+
FetchRecord,
|
|
15
|
+
Parser,
|
|
16
|
+
Source,
|
|
17
|
+
build,
|
|
18
|
+
fetch,
|
|
19
|
+
load_manifest,
|
|
20
|
+
parse_csv,
|
|
21
|
+
read_output,
|
|
22
|
+
revisions,
|
|
23
|
+
validate,
|
|
24
|
+
verified,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"USER_AGENT",
|
|
29
|
+
"FetchError",
|
|
30
|
+
"FetchRecord",
|
|
31
|
+
"Parser",
|
|
32
|
+
"Source",
|
|
33
|
+
"build",
|
|
34
|
+
"fetch",
|
|
35
|
+
"load_manifest",
|
|
36
|
+
"main",
|
|
37
|
+
"parse_csv",
|
|
38
|
+
"read_output",
|
|
39
|
+
"revisions",
|
|
40
|
+
"validate",
|
|
41
|
+
"verified",
|
|
42
|
+
]
|
sourceledger/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The command line: `<project> fetch|build|validate|revisions`, with the project's own parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sourceledger.ledger import (
|
|
12
|
+
USER_AGENT,
|
|
13
|
+
FetchError,
|
|
14
|
+
FetchRecord,
|
|
15
|
+
Parser,
|
|
16
|
+
build,
|
|
17
|
+
fetch,
|
|
18
|
+
load_manifest,
|
|
19
|
+
one_line,
|
|
20
|
+
parse_csv,
|
|
21
|
+
read_output,
|
|
22
|
+
revisions,
|
|
23
|
+
validate,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fetch_line(record: FetchRecord) -> str:
|
|
28
|
+
return f"{record.source_id} {record.bytes:>9,} B {record.sha256[:12]} {record.vintage} {record.url}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(
|
|
32
|
+
argv: list[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
parse: Parser = parse_csv,
|
|
35
|
+
prog: str = "sourceledger",
|
|
36
|
+
user_agent: str = USER_AGENT,
|
|
37
|
+
) -> int:
|
|
38
|
+
"""Run one stage: fetch, build, validate, or revisions against an earlier copy of the fetch log.
|
|
39
|
+
|
|
40
|
+
A project's own entry point calls this with its `parse`, its name as `prog`, and that name
|
|
41
|
+
as `user_agent`. Returns the exit code: 0, or 1 for any failure or validation problem.
|
|
42
|
+
"""
|
|
43
|
+
parser = argparse.ArgumentParser(prog=prog, description="Fetch, build and validate the sources in sources.toml.")
|
|
44
|
+
parser.add_argument("stage", choices=["fetch", "build", "validate", "revisions"])
|
|
45
|
+
parser.add_argument("before", nargs="?", type=Path, help="revisions only: an earlier copy of data/fetches.jsonl")
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
# Retries and recovered fetch logs are reported as warnings, on stderr.
|
|
48
|
+
logging.basicConfig(format=f"{prog}: %(message)s", level=logging.WARNING)
|
|
49
|
+
try:
|
|
50
|
+
problems = _run(args.stage, args.before, parse, user_agent, parser)
|
|
51
|
+
except FetchError as error:
|
|
52
|
+
for record in error.records:
|
|
53
|
+
print(_fetch_line(record))
|
|
54
|
+
for failure in error.failures:
|
|
55
|
+
print(f"{args.stage}: {failure}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
# OSError: a missing file or a failed download. ValueError: a bad manifest, a download
|
|
58
|
+
# that fails its checks, or a stale fetch. csv.Error: a source that is not readable CSV.
|
|
59
|
+
except (OSError, ValueError, csv.Error) as error:
|
|
60
|
+
print(f"{args.stage}: {one_line(error)}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
for problem in problems:
|
|
63
|
+
print(problem, file=sys.stderr)
|
|
64
|
+
return 1 if problems else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(stage: str, before: Path | None, parse: Parser, user_agent: str, parser: argparse.ArgumentParser) -> list[str]:
|
|
68
|
+
"""Run `stage`, print its report, and return its problems: only validate has any."""
|
|
69
|
+
match stage:
|
|
70
|
+
case "revisions":
|
|
71
|
+
if before is None:
|
|
72
|
+
parser.error("revisions needs an earlier copy of the fetch log")
|
|
73
|
+
# Not a failure: a revised source is news, and the tripwire test decides whether it breaks anything.
|
|
74
|
+
for line in revisions(before):
|
|
75
|
+
print(line)
|
|
76
|
+
case "fetch":
|
|
77
|
+
for record in fetch(load_manifest(), user_agent=user_agent):
|
|
78
|
+
print(_fetch_line(record))
|
|
79
|
+
case "build":
|
|
80
|
+
for path in build(load_manifest(), parse=parse):
|
|
81
|
+
print(f"{len(read_output(path))} rows -> {path}")
|
|
82
|
+
case _:
|
|
83
|
+
problems = validate(load_manifest())
|
|
84
|
+
print(f"{len(problems)} problems")
|
|
85
|
+
return problems
|
|
86
|
+
return []
|
sourceledger/ledger.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Fetch, build and validate the sources a dataset is built from, as `sources.toml` declares them.
|
|
2
|
+
|
|
3
|
+
Raw files stay in data/raw/ and outputs in data/processed/. Each fetch leaves a
|
|
4
|
+
`<file>.fetch.json` record beside its raw file: the URL, when it was retrieved, the SHA-256
|
|
5
|
+
of the bytes, the vintage the manifest declares, and the server's Last-Modified and ETag
|
|
6
|
+
headers. A later fetch sends those headers back, and when the server answers 304 Not
|
|
7
|
+
Modified it keeps the raw file and its record as they are.
|
|
8
|
+
|
|
9
|
+
Each fetch that brings bytes a source has not had before also appends its record to
|
|
10
|
+
data/fetches.jsonl, the history of every version of every source the pipeline has seen.
|
|
11
|
+
|
|
12
|
+
Paths are relative to the working directory, the project root.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import dataclasses
|
|
19
|
+
import errno
|
|
20
|
+
import hashlib
|
|
21
|
+
import http.client
|
|
22
|
+
import io
|
|
23
|
+
import ipaddress
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import socket
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
import tomllib
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
import urllib.response
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from email.utils import parsedate_to_datetime
|
|
38
|
+
from http import HTTPStatus
|
|
39
|
+
from importlib.metadata import version
|
|
40
|
+
from pathlib import Path, PurePosixPath
|
|
41
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
42
|
+
from urllib.parse import urlsplit
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from email.message import Message
|
|
46
|
+
|
|
47
|
+
MANIFEST = Path("sources.toml")
|
|
48
|
+
RAW = Path("data/raw")
|
|
49
|
+
PROCESSED = Path("data/processed")
|
|
50
|
+
FETCH_LOG = Path("data/fetches.jsonl")
|
|
51
|
+
|
|
52
|
+
IMPLEMENTED = "implemented"
|
|
53
|
+
UNIMPLEMENTED = "unimplemented"
|
|
54
|
+
# Bounds each connect and each read. A server that trickles bytes is stopped by _BUDGET_S.
|
|
55
|
+
_SOCKET_TIMEOUT_S = 30
|
|
56
|
+
# One source's whole fetch, every attempt and wait included, so a stalled publisher costs
|
|
57
|
+
# at most 5 minutes of a CI job.
|
|
58
|
+
_BUDGET_S = 300
|
|
59
|
+
# Waits of 5, 10 and 20 s between four attempts ride out a brief publisher outage.
|
|
60
|
+
_ATTEMPTS = 4
|
|
61
|
+
_BACKOFF_S = 5
|
|
62
|
+
# A server asking for hours would otherwise stall the run.
|
|
63
|
+
_MAX_RETRY_AFTER_S = 120
|
|
64
|
+
# Statuses a publisher recovers from. 501 and 505 never will.
|
|
65
|
+
_RETRIED = frozenset({429, 500, 502, 503, 504})
|
|
66
|
+
_CHUNK_BYTES = 1024 * 1024
|
|
67
|
+
# A network error or timeout, or a connection dropped or garbled mid-response.
|
|
68
|
+
_TRANSIENT = (OSError, http.client.HTTPException)
|
|
69
|
+
# A raw file or fetch record that is missing, edited or unreadable.
|
|
70
|
+
_UNUSABLE = (OSError, ValueError)
|
|
71
|
+
# A runaway or wrong URL fails the fetch instead of filling the disk. A big source sets
|
|
72
|
+
# its own `max_bytes` in sources.toml.
|
|
73
|
+
_MAX_BYTES = 200 * 1024 * 1024
|
|
74
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
75
|
+
|
|
76
|
+
# Sent with every request, so a publisher can tell who is fetching. `main` takes the project's own.
|
|
77
|
+
USER_AGENT = f"sourceledger/{version('sourceledger')}"
|
|
78
|
+
|
|
79
|
+
_logger = logging.getLogger("sourceledger")
|
|
80
|
+
|
|
81
|
+
type Source = dict[str, Any]
|
|
82
|
+
# What the opener returns: an HTTP response, or a file:// one.
|
|
83
|
+
type _Response = http.client.HTTPResponse | urllib.response.addinfourl
|
|
84
|
+
# Reads one raw file as its columns and rows. `parse_csv` is the default.
|
|
85
|
+
type Parser = Callable[[Source, bytes], tuple[list[str], list[dict[str, str]]]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
89
|
+
class FetchRecord:
|
|
90
|
+
"""What one download was, so every output row traces back to the bytes it came from."""
|
|
91
|
+
|
|
92
|
+
source_id: str
|
|
93
|
+
url: str
|
|
94
|
+
retrieved_at: str
|
|
95
|
+
sha256: str
|
|
96
|
+
bytes: int
|
|
97
|
+
vintage: str
|
|
98
|
+
# Empty when the server sends none. Defaults keep records written before these fields readable.
|
|
99
|
+
last_modified: str = ""
|
|
100
|
+
etag: str = ""
|
|
101
|
+
# Where the bytes came from after redirects. Empty when there were none.
|
|
102
|
+
final_url: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FetchError(OSError):
|
|
106
|
+
"""One or more sources failed to fetch. The others were fetched and recorded as usual."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, records: list[FetchRecord], failures: list[str]) -> None:
|
|
109
|
+
super().__init__("\n".join(failures))
|
|
110
|
+
self.records = records # the sources that succeeded
|
|
111
|
+
self.failures = failures # one line per source that did not
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_manifest(path: Path = MANIFEST) -> list[Source]:
|
|
115
|
+
"""The `[[source]]` tables of sources.toml, after checking ids, URLs, caps and pins."""
|
|
116
|
+
with path.open("rb") as fh:
|
|
117
|
+
sources = tomllib.load(fh).get("source", [])
|
|
118
|
+
if not isinstance(sources, list) or not all(isinstance(s, dict) and isinstance(s.get("id"), str) for s in sources):
|
|
119
|
+
msg = "sources.toml: every [[source]] needs a string id"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
ids = [s["id"] for s in sources]
|
|
122
|
+
if bad := [i for i in ids if not re.fullmatch(r"[\w-]+", i)]:
|
|
123
|
+
msg = f"ids {bad} in sources.toml may hold only letters, digits, _ and -"
|
|
124
|
+
raise ValueError(msg)
|
|
125
|
+
if dupes := sorted({i for i in ids if ids.count(i) > 1}):
|
|
126
|
+
msg = f"ids {dupes} appear more than once in sources.toml"
|
|
127
|
+
raise ValueError(msg)
|
|
128
|
+
if no_url := [s["id"] for s in implemented(sources) if not isinstance(s.get("url"), str)]:
|
|
129
|
+
msg = f"implemented sources {no_url} in sources.toml have no url"
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
# Not isinstance: bool is an int subclass, so `max_bytes = true` would pass as a 1-byte cap.
|
|
132
|
+
caps = {s["id"]: s.get("max_bytes", _MAX_BYTES) for s in sources}
|
|
133
|
+
if bad_cap := [i for i, cap in caps.items() if type(cap) is not int or cap < 1]:
|
|
134
|
+
msg = f"sources {bad_cap} in sources.toml need max_bytes to be a positive whole number"
|
|
135
|
+
raise ValueError(msg)
|
|
136
|
+
for source in sources:
|
|
137
|
+
_check_pin(source)
|
|
138
|
+
_check_vintage(source)
|
|
139
|
+
return sources
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_pin(source: Source) -> None:
|
|
143
|
+
"""Normalize a `sha256` pin to lower case, as `sha256sum` prints it, and reject one that is not a SHA-256."""
|
|
144
|
+
if (pin := source.get("sha256")) is None:
|
|
145
|
+
return
|
|
146
|
+
if not (isinstance(pin, str) and _SHA256.fullmatch(pin.lower())):
|
|
147
|
+
msg = f"{source['id']}: sha256 in sources.toml must be 64 hexadecimal characters"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
source["sha256"] = pin.lower()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_vintage(source: Source) -> None:
|
|
153
|
+
"""Accept the old key `release` for `vintage`, with a warning."""
|
|
154
|
+
if (release := source.pop("release", None)) is None:
|
|
155
|
+
return
|
|
156
|
+
if source.get("vintage") is not None:
|
|
157
|
+
msg = f"{source['id']}: give vintage or release in sources.toml, not both"
|
|
158
|
+
raise ValueError(msg)
|
|
159
|
+
_logger.warning("%s: `release` in sources.toml is now `vintage`; rename it", source["id"])
|
|
160
|
+
source["vintage"] = release
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _vintage(source: Source) -> str:
|
|
164
|
+
# str(): a TOML date (`vintage = 2024-06-01`) loads as a datetime.date.
|
|
165
|
+
return str(source.get("vintage", source.get("release", "not stated")))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def implemented(sources: list[Source]) -> list[Source]:
|
|
169
|
+
"""The sources the pipeline reads."""
|
|
170
|
+
return [s for s in sources if s.get("status") == IMPLEMENTED]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def raw_name(source: Source) -> str:
|
|
174
|
+
"""The raw file's name: the source id plus the URL's extension, e.g. `example-co2.csv`."""
|
|
175
|
+
return source["id"] + PurePosixPath(urlsplit(source["url"]).path).suffix
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_atomic(path: Path, data: bytes) -> None:
|
|
179
|
+
"""Write a new temp file beside `path`, flush it to disk, then rename it over `path`.
|
|
180
|
+
|
|
181
|
+
A reader never sees half a file. The temp file gets a fresh name, so two runs at once
|
|
182
|
+
don't share it, and it is created rather than opened, so a symlink in its place is
|
|
183
|
+
never followed.
|
|
184
|
+
"""
|
|
185
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".part")
|
|
186
|
+
try:
|
|
187
|
+
with os.fdopen(fd, "wb") as fh:
|
|
188
|
+
fh.write(data)
|
|
189
|
+
fh.flush()
|
|
190
|
+
os.fsync(fh.fileno())
|
|
191
|
+
Path(temp).replace(path)
|
|
192
|
+
except BaseException:
|
|
193
|
+
Path(temp).unlink(missing_ok=True)
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_public(host: str) -> bool:
|
|
198
|
+
"""Whether every address `host` resolves to is on the public internet."""
|
|
199
|
+
try:
|
|
200
|
+
infos = socket.getaddrinfo(host, None)
|
|
201
|
+
except OSError:
|
|
202
|
+
return True # unresolvable: the request itself will fail
|
|
203
|
+
return all(ipaddress.ip_address(info[4][0]).is_global for info in infos)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _NoRedirectInward(urllib.request.HTTPRedirectHandler):
|
|
207
|
+
"""Refuses a redirect from a public host to a loopback, private or link-local one.
|
|
208
|
+
|
|
209
|
+
Otherwise a third-party server could have the fetch save an internal service's response
|
|
210
|
+
under the manifest's URL. A source that lives on an internal host can still redirect
|
|
211
|
+
within it.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def redirect_request(
|
|
215
|
+
self,
|
|
216
|
+
req: urllib.request.Request,
|
|
217
|
+
fp: IO[bytes],
|
|
218
|
+
code: int,
|
|
219
|
+
msg: str,
|
|
220
|
+
headers: http.client.HTTPMessage,
|
|
221
|
+
newurl: str,
|
|
222
|
+
) -> urllib.request.Request | None:
|
|
223
|
+
"""Raise HTTPError for an inward redirect; follow any other as urllib does."""
|
|
224
|
+
if _is_public(urlsplit(req.full_url).hostname or "") and not _is_public(urlsplit(newurl).hostname or ""):
|
|
225
|
+
reason = f"refused a redirect from a public host to {newurl}"
|
|
226
|
+
raise urllib.error.HTTPError(newurl, code, reason, headers, fp)
|
|
227
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_OPENER = urllib.request.build_opener(_NoRedirectInward())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _open(request: urllib.request.Request, timeout: float) -> _Response:
|
|
234
|
+
# NOTE: the opener follows file:// too, which the tests use for fixtures. The URLs come
|
|
235
|
+
# from sources.toml, which is as trusted as the code.
|
|
236
|
+
return _OPENER.open(request, timeout=timeout)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _retry_after(headers: Message | None) -> float:
|
|
240
|
+
"""Seconds a 429 or 503 asks to wait, from Retry-After as seconds or an HTTP date, capped."""
|
|
241
|
+
value = (headers.get("Retry-After", "") if headers else "").strip()
|
|
242
|
+
if value.isascii() and value.isdigit():
|
|
243
|
+
seconds = float(value)
|
|
244
|
+
else:
|
|
245
|
+
try:
|
|
246
|
+
seconds = (parsedate_to_datetime(value) - datetime.now(UTC)).total_seconds()
|
|
247
|
+
except (TypeError, ValueError): # absent, malformed, or a date with no time zone
|
|
248
|
+
return 0.0
|
|
249
|
+
return min(max(seconds, 0.0), _MAX_RETRY_AFTER_S)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _content_length(headers: Message) -> int | None:
|
|
253
|
+
"""The Content-Length header as a byte count, or None when it is absent or not a plain number."""
|
|
254
|
+
value = headers.get("Content-Length", "").strip()
|
|
255
|
+
return int(value) if value.isascii() and value.isdigit() else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _read(response: _Response, source: Source, deadline: float) -> bytes:
|
|
259
|
+
"""The whole body, refused past the source's `max_bytes` or past the fetch's time budget."""
|
|
260
|
+
cap = source.get("max_bytes", _MAX_BYTES)
|
|
261
|
+
encoding = response.headers.get("Content-Encoding", "identity").strip().lower()
|
|
262
|
+
if encoding not in {"", "identity"}:
|
|
263
|
+
msg = f"{source['id']}: served with Content-Encoding {encoding}, so its bytes are not the file itself"
|
|
264
|
+
raise ValueError(msg)
|
|
265
|
+
chunks: list[bytes] = []
|
|
266
|
+
size = 0
|
|
267
|
+
while chunk := response.read(_CHUNK_BYTES):
|
|
268
|
+
size += len(chunk)
|
|
269
|
+
if size > cap:
|
|
270
|
+
msg = f"{source['id']}: download exceeds {cap} bytes; raise max_bytes in sources.toml if expected"
|
|
271
|
+
raise ValueError(msg)
|
|
272
|
+
if time.monotonic() > deadline:
|
|
273
|
+
msg = f"download still unfinished after {_BUDGET_S} s"
|
|
274
|
+
raise TimeoutError(msg)
|
|
275
|
+
chunks.append(chunk)
|
|
276
|
+
return b"".join(chunks)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _get(source: Source, previous: FetchRecord | None, user_agent: str) -> tuple[bytes, Message, str] | FetchRecord:
|
|
280
|
+
"""GET one source's URL: its body, headers and final URL, retrying failures a publisher recovers from.
|
|
281
|
+
|
|
282
|
+
The request is conditional on the ETag and Last-Modified of a `previous` record, which
|
|
283
|
+
is returned as it is when the server answers 304 Not Modified.
|
|
284
|
+
|
|
285
|
+
429, 500, 502, 503 and 504, network errors and a body shorter than its Content-Length
|
|
286
|
+
are retried with exponential backoff, waiting at least the server's Retry-After (up to
|
|
287
|
+
120 s). The whole fetch stops after 300 s. Any other error status is raised at once.
|
|
288
|
+
|
|
289
|
+
Raises ValueError for a body over the source's `max_bytes` (default 200 MiB), or one
|
|
290
|
+
served compressed.
|
|
291
|
+
"""
|
|
292
|
+
conditional = _conditional(previous)
|
|
293
|
+
headers = {"User-Agent": user_agent, "Accept-Encoding": "identity", **conditional}
|
|
294
|
+
request = urllib.request.Request(source["url"], headers=headers) # noqa: S310 # see _open
|
|
295
|
+
deadline = time.monotonic() + _BUDGET_S
|
|
296
|
+
for attempt in range(1, _ATTEMPTS + 1):
|
|
297
|
+
wait = _BACKOFF_S * 2 ** (attempt - 1)
|
|
298
|
+
try:
|
|
299
|
+
with _open(request, timeout=_SOCKET_TIMEOUT_S) as response:
|
|
300
|
+
payload = _read(response, source, deadline)
|
|
301
|
+
# A connection dropped mid-body can end the read early without an error.
|
|
302
|
+
expected = _content_length(response.headers)
|
|
303
|
+
if expected is not None and expected != len(payload):
|
|
304
|
+
raise http.client.IncompleteRead(payload, expected - len(payload))
|
|
305
|
+
return payload, response.headers, response.url
|
|
306
|
+
except urllib.error.HTTPError as error:
|
|
307
|
+
# urllib raises a 304 like an error. Only a conditional request can get one.
|
|
308
|
+
if error.code == HTTPStatus.NOT_MODIFIED and previous and conditional:
|
|
309
|
+
return previous
|
|
310
|
+
if attempt == _ATTEMPTS or error.code not in _RETRIED:
|
|
311
|
+
raise
|
|
312
|
+
failure: Exception = error
|
|
313
|
+
wait = max(wait, _retry_after(error.headers))
|
|
314
|
+
except _TRANSIENT as error:
|
|
315
|
+
if attempt == _ATTEMPTS:
|
|
316
|
+
raise
|
|
317
|
+
failure = error
|
|
318
|
+
if time.monotonic() + wait > deadline:
|
|
319
|
+
raise failure
|
|
320
|
+
_logger.warning(
|
|
321
|
+
"%s: attempt %d of %d failed (%s); retrying in %.0f s", source["id"], attempt, _ATTEMPTS, failure, wait
|
|
322
|
+
)
|
|
323
|
+
time.sleep(wait)
|
|
324
|
+
raise AssertionError # unreachable: the last attempt returns or raises
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _previous(source: Source, raw: Path) -> FetchRecord | None:
|
|
328
|
+
"""The last fetch record of `source`, if it and its raw file still match the manifest."""
|
|
329
|
+
try:
|
|
330
|
+
record, _ = verified(source, raw=raw)
|
|
331
|
+
except _UNUSABLE:
|
|
332
|
+
return None
|
|
333
|
+
return record
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _conditional(previous: FetchRecord | None) -> dict[str, str]:
|
|
337
|
+
"""The headers that ask the server to send the source only if it changed since `previous`."""
|
|
338
|
+
if previous is None:
|
|
339
|
+
return {}
|
|
340
|
+
headers = {"If-None-Match": previous.etag, "If-Modified-Since": previous.last_modified}
|
|
341
|
+
return {name: value for name, value in headers.items() if value}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _last_records(log: Path) -> dict[str, dict[str, Any]]:
|
|
345
|
+
"""The record most recently logged for each source id, from the fetch log.
|
|
346
|
+
|
|
347
|
+
A last line cut off by an interrupted fetch is skipped with a warning. Any other line
|
|
348
|
+
that is not a record is an error that names the line.
|
|
349
|
+
"""
|
|
350
|
+
if not log.exists():
|
|
351
|
+
return {}
|
|
352
|
+
text = log.read_text(encoding="utf-8")
|
|
353
|
+
lines = text.splitlines()
|
|
354
|
+
records: dict[str, dict[str, Any]] = {}
|
|
355
|
+
for number, line in enumerate(lines, start=1):
|
|
356
|
+
if not line.strip():
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
record = json.loads(line)
|
|
360
|
+
except json.JSONDecodeError as error:
|
|
361
|
+
if number == len(lines) and not text.endswith("\n"):
|
|
362
|
+
_logger.warning("%s: skipping a last line cut off by an interrupted fetch", log)
|
|
363
|
+
continue
|
|
364
|
+
msg = f"{log}:{number} is not a fetch record ({error})"
|
|
365
|
+
raise ValueError(msg) from error
|
|
366
|
+
records[record["source_id"]] = record
|
|
367
|
+
return records
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _append(log: Path, record: FetchRecord) -> None:
|
|
371
|
+
"""Add `record` as one line at the end of the log, in one write, flushed to disk.
|
|
372
|
+
|
|
373
|
+
A last line cut off by an interrupted fetch is dropped first, so the new line starts
|
|
374
|
+
on a line of its own.
|
|
375
|
+
"""
|
|
376
|
+
if log.exists() and (text := log.read_bytes()) and not text.endswith(b"\n"):
|
|
377
|
+
_logger.warning("%s: dropping a last line cut off by an interrupted fetch", log)
|
|
378
|
+
os.truncate(log, text.rfind(b"\n") + 1)
|
|
379
|
+
with log.open("a", encoding="utf-8") as fh:
|
|
380
|
+
fh.write(json.dumps(dataclasses.asdict(record)) + "\n")
|
|
381
|
+
fh.flush()
|
|
382
|
+
os.fsync(fh.fileno())
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def revisions(before: Path, *, log: Path = FETCH_LOG) -> list[str]:
|
|
386
|
+
"""One Markdown line per source whose bytes changed between `before`, an earlier copy of the log, and `log`.
|
|
387
|
+
|
|
388
|
+
A source that `before` never logged is new, not revised, and is left out. Raises
|
|
389
|
+
FileNotFoundError when `before` does not exist: an empty report must mean no
|
|
390
|
+
revisions, not a missing copy.
|
|
391
|
+
"""
|
|
392
|
+
if not before.exists():
|
|
393
|
+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(before))
|
|
394
|
+
old, new = _last_records(before), _last_records(log)
|
|
395
|
+
return [
|
|
396
|
+
f"- `{sid}`: {old[sid]['sha256'][:12]} -> {r['sha256'][:12]}, {r['bytes']:,} bytes, "
|
|
397
|
+
f"fetched {r['retrieved_at']} from <{r['url']}>"
|
|
398
|
+
for sid, r in new.items()
|
|
399
|
+
if sid in old and old[sid]["sha256"] != r["sha256"]
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def fetch(
|
|
404
|
+
sources: list[Source], *, raw: Path = RAW, log: Path = FETCH_LOG, user_agent: str = USER_AGENT
|
|
405
|
+
) -> list[FetchRecord]:
|
|
406
|
+
"""Download each implemented source into `raw`, with a fetch record beside it.
|
|
407
|
+
|
|
408
|
+
A source the server reports unchanged since its last intact fetch keeps that raw file
|
|
409
|
+
and record, and its old record is returned. A record whose bytes differ from the last
|
|
410
|
+
ones logged for its source is appended to `log`.
|
|
411
|
+
|
|
412
|
+
Every source is tried. Raises FetchError after the loop when any failed, listing each
|
|
413
|
+
with its reason; nothing is written for those. A source fails for a download over its
|
|
414
|
+
`max_bytes` (default 200 MiB), bytes that miss the `sha256` the manifest pins, or an
|
|
415
|
+
error that outlasts its retries.
|
|
416
|
+
"""
|
|
417
|
+
raw.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
logged = {sid: r["sha256"] for sid, r in _last_records(log).items()}
|
|
420
|
+
records: list[FetchRecord] = []
|
|
421
|
+
failures: list[str] = []
|
|
422
|
+
for source in implemented(sources):
|
|
423
|
+
try:
|
|
424
|
+
records.append(_fetch_one(source, raw, log, logged, user_agent))
|
|
425
|
+
except ValueError as error: # messages already name the source
|
|
426
|
+
failures.append(str(error))
|
|
427
|
+
except _TRANSIENT as error:
|
|
428
|
+
failures.append(f"{source['id']}: fetching {source['url']} failed: {error}")
|
|
429
|
+
if failures:
|
|
430
|
+
raise FetchError(records, failures)
|
|
431
|
+
return records
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _fetch_one(source: Source, raw: Path, log: Path, logged: dict[str, str], user_agent: str) -> FetchRecord:
|
|
435
|
+
"""Fetch one source, write its raw file, log and record, and return the record."""
|
|
436
|
+
response = _get(source, _previous(source, raw), user_agent)
|
|
437
|
+
if isinstance(response, FetchRecord):
|
|
438
|
+
# A version written to disk by a fetch that died before logging it gets logged now.
|
|
439
|
+
_log_new_version(log, logged, response)
|
|
440
|
+
return response
|
|
441
|
+
payload, headers, final_url = response
|
|
442
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
443
|
+
if source.get("sha256", digest) != digest:
|
|
444
|
+
msg = f"{source['id']}: downloaded bytes hash to {digest}, not the sha256 in sources.toml"
|
|
445
|
+
raise ValueError(msg)
|
|
446
|
+
record = FetchRecord(
|
|
447
|
+
source_id=source["id"],
|
|
448
|
+
url=source["url"],
|
|
449
|
+
retrieved_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
450
|
+
sha256=digest,
|
|
451
|
+
bytes=len(payload),
|
|
452
|
+
vintage=_vintage(source),
|
|
453
|
+
# Kept for a conditional request (If-Modified-Since, If-None-Match) on a later run.
|
|
454
|
+
last_modified=headers.get("Last-Modified", ""),
|
|
455
|
+
etag=headers.get("ETag", ""),
|
|
456
|
+
final_url="" if final_url == source["url"] else final_url,
|
|
457
|
+
)
|
|
458
|
+
name = raw_name(source)
|
|
459
|
+
_write_atomic(raw / name, payload)
|
|
460
|
+
# Logged before the record is written: until the record matches the raw file, the next
|
|
461
|
+
# run fetches in full, so a crash in between loses nothing.
|
|
462
|
+
_log_new_version(log, logged, record)
|
|
463
|
+
_write_atomic(raw / f"{name}.fetch.json", (json.dumps(dataclasses.asdict(record), indent=2) + "\n").encode())
|
|
464
|
+
return record
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _log_new_version(log: Path, logged: dict[str, str], record: FetchRecord) -> None:
|
|
468
|
+
# Appended, not rewritten: the log is history. A re-download of the same bytes adds nothing.
|
|
469
|
+
if logged.get(record.source_id) != record.sha256:
|
|
470
|
+
_append(log, record)
|
|
471
|
+
logged[record.source_id] = record.sha256
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def verified(source: Source, *, raw: Path = RAW) -> tuple[FetchRecord, bytes]:
|
|
475
|
+
"""The fetch record and raw bytes for `source`, after checking that both are current.
|
|
476
|
+
|
|
477
|
+
Raises ValueError when the record was made for a different request than the manifest
|
|
478
|
+
now makes (another URL, vintage or `sha256` pin), or when the raw file no longer hashes
|
|
479
|
+
to the record.
|
|
480
|
+
"""
|
|
481
|
+
name = raw_name(source)
|
|
482
|
+
try:
|
|
483
|
+
record = FetchRecord(**json.loads((raw / f"{name}.fetch.json").read_text(encoding="utf-8")))
|
|
484
|
+
except TypeError as error:
|
|
485
|
+
msg = f"{name}.fetch.json is not a fetch record; re-run fetch"
|
|
486
|
+
raise ValueError(msg) from error
|
|
487
|
+
if (record.source_id, record.url) != (source["id"], source["url"]):
|
|
488
|
+
msg = f"{name} was fetched from {record.url}, but sources.toml now requests {source['url']}; re-run fetch"
|
|
489
|
+
raise ValueError(msg)
|
|
490
|
+
if record.vintage != _vintage(source):
|
|
491
|
+
msg = f"{name} was fetched as vintage {record.vintage!r}, not {_vintage(source)!r}; re-run fetch"
|
|
492
|
+
raise ValueError(msg)
|
|
493
|
+
if source.get("sha256", record.sha256).lower() != record.sha256:
|
|
494
|
+
msg = f"{name} hashes to {record.sha256}, not the sha256 sources.toml now pins; re-run fetch"
|
|
495
|
+
raise ValueError(msg)
|
|
496
|
+
payload = (raw / name).read_bytes()
|
|
497
|
+
if hashlib.sha256(payload).hexdigest() != record.sha256:
|
|
498
|
+
msg = f"{name} changed since it was fetched; re-run fetch"
|
|
499
|
+
raise ValueError(msg)
|
|
500
|
+
return record, payload
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def parse_csv(source: Source, payload: bytes) -> tuple[list[str], list[dict[str, str]]]:
|
|
504
|
+
"""One raw CSV file as its columns and rows: the default `Parser`.
|
|
505
|
+
|
|
506
|
+
Raises ValueError for a repeated column name, or a row with more or fewer fields than the header.
|
|
507
|
+
"""
|
|
508
|
+
# utf-8-sig drops the byte-order mark that spreadsheet exports start with. Plain UTF-8 reads the same.
|
|
509
|
+
text = payload.decode("utf-8-sig")
|
|
510
|
+
reader = csv.DictReader(io.StringIO(text, newline=""))
|
|
511
|
+
columns = list(reader.fieldnames or [])
|
|
512
|
+
# DictReader keeps only the last of two same-named columns, silently dropping the other.
|
|
513
|
+
if dupes := sorted({c for c in columns if columns.count(c) > 1}):
|
|
514
|
+
msg = f"{source['id']}: columns {dupes} appear more than once in the header"
|
|
515
|
+
raise ValueError(msg)
|
|
516
|
+
rows = []
|
|
517
|
+
for row in reader:
|
|
518
|
+
# DictReader files the surplus fields of a long row under the key None.
|
|
519
|
+
if None in row:
|
|
520
|
+
msg = f"{source['id']}: line {reader.line_num} has more fields than the header"
|
|
521
|
+
raise ValueError(msg)
|
|
522
|
+
# It fills the missing fields of a short row with None, which would publish as "".
|
|
523
|
+
if None in row.values():
|
|
524
|
+
msg = f"{source['id']}: line {reader.line_num} has fewer fields than the header"
|
|
525
|
+
raise ValueError(msg)
|
|
526
|
+
rows.append(row)
|
|
527
|
+
return columns, rows
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _stamp(record: FetchRecord) -> dict[str, str]:
|
|
531
|
+
"""The provenance columns `build` adds to every output row."""
|
|
532
|
+
return {
|
|
533
|
+
"source_id": record.source_id,
|
|
534
|
+
"source_url": record.url,
|
|
535
|
+
"retrieved_at": record.retrieved_at,
|
|
536
|
+
"vintage": record.vintage,
|
|
537
|
+
"raw_sha256": record.sha256,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def build(
|
|
542
|
+
sources: list[Source], *, raw: Path = RAW, processed: Path = PROCESSED, parse: Parser = parse_csv
|
|
543
|
+
) -> list[Path]:
|
|
544
|
+
"""Write each implemented source to `processed/<id>.csv`, each row stamped with its fetch record.
|
|
545
|
+
|
|
546
|
+
`parse` reads each raw file. Give it a function that branches on `source["id"]` for sources that are not CSV.
|
|
547
|
+
"""
|
|
548
|
+
processed.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
written = []
|
|
550
|
+
for source in implemented(sources):
|
|
551
|
+
record, payload = verified(source, raw=raw)
|
|
552
|
+
stamp = _stamp(record)
|
|
553
|
+
columns, rows = parse(source, payload)
|
|
554
|
+
if clash := stamp.keys() & set(columns):
|
|
555
|
+
msg = f"{source['id']}: source columns {sorted(clash)} collide with the provenance columns"
|
|
556
|
+
raise ValueError(msg)
|
|
557
|
+
buffer = io.StringIO(newline="")
|
|
558
|
+
writer = csv.DictWriter(buffer, fieldnames=[*stamp, *columns])
|
|
559
|
+
writer.writeheader()
|
|
560
|
+
# The stamp last: a parser's row cannot overwrite a provenance column.
|
|
561
|
+
writer.writerows({**row, **stamp} for row in rows)
|
|
562
|
+
out = processed / f"{source['id']}.csv"
|
|
563
|
+
_write_atomic(out, buffer.getvalue().encode())
|
|
564
|
+
written.append(out)
|
|
565
|
+
return written
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def read_output(path: Path) -> list[dict[str, str]]:
|
|
569
|
+
"""A published output file, as written."""
|
|
570
|
+
with path.open(newline="", encoding="utf-8") as fh:
|
|
571
|
+
return list(csv.DictReader(fh))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _terms_problems(source: Source) -> list[str]:
|
|
575
|
+
"""What an implemented source is missing of its reuse terms: in the publisher's words, and machine-readable."""
|
|
576
|
+
sid, problems = source["id"], []
|
|
577
|
+
if not (source.get("terms") and source.get("terms_url")):
|
|
578
|
+
problems.append(f"{sid}: no terms or terms_url; record the publisher's reuse terms")
|
|
579
|
+
license_ = source.get("license")
|
|
580
|
+
if not (isinstance(license_, str) and license_.strip()):
|
|
581
|
+
problems.append(f"{sid}: no license; give the SPDX id of the terms, or LicenseRef-<name> for custom ones")
|
|
582
|
+
if not isinstance(source.get("redistribute"), bool):
|
|
583
|
+
problems.append(f"{sid}: redistribute must be true or false: may the raw file be republished?")
|
|
584
|
+
return problems
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def validate(sources: list[Source], *, raw: Path = RAW, processed: Path = PROCESSED) -> list[str]:
|
|
588
|
+
"""Check the published output against the manifest and the fetch records, and return one string per problem."""
|
|
589
|
+
problems = []
|
|
590
|
+
ids = {source["id"] for source in sources}
|
|
591
|
+
# Output of a source since removed or renamed would otherwise pass as published data.
|
|
592
|
+
problems += [
|
|
593
|
+
f"{out}: no source {out.stem!r} in sources.toml; delete it"
|
|
594
|
+
for out in sorted(processed.glob("*.csv"))
|
|
595
|
+
if out.stem not in ids
|
|
596
|
+
]
|
|
597
|
+
for source in sources:
|
|
598
|
+
sid, status = source["id"], source.get("status")
|
|
599
|
+
out = processed / f"{sid}.csv"
|
|
600
|
+
if status == UNIMPLEMENTED:
|
|
601
|
+
if out.exists():
|
|
602
|
+
problems.append(f"{sid}: status is unimplemented, but {out} exists")
|
|
603
|
+
continue
|
|
604
|
+
if status != IMPLEMENTED:
|
|
605
|
+
problems.append(f"{sid}: status must be {IMPLEMENTED!r} or {UNIMPLEMENTED!r}, not {status!r}")
|
|
606
|
+
continue
|
|
607
|
+
problems += _terms_problems(source)
|
|
608
|
+
try:
|
|
609
|
+
record, _ = verified(source, raw=raw)
|
|
610
|
+
rows = read_output(out)
|
|
611
|
+
except (OSError, ValueError, csv.Error) as error:
|
|
612
|
+
problems.append(one_line(error))
|
|
613
|
+
continue
|
|
614
|
+
if not rows:
|
|
615
|
+
problems.append(f"{out}: no rows")
|
|
616
|
+
stamp = _stamp(record)
|
|
617
|
+
if any({column: row.get(column) for column in stamp} != stamp for row in rows):
|
|
618
|
+
problems.append(f"{out}: rows come from an earlier fetch than {raw_name(source)}; re-run build")
|
|
619
|
+
return problems
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def one_line(error: Exception) -> str:
|
|
623
|
+
"""One line for an expected failure: a missing file by name, anything else as raised."""
|
|
624
|
+
if isinstance(error, FileNotFoundError) and error.filename:
|
|
625
|
+
return f"{error.filename} is missing"
|
|
626
|
+
return str(error)
|
sourceledger/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sourceledger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fetch, verify and validate the public sources a dataset is built from, with a provenance record for every download
|
|
5
|
+
Keywords: provenance,data pipeline,reproducibility,open data,research software,FAIR
|
|
6
|
+
Author: Simon van Lierde
|
|
7
|
+
Author-email: Simon van Lierde <90462640+simonvanlierde@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Project-URL: Homepage, https://github.com/simonvanlierde/sourceledger
|
|
17
|
+
Project-URL: Issues, https://github.com/simonvanlierde/sourceledger/issues
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# sourceledger
|
|
21
|
+
|
|
22
|
+
[](https://github.com/simonvanlierde/sourceledger/actions/workflows/python.yml)
|
|
23
|
+
[](LICENSE)
|
|
24
|
+
|
|
25
|
+
Build a dataset from public sources where every row says which download it came from.
|
|
26
|
+
|
|
27
|
+
A project lists its inputs in `sources.toml`: each source's URL, the publisher's reuse
|
|
28
|
+
terms, and whether the raw file may be republished. sourceledger runs four stages over
|
|
29
|
+
that manifest:
|
|
30
|
+
|
|
31
|
+
- `fetch` downloads each source into `data/raw/`, with a record beside it: the URL, when
|
|
32
|
+
it was fetched, the SHA-256 and size of the bytes, the vintage the manifest declares,
|
|
33
|
+
and the server's ETag and Last-Modified. The next fetch sends the ETag back, so an
|
|
34
|
+
unchanged source is not downloaded again. Each new version of a source is also
|
|
35
|
+
appended to `data/fetches.jsonl`.
|
|
36
|
+
- `build` re-hashes each download against its record, reads it, and writes
|
|
37
|
+
`data/processed/<id>.csv` with five provenance columns in front of the source's own:
|
|
38
|
+
`source_id`, `source_url`, `retrieved_at`, `vintage` and `raw_sha256`.
|
|
39
|
+
- `validate` re-reads the output and reports every problem it finds: output built from
|
|
40
|
+
an older fetch, a raw file changed since its fetch, a record made for another URL,
|
|
41
|
+
vintage or pin, a source with no reuse terms, an output with no rows, and output left
|
|
42
|
+
by a source that is gone or marked unimplemented.
|
|
43
|
+
- `revisions OLD_LOG` lists the sources whose bytes changed since an earlier copy of the
|
|
44
|
+
fetch log. A weekly CI run can use it to say when a publisher revised its data.
|
|
45
|
+
|
|
46
|
+
sourceledger has no dependencies: the standard library does all of it.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uv add sourceledger
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Until the first release is on PyPI, install it from GitHub:
|
|
55
|
+
`uv add git+https://github.com/simonvanlierde/sourceledger`.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
Write a `sources.toml` in the project root:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[[source]]
|
|
63
|
+
id = "co2"
|
|
64
|
+
status = "implemented"
|
|
65
|
+
url = "https://raw.githubusercontent.com/datasets/co2-ppm/main/data/co2-annmean-mlo.csv"
|
|
66
|
+
vintage = "main branch, unversioned"
|
|
67
|
+
terms = "ODC-PDDL-1.0 (datasets/co2-ppm package; original data from NOAA GML)"
|
|
68
|
+
terms_url = "https://opendatacommons.org/licenses/pddl/"
|
|
69
|
+
license = "PDDL-1.0"
|
|
70
|
+
redistribute = true
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then run the stages from that directory:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
sourceledger fetch
|
|
77
|
+
sourceledger build
|
|
78
|
+
sourceledger validate
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`sourceledger` reads every source as CSV. For other formats, see
|
|
82
|
+
[Your own readers](#your-own-readers).
|
|
83
|
+
|
|
84
|
+
## What to commit
|
|
85
|
+
|
|
86
|
+
Commit `sources.toml` and `data/fetches.jsonl`. The log holds checksums and URLs, not
|
|
87
|
+
data, and it is the history of every version of every source you have fetched.
|
|
88
|
+
|
|
89
|
+
Keep `data/raw/` and `data/processed/` out of git, so the repository never
|
|
90
|
+
redistributes a publisher's data. The per-file `.fetch.json` records live in
|
|
91
|
+
`data/raw/` too. A fresh clone runs `fetch` before `build` and `validate`.
|
|
92
|
+
|
|
93
|
+
## sources.toml
|
|
94
|
+
|
|
95
|
+
Each `[[source]]` table takes these keys:
|
|
96
|
+
|
|
97
|
+
| Key | Required | Meaning |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `id` | yes | Letters, digits, `_` and `-`. Names the raw file (`id` plus the URL's extension) and the output. |
|
|
100
|
+
| `status` | yes | `"implemented"`, read by every stage, or `"unimplemented"`: a source you know about but don't read yet. `validate` fails if one of those has output. |
|
|
101
|
+
| `url` | for implemented sources | What `fetch` downloads. Change it and `build` refuses the old download until you fetch again. |
|
|
102
|
+
| `terms`, `terms_url` | for implemented sources | The publisher's reuse terms in their words, and where they are stated. |
|
|
103
|
+
| `license` | for implemented sources | The same terms as an [SPDX id](https://spdx.org/licenses/), such as `"CC-BY-4.0"`, or `"LicenseRef-<name>"` for terms with none. |
|
|
104
|
+
| `redistribute` | for implemented sources | `true` if the terms let you republish the raw file, `false` if not. |
|
|
105
|
+
| `vintage` | no | The edition you expect, such as `"2024 release"` or a TOML date. Recorded with each fetch and stamped on each row. `"not stated"` when absent. The old key `release` still works, with a warning. |
|
|
106
|
+
| `sha256` | no | The SHA-256 the download must have. Pin it for a fixed release, and `fetch` refuses bytes that changed upstream. |
|
|
107
|
+
| `max_bytes` | no | The largest download `fetch` accepts. 200 MiB unless set. |
|
|
108
|
+
| `reason` | no | A note for people, such as why a source is unimplemented. sourceledger doesn't read it. |
|
|
109
|
+
|
|
110
|
+
`validate` reports a missing `terms`, `terms_url`, `license` or `redistribute`. The other
|
|
111
|
+
keys are checked when the manifest loads.
|
|
112
|
+
|
|
113
|
+
## How fetch behaves
|
|
114
|
+
|
|
115
|
+
- It tries four times, waiting 5, 10 and 20 s between attempts, on 429, 500, 502, 503,
|
|
116
|
+
504, network errors and a body cut short. A `Retry-After` header, in seconds or as a
|
|
117
|
+
date, raises the wait to at most 120 s. Any other error status fails at once.
|
|
118
|
+
- Each connect and read times out after 30 s, and one source's whole fetch after 300 s.
|
|
119
|
+
- It asks for the file uncompressed, and refuses a compressed reply. The checksum is
|
|
120
|
+
always of the file as published.
|
|
121
|
+
- It refuses a redirect from a public host to a loopback, private or link-local
|
|
122
|
+
address. When a redirect is followed, the record keeps the final URL too.
|
|
123
|
+
- Every source is tried. When some fail, the others are still written, and the run
|
|
124
|
+
exits 1 with one line per failed source.
|
|
125
|
+
- Each file is written to a new temporary file and renamed into place, so a crash never
|
|
126
|
+
leaves half a file. A fetch log line cut off by a crash is dropped on the next run.
|
|
127
|
+
|
|
128
|
+
## Your own readers
|
|
129
|
+
|
|
130
|
+
`sourceledger` reads every source with `parse_csv`. A project with other formats gives
|
|
131
|
+
it its own reader, a function from a source's manifest table and raw bytes to columns
|
|
132
|
+
and rows, and its own command:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# src/my_dataset/pipeline.py
|
|
136
|
+
import sourceledger
|
|
137
|
+
from sourceledger import Source
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse(source: Source, payload: bytes) -> tuple[list[str], list[dict[str, str]]]:
|
|
141
|
+
"""Every source is CSV here. Branch on source["id"] for one that is not."""
|
|
142
|
+
return sourceledger.parse_csv(source, payload)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> int:
|
|
146
|
+
return sourceledger.main(argv, parse=parse, prog="my-dataset", user_agent="my-dataset")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```toml
|
|
150
|
+
# pyproject.toml
|
|
151
|
+
[project.scripts]
|
|
152
|
+
my-dataset = "my_dataset.pipeline:main"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`my-dataset fetch`, `my-dataset build` and `my-dataset validate` then run with that
|
|
156
|
+
reader, and publishers see `my-dataset` as the User-Agent. The
|
|
157
|
+
[project-templates](https://github.com/simonvanlierde/project-templates) `data-pipeline`
|
|
158
|
+
kind sets all of this up, with CI and a weekly run that reports revised sources.
|
|
159
|
+
|
|
160
|
+
The same stages are functions too: `load_manifest`, `fetch`, `build`, `validate` and
|
|
161
|
+
`revisions`.
|
|
162
|
+
|
|
163
|
+
## Stability
|
|
164
|
+
|
|
165
|
+
Until 1.0, the public API is what `sourceledger` exports in `__all__`, the keys of
|
|
166
|
+
`sources.toml`, and the format of fetch records and the fetch log. A breaking change to
|
|
167
|
+
any of them comes only in a minor version, listed in [CHANGELOG.md](CHANGELOG.md), with
|
|
168
|
+
a warning first where that is cheap. Fetch logs written by an earlier version stay
|
|
169
|
+
readable.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
uv sync
|
|
175
|
+
uv tool install prek # once per machine
|
|
176
|
+
prek install # git hooks
|
|
177
|
+
just check # lint, types and tests, as CI runs them
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sourceledger/__init__.py,sha256=Kcn3BS_q1lkS6_kxuQMAuJpWO42SguMYU5gNm9OSUH8,936
|
|
2
|
+
sourceledger/cli.py,sha256=Le7dOIeazrIEjedrsZ0ouD_Oee_9isjg5lS0FClr-iA,3279
|
|
3
|
+
sourceledger/ledger.py,sha256=nEcR5TX3D1H6VhaeKLDNZ2o_FNNbnLzvDu01ksaHm_w,27491
|
|
4
|
+
sourceledger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sourceledger-0.1.0.dist-info/licenses/LICENSE,sha256=UyQB0-mQ8EWLdthWsp_QwjcaKj1mz4BgZVTwwBeednA,1073
|
|
6
|
+
sourceledger-0.1.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
7
|
+
sourceledger-0.1.0.dist-info/entry_points.txt,sha256=cAJLbTo4_3LGZQTGDPYy4yXWCmwXytV0YVojTnV-LUM,56
|
|
8
|
+
sourceledger-0.1.0.dist-info/METADATA,sha256=5u8PMcUEtE2pUQsYBGrFrdSr_4HuEankCU4rSRnCdBo,8090
|
|
9
|
+
sourceledger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon van Lierde
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|