lgd-query 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.
- lgd_query-0.1.0/MANIFEST.in +1 -0
- lgd_query-0.1.0/PKG-INFO +227 -0
- lgd_query-0.1.0/README.md +209 -0
- lgd_query-0.1.0/pyproject.toml +36 -0
- lgd_query-0.1.0/setup.cfg +4 -0
- lgd_query-0.1.0/src/lgd_query/__init__.py +12 -0
- lgd_query-0.1.0/src/lgd_query/__main__.py +3 -0
- lgd_query-0.1.0/src/lgd_query/_http.py +66 -0
- lgd_query-0.1.0/src/lgd_query/cli.py +151 -0
- lgd_query-0.1.0/src/lgd_query/errors.py +17 -0
- lgd_query-0.1.0/src/lgd_query/files.py +60 -0
- lgd_query-0.1.0/src/lgd_query/metadata.py +215 -0
- lgd_query-0.1.0/src/lgd_query/py.typed +0 -0
- lgd_query-0.1.0/src/lgd_query/query.py +45 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/PKG-INFO +227 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/SOURCES.txt +21 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/dependency_links.txt +1 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/entry_points.txt +2 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/requires.txt +6 -0
- lgd_query-0.1.0/src/lgd_query.egg-info/top_level.txt +1 -0
- lgd_query-0.1.0/tests/test_reader.py +440 -0
- lgd_query-0.1.0/tests/test_release.py +62 -0
- lgd_query-0.1.0/tools/check_dist.py +111 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include tools/check_dist.py
|
lgd_query-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lgd-query
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolve, download and query public Local Government Directory Parquet releases
|
|
5
|
+
Project-URL: Repository, https://github.com/ramSeraph/opendata
|
|
6
|
+
Project-URL: Documentation, https://github.com/ramSeraph/opendata/tree/master/lgd-query
|
|
7
|
+
Project-URL: Issues, https://github.com/ramSeraph/opendata/issues
|
|
8
|
+
Keywords: india,lgd,local-government-directory,parquet,duckdb
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: duckdb
|
|
15
|
+
Requires-Dist: duckdb>=2.0.0; extra == "duckdb"
|
|
16
|
+
Provides-Extra: cli
|
|
17
|
+
Requires-Dist: rich>=14; extra == "cli"
|
|
18
|
+
|
|
19
|
+
# lgd-query
|
|
20
|
+
|
|
21
|
+
A small Python client for the public [LGD Parquet releases](https://github.com/ramSeraph/opendata/releases/tag/lgd-parquet-latest).
|
|
22
|
+
It has three public APIs: metadata resolution, verified downloading and DuckDB
|
|
23
|
+
access. The CLI uses those same APIs. No GitHub account or API server is needed.
|
|
24
|
+
|
|
25
|
+
This directory is a standalone distribution. Its wheel contains only
|
|
26
|
+
`lgd_query`; it has no dependency on the repository's scrapers, converters or
|
|
27
|
+
history code. Python 3.11+ is required, matching DuckDB 2's minimum. Metadata and downloads use the standard
|
|
28
|
+
library; SQL access additionally requires DuckDB 2 or newer.
|
|
29
|
+
Rich is an optional dependency for CLI tables.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Until the first PyPI release, install from this directory:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install . # Metadata API, download API and their CLI commands
|
|
37
|
+
pip install '.[duckdb]' # Also include DuckDB querying
|
|
38
|
+
pip install '.[cli,duckdb]' # All APIs and Rich table output
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The distribution name is `lgd-query`; the Python import is `lgd_query`.
|
|
42
|
+
After publication, the corresponding installs are `pip install lgd-query` and
|
|
43
|
+
`pip install 'lgd-query[duckdb]'`.
|
|
44
|
+
|
|
45
|
+
## 1. Metadata API
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from lgd_query import load_release, load_manifest
|
|
49
|
+
|
|
50
|
+
release = load_release()
|
|
51
|
+
villages = release.dataset("villages")
|
|
52
|
+
|
|
53
|
+
print(release.generation, release.published_at, release.immutable_url)
|
|
54
|
+
print(villages.observed_on, villages.rows, villages.status)
|
|
55
|
+
print(villages.parquet.url, villages.parquet.size, villages.parquet.sha256)
|
|
56
|
+
|
|
57
|
+
# Optional extra request for canonical schema and original-source provenance.
|
|
58
|
+
manifest = load_manifest(villages)
|
|
59
|
+
print(manifest["schema"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`load_release()` fetches `metadata.json` once. Its `Release` object retains that
|
|
63
|
+
generation, and datasets refer directly to immutable files even if the latest
|
|
64
|
+
index changes. Accessing `release.datasets` or `release.dataset()` makes no
|
|
65
|
+
requests. New datasets can be discovered without updating a hardcoded registry.
|
|
66
|
+
`load_manifest()` verifies the manifest's size/hash and agreement with the index.
|
|
67
|
+
|
|
68
|
+
`release.datasets` is a read-only mapping including unavailable datasets. Each
|
|
69
|
+
`Dataset` exposes `name`, `available`, `status`, `reason`, `representation`,
|
|
70
|
+
`observed_on`, `uploaded_at`, `rows`, `parquet`, `manifest`, `identity`,
|
|
71
|
+
`coverage` and `metadata`. `parquet` and `manifest` are `Asset` references with
|
|
72
|
+
`url`, `size` and `sha256`. An unavailable dataset has no file references;
|
|
73
|
+
`release.dataset(name)` raises `DatasetUnavailableError` for it. An unknown name
|
|
74
|
+
raises `KeyError`.
|
|
75
|
+
|
|
76
|
+
“Latest” means the files advertised in one release index. Datasets can have
|
|
77
|
+
different source dates. A failed update can retain a usable older file, with its
|
|
78
|
+
actual date and failure status. Require an exact date when freshness matters:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
villages = release.dataset("villages", observed_on="2026-10-23")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A mismatch raises `ObservationDateError`; this is a freshness check, not a
|
|
85
|
+
historical lookup. Cumulative `changes` and `transactions` reports have
|
|
86
|
+
`observed_on=None`; their `coverage` describes event dates and collection coverage.
|
|
87
|
+
Publication/upload timestamps do not establish an observation date. These files
|
|
88
|
+
contain supplied report rows; omissions do not prove deletions.
|
|
89
|
+
|
|
90
|
+
To reopen a generation, pass its immutable URL to `load_release(url)`. For an
|
|
91
|
+
offline metadata lookup, save `release.metadata` as JSON and reopen it with
|
|
92
|
+
`Release.from_dict(document)`. `metadata` and `coverage` return detached copies.
|
|
93
|
+
Metadata requests accept `timeout=30` and `retries=2`. Transient HTTP responses,
|
|
94
|
+
including a brief 404 during index replacement, get bounded retries. The reader
|
|
95
|
+
does not silently fall back to `metadata.previous.json`; that URL can be supplied
|
|
96
|
+
explicitly if the preceding generation is wanted.
|
|
97
|
+
|
|
98
|
+
## 2. Download API
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from lgd_query import download
|
|
102
|
+
|
|
103
|
+
path = download(villages, directory="./data")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The returned `pathlib.Path` names a complete Parquet file whose size and SHA-256
|
|
107
|
+
match the resolved metadata. Files are named `<dataset>.<sha256>.parquet`, so
|
|
108
|
+
different contents coexist in the cache. Every reuse verifies the cached file.
|
|
109
|
+
A corrupt cached file is replaced only after its replacement verifies. Downloads
|
|
110
|
+
stream to unique temporary files and rename after verification; failures clean
|
|
111
|
+
up temporary files and never expose partial final files. No cache pruning occurs.
|
|
112
|
+
|
|
113
|
+
`download()` accepts `timeout=30` and `retries=2` for HTTP connection/response
|
|
114
|
+
retries. A failed transfer or checksum check raises an error; a subsequent call
|
|
115
|
+
can retry the download. It requires neither DuckDB nor another metadata request.
|
|
116
|
+
|
|
117
|
+
## 3. DuckDB API
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from lgd_query import connect
|
|
121
|
+
|
|
122
|
+
states = release.dataset("states")
|
|
123
|
+
districts = release.dataset("districts")
|
|
124
|
+
|
|
125
|
+
with connect([states, districts]) as db:
|
|
126
|
+
rows = db.sql("""
|
|
127
|
+
SELECT s.state_name, count(*) AS district_count
|
|
128
|
+
FROM districts d JOIN states s USING (state_code)
|
|
129
|
+
GROUP BY s.state_name ORDER BY s.state_name
|
|
130
|
+
""").fetchall()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`connect()` returns a native `duckdb.DuckDBPyConnection`, with one view per
|
|
134
|
+
selected dataset using its canonical name. Close it or use a context manager.
|
|
135
|
+
Only the requested datasets are opened. Resolve them from one `Release` to pin
|
|
136
|
+
one publication generation; their actual observation dates can still differ.
|
|
137
|
+
|
|
138
|
+
By default DuckDB queries the resolved remote Parquet URLs with HTTP range
|
|
139
|
+
requests. Its `httpfs` extension may be installed automatically on first use.
|
|
140
|
+
Range queries do not verify the checksum of the entire file. For fully verified
|
|
141
|
+
files and reusable downloads:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
with connect([states, districts], cache_dir="./data") as db:
|
|
145
|
+
rows = db.sql("SELECT * FROM districts WHERE state_code = ?", params=["33"]).fetchall()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
With valid cached files and a saved index, this path works offline. DuckDB
|
|
149
|
+
settings can be supplied with `config={"threads": 2, "memory_limit": "512MB"}`.
|
|
150
|
+
`timeout` and `retries` affect cache downloads; remote range-request settings
|
|
151
|
+
are controlled by DuckDB. No domain-specific query helpers are included yet.
|
|
152
|
+
|
|
153
|
+
## CLI
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
lgd-query list
|
|
157
|
+
lgd-query list --available-only
|
|
158
|
+
lgd-query list --format json
|
|
159
|
+
lgd-query resolve villages
|
|
160
|
+
lgd-query resolve villages --manifest --observed-on 2026-10-23
|
|
161
|
+
lgd-query download states districts --directory ./data
|
|
162
|
+
lgd-query query 'SELECT state_code, count(*) FROM districts GROUP BY state_code' --datasets districts
|
|
163
|
+
lgd-query query 'SELECT * FROM districts WHERE state_code = ?' --datasets districts --params '["33"]' --cache-dir ./data
|
|
164
|
+
lgd-query query 'SELECT count(*) FROM districts' --datasets districts --format json
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
All commands default to Rich tables. Use `--format json` for scriptable output;
|
|
168
|
+
JSON mode does not require Rich. Resolved metadata tables summarize the dataset
|
|
169
|
+
and optional schema; JSON retains all manifest/source details. SQL JSON results
|
|
170
|
+
have `columns` and `rows` arrays, preserving duplicate column names. Both formats
|
|
171
|
+
use batches of at most 1,000 query rows; tables repeat their headers per batch.
|
|
172
|
+
Dates and other non-JSON scalar types are represented as strings. Non-finite
|
|
173
|
+
SQL floats use the JSON strings `"NaN"`, `"Infinity"` and `"-Infinity"`, including
|
|
174
|
+
inside lists and structs; SQL NULL remains JSON null. Errors exit
|
|
175
|
+
nonzero with a diagnostic.
|
|
176
|
+
Maps with non-string keys use parallel `key` and `value` arrays, matching
|
|
177
|
+
DuckDB's representation for complex map keys, so JSON object keys cannot
|
|
178
|
+
silently change their meaning. Maps with string keys remain JSON objects.
|
|
179
|
+
`python -m lgd_query` is equivalent to the installed `lgd-query` command.
|
|
180
|
+
|
|
181
|
+
Pass `--metadata-url URL` before the command to select an immutable generation
|
|
182
|
+
or mirror. `resolve`, `download` and `query` accept `--observed-on YYYY-MM-DD` to
|
|
183
|
+
require the exact report date. Every command fetches discovery metadata only once.
|
|
184
|
+
|
|
185
|
+
## Development and packaging
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
pip install -e '.[cli,duckdb]'
|
|
189
|
+
python -m unittest discover -s tests -v
|
|
190
|
+
uv build --out-dir dist
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Build from this directory. Package discovery is restricted to `src/lgd_query`;
|
|
194
|
+
the rest of the repository is outside the distribution. Tests use synthetic
|
|
195
|
+
release metadata and small generated Parquet files. Tests that require DuckDB
|
|
196
|
+
are skipped when the optional dependency is absent.
|
|
197
|
+
|
|
198
|
+
## Version releases
|
|
199
|
+
|
|
200
|
+
The repository workflow is `.github/workflows/lgd-query-publish.yml`, matching
|
|
201
|
+
the PyPI trusted-publisher configuration. It uses the `pypi` GitHub environment
|
|
202
|
+
and [OIDC trusted publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/).
|
|
203
|
+
|
|
204
|
+
A pushed `lgd-query-v<version>` tag builds this directory, checks that the tag
|
|
205
|
+
matches `pyproject.toml`, validates the distribution contents and PyPI metadata,
|
|
206
|
+
and tests the built wheel on Python 3.11 and 3.14. Only after these checks pass
|
|
207
|
+
does it upload the same wheel/source archive to PyPI and create a corresponding
|
|
208
|
+
GitHub release with those files attached. No scraper or history files are bundled.
|
|
209
|
+
|
|
210
|
+
For example, after committing version `0.1.0`:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
git tag lgd-query-v0.1.0
|
|
214
|
+
git push origin lgd-query-v0.1.0
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Manual workflow runs on a branch only build/test. A manual run on a matching
|
|
218
|
+
version tag also publishes. Failed publication jobs can be retried using the
|
|
219
|
+
same built artifacts. GitHub release titles and tags use the library prefix to
|
|
220
|
+
keep package versions separate from the data releases.
|
|
221
|
+
|
|
222
|
+
Before a retry uploads files, the workflow compares any existing PyPI files with
|
|
223
|
+
the checked local sizes and SHA-256 hashes. Different bytes under the same version
|
|
224
|
+
stop publication. Before creating/updating the GitHub release, it verifies that
|
|
225
|
+
both files match PyPI. If PyPI's metadata has not caught up yet, retry that job;
|
|
226
|
+
it will not attach unconfirmed files. For a partial upload, rerun failed jobs
|
|
227
|
+
with the original build artifacts instead of rebuilding the same version.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# lgd-query
|
|
2
|
+
|
|
3
|
+
A small Python client for the public [LGD Parquet releases](https://github.com/ramSeraph/opendata/releases/tag/lgd-parquet-latest).
|
|
4
|
+
It has three public APIs: metadata resolution, verified downloading and DuckDB
|
|
5
|
+
access. The CLI uses those same APIs. No GitHub account or API server is needed.
|
|
6
|
+
|
|
7
|
+
This directory is a standalone distribution. Its wheel contains only
|
|
8
|
+
`lgd_query`; it has no dependency on the repository's scrapers, converters or
|
|
9
|
+
history code. Python 3.11+ is required, matching DuckDB 2's minimum. Metadata and downloads use the standard
|
|
10
|
+
library; SQL access additionally requires DuckDB 2 or newer.
|
|
11
|
+
Rich is an optional dependency for CLI tables.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Until the first PyPI release, install from this directory:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install . # Metadata API, download API and their CLI commands
|
|
19
|
+
pip install '.[duckdb]' # Also include DuckDB querying
|
|
20
|
+
pip install '.[cli,duckdb]' # All APIs and Rich table output
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The distribution name is `lgd-query`; the Python import is `lgd_query`.
|
|
24
|
+
After publication, the corresponding installs are `pip install lgd-query` and
|
|
25
|
+
`pip install 'lgd-query[duckdb]'`.
|
|
26
|
+
|
|
27
|
+
## 1. Metadata API
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from lgd_query import load_release, load_manifest
|
|
31
|
+
|
|
32
|
+
release = load_release()
|
|
33
|
+
villages = release.dataset("villages")
|
|
34
|
+
|
|
35
|
+
print(release.generation, release.published_at, release.immutable_url)
|
|
36
|
+
print(villages.observed_on, villages.rows, villages.status)
|
|
37
|
+
print(villages.parquet.url, villages.parquet.size, villages.parquet.sha256)
|
|
38
|
+
|
|
39
|
+
# Optional extra request for canonical schema and original-source provenance.
|
|
40
|
+
manifest = load_manifest(villages)
|
|
41
|
+
print(manifest["schema"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`load_release()` fetches `metadata.json` once. Its `Release` object retains that
|
|
45
|
+
generation, and datasets refer directly to immutable files even if the latest
|
|
46
|
+
index changes. Accessing `release.datasets` or `release.dataset()` makes no
|
|
47
|
+
requests. New datasets can be discovered without updating a hardcoded registry.
|
|
48
|
+
`load_manifest()` verifies the manifest's size/hash and agreement with the index.
|
|
49
|
+
|
|
50
|
+
`release.datasets` is a read-only mapping including unavailable datasets. Each
|
|
51
|
+
`Dataset` exposes `name`, `available`, `status`, `reason`, `representation`,
|
|
52
|
+
`observed_on`, `uploaded_at`, `rows`, `parquet`, `manifest`, `identity`,
|
|
53
|
+
`coverage` and `metadata`. `parquet` and `manifest` are `Asset` references with
|
|
54
|
+
`url`, `size` and `sha256`. An unavailable dataset has no file references;
|
|
55
|
+
`release.dataset(name)` raises `DatasetUnavailableError` for it. An unknown name
|
|
56
|
+
raises `KeyError`.
|
|
57
|
+
|
|
58
|
+
“Latest” means the files advertised in one release index. Datasets can have
|
|
59
|
+
different source dates. A failed update can retain a usable older file, with its
|
|
60
|
+
actual date and failure status. Require an exact date when freshness matters:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
villages = release.dataset("villages", observed_on="2026-10-23")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A mismatch raises `ObservationDateError`; this is a freshness check, not a
|
|
67
|
+
historical lookup. Cumulative `changes` and `transactions` reports have
|
|
68
|
+
`observed_on=None`; their `coverage` describes event dates and collection coverage.
|
|
69
|
+
Publication/upload timestamps do not establish an observation date. These files
|
|
70
|
+
contain supplied report rows; omissions do not prove deletions.
|
|
71
|
+
|
|
72
|
+
To reopen a generation, pass its immutable URL to `load_release(url)`. For an
|
|
73
|
+
offline metadata lookup, save `release.metadata` as JSON and reopen it with
|
|
74
|
+
`Release.from_dict(document)`. `metadata` and `coverage` return detached copies.
|
|
75
|
+
Metadata requests accept `timeout=30` and `retries=2`. Transient HTTP responses,
|
|
76
|
+
including a brief 404 during index replacement, get bounded retries. The reader
|
|
77
|
+
does not silently fall back to `metadata.previous.json`; that URL can be supplied
|
|
78
|
+
explicitly if the preceding generation is wanted.
|
|
79
|
+
|
|
80
|
+
## 2. Download API
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from lgd_query import download
|
|
84
|
+
|
|
85
|
+
path = download(villages, directory="./data")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The returned `pathlib.Path` names a complete Parquet file whose size and SHA-256
|
|
89
|
+
match the resolved metadata. Files are named `<dataset>.<sha256>.parquet`, so
|
|
90
|
+
different contents coexist in the cache. Every reuse verifies the cached file.
|
|
91
|
+
A corrupt cached file is replaced only after its replacement verifies. Downloads
|
|
92
|
+
stream to unique temporary files and rename after verification; failures clean
|
|
93
|
+
up temporary files and never expose partial final files. No cache pruning occurs.
|
|
94
|
+
|
|
95
|
+
`download()` accepts `timeout=30` and `retries=2` for HTTP connection/response
|
|
96
|
+
retries. A failed transfer or checksum check raises an error; a subsequent call
|
|
97
|
+
can retry the download. It requires neither DuckDB nor another metadata request.
|
|
98
|
+
|
|
99
|
+
## 3. DuckDB API
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from lgd_query import connect
|
|
103
|
+
|
|
104
|
+
states = release.dataset("states")
|
|
105
|
+
districts = release.dataset("districts")
|
|
106
|
+
|
|
107
|
+
with connect([states, districts]) as db:
|
|
108
|
+
rows = db.sql("""
|
|
109
|
+
SELECT s.state_name, count(*) AS district_count
|
|
110
|
+
FROM districts d JOIN states s USING (state_code)
|
|
111
|
+
GROUP BY s.state_name ORDER BY s.state_name
|
|
112
|
+
""").fetchall()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`connect()` returns a native `duckdb.DuckDBPyConnection`, with one view per
|
|
116
|
+
selected dataset using its canonical name. Close it or use a context manager.
|
|
117
|
+
Only the requested datasets are opened. Resolve them from one `Release` to pin
|
|
118
|
+
one publication generation; their actual observation dates can still differ.
|
|
119
|
+
|
|
120
|
+
By default DuckDB queries the resolved remote Parquet URLs with HTTP range
|
|
121
|
+
requests. Its `httpfs` extension may be installed automatically on first use.
|
|
122
|
+
Range queries do not verify the checksum of the entire file. For fully verified
|
|
123
|
+
files and reusable downloads:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
with connect([states, districts], cache_dir="./data") as db:
|
|
127
|
+
rows = db.sql("SELECT * FROM districts WHERE state_code = ?", params=["33"]).fetchall()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With valid cached files and a saved index, this path works offline. DuckDB
|
|
131
|
+
settings can be supplied with `config={"threads": 2, "memory_limit": "512MB"}`.
|
|
132
|
+
`timeout` and `retries` affect cache downloads; remote range-request settings
|
|
133
|
+
are controlled by DuckDB. No domain-specific query helpers are included yet.
|
|
134
|
+
|
|
135
|
+
## CLI
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
lgd-query list
|
|
139
|
+
lgd-query list --available-only
|
|
140
|
+
lgd-query list --format json
|
|
141
|
+
lgd-query resolve villages
|
|
142
|
+
lgd-query resolve villages --manifest --observed-on 2026-10-23
|
|
143
|
+
lgd-query download states districts --directory ./data
|
|
144
|
+
lgd-query query 'SELECT state_code, count(*) FROM districts GROUP BY state_code' --datasets districts
|
|
145
|
+
lgd-query query 'SELECT * FROM districts WHERE state_code = ?' --datasets districts --params '["33"]' --cache-dir ./data
|
|
146
|
+
lgd-query query 'SELECT count(*) FROM districts' --datasets districts --format json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
All commands default to Rich tables. Use `--format json` for scriptable output;
|
|
150
|
+
JSON mode does not require Rich. Resolved metadata tables summarize the dataset
|
|
151
|
+
and optional schema; JSON retains all manifest/source details. SQL JSON results
|
|
152
|
+
have `columns` and `rows` arrays, preserving duplicate column names. Both formats
|
|
153
|
+
use batches of at most 1,000 query rows; tables repeat their headers per batch.
|
|
154
|
+
Dates and other non-JSON scalar types are represented as strings. Non-finite
|
|
155
|
+
SQL floats use the JSON strings `"NaN"`, `"Infinity"` and `"-Infinity"`, including
|
|
156
|
+
inside lists and structs; SQL NULL remains JSON null. Errors exit
|
|
157
|
+
nonzero with a diagnostic.
|
|
158
|
+
Maps with non-string keys use parallel `key` and `value` arrays, matching
|
|
159
|
+
DuckDB's representation for complex map keys, so JSON object keys cannot
|
|
160
|
+
silently change their meaning. Maps with string keys remain JSON objects.
|
|
161
|
+
`python -m lgd_query` is equivalent to the installed `lgd-query` command.
|
|
162
|
+
|
|
163
|
+
Pass `--metadata-url URL` before the command to select an immutable generation
|
|
164
|
+
or mirror. `resolve`, `download` and `query` accept `--observed-on YYYY-MM-DD` to
|
|
165
|
+
require the exact report date. Every command fetches discovery metadata only once.
|
|
166
|
+
|
|
167
|
+
## Development and packaging
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
pip install -e '.[cli,duckdb]'
|
|
171
|
+
python -m unittest discover -s tests -v
|
|
172
|
+
uv build --out-dir dist
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Build from this directory. Package discovery is restricted to `src/lgd_query`;
|
|
176
|
+
the rest of the repository is outside the distribution. Tests use synthetic
|
|
177
|
+
release metadata and small generated Parquet files. Tests that require DuckDB
|
|
178
|
+
are skipped when the optional dependency is absent.
|
|
179
|
+
|
|
180
|
+
## Version releases
|
|
181
|
+
|
|
182
|
+
The repository workflow is `.github/workflows/lgd-query-publish.yml`, matching
|
|
183
|
+
the PyPI trusted-publisher configuration. It uses the `pypi` GitHub environment
|
|
184
|
+
and [OIDC trusted publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/).
|
|
185
|
+
|
|
186
|
+
A pushed `lgd-query-v<version>` tag builds this directory, checks that the tag
|
|
187
|
+
matches `pyproject.toml`, validates the distribution contents and PyPI metadata,
|
|
188
|
+
and tests the built wheel on Python 3.11 and 3.14. Only after these checks pass
|
|
189
|
+
does it upload the same wheel/source archive to PyPI and create a corresponding
|
|
190
|
+
GitHub release with those files attached. No scraper or history files are bundled.
|
|
191
|
+
|
|
192
|
+
For example, after committing version `0.1.0`:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
git tag lgd-query-v0.1.0
|
|
196
|
+
git push origin lgd-query-v0.1.0
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Manual workflow runs on a branch only build/test. A manual run on a matching
|
|
200
|
+
version tag also publishes. Failed publication jobs can be retried using the
|
|
201
|
+
same built artifacts. GitHub release titles and tags use the library prefix to
|
|
202
|
+
keep package versions separate from the data releases.
|
|
203
|
+
|
|
204
|
+
Before a retry uploads files, the workflow compares any existing PyPI files with
|
|
205
|
+
the checked local sizes and SHA-256 hashes. Different bytes under the same version
|
|
206
|
+
stop publication. Before creating/updating the GitHub release, it verifies that
|
|
207
|
+
both files match PyPI. If PyPI's metadata has not caught up yet, retry that job;
|
|
208
|
+
it will not attach unconfirmed files. For a partial upload, rerun failed jobs
|
|
209
|
+
with the original build artifacts instead of rebuilding the same version.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lgd-query"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Resolve, download and query public Local Government Directory Parquet releases"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
keywords = ["india", "lgd", "local-government-directory", "parquet", "duckdb"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Database",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
duckdb = ["duckdb>=2.0.0"]
|
|
21
|
+
cli = ["rich>=14"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
lgd-query = "lgd_query.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/ramSeraph/opendata"
|
|
28
|
+
Documentation = "https://github.com/ramSeraph/opendata/tree/master/lgd-query"
|
|
29
|
+
Issues = "https://github.com/ramSeraph/opendata/issues"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
include = ["lgd_query", "lgd_query.*"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
lgd_query = ["py.typed"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public APIs for LGD release metadata, downloads and optional DuckDB access."""
|
|
2
|
+
|
|
3
|
+
from .metadata import Asset, Dataset, Release, DEFAULT_METADATA_URL, load_manifest, load_release
|
|
4
|
+
from .files import download
|
|
5
|
+
from .query import connect
|
|
6
|
+
from .errors import DatasetUnavailableError, IntegrityError, MetadataError, ObservationDateError
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'Asset', 'Dataset', 'Release', 'DEFAULT_METADATA_URL', 'load_release', 'load_manifest',
|
|
10
|
+
'download', 'connect', 'DatasetUnavailableError', 'IntegrityError', 'MetadataError',
|
|
11
|
+
'ObservationDateError',
|
|
12
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Anonymous HTTP transport shared by metadata and file downloads."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from urllib.error import HTTPError, URLError
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
from .errors import MetadataError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MAX_METADATA_BYTES = 8 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def http_url(value):
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
raise MetadataError('Expected an HTTP(S) URL')
|
|
18
|
+
try:
|
|
19
|
+
parts = urlsplit(value)
|
|
20
|
+
# Accessing port performs validation; urlsplit alone accepts bad ports.
|
|
21
|
+
parts.port
|
|
22
|
+
valid = (parts.scheme in ('https', 'http') and parts.hostname and
|
|
23
|
+
parts.username is None and parts.password is None and not parts.fragment and
|
|
24
|
+
not any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value))
|
|
25
|
+
except ValueError as error:
|
|
26
|
+
raise MetadataError(f'Invalid public HTTP(S) URL: {value!r}') from error
|
|
27
|
+
if not valid:
|
|
28
|
+
raise MetadataError(f'Invalid public HTTP(S) URL: {value!r}')
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def open_url(url, *, timeout=30, retries=2):
|
|
33
|
+
http_url(url)
|
|
34
|
+
if timeout <= 0 or type(retries) is not int or retries < 0:
|
|
35
|
+
raise ValueError('timeout must be positive and retries a nonnegative integer')
|
|
36
|
+
request = Request(url, headers={'User-Agent': 'lgd-query', 'Accept-Encoding': 'identity'})
|
|
37
|
+
for attempt in range(retries + 1):
|
|
38
|
+
try:
|
|
39
|
+
return urlopen(request, timeout=timeout)
|
|
40
|
+
except HTTPError as error:
|
|
41
|
+
# metadata.json can briefly disappear during release replacement.
|
|
42
|
+
error.close()
|
|
43
|
+
if error.code not in (404, 408, 429, 500, 502, 503, 504) or attempt == retries:
|
|
44
|
+
raise
|
|
45
|
+
except (URLError, TimeoutError):
|
|
46
|
+
if attempt == retries:
|
|
47
|
+
raise
|
|
48
|
+
time.sleep(0.5 * 2**attempt)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_json(url, *, asset=None, timeout=30, retries=2):
|
|
52
|
+
if asset is not None and asset.size > MAX_METADATA_BYTES:
|
|
53
|
+
raise MetadataError('Manifest exceeds the metadata size limit')
|
|
54
|
+
with open_url(url, timeout=timeout, retries=retries) as response:
|
|
55
|
+
content = response.read(MAX_METADATA_BYTES + 1)
|
|
56
|
+
if len(content) > MAX_METADATA_BYTES:
|
|
57
|
+
raise MetadataError('Response exceeds the metadata size limit')
|
|
58
|
+
if asset is not None:
|
|
59
|
+
asset.verify(content)
|
|
60
|
+
try:
|
|
61
|
+
result = json.loads(content)
|
|
62
|
+
except (ValueError, UnicodeError) as error:
|
|
63
|
+
raise MetadataError(f'Invalid JSON metadata: {url}') from error
|
|
64
|
+
if not isinstance(result, dict):
|
|
65
|
+
raise MetadataError('Metadata must be a JSON object')
|
|
66
|
+
return result
|