Skip to content

Commit bcbe5d0

Browse files
mmckyclaude
andauthored
Add generated dataset catalog (CATALOG.md + build_catalog.py) (#19)
* Add generated dataset catalog (CATALOG.md + build_catalog.py) Realises PLAN Phase 2's "index/catalog page from the manifests — doubles as the dataset registry". Migrated-only scope: a dataset appears once it has a manifest. - scripts/build_catalog.py reads lectures/*.yml and emits CATALOG.md — one row per dataset with class, source, licence + redistribution, integrity status, builder status, size and consumers (which lectures use it, linked). Generated, not hand-maintained, so it cannot drift from the manifests; a Phase 5 CI check will assert currency via `git diff --exit-code CATALOG.md`. - CATALOG.md — first generation: the 4 migrated datasets (P1 lingcod + P2 pandas_panel trio). Surfaces the honest gaps AGENTS.md promises the catalog shows: unverifiable upstream integrity, the countries.csv restricted licence (tracked in QuantEcon/workspace-lectures#20), and unrecovered builders. - requirements.txt: add PyYAML for the parser. - PLAN.md: tick the Phase 2 catalog item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * catalog: force UTF-8 when reading manifests and writing CATALOG.md Copilot review (data-lectures#19): Path.open()/write_text() used the platform default encoding. Manifests carry non-ASCII (en-dashes, curly quotes) and the catalog emits Unicode marks (checkmark/warning), so on a non-UTF-8 locale (e.g. Windows cp1252) reads/writes could raise UnicodeDecode/EncodeError. This script is headed for CI where the runner locale is not guaranteed. No output change on UTF-8 systems. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e73500f commit bcbe5d0

4 files changed

Lines changed: 210 additions & 2 deletions

File tree

CATALOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!-- GENERATED FILE — do not edit by hand. -->
2+
<!-- Regenerate: python scripts/build_catalog.py -->
3+
<!-- Source of truth: the per-dataset manifests at lectures/<file>.yml -->
4+
5+
# Dataset catalog — `QuantEcon/data-lectures`
6+
7+
The migrated-dataset registry, **auto-generated** from the sidecar manifests (`lectures/*.yml`). Do not edit by hand — run `python scripts/build_catalog.py`. A dataset appears here once it has a manifest; files not yet migrated are tracked in [PLAN.md](PLAN.md) Phase 9.
8+
9+
**4 datasets migrated** · 1.8 MB total · 3 permitted / 1 restricted redistribution
10+
11+
| Dataset | Class | Source | Licence | Redist. | Integrity | Builder | Size | Used by |
12+
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
13+
| [**countries.csv**](https://github.com/QuantEcon/data-lectures/raw/main/lectures/countries.csv)<br><sub>WorldData.info country reference table</sub> | verbatim | [WorldData.info — country data downloads](https://www.worlddata.info/downloads/) | Proprietary — © WorldData.info, all rights reserved | ⚠️ restricted | ⚠️ unverifiable | n/a (verbatim) | 48.4 KB | [lecture-python-programming · pandas_panel.md](https://github.com/QuantEcon/lecture-python-programming/blob/main/lectures/pandas_panel.md)<br>[lecture-python.myst · pandas_panel.md](https://github.com/QuantEcon/lecture-python.myst/blob/main/lectures/pandas_panel.md) |
14+
| [**employ.csv**](https://github.com/QuantEcon/data-lectures/raw/main/lectures/employ.csv)<br><sub>Eurostat employment in Europe — by age and sex, 2007–2016</sub> | constructed | [Eurostat — Employment database](https://ec.europa.eu/eurostat/data/database) | Eurostat reuse (Commission Decision 2011/833/EU) | ✅ permitted | ⚠️ unverifiable | ⚠️ unrecovered | 1.6 MB | [lecture-python-programming · pandas_panel.md](https://github.com/QuantEcon/lecture-python-programming/blob/main/lectures/pandas_panel.md)<br>[lecture-python.myst · pandas_panel.md](https://github.com/QuantEcon/lecture-python.myst/blob/main/lectures/pandas_panel.md) |
15+
| [**lingcod_msy_recovery.csv**](https://github.com/QuantEcon/data-lectures/raw/main/lectures/lingcod_msy_recovery.csv)<br><sub>Pacific Coast lingcod — biomass and fishing pressure relative to MSY</sub> | constructed | [RAM Legacy Stock Assessment Database](https://www.ramlegacy.org/) | CC BY 4.0 | ✅ permitted | ⚠️ unverifiable | ⚠️ unrecovered | 2.3 KB | [lecture-python-intro · msy_fishery.md](https://github.com/QuantEcon/lecture-python-intro/blob/main/lectures/msy_fishery.md) |
16+
| [**realwage.csv**](https://github.com/QuantEcon/data-lectures/raw/main/lectures/realwage.csv)<br><sub>OECD real minimum wages — 32 countries, 2006–2016</sub> | constructed | [OECD — Real minimum wages (RMW)](https://stats.oecd.org/Index.aspx?DataSetCode=RMW) | CC BY 4.0 | ✅ permitted | ⚠️ unverifiable | ⚠️ unrecovered | 118.7 KB | [lecture-python-programming · pandas_panel.md](https://github.com/QuantEcon/lecture-python-programming/blob/main/lectures/pandas_panel.md)<br>[lecture-python.myst · pandas_panel.md](https://github.com/QuantEcon/lecture-python.myst/blob/main/lectures/pandas_panel.md) |
17+
18+
---
19+
20+
**Legend***Integrity* is the `integrity.upstream.status` (is this what the source says?): ✅ verified · ◑ spot-checked · ⚠️ unverifiable · … unverified · ❌ failing. *Redist.* ⚠️ restricted files are cached as inherited exposures and tracked for licence review ([workspace-lectures#20](https://github.com/QuantEcon/workspace-lectures/issues/20)). *Builder* ⚠️ unrecovered marks a constructed dataset whose builder was never committed (PLAN Phase 9).

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ Ordering note: phases 1–3 and 6 can proceed now; phase 4 needs the DNS questio
5353
- [x] Rename `QuantEcon/data``data-lectures` (GitHub redirects preserve all existing URLs, so this was non-breaking)
5454
- [x] Add repo description and topics (`quantecon`, `datasets`, `economics`, `open-data`, `teaching-materials`)
5555

56-
### Phase 2 — Layout (2026-07-16 — catalog outstanding)
56+
### Phase 2 — Layout (2026-07-16)
5757

5858
- [x] Restructure consumer-keyed tree → flat published tree — `lectures/` is the published root; no folder implies series ownership. Done while zero lectures referenced the repo, so no consumer could break; **that window is now closed**
5959
- [x] Decide where non-published assets live relative to the published tree — `scripts/` and `manifest-schema.yml` sit at the root, outside `lectures/`, and are never served. Manifests are the exception: they live *inside* `lectures/` as sidecars named `<filename>.yml`, so a dataset cannot be moved or removed without its metadata, and CI can assert the pairing with a glob
60-
- [ ] Generate an index/catalog page from the manifests — doubles as the dataset registry
60+
- [x] Generate an index/catalog page from the manifests — doubles as the dataset registry. `scripts/build_catalog.py` emits `CATALOG.md` (migrated-only registry) from `lectures/*.yml`; regenerate on any manifest change, and a Phase 5 CI check will assert it is current (`git diff --exit-code`). Feeds the Pages index at Phase 4
6161

6262
The sidecar naming uses the **full filename** (`mpd2020.xlsx.yml`, not `mpd2020.yml`) because a stem-keyed sidecar collides when one dataset ships in two formats — exactly the `fig_3.xlsx` / `fig_3.ods` case this repo already had. Strawman until the pilot tests it; see `manifest-schema.yml`.
6363

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
wbgapi==1.0.12
2+
PyYAML==6.0.3 # scripts/build_catalog.py — parse the sidecar manifests

scripts/build_catalog.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""Generate CATALOG.md — the migrated-dataset registry — from the sidecar
3+
manifests in lectures/*.yml.
4+
5+
CATALOG.md is a GENERATED file: the per-dataset manifests are the source of
6+
truth, and this script is the only thing that should write the catalog. Run it
7+
after adding or editing any manifest:
8+
9+
python scripts/build_catalog.py
10+
11+
CI asserts the catalog is current with `git diff --exit-code CATALOG.md` after
12+
regenerating, so a stale catalog fails the build (PLAN Phase 5).
13+
14+
Scope: migrated-only. A dataset appears here once it has a manifest; files in
15+
lectures/ without a manifest are not yet migrated and are tracked in PLAN
16+
Phase 9, not here.
17+
"""
18+
from __future__ import annotations
19+
20+
import sys
21+
from pathlib import Path
22+
23+
import yaml
24+
25+
REPO = Path(__file__).resolve().parent.parent
26+
LECTURES = REPO / "lectures"
27+
OUT = REPO / "CATALOG.md"
28+
29+
# Interim URL form (AGENTS.md); swaps to data.quantecon.org/lectures/ at Phase 4.
30+
RAW = "https://github.com/QuantEcon/data-lectures/raw/main/lectures"
31+
32+
33+
def human_size(n: int) -> str:
34+
size = float(n)
35+
for unit in ("B", "KB", "MB", "GB"):
36+
if size < 1024 or unit == "GB":
37+
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
38+
size /= 1024
39+
return f"{size:.1f} GB"
40+
41+
42+
def oneline(text) -> str:
43+
"""Collapse a folded/multiline scalar to a single trimmed line."""
44+
if text is None:
45+
return ""
46+
return " ".join(str(text).split())
47+
48+
49+
def load_manifests():
50+
manifests = []
51+
for path in sorted(LECTURES.glob("*.yml")):
52+
with path.open(encoding="utf-8") as f:
53+
m = yaml.safe_load(f)
54+
if not isinstance(m, dict) or "filename" not in m:
55+
print(f"skip (not a manifest): {path.name}", file=sys.stderr)
56+
continue
57+
m["_manifest_path"] = path
58+
data_file = LECTURES / m["filename"]
59+
m["_size"] = data_file.stat().st_size if data_file.exists() else None
60+
manifests.append(m)
61+
return manifests
62+
63+
64+
def fmt_consumers(consumers) -> str:
65+
if not consumers:
66+
return "—"
67+
parts = []
68+
for c in consumers:
69+
repo = c.get("repo", "")
70+
short = repo.split("/")[-1] if repo else "?"
71+
file = c.get("file", "")
72+
stem = file.split("/")[-1] if file else ""
73+
if repo and file:
74+
parts.append(f"[{short} · {stem}](https://github.com/{repo}/blob/main/{file})")
75+
else:
76+
parts.append(f"{short} · {stem}".strip(" ·"))
77+
return "<br>".join(parts)
78+
79+
80+
def fmt_source(source) -> str:
81+
if not isinstance(source, dict):
82+
return oneline(source)
83+
name = oneline(source.get("name", ""))
84+
url = source.get("url")
85+
return f"[{name}]({url})" if url else name
86+
87+
88+
def fmt_redist(license) -> str:
89+
if not isinstance(license, dict):
90+
return "?"
91+
r = license.get("redistribution", "?")
92+
return {"permitted": "✅ permitted", "restricted": "⚠️ restricted"}.get(r, str(r))
93+
94+
95+
def fmt_integrity(integrity) -> str:
96+
if not isinstance(integrity, dict):
97+
return "?"
98+
up = integrity.get("upstream") or {}
99+
status = up.get("status", "?")
100+
mark = {"verified": "✅", "spot-checked": "◑", "unverifiable": "⚠️",
101+
"unverified": "…", "failing": "❌"}.get(status, "")
102+
return f"{mark} {status}".strip()
103+
104+
105+
def fmt_builder(m) -> str:
106+
if m.get("class") == "verbatim":
107+
return "n/a (verbatim)"
108+
bs = m.get("builder_status", "?")
109+
return {"committed": "✅ committed", "unrecovered": "⚠️ unrecovered",
110+
"not-applicable": "n/a"}.get(bs, str(bs))
111+
112+
113+
def build(manifests) -> str:
114+
total = sum(m["_size"] or 0 for m in manifests)
115+
permitted = sum(1 for m in manifests
116+
if (m.get("license") or {}).get("redistribution") == "permitted")
117+
restricted = len(manifests) - permitted
118+
119+
lines = []
120+
lines.append("<!-- GENERATED FILE — do not edit by hand. -->")
121+
lines.append("<!-- Regenerate: python scripts/build_catalog.py -->")
122+
lines.append("<!-- Source of truth: the per-dataset manifests at lectures/<file>.yml -->")
123+
lines.append("")
124+
lines.append("# Dataset catalog — `QuantEcon/data-lectures`")
125+
lines.append("")
126+
lines.append(
127+
"The migrated-dataset registry, **auto-generated** from the sidecar "
128+
"manifests (`lectures/*.yml`). Do not edit by hand — run "
129+
"`python scripts/build_catalog.py`. A dataset appears here once it has a "
130+
"manifest; files not yet migrated are tracked in "
131+
"[PLAN.md](PLAN.md) Phase 9."
132+
)
133+
lines.append("")
134+
lines.append(
135+
f"**{len(manifests)} datasets migrated** · {human_size(total)} total · "
136+
f"{permitted} permitted / {restricted} restricted redistribution"
137+
)
138+
lines.append("")
139+
lines.append("| Dataset | Class | Source | Licence | Redist. | Integrity | Builder | Size | Used by |")
140+
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
141+
for m in manifests:
142+
fn = m["filename"]
143+
title = oneline(m.get("title", ""))
144+
dataset = f"[**{fn}**]({RAW}/{fn})"
145+
if title:
146+
dataset += f"<br><sub>{title}</sub>"
147+
license = m.get("license") or {}
148+
row = [
149+
dataset,
150+
m.get("class", "?"),
151+
fmt_source(m.get("source")),
152+
oneline(license.get("name", "?")),
153+
fmt_redist(license),
154+
fmt_integrity(m.get("integrity")),
155+
fmt_builder(m),
156+
human_size(m["_size"]) if m["_size"] is not None else "—",
157+
fmt_consumers(m.get("consumers")),
158+
]
159+
lines.append("| " + " | ".join(row) + " |")
160+
lines.append("")
161+
lines.append("---")
162+
lines.append("")
163+
lines.append(
164+
"**Legend** — *Integrity* is the `integrity.upstream.status` (is this "
165+
"what the source says?): ✅ verified · ◑ spot-checked · ⚠️ unverifiable · "
166+
"… unverified · ❌ failing. *Redist.* ⚠️ restricted files are cached as "
167+
"inherited exposures and tracked for licence review "
168+
"([workspace-lectures#20](https://github.com/QuantEcon/workspace-lectures/issues/20)). "
169+
"*Builder* ⚠️ unrecovered marks a constructed dataset whose builder was "
170+
"never committed (PLAN Phase 9)."
171+
)
172+
lines.append("")
173+
return "\n".join(lines)
174+
175+
176+
def main() -> int:
177+
manifests = load_manifests()
178+
if not manifests:
179+
print("no manifests found under lectures/*.yml", file=sys.stderr)
180+
return 1
181+
OUT.write_text(build(manifests), encoding="utf-8")
182+
print(f"wrote {OUT.relative_to(REPO)}{len(manifests)} datasets")
183+
return 0
184+
185+
186+
if __name__ == "__main__":
187+
raise SystemExit(main())

0 commit comments

Comments
 (0)