sourceledger 0.1.0__tar.gz

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.
@@ -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.
@@ -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
+ [![python](https://github.com/simonvanlierde/sourceledger/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/sourceledger/actions/workflows/python.yml)
23
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](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,163 @@
1
+ # sourceledger
2
+
3
+ [![python](https://github.com/simonvanlierde/sourceledger/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/sourceledger/actions/workflows/python.yml)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Build a dataset from public sources where every row says which download it came from.
7
+
8
+ A project lists its inputs in `sources.toml`: each source's URL, the publisher's reuse
9
+ terms, and whether the raw file may be republished. sourceledger runs four stages over
10
+ that manifest:
11
+
12
+ - `fetch` downloads each source into `data/raw/`, with a record beside it: the URL, when
13
+ it was fetched, the SHA-256 and size of the bytes, the vintage the manifest declares,
14
+ and the server's ETag and Last-Modified. The next fetch sends the ETag back, so an
15
+ unchanged source is not downloaded again. Each new version of a source is also
16
+ appended to `data/fetches.jsonl`.
17
+ - `build` re-hashes each download against its record, reads it, and writes
18
+ `data/processed/<id>.csv` with five provenance columns in front of the source's own:
19
+ `source_id`, `source_url`, `retrieved_at`, `vintage` and `raw_sha256`.
20
+ - `validate` re-reads the output and reports every problem it finds: output built from
21
+ an older fetch, a raw file changed since its fetch, a record made for another URL,
22
+ vintage or pin, a source with no reuse terms, an output with no rows, and output left
23
+ by a source that is gone or marked unimplemented.
24
+ - `revisions OLD_LOG` lists the sources whose bytes changed since an earlier copy of the
25
+ fetch log. A weekly CI run can use it to say when a publisher revised its data.
26
+
27
+ sourceledger has no dependencies: the standard library does all of it.
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ uv add sourceledger
33
+ ```
34
+
35
+ Until the first release is on PyPI, install it from GitHub:
36
+ `uv add git+https://github.com/simonvanlierde/sourceledger`.
37
+
38
+ ## Quick start
39
+
40
+ Write a `sources.toml` in the project root:
41
+
42
+ ```toml
43
+ [[source]]
44
+ id = "co2"
45
+ status = "implemented"
46
+ url = "https://raw.githubusercontent.com/datasets/co2-ppm/main/data/co2-annmean-mlo.csv"
47
+ vintage = "main branch, unversioned"
48
+ terms = "ODC-PDDL-1.0 (datasets/co2-ppm package; original data from NOAA GML)"
49
+ terms_url = "https://opendatacommons.org/licenses/pddl/"
50
+ license = "PDDL-1.0"
51
+ redistribute = true
52
+ ```
53
+
54
+ Then run the stages from that directory:
55
+
56
+ ```sh
57
+ sourceledger fetch
58
+ sourceledger build
59
+ sourceledger validate
60
+ ```
61
+
62
+ `sourceledger` reads every source as CSV. For other formats, see
63
+ [Your own readers](#your-own-readers).
64
+
65
+ ## What to commit
66
+
67
+ Commit `sources.toml` and `data/fetches.jsonl`. The log holds checksums and URLs, not
68
+ data, and it is the history of every version of every source you have fetched.
69
+
70
+ Keep `data/raw/` and `data/processed/` out of git, so the repository never
71
+ redistributes a publisher's data. The per-file `.fetch.json` records live in
72
+ `data/raw/` too. A fresh clone runs `fetch` before `build` and `validate`.
73
+
74
+ ## sources.toml
75
+
76
+ Each `[[source]]` table takes these keys:
77
+
78
+ | Key | Required | Meaning |
79
+ | --- | --- | --- |
80
+ | `id` | yes | Letters, digits, `_` and `-`. Names the raw file (`id` plus the URL's extension) and the output. |
81
+ | `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. |
82
+ | `url` | for implemented sources | What `fetch` downloads. Change it and `build` refuses the old download until you fetch again. |
83
+ | `terms`, `terms_url` | for implemented sources | The publisher's reuse terms in their words, and where they are stated. |
84
+ | `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. |
85
+ | `redistribute` | for implemented sources | `true` if the terms let you republish the raw file, `false` if not. |
86
+ | `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. |
87
+ | `sha256` | no | The SHA-256 the download must have. Pin it for a fixed release, and `fetch` refuses bytes that changed upstream. |
88
+ | `max_bytes` | no | The largest download `fetch` accepts. 200 MiB unless set. |
89
+ | `reason` | no | A note for people, such as why a source is unimplemented. sourceledger doesn't read it. |
90
+
91
+ `validate` reports a missing `terms`, `terms_url`, `license` or `redistribute`. The other
92
+ keys are checked when the manifest loads.
93
+
94
+ ## How fetch behaves
95
+
96
+ - It tries four times, waiting 5, 10 and 20 s between attempts, on 429, 500, 502, 503,
97
+ 504, network errors and a body cut short. A `Retry-After` header, in seconds or as a
98
+ date, raises the wait to at most 120 s. Any other error status fails at once.
99
+ - Each connect and read times out after 30 s, and one source's whole fetch after 300 s.
100
+ - It asks for the file uncompressed, and refuses a compressed reply. The checksum is
101
+ always of the file as published.
102
+ - It refuses a redirect from a public host to a loopback, private or link-local
103
+ address. When a redirect is followed, the record keeps the final URL too.
104
+ - Every source is tried. When some fail, the others are still written, and the run
105
+ exits 1 with one line per failed source.
106
+ - Each file is written to a new temporary file and renamed into place, so a crash never
107
+ leaves half a file. A fetch log line cut off by a crash is dropped on the next run.
108
+
109
+ ## Your own readers
110
+
111
+ `sourceledger` reads every source with `parse_csv`. A project with other formats gives
112
+ it its own reader, a function from a source's manifest table and raw bytes to columns
113
+ and rows, and its own command:
114
+
115
+ ```python
116
+ # src/my_dataset/pipeline.py
117
+ import sourceledger
118
+ from sourceledger import Source
119
+
120
+
121
+ def parse(source: Source, payload: bytes) -> tuple[list[str], list[dict[str, str]]]:
122
+ """Every source is CSV here. Branch on source["id"] for one that is not."""
123
+ return sourceledger.parse_csv(source, payload)
124
+
125
+
126
+ def main(argv: list[str] | None = None) -> int:
127
+ return sourceledger.main(argv, parse=parse, prog="my-dataset", user_agent="my-dataset")
128
+ ```
129
+
130
+ ```toml
131
+ # pyproject.toml
132
+ [project.scripts]
133
+ my-dataset = "my_dataset.pipeline:main"
134
+ ```
135
+
136
+ `my-dataset fetch`, `my-dataset build` and `my-dataset validate` then run with that
137
+ reader, and publishers see `my-dataset` as the User-Agent. The
138
+ [project-templates](https://github.com/simonvanlierde/project-templates) `data-pipeline`
139
+ kind sets all of this up, with CI and a weekly run that reports revised sources.
140
+
141
+ The same stages are functions too: `load_manifest`, `fetch`, `build`, `validate` and
142
+ `revisions`.
143
+
144
+ ## Stability
145
+
146
+ Until 1.0, the public API is what `sourceledger` exports in `__all__`, the keys of
147
+ `sources.toml`, and the format of fetch records and the fetch log. A breaking change to
148
+ any of them comes only in a minor version, listed in [CHANGELOG.md](CHANGELOG.md), with
149
+ a warning first where that is cheap. Fetch logs written by an earlier version stay
150
+ readable.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ uv sync
156
+ uv tool install prek # once per machine
157
+ prek install # git hooks
158
+ just check # lint, types and tests, as CI runs them
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,108 @@
1
+ [project]
2
+ name = "sourceledger"
3
+ version = "0.1.0"
4
+ description = "Fetch, verify and validate the public sources a dataset is built from, with a provenance record for every download"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "provenance",
11
+ "data pipeline",
12
+ "reproducibility",
13
+ "open data",
14
+ "research software",
15
+ "FAIR",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Scientific/Engineering",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [[project.authors]]
27
+ name = "Simon van Lierde"
28
+ email = "90462640+simonvanlierde@users.noreply.github.com"
29
+
30
+ [project.scripts]
31
+ sourceledger = "sourceledger.cli:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/simonvanlierde/sourceledger"
35
+ Issues = "https://github.com/simonvanlierde/sourceledger/issues"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.1.1",
40
+ "pytest-cov>=7.1.0",
41
+ "ruff>=0.16.10",
42
+ "ty>=0.0.84",
43
+ ]
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.12.19,<0.14.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [tool.uv]
50
+ exclude-newer = "1 week"
51
+
52
+ [tool.uv.build-backend]
53
+ module-name = "sourceledger"
54
+
55
+ [tool.ruff]
56
+ line-length = 120
57
+
58
+ [tool.ruff.format]
59
+ docstring-code-format = true
60
+
61
+ [tool.ruff.lint]
62
+ select = ["ALL"]
63
+ ignore = [
64
+ "ANN204",
65
+ "COM812",
66
+ "CPY001",
67
+ "D105",
68
+ "D107",
69
+ "TD002",
70
+ "TD003",
71
+ ]
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ "**/tests/**" = [
75
+ "S101",
76
+ "D1",
77
+ "INP001",
78
+ "PLR2004",
79
+ "ANN201",
80
+ ]
81
+ "src/sourceledger/cli.py" = ["T201"]
82
+
83
+ [tool.ruff.lint.pydocstyle]
84
+ convention = "google"
85
+
86
+ [tool.ruff.lint.pylint]
87
+ allow-magic-value-types = ["int"]
88
+ max-args = 10
89
+
90
+ [tool.pytest.ini_options]
91
+ addopts = [
92
+ "-ra",
93
+ "--strict-config",
94
+ "--strict-markers",
95
+ "--import-mode=importlib",
96
+ ]
97
+ testpaths = ["tests"]
98
+
99
+ [tool.coverage.run]
100
+ source = ["src"]
101
+ branch = true
102
+
103
+ [tool.coverage.report]
104
+ show_missing = true
105
+ exclude_also = [
106
+ "if TYPE_CHECKING:",
107
+ "raise NotImplementedError",
108
+ ]
@@ -0,0 +1,99 @@
1
+ [project]
2
+ name = "sourceledger"
3
+ version = "0.1.0"
4
+ description = "Fetch, verify and validate the public sources a dataset is built from, with a provenance record for every download"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "Simon van Lierde", email = "90462640+simonvanlierde@users.noreply.github.com" }]
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ keywords = ["provenance", "data pipeline", "reproducibility", "open data", "research software", "FAIR"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Science/Research",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Scientific/Engineering",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = []
19
+
20
+ # `sourceledger fetch|build|validate|revisions`, reading every source as CSV. A project
21
+ # with its own readers calls `sourceledger.main` from its own entry point instead.
22
+ [project.scripts]
23
+ sourceledger = "sourceledger.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/simonvanlierde/sourceledger"
27
+ Issues = "https://github.com/simonvanlierde/sourceledger/issues"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.1.1",
32
+ "pytest-cov>=7.1.0",
33
+ "ruff>=0.16.10",
34
+ "ty>=0.0.84",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.12.19,<0.14.0"]
39
+ build-backend = "uv_build"
40
+
41
+ # Resolve only releases at least a week old: a compromised release is usually
42
+ # caught and yanked within that window. Dependabot's cooldown waits the same. Set here,
43
+ # not user-wide, so every machine writes the same uv.lock.
44
+ [tool.uv]
45
+ exclude-newer = "1 week"
46
+
47
+ # uv_build derives the package dir from the normalized project.name, so pin it
48
+ # for a module_name that differs.
49
+ [tool.uv.build-backend]
50
+ module-name = "sourceledger"
51
+
52
+ # NOTE: self-contained ruff config, deliberately not extended from any shared base.
53
+ # No target-version: ruff derives it from requires-python above.
54
+ [tool.ruff]
55
+ line-length = 120
56
+
57
+ [tool.ruff.format]
58
+ docstring-code-format = true
59
+
60
+ [tool.ruff.lint]
61
+ select = ["ALL"]
62
+ ignore = [
63
+ "ANN204", # missing-return-type-special-method
64
+ "COM812", # trailing-comma-missing: conflicts with the formatter
65
+ "CPY001", # missing copyright notice
66
+ "D105", # undocumented-magic-method
67
+ "D107", # undocumented-public-init
68
+ "TD002", # missing-todo-author: a plain `# TODO:` is enough
69
+ "TD003", # missing-todo-link
70
+ ]
71
+
72
+ # Tests assert, use literal expectations, and need no docstrings, return
73
+ # annotations, or package __init__.py.
74
+ [tool.ruff.lint.per-file-ignores]
75
+ "**/tests/**" = ["S101", "D1", "INP001", "PLR2004", "ANN201"]
76
+ # The command line reports to the terminal.
77
+ "src/sourceledger/cli.py" = ["T201"]
78
+
79
+ [tool.ruff.lint.pydocstyle]
80
+ convention = "google"
81
+
82
+ [tool.ruff.lint.pylint]
83
+ allow-magic-value-types = ["int"]
84
+ max-args = 10
85
+
86
+ [tool.pytest.ini_options]
87
+ addopts = ["-ra", "--strict-config", "--strict-markers", "--import-mode=importlib"]
88
+ testpaths = ["tests"]
89
+
90
+ # Coverage is opt-in via `pytest --cov` (`just py-cov`), not addopts, because it
91
+ # slows every plain test run. No fail_under until there is a suite worth setting
92
+ # one against.
93
+ [tool.coverage.run]
94
+ source = ["src"]
95
+ branch = true
96
+
97
+ [tool.coverage.report]
98
+ show_missing = true
99
+ exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
@@ -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
+ ]
@@ -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 []
@@ -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)
File without changes