dclimate-tabular-py 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.
- dclimate_tabular_py-0.1.0/.gitignore +11 -0
- dclimate_tabular_py-0.1.0/PKG-INFO +126 -0
- dclimate_tabular_py-0.1.0/README.md +110 -0
- dclimate_tabular_py-0.1.0/pyproject.toml +88 -0
- dclimate_tabular_py-0.1.0/scripts/capture_fixture.py +106 -0
- dclimate_tabular_py-0.1.0/tabular_py/__init__.py +245 -0
- dclimate_tabular_py-0.1.0/tabular_py/codec.py +114 -0
- dclimate_tabular_py-0.1.0/tabular_py/errors.py +85 -0
- dclimate_tabular_py-0.1.0/tabular_py/geo.py +167 -0
- dclimate_tabular_py-0.1.0/tabular_py/geo_index.py +463 -0
- dclimate_tabular_py-0.1.0/tabular_py/model.py +422 -0
- dclimate_tabular_py-0.1.0/tabular_py/predicate.py +317 -0
- dclimate_tabular_py-0.1.0/tabular_py/reader.py +691 -0
- dclimate_tabular_py-0.1.0/tabular_py/source.py +212 -0
- dclimate_tabular_py-0.1.0/tabular_py/station_dataset.py +571 -0
- dclimate_tabular_py-0.1.0/tabular_py/station_index.py +161 -0
- dclimate_tabular_py-0.1.0/tabular_py/wire.py +550 -0
- dclimate_tabular_py-0.1.0/tests/conftest.py +49 -0
- dclimate_tabular_py-0.1.0/tests/fixtures/ghcnd_sample.json +30 -0
- dclimate_tabular_py-0.1.0/tests/test_codec.py +97 -0
- dclimate_tabular_py-0.1.0/tests/test_conformance.py +139 -0
- dclimate_tabular_py-0.1.0/tests/test_geo.py +131 -0
- dclimate_tabular_py-0.1.0/tests/test_predicate.py +183 -0
- dclimate_tabular_py-0.1.0/tests/test_reader.py +235 -0
- dclimate_tabular_py-0.1.0/tests/test_wire.py +124 -0
- dclimate_tabular_py-0.1.0/uv.lock +1273 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dclimate-tabular-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Content-addressed tabular/telemetry manifest for climate data on IPFS (dclimate-tabular/0)
|
|
5
|
+
Project-URL: Homepage, https://github.com/dClimate/tabular-py
|
|
6
|
+
Project-URL: Repository, https://github.com/dClimate/tabular-py
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: blake3>=0.4.1
|
|
10
|
+
Requires-Dist: dag-cbor>=0.3.3
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: multiformats[full]>=0.3.1.post4
|
|
13
|
+
Requires-Dist: py-hamt>=3.6.0
|
|
14
|
+
Requires-Dist: pyarrow>=18.0.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# tabular-py
|
|
18
|
+
|
|
19
|
+
Python reader for `dclimate-tabular/0` — content-addressed station/telemetry data on IPFS.
|
|
20
|
+
|
|
21
|
+
The Python counterpart to [`tabular-js`](https://github.com/dClimate/tabular-js), reading the
|
|
22
|
+
same format from the same CIDs. Built on [`py-hamt`](https://github.com/dClimate/py-hamt),
|
|
23
|
+
which supplies the HAMT and the content-addressed store the same way
|
|
24
|
+
`@dclimate/ipld-index` does for JS.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
tabular-js -> @dclimate/ipld-index (HAMT, CAS, range reads)
|
|
28
|
+
tabular-py -> py-hamt (same, already existed)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Status
|
|
32
|
+
|
|
33
|
+
**Reader only.** Publishing, compaction, and rollup stay in `tabular-js` — the ETL that
|
|
34
|
+
writes these datasets is JS, and a second writer would be a second thing to keep
|
|
35
|
+
byte-identical for no current gain. Everything needed to *read* a dataset published by
|
|
36
|
+
`tabular-js` is here.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv pip install dclimate-tabular-py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from tabular_py import GatewayRangeSource, StationDataset
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
source = GatewayRangeSource("https://ipfs-gateway.dclimate.net")
|
|
52
|
+
ds = await StationDataset.open(source, "bafyr4if2wbttslbxpzmro427j4l4nvcrxqo4tufuffqqmqz7afj2pxyu4a")
|
|
53
|
+
|
|
54
|
+
# nearest station to a point, then a year of readings
|
|
55
|
+
near = await ds.nearest(34.05, -118.24, max_km=100)
|
|
56
|
+
rows = await near.time_range("2024-01-01", "2024-12-31").elements("PRCP").rows()
|
|
57
|
+
for row in rows[:5]:
|
|
58
|
+
print(row.station_id, row.ts, row.values)
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The chainable selection API mirrors `tabular-js`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
ds.select("USW00023174") # explicit station ids
|
|
67
|
+
ds.circle(34.05, -118.24, 50) # within 50 km
|
|
68
|
+
ds.rectangle(33.0, -119.0, 35.0, -117.0)
|
|
69
|
+
ds.polygon([[(lon, lat), ...]])
|
|
70
|
+
await ds.nearest(lat, lon) # async: reads the geo index
|
|
71
|
+
ds.time_range(start, end)
|
|
72
|
+
ds.elements("PRCP", "TMAX")
|
|
73
|
+
ds.where(gt("TMAX", 300)) # pushed down to fragment statistics
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Selections are immutable — each call returns a new `StationDataset`, so a base dataset can
|
|
77
|
+
be reused across queries.
|
|
78
|
+
|
|
79
|
+
Terminal operations:
|
|
80
|
+
|
|
81
|
+
| Call | Returns |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `await ds.rows()` | `list[ResultRow]` |
|
|
84
|
+
| `await ds.to_records()` | `list[dict]` — `station_id`, `time`, `values` |
|
|
85
|
+
| `await ds.to_records("TMAX")` | `list[dict]` — `station_id`, `time`, `value` |
|
|
86
|
+
| `await ds.to_arrow()` | `pyarrow.Table` |
|
|
87
|
+
| `await ds.plan()` | `QueryPlan` — what *would* be fetched, without fetching |
|
|
88
|
+
| `await ds.list_stations()` | `list[StationInfo]` |
|
|
89
|
+
|
|
90
|
+
## How reads stay small
|
|
91
|
+
|
|
92
|
+
A query never scans the dataset. Three things prune before any Parquet byte is fetched:
|
|
93
|
+
|
|
94
|
+
1. **The station index** (a HAMT keyed by station id) resolves named stations directly.
|
|
95
|
+
2. **The geo projection** answers region queries by reading one or two shard blocks
|
|
96
|
+
instead of walking all 132k stations.
|
|
97
|
+
3. **Fragment statistics in the manifest** — per-column min/max and null counts — let a
|
|
98
|
+
predicate skip whole fragments unread.
|
|
99
|
+
|
|
100
|
+
What survives is fetched with HTTP range requests against the exact column-chunk byte
|
|
101
|
+
ranges the manifest records, so a query for one column of one year moves kilobytes.
|
|
102
|
+
|
|
103
|
+
### One deviation from `tabular-js`, and why
|
|
104
|
+
|
|
105
|
+
`tabular-js` synthesizes Parquet `FileMetaData` client-side from the manifest and reads a
|
|
106
|
+
fragment with **zero** footer fetches. PyArrow exposes no public `FileMetaData`
|
|
107
|
+
constructor, so that trick does not transfer.
|
|
108
|
+
|
|
109
|
+
Instead this reader fetches the footer by its manifest-recorded
|
|
110
|
+
`footer_offset`/`footer_length` in a single ranged GET, verifies it against the
|
|
111
|
+
manifest's `footer_digest`, and hands the parsed metadata to PyArrow. Cost is one extra
|
|
112
|
+
range request per fragment — and it buys a corruption check `tabular-js` does not
|
|
113
|
+
perform. Footers are cached per fragment CID, so a repeated query pays it once.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
uv sync
|
|
119
|
+
uv run pytest # unit tests, no network
|
|
120
|
+
uv run pytest -m network # conformance against the live gateway
|
|
121
|
+
uv run ruff check . && uv run mypy tabular_py
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`tests/test_conformance.py` reads a real published GHCNd dataset
|
|
125
|
+
(`bafyr4if2wbttslbxpzmro427j4l4nvcrxqo4tufuffqqmqz7afj2pxyu4a`, 132,437 stations,
|
|
126
|
+
1.15 B rows) and asserts this implementation agrees with `tabular-js` on decoded values.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# tabular-py
|
|
2
|
+
|
|
3
|
+
Python reader for `dclimate-tabular/0` — content-addressed station/telemetry data on IPFS.
|
|
4
|
+
|
|
5
|
+
The Python counterpart to [`tabular-js`](https://github.com/dClimate/tabular-js), reading the
|
|
6
|
+
same format from the same CIDs. Built on [`py-hamt`](https://github.com/dClimate/py-hamt),
|
|
7
|
+
which supplies the HAMT and the content-addressed store the same way
|
|
8
|
+
`@dclimate/ipld-index` does for JS.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
tabular-js -> @dclimate/ipld-index (HAMT, CAS, range reads)
|
|
12
|
+
tabular-py -> py-hamt (same, already existed)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Status
|
|
16
|
+
|
|
17
|
+
**Reader only.** Publishing, compaction, and rollup stay in `tabular-js` — the ETL that
|
|
18
|
+
writes these datasets is JS, and a second writer would be a second thing to keep
|
|
19
|
+
byte-identical for no current gain. Everything needed to *read* a dataset published by
|
|
20
|
+
`tabular-js` is here.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv pip install dclimate-tabular-py
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from tabular_py import GatewayRangeSource, StationDataset
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
source = GatewayRangeSource("https://ipfs-gateway.dclimate.net")
|
|
36
|
+
ds = await StationDataset.open(source, "bafyr4if2wbttslbxpzmro427j4l4nvcrxqo4tufuffqqmqz7afj2pxyu4a")
|
|
37
|
+
|
|
38
|
+
# nearest station to a point, then a year of readings
|
|
39
|
+
near = await ds.nearest(34.05, -118.24, max_km=100)
|
|
40
|
+
rows = await near.time_range("2024-01-01", "2024-12-31").elements("PRCP").rows()
|
|
41
|
+
for row in rows[:5]:
|
|
42
|
+
print(row.station_id, row.ts, row.values)
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The chainable selection API mirrors `tabular-js`:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
ds.select("USW00023174") # explicit station ids
|
|
51
|
+
ds.circle(34.05, -118.24, 50) # within 50 km
|
|
52
|
+
ds.rectangle(33.0, -119.0, 35.0, -117.0)
|
|
53
|
+
ds.polygon([[(lon, lat), ...]])
|
|
54
|
+
await ds.nearest(lat, lon) # async: reads the geo index
|
|
55
|
+
ds.time_range(start, end)
|
|
56
|
+
ds.elements("PRCP", "TMAX")
|
|
57
|
+
ds.where(gt("TMAX", 300)) # pushed down to fragment statistics
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Selections are immutable — each call returns a new `StationDataset`, so a base dataset can
|
|
61
|
+
be reused across queries.
|
|
62
|
+
|
|
63
|
+
Terminal operations:
|
|
64
|
+
|
|
65
|
+
| Call | Returns |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `await ds.rows()` | `list[ResultRow]` |
|
|
68
|
+
| `await ds.to_records()` | `list[dict]` — `station_id`, `time`, `values` |
|
|
69
|
+
| `await ds.to_records("TMAX")` | `list[dict]` — `station_id`, `time`, `value` |
|
|
70
|
+
| `await ds.to_arrow()` | `pyarrow.Table` |
|
|
71
|
+
| `await ds.plan()` | `QueryPlan` — what *would* be fetched, without fetching |
|
|
72
|
+
| `await ds.list_stations()` | `list[StationInfo]` |
|
|
73
|
+
|
|
74
|
+
## How reads stay small
|
|
75
|
+
|
|
76
|
+
A query never scans the dataset. Three things prune before any Parquet byte is fetched:
|
|
77
|
+
|
|
78
|
+
1. **The station index** (a HAMT keyed by station id) resolves named stations directly.
|
|
79
|
+
2. **The geo projection** answers region queries by reading one or two shard blocks
|
|
80
|
+
instead of walking all 132k stations.
|
|
81
|
+
3. **Fragment statistics in the manifest** — per-column min/max and null counts — let a
|
|
82
|
+
predicate skip whole fragments unread.
|
|
83
|
+
|
|
84
|
+
What survives is fetched with HTTP range requests against the exact column-chunk byte
|
|
85
|
+
ranges the manifest records, so a query for one column of one year moves kilobytes.
|
|
86
|
+
|
|
87
|
+
### One deviation from `tabular-js`, and why
|
|
88
|
+
|
|
89
|
+
`tabular-js` synthesizes Parquet `FileMetaData` client-side from the manifest and reads a
|
|
90
|
+
fragment with **zero** footer fetches. PyArrow exposes no public `FileMetaData`
|
|
91
|
+
constructor, so that trick does not transfer.
|
|
92
|
+
|
|
93
|
+
Instead this reader fetches the footer by its manifest-recorded
|
|
94
|
+
`footer_offset`/`footer_length` in a single ranged GET, verifies it against the
|
|
95
|
+
manifest's `footer_digest`, and hands the parsed metadata to PyArrow. Cost is one extra
|
|
96
|
+
range request per fragment — and it buys a corruption check `tabular-js` does not
|
|
97
|
+
perform. Footers are cached per fragment CID, so a repeated query pays it once.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv sync
|
|
103
|
+
uv run pytest # unit tests, no network
|
|
104
|
+
uv run pytest -m network # conformance against the live gateway
|
|
105
|
+
uv run ruff check . && uv run mypy tabular_py
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`tests/test_conformance.py` reads a real published GHCNd dataset
|
|
109
|
+
(`bafyr4if2wbttslbxpzmro427j4l4nvcrxqo4tufuffqqmqz7afj2pxyu4a`, 132,437 stations,
|
|
110
|
+
1.15 B rows) and asserts this implementation agrees with `tabular-js` on decoded values.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# PyPI rejects "tabular-py": it normalises to the existing "tabularpy" project.
|
|
3
|
+
# The distribution name is namespaced instead, matching @dclimate/tabular on npm.
|
|
4
|
+
# The import name stays `tabular_py`, which PyPI has no opinion about.
|
|
5
|
+
name = "dclimate-tabular-py"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "Content-addressed tabular/telemetry manifest for climate data on IPFS (dclimate-tabular/0)"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"dag-cbor>=0.3.3",
|
|
13
|
+
"multiformats[full]>=0.3.1.post4",
|
|
14
|
+
# Declared explicitly rather than relied on through multiformats[full]:
|
|
15
|
+
# py-hamt imports blake3 the same way without declaring it, and a transitive
|
|
16
|
+
# dep that the format's only hash function depends on is not one to inherit.
|
|
17
|
+
"blake3>=0.4.1",
|
|
18
|
+
"py-hamt>=3.6.0",
|
|
19
|
+
"pyarrow>=18.0.0",
|
|
20
|
+
"httpx>=0.28.1",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/dClimate/tabular-py"
|
|
25
|
+
Repository = "https://github.com/dClimate/tabular-py"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["tabular_py"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.3.3",
|
|
37
|
+
"pytest-asyncio>=1.0.0",
|
|
38
|
+
"pytest-cov>=6.0.0",
|
|
39
|
+
"ruff>=0.7.1",
|
|
40
|
+
"mypy>=1.15.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
# py-hamt pins every async fixture/test to a function-scoped loop; the same rule
|
|
46
|
+
# applies here because KuboCAS caches an httpx client per running loop.
|
|
47
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
markers = [
|
|
50
|
+
"network: hits a real IPFS gateway; deselect with -m 'not network'",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py312"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
59
|
+
# TC00x is deliberately off. `from __future__ import annotations` is on
|
|
60
|
+
# everywhere, so annotations are already lazy, and the types these rules want
|
|
61
|
+
# moved into TYPE_CHECKING blocks are needed at runtime anyway -- CID and the
|
|
62
|
+
# model classes back isinstance checks, and the dataclass field types are
|
|
63
|
+
# resolved when slots are built.
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.per-file-ignores]
|
|
66
|
+
# Tests reach into private attributes to corrupt a block and assert the
|
|
67
|
+
# integrity check fires; that is the point of the test.
|
|
68
|
+
"tests/*" = ["SLF001"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
python_version = "3.12"
|
|
72
|
+
strict = true
|
|
73
|
+
# Off because the exhaustiveness fallbacks at the end of the predicate
|
|
74
|
+
# dispatchers are unreachable only as long as the Literal unions stay in sync
|
|
75
|
+
# with the branches above them. Keeping the guard is worth more than the check.
|
|
76
|
+
warn_unreachable = false
|
|
77
|
+
|
|
78
|
+
[[tool.mypy.overrides]]
|
|
79
|
+
# Neither ships a py.typed marker, so everything they export arrives as Any.
|
|
80
|
+
module = ["py_hamt.*", "pyarrow.*"]
|
|
81
|
+
ignore_missing_imports = true
|
|
82
|
+
|
|
83
|
+
[[tool.mypy.overrides]]
|
|
84
|
+
# The wire decoders funnel every value through helpers that narrow `Any` by
|
|
85
|
+
# hand and raise otherwise. mypy cannot see through that, so it reports each
|
|
86
|
+
# validated return as "returning Any"; the validation is the point.
|
|
87
|
+
module = ["tabular_py.wire", "tabular_py.source", "tabular_py.reader"]
|
|
88
|
+
warn_return_any = false
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Capture a real published dataset into an offline fixture.
|
|
2
|
+
|
|
3
|
+
Records every block a small set of queries touches, so the test suite exercises
|
|
4
|
+
genuine `tabular-js`-written bytes without needing a gateway. Re-run when the
|
|
5
|
+
reference dataset changes:
|
|
6
|
+
|
|
7
|
+
uv run python scripts/capture_fixture.py
|
|
8
|
+
|
|
9
|
+
Blocks are stored base64 in a single JSON file, keyed by CID string.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
21
|
+
|
|
22
|
+
from multiformats import CID # noqa: E402
|
|
23
|
+
|
|
24
|
+
from tabular_py import ( # noqa: E402
|
|
25
|
+
Circle,
|
|
26
|
+
GatewayRangeSource,
|
|
27
|
+
StationDataset,
|
|
28
|
+
cid_str,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
GATEWAY = "https://ipfs-gateway.dclimate.net"
|
|
32
|
+
ROOT_CID = "bafyr4if2wbttslbxpzmro427j4l4nvcrxqo4tufuffqqmqz7afj2pxyu4a"
|
|
33
|
+
OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "ghcnd_sample.json"
|
|
34
|
+
|
|
35
|
+
# Stations chosen for coverage rather than convenience: one with pre-epoch
|
|
36
|
+
# timestamps, one modern, one in the LA cluster the geo tests query.
|
|
37
|
+
STATIONS = ["US1GAFS0003", "USC00045111"]
|
|
38
|
+
|
|
39
|
+
# Ids the dataset does not contain, so the not-found path is covered offline.
|
|
40
|
+
MISSING_STATIONS = ["NO_SUCH_STATION", "ZZZZZZZZZZZ9"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RecordingSource:
|
|
44
|
+
"""Passes through to a real source and records every block it serves."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, inner: GatewayRangeSource) -> None:
|
|
47
|
+
self._inner = inner
|
|
48
|
+
self.blocks: dict[str, bytes] = {}
|
|
49
|
+
|
|
50
|
+
async def get_block(self, cid: CID) -> bytes:
|
|
51
|
+
key = cid_str(cid)
|
|
52
|
+
if key not in self.blocks:
|
|
53
|
+
self.blocks[key] = await self._inner.get_block(cid)
|
|
54
|
+
return self.blocks[key]
|
|
55
|
+
|
|
56
|
+
async def get_range(self, cid: CID, offset: int, length: int) -> bytes:
|
|
57
|
+
# Record the whole block so the fixture can serve any range of it.
|
|
58
|
+
data = await self.get_block(cid)
|
|
59
|
+
return data[offset : offset + length]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def main() -> None:
|
|
63
|
+
async with GatewayRangeSource(GATEWAY) as gateway:
|
|
64
|
+
source = RecordingSource(gateway)
|
|
65
|
+
ds = await StationDataset.open(source, ROOT_CID)
|
|
66
|
+
|
|
67
|
+
for station_id in STATIONS:
|
|
68
|
+
await ds.info_for(station_id)
|
|
69
|
+
await ds.columns_for(station_id)
|
|
70
|
+
await ds.select(station_id).elements("PRCP").rows()
|
|
71
|
+
|
|
72
|
+
# A lookup that misses still descends the trie, and lands on different
|
|
73
|
+
# nodes than any successful lookup does. Capturing that path is what
|
|
74
|
+
# lets the offline suite test "station is not in this dataset".
|
|
75
|
+
from tabular_py import lookup_station
|
|
76
|
+
|
|
77
|
+
for absent in MISSING_STATIONS:
|
|
78
|
+
await lookup_station(ds.reader.root.stations, source, absent)
|
|
79
|
+
|
|
80
|
+
# Geo paths: the projection directory plus the shards around LA.
|
|
81
|
+
await ds.find_nearest_station(34.05, -118.24)
|
|
82
|
+
from tabular_py import lookup_geo_stations
|
|
83
|
+
|
|
84
|
+
projection = ds.reader.root.projections.get("geo")
|
|
85
|
+
if projection is not None:
|
|
86
|
+
await lookup_geo_stations(
|
|
87
|
+
projection, source, Circle(34_052_000, -118_243_000, 25_000)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
payload = {
|
|
92
|
+
"root": ROOT_CID,
|
|
93
|
+
"gateway": GATEWAY,
|
|
94
|
+
"stations": STATIONS,
|
|
95
|
+
"blocks": {
|
|
96
|
+
key: base64.b64encode(data).decode("ascii")
|
|
97
|
+
for key, data in sorted(source.blocks.items())
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
OUT.write_text(json.dumps(payload, indent=1))
|
|
101
|
+
total = sum(len(v) for v in source.blocks.values())
|
|
102
|
+
print(f"wrote {OUT} -- {len(source.blocks)} blocks, {total} bytes")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""tabular-py -- Python reader for ``dclimate-tabular/0``.
|
|
2
|
+
|
|
3
|
+
The Python counterpart to ``@dclimate/tabular``, reading the same content-addressed
|
|
4
|
+
station datasets from the same CIDs, built on ``py-hamt``.
|
|
5
|
+
|
|
6
|
+
from tabular_py import GatewayRangeSource, StationDataset
|
|
7
|
+
|
|
8
|
+
source = GatewayRangeSource("https://ipfs-gateway.dclimate.net")
|
|
9
|
+
ds = await StationDataset.open(source, root_cid)
|
|
10
|
+
rows = await ds.select("USW00023174").time_range("2024-01-01", "2024-12-31").rows()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .codec import (
|
|
16
|
+
BLOCK_HARD_LIMIT_BYTES,
|
|
17
|
+
BLOCK_TARGET_BYTES,
|
|
18
|
+
blake3_digest,
|
|
19
|
+
cid_for_bytes,
|
|
20
|
+
cid_str,
|
|
21
|
+
decode_block,
|
|
22
|
+
encode_block,
|
|
23
|
+
make_block,
|
|
24
|
+
verify_cid,
|
|
25
|
+
)
|
|
26
|
+
from .errors import (
|
|
27
|
+
BlockSizeError,
|
|
28
|
+
CidMismatchError,
|
|
29
|
+
CodecError,
|
|
30
|
+
DatasetIntegrityError,
|
|
31
|
+
DatasetReaderError,
|
|
32
|
+
DclimateTabularError,
|
|
33
|
+
GeoFilterError,
|
|
34
|
+
GeoIndexError,
|
|
35
|
+
PredicateError,
|
|
36
|
+
RangeSourceError,
|
|
37
|
+
StationIndexError,
|
|
38
|
+
StationSelectionError,
|
|
39
|
+
WireError,
|
|
40
|
+
)
|
|
41
|
+
from .geo import (
|
|
42
|
+
BBox,
|
|
43
|
+
Circle,
|
|
44
|
+
GeoFilter,
|
|
45
|
+
Polygon,
|
|
46
|
+
haversine_meters,
|
|
47
|
+
matches_geo_filter,
|
|
48
|
+
validate_geo_filter,
|
|
49
|
+
)
|
|
50
|
+
from .geo_index import (
|
|
51
|
+
bounding_boxes_for_filter,
|
|
52
|
+
first_accepted_in_order,
|
|
53
|
+
lookup_geo_stations,
|
|
54
|
+
nearest_station,
|
|
55
|
+
nearest_station_where,
|
|
56
|
+
)
|
|
57
|
+
from .model import (
|
|
58
|
+
SPEC_VERSION,
|
|
59
|
+
CellValue,
|
|
60
|
+
ColumnStat,
|
|
61
|
+
DatasetRoot,
|
|
62
|
+
FragmentEntry,
|
|
63
|
+
GeoProjection,
|
|
64
|
+
GeoShardRef,
|
|
65
|
+
HamtStationIndex,
|
|
66
|
+
InlineStationIndex,
|
|
67
|
+
StationEntry,
|
|
68
|
+
StationIndex,
|
|
69
|
+
StationSummary,
|
|
70
|
+
TableField,
|
|
71
|
+
TableSchema,
|
|
72
|
+
columns_from_fragments,
|
|
73
|
+
correction_schema,
|
|
74
|
+
default_column_key,
|
|
75
|
+
)
|
|
76
|
+
from .predicate import (
|
|
77
|
+
And,
|
|
78
|
+
Comparison,
|
|
79
|
+
Not,
|
|
80
|
+
NullCheck,
|
|
81
|
+
Or,
|
|
82
|
+
Predicate,
|
|
83
|
+
and_,
|
|
84
|
+
eq,
|
|
85
|
+
evaluate_row_predicate,
|
|
86
|
+
evaluate_stats_predicate,
|
|
87
|
+
ge,
|
|
88
|
+
gt,
|
|
89
|
+
is_null,
|
|
90
|
+
le,
|
|
91
|
+
lt,
|
|
92
|
+
ne,
|
|
93
|
+
not_,
|
|
94
|
+
not_null,
|
|
95
|
+
or_,
|
|
96
|
+
predicate_elements,
|
|
97
|
+
)
|
|
98
|
+
from .reader import (
|
|
99
|
+
DatasetReader,
|
|
100
|
+
PlanRange,
|
|
101
|
+
Query,
|
|
102
|
+
QueryPlan,
|
|
103
|
+
QueryPlanFragment,
|
|
104
|
+
ResultRow,
|
|
105
|
+
)
|
|
106
|
+
from .source import (
|
|
107
|
+
CasRangeSource,
|
|
108
|
+
CountingSource,
|
|
109
|
+
GatewayRangeSource,
|
|
110
|
+
MemoryRangeSource,
|
|
111
|
+
RangeSource,
|
|
112
|
+
)
|
|
113
|
+
from .station_dataset import NearestStation, StationDataset, StationInfo, WithinRange
|
|
114
|
+
from .station_index import (
|
|
115
|
+
list_station_entries,
|
|
116
|
+
list_stations,
|
|
117
|
+
lookup_station,
|
|
118
|
+
station_count,
|
|
119
|
+
)
|
|
120
|
+
from .wire import (
|
|
121
|
+
fragment_from_wire,
|
|
122
|
+
fragment_to_wire,
|
|
123
|
+
geo_projection_from_wire,
|
|
124
|
+
geo_projection_to_wire,
|
|
125
|
+
geo_shard_from_wire,
|
|
126
|
+
geo_shard_to_wire,
|
|
127
|
+
root_from_wire,
|
|
128
|
+
root_to_wire,
|
|
129
|
+
station_entry_from_wire,
|
|
130
|
+
station_entry_to_wire,
|
|
131
|
+
station_summary_from_wire,
|
|
132
|
+
station_summary_to_wire,
|
|
133
|
+
table_schema_from_wire,
|
|
134
|
+
table_schema_to_wire,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
__version__ = "0.1.0"
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"BLOCK_HARD_LIMIT_BYTES",
|
|
141
|
+
"BLOCK_TARGET_BYTES",
|
|
142
|
+
"SPEC_VERSION",
|
|
143
|
+
"And",
|
|
144
|
+
"BBox",
|
|
145
|
+
"BlockSizeError",
|
|
146
|
+
"CasRangeSource",
|
|
147
|
+
"CellValue",
|
|
148
|
+
"CidMismatchError",
|
|
149
|
+
"Circle",
|
|
150
|
+
"CodecError",
|
|
151
|
+
"ColumnStat",
|
|
152
|
+
"Comparison",
|
|
153
|
+
"CountingSource",
|
|
154
|
+
"DatasetIntegrityError",
|
|
155
|
+
"DatasetReader",
|
|
156
|
+
"DatasetReaderError",
|
|
157
|
+
"DatasetRoot",
|
|
158
|
+
"DclimateTabularError",
|
|
159
|
+
"FragmentEntry",
|
|
160
|
+
"GatewayRangeSource",
|
|
161
|
+
"GeoFilter",
|
|
162
|
+
"GeoFilterError",
|
|
163
|
+
"GeoIndexError",
|
|
164
|
+
"GeoProjection",
|
|
165
|
+
"GeoShardRef",
|
|
166
|
+
"HamtStationIndex",
|
|
167
|
+
"InlineStationIndex",
|
|
168
|
+
"MemoryRangeSource",
|
|
169
|
+
"NearestStation",
|
|
170
|
+
"Not",
|
|
171
|
+
"NullCheck",
|
|
172
|
+
"Or",
|
|
173
|
+
"PlanRange",
|
|
174
|
+
"Polygon",
|
|
175
|
+
"Predicate",
|
|
176
|
+
"PredicateError",
|
|
177
|
+
"Query",
|
|
178
|
+
"QueryPlan",
|
|
179
|
+
"QueryPlanFragment",
|
|
180
|
+
"RangeSource",
|
|
181
|
+
"RangeSourceError",
|
|
182
|
+
"ResultRow",
|
|
183
|
+
"StationDataset",
|
|
184
|
+
"StationEntry",
|
|
185
|
+
"StationIndex",
|
|
186
|
+
"StationIndexError",
|
|
187
|
+
"StationInfo",
|
|
188
|
+
"StationSelectionError",
|
|
189
|
+
"StationSummary",
|
|
190
|
+
"TableField",
|
|
191
|
+
"TableSchema",
|
|
192
|
+
"WireError",
|
|
193
|
+
"WithinRange",
|
|
194
|
+
"__version__",
|
|
195
|
+
"and_",
|
|
196
|
+
"blake3_digest",
|
|
197
|
+
"bounding_boxes_for_filter",
|
|
198
|
+
"cid_for_bytes",
|
|
199
|
+
"cid_str",
|
|
200
|
+
"columns_from_fragments",
|
|
201
|
+
"correction_schema",
|
|
202
|
+
"decode_block",
|
|
203
|
+
"default_column_key",
|
|
204
|
+
"encode_block",
|
|
205
|
+
"eq",
|
|
206
|
+
"first_accepted_in_order",
|
|
207
|
+
"evaluate_row_predicate",
|
|
208
|
+
"evaluate_stats_predicate",
|
|
209
|
+
"fragment_from_wire",
|
|
210
|
+
"fragment_to_wire",
|
|
211
|
+
"ge",
|
|
212
|
+
"geo_projection_from_wire",
|
|
213
|
+
"geo_projection_to_wire",
|
|
214
|
+
"geo_shard_from_wire",
|
|
215
|
+
"geo_shard_to_wire",
|
|
216
|
+
"gt",
|
|
217
|
+
"haversine_meters",
|
|
218
|
+
"is_null",
|
|
219
|
+
"le",
|
|
220
|
+
"list_station_entries",
|
|
221
|
+
"list_stations",
|
|
222
|
+
"lookup_geo_stations",
|
|
223
|
+
"lookup_station",
|
|
224
|
+
"lt",
|
|
225
|
+
"make_block",
|
|
226
|
+
"matches_geo_filter",
|
|
227
|
+
"ne",
|
|
228
|
+
"nearest_station",
|
|
229
|
+
"nearest_station_where",
|
|
230
|
+
"not_",
|
|
231
|
+
"not_null",
|
|
232
|
+
"or_",
|
|
233
|
+
"predicate_elements",
|
|
234
|
+
"root_from_wire",
|
|
235
|
+
"root_to_wire",
|
|
236
|
+
"station_count",
|
|
237
|
+
"station_entry_from_wire",
|
|
238
|
+
"station_entry_to_wire",
|
|
239
|
+
"station_summary_from_wire",
|
|
240
|
+
"station_summary_to_wire",
|
|
241
|
+
"table_schema_from_wire",
|
|
242
|
+
"table_schema_to_wire",
|
|
243
|
+
"validate_geo_filter",
|
|
244
|
+
"verify_cid",
|
|
245
|
+
]
|