Skip to content

Commit 8749f33

Browse files
committed
refactor: govdata
1 parent 214e308 commit 8749f33

28 files changed

Lines changed: 1274 additions & 107 deletions

README.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,50 @@ Connectors for German open government data, built on [mloda](https://github.com/
1010

1111
Three example datasets cover the M1 themes: population (GovData CSV), elections (Bundeswahlleiterin `kerg.csv`), and environment (UBA Air Data JSON).
1212

13+
## Status
14+
15+
Young but working. All three example readers run end to end, with paginated dataset search, cached downloads with retries, and unit plus property-based tests behind them. Every reader is a thin subclass of `BaseGovDataReader` that overrides only the parse step; new datasets follow the same path (see [docs/adding-a-reader.md](docs/adding-a-reader.md)). Development happens in a 6-month Prototype Fund stage (June to November 2026), so the API may still shift between releases.
16+
1317
## Usage
1418

1519
Read the Stuttgart population dataset (via GovData) as a typed PyArrow table:
1620

1721
```python
1822
from mloda.user import Feature, mloda
19-
from mloda_plugin_govdata.feature_groups.govdata import GovDataReader
23+
from mloda_plugin_govdata.feature_groups.govdata import StuttgartPopulationReader
2024

2125
slug = "einwohner-nach-altersgruppen-und-stadtbezirken"
2226
result = mloda.run_all(
2327
[
24-
Feature("Einwohner", options={GovDataReader.__name__: slug}),
25-
Feature("Stadtbezirk", options={GovDataReader.__name__: slug}),
28+
Feature("Einwohner", options={StuttgartPopulationReader.__name__: slug}),
29+
Feature("Stadtbezirk", options={StuttgartPopulationReader.__name__: slug}),
2630
],
2731
compute_frameworks=["PyArrowTable"],
2832
)
2933
table = result[0] # pyarrow.Table with the requested columns
3034
```
3135

32-
The option value is a GovData dataset slug or a direct distribution URL. The license is read from the CKAN distribution metadata. Set `GovDataReader.cache_dir` to control where downloads are cached.
36+
The option value is a GovData dataset slug or a direct distribution URL. The license is read from the CKAN distribution metadata. Set `BaseGovDataReader.cache_dir` to control where downloads are cached. For any other GovData CSV dataset, `GovDataReader` works out of the box and reads every column as a string; subclass it and set `schema` for typed columns.
37+
38+
Don't know the slug yet? Search GovData with the paginated CKAN `package_search` API:
39+
40+
```python
41+
from mloda_plugin_govdata.feature_groups.govdata import build_client, search_datasets
42+
43+
with build_client() as client:
44+
for dataset in search_datasets(client, "einwohner stuttgart", max_results=10):
45+
print(dataset.name, "|", dataset.title)
46+
```
47+
48+
`search_datasets` walks the result pages lazily (`page_size` per request) and stops at `max_results` or the end of the result set.
49+
50+
Got a slug but not the column names? `peek` lists what you can request as features:
51+
52+
```python
53+
StuttgartPopulationReader.peek(slug) # {"Stichtag": "date32[day]", "Stadtbezirk": "string", ...}
54+
```
55+
56+
It works on every reader (`BundeswahlleiterinReader.peek(kerg)`, `UbaAirReader.peek(url)`) and downloads through the cache, so the actual feature request reuses the file. A typo in a feature name fails with the available columns and a close-match suggestion instead of a raw KeyError.
3357

3458
The elections reader handles a direct CSV URL whose file has a multi-row merged header (Bundeswahlleiterin `kerg.csv`):
3559

@@ -60,11 +84,24 @@ result = mloda.run_all(
6084

6185
Columns are `station_id`, `date_start`, `component_id`, `scope_id`, `value`, `date_end`, and `index` (the air-quality index). Component and scope ids come from the UBA `components` and `scopes` endpoints.
6286

87+
## Demo
88+
89+
An interactive [marimo](https://marimo.io) notebook walks through dataset discovery and all three example datasets. The notebook lives in the repository (not in the published package), so run it from a source checkout:
90+
91+
```bash
92+
git clone https://github.com/TomKaltofen/mloda-plugin-govdata.git
93+
cd mloda-plugin-govdata
94+
uv sync --all-extras
95+
uv run marimo edit demos/govdata_demo.py
96+
```
97+
98+
The notebook hits the live GovData, Bundeswahlleiterin, and UBA endpoints; downloads are cached locally after the first run.
99+
63100
## Related Repositories
64101

65-
- **[mloda](https://github.com/mloda-ai/mloda)**: The core library for open data access. Declaratively define what data you need, not how to get it. mloda handles feature resolution, dependency management, and compute framework abstraction automatically.
102+
- **[mloda](https://github.com/mloda-ai/mloda)**: the core library this plugin builds on. You declare which features you need; mloda resolves how to compute them.
66103

67-
- **[mloda-registry](https://github.com/mloda-ai/mloda-registry)**: The central hub for discovering and sharing mloda plugins. Browse community-contributed FeatureGroups, find integration guides, and publish your own plugins for others to use.
104+
- **[mloda-registry](https://github.com/mloda-ai/mloda-registry)**: plugin registry and development guides for the mloda ecosystem.
68105

69106
## Funding
70107

demos/govdata_demo.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Marimo demo: discover GovData datasets and read the three M1 example datasets.
2+
3+
Run with: marimo edit demos/govdata_demo.py (needs network access; install the
4+
"demo" extra for marimo itself).
5+
"""
6+
7+
import marimo
8+
9+
__generated_with = "0.14.16"
10+
app = marimo.App(width="medium")
11+
12+
13+
@app.cell
14+
def _():
15+
import marimo as mo
16+
17+
return (mo,)
18+
19+
20+
@app.cell
21+
def _(mo):
22+
mo.md(
23+
"""
24+
# mloda-plugin-govdata demo
25+
26+
German open government data as mloda features: search GovData via the
27+
paginated CKAN API, then read the three M1 example datasets (population,
28+
elections, environment) as typed Arrow tables. Every cell below talks to
29+
the live endpoints; downloads are cached locally after the first run.
30+
31+
Part of the Prototype Fund project mloda-plugin-govdata (FKZ 16IS26S11).
32+
"""
33+
)
34+
return
35+
36+
37+
@app.cell
38+
def _():
39+
import pandas as pd
40+
41+
from mloda.user import Feature, mloda
42+
from mloda_plugin_govdata.feature_groups.govdata import (
43+
BundeswahlleiterinReader,
44+
StuttgartPopulationReader,
45+
UbaAirReader,
46+
build_client,
47+
search_datasets,
48+
uba_measures_url,
49+
)
50+
51+
return (
52+
BundeswahlleiterinReader,
53+
Feature,
54+
StuttgartPopulationReader,
55+
UbaAirReader,
56+
build_client,
57+
mloda,
58+
pd,
59+
search_datasets,
60+
uba_measures_url,
61+
)
62+
63+
64+
@app.cell
65+
def _(mo):
66+
mo.md("""## 1. Discover datasets (paginated CKAN `package_search`)""")
67+
return
68+
69+
70+
@app.cell
71+
def _(mo):
72+
query = mo.ui.text(value="einwohner stuttgart altersgruppen", label="GovData search", full_width=True)
73+
query
74+
return (query,)
75+
76+
77+
@app.cell
78+
def _(build_client, pd, query, search_datasets):
79+
with build_client() as _client:
80+
_hits = list(search_datasets(_client, query.value, max_results=10))
81+
hits = pd.DataFrame({"name": [d.name for d in _hits], "title": [d.title for d in _hits]})
82+
hits
83+
return
84+
85+
86+
@app.cell
87+
def _(mo):
88+
mo.md("""## 2. Population: Stuttgart residents by age group (GovData CSV)""")
89+
return
90+
91+
92+
@app.cell
93+
def _(Feature, StuttgartPopulationReader, mloda):
94+
_slug = "einwohner-nach-altersgruppen-und-stadtbezirken"
95+
_result = mloda.run_all(
96+
[
97+
Feature("Stichtag", options={StuttgartPopulationReader.__name__: _slug}),
98+
Feature("Stadtbezirk", options={StuttgartPopulationReader.__name__: _slug}),
99+
Feature("Alter in 10 Gruppen", options={StuttgartPopulationReader.__name__: _slug}),
100+
Feature("Einwohner", options={StuttgartPopulationReader.__name__: _slug}),
101+
],
102+
compute_frameworks=["PyArrowTable"],
103+
)
104+
population = _result[0].to_pandas()
105+
population
106+
return
107+
108+
109+
@app.cell
110+
def _(mo):
111+
mo.md("""## 3. Elections: Bundestagswahl 2025 results (`kerg.csv`, merged header)""")
112+
return
113+
114+
115+
@app.cell
116+
def _(BundeswahlleiterinReader, Feature, mloda):
117+
_kerg = "https://www.bundeswahlleiterin.de/bundestagswahlen/2025/ergebnisse/opendata/btw25/csv/kerg.csv"
118+
_result = mloda.run_all(
119+
[Feature("Gebiet", options={BundeswahlleiterinReader.__name__: _kerg})],
120+
compute_frameworks=["PyArrowTable"],
121+
)
122+
elections = _result[0].to_pandas()
123+
elections.head(20)
124+
return
125+
126+
127+
@app.cell
128+
def _(mo):
129+
mo.md("""## 4. Environment: hourly ozone at one station (UBA Air Data JSON)""")
130+
return
131+
132+
133+
@app.cell
134+
def _(Feature, UbaAirReader, mloda, uba_measures_url):
135+
_url = uba_measures_url(station=143, component=3, scope=2, date_from="2025-01-01", date_to="2025-01-01")
136+
_result = mloda.run_all(
137+
[
138+
Feature("date_start", options={UbaAirReader.__name__: _url}),
139+
Feature("value", options={UbaAirReader.__name__: _url}),
140+
],
141+
compute_frameworks=["PyArrowTable"],
142+
)
143+
environment = _result[0].to_pandas()
144+
environment
145+
return
146+
147+
148+
if __name__ == "__main__":
149+
app.run()

docs/adding-a-reader.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Adding a Reader
2+
3+
`BaseGovDataReader` (in `mloda_plugin_govdata/feature_groups/govdata/reader.py`) does the plumbing: locator coercion, CKAN discovery, cached download with retries, and column selection. Subclasses implement `_parse` (or, for plain CSVs, just set `schema` on a `GovDataReader` subclass), plus `suffix` when the payload is not CSV. Each data source lives in its own module (`population.py`, `bundeswahlleiterin.py`, `uba.py`); shared source-agnostic code (client, cache, discovery, locator, CSV parsing) lives in `core/`.
4+
5+
To connect a new dataset:
6+
7+
1. Check whether `GovDataReader` already handles it. A GovData slug or a direct CSV URL with a regular single-row header needs no code; every column is read as a string. For typed columns, subclass `GovDataReader` in a new module and set `schema` (see `population.py`).
8+
2. For a different payload shape, subclass `BaseGovDataReader` and implement `_parse` (and `suffix` for non-CSV payloads); `bundeswahlleiterin.py` and `uba.py` show the pattern.
9+
3. Keep source-specific parse logic in the source's module as a `parse_*_bytes` function plus a path wrapper, like `uba.py`. Generic parsing belongs in `core/parse.py`. That keeps it testable from fixture files without network access.
10+
4. Add tests under `mloda_plugin_govdata/feature_groups/govdata/tests/` with a small real sample in `tests/fixtures/`.
11+
5. Export the reader from `feature_groups/govdata/__init__.py` and add a usage snippet to the README.
12+
6. Run `tox` (pytest, ruff, mypy strict, bandit); it must pass before a PR.

mloda_plugin_govdata/compute_frameworks/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

mloda_plugin_govdata/extenders/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
1-
"""GovData connector: read German open-government CSV distributions into mloda."""
1+
"""GovData connector: read German open-government data into mloda."""
22

3-
from .discovery import Dataset, ResolvedDistribution, Resource, normalize_license, resolve_distribution
4-
from .locator import GovDataLocator
5-
from .parse import ColumnType, parse_german_csv, parse_german_csv_bytes, parse_multi_header_csv
6-
from .reader import BundeswahlleiterinReader, GovDataFeature, GovDataReader, UbaAirReader
7-
from .uba import UBA_AIR_BASE, parse_uba_measures, parse_uba_measures_bytes, uba_measures_url
3+
from .bundeswahlleiterin import BundeswahlleiterinReader
4+
from .core.client import build_client
5+
from .core.discovery import (
6+
Dataset,
7+
ResolvedDistribution,
8+
Resource,
9+
normalize_license,
10+
resolve_distribution,
11+
search_datasets,
12+
)
13+
from .core.locator import GovDataLocator
14+
from .core.parse import ColumnType, parse_german_csv, parse_german_csv_bytes, parse_multi_header_csv
15+
from .feature import GovDataFeature
16+
from .population import POPULATION_SCHEMA, POPULATION_SLUG, StuttgartPopulationReader
17+
from .reader import BaseGovDataReader, GovDataReader
18+
from .uba import UBA_AIR_BASE, UbaAirReader, parse_uba_measures, parse_uba_measures_bytes, uba_measures_url
819

920
__all__ = [
21+
"POPULATION_SCHEMA",
22+
"POPULATION_SLUG",
1023
"UBA_AIR_BASE",
24+
"BaseGovDataReader",
1125
"BundeswahlleiterinReader",
1226
"ColumnType",
1327
"Dataset",
@@ -16,13 +30,16 @@
1630
"GovDataReader",
1731
"Resource",
1832
"ResolvedDistribution",
33+
"StuttgartPopulationReader",
1934
"UbaAirReader",
35+
"build_client",
2036
"normalize_license",
2137
"parse_german_csv",
2238
"parse_german_csv_bytes",
2339
"parse_multi_header_csv",
2440
"parse_uba_measures",
2541
"parse_uba_measures_bytes",
2642
"resolve_distribution",
43+
"search_datasets",
2744
"uba_measures_url",
2845
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Bundeswahlleiterin federal election results (M1 elections theme, kerg.csv)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pyarrow as pa
8+
9+
from .core.discovery import ResolvedDistribution
10+
from .core.locator import GovDataLocator
11+
from .core.parse import ColumnType, parse_multi_header_csv
12+
from .reader import BaseGovDataReader
13+
14+
15+
class BundeswahlleiterinReader(BaseGovDataReader):
16+
"""Reads the Bundeswahlleiterin kerg.csv result file (5-line preamble, 3-row merged header)."""
17+
18+
@classmethod
19+
def _parse(cls, path: Path, locator: GovDataLocator, distribution: ResolvedDistribution) -> pa.Table:
20+
return parse_multi_header_csv(path, skiprows=5, header_rows=3, label_columns=4, value_type=ColumnType.INTEGER)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Source-agnostic plumbing: HTTP client, download cache, CKAN discovery, locator, CSV parsing."""

mloda_plugin_govdata/feature_groups/govdata/cache.py renamed to mloda_plugin_govdata/feature_groups/govdata/core/cache.py

File renamed without changes.

mloda_plugin_govdata/feature_groups/govdata/client.py renamed to mloda_plugin_govdata/feature_groups/govdata/core/client.py

File renamed without changes.

0 commit comments

Comments
 (0)