lgd-query 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
lgd_query/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
lgd_query/_http.py ADDED
@@ -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
lgd_query/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command-line wrappers around the three public reader APIs."""
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import sys
7
+
8
+ from . import DEFAULT_METADATA_URL, connect, download, load_manifest, load_release
9
+
10
+
11
+ def _json_value(value):
12
+ if isinstance(value, float) and not math.isfinite(value):
13
+ return 'NaN' if math.isnan(value) else ('Infinity' if value > 0 else '-Infinity')
14
+ if isinstance(value, (list, tuple)):
15
+ return [_json_value(item) for item in value]
16
+ if isinstance(value, dict):
17
+ if any(not isinstance(key, str) for key in value):
18
+ # Match DuckDB's representation for MAP keys that cannot be dict
19
+ # keys. JSON objects would stringify keys and can lose identity.
20
+ return {'key': [_json_value(key) for key in value],
21
+ 'value': [_json_value(item) for item in value.values()]}
22
+ return {key: _json_value(item) for key, item in value.items()}
23
+ return value
24
+
25
+
26
+ def _print(value):
27
+ print(json.dumps(_json_value(value), ensure_ascii=False, default=str, allow_nan=False, indent=2))
28
+
29
+
30
+ def _rich():
31
+ try:
32
+ from rich.console import Console
33
+ from rich.table import Column, Table
34
+ from rich.text import Text
35
+ except ImportError as error:
36
+ raise ImportError('Table output requires: pip install "lgd-query[cli]"; '
37
+ 'or use --format json') from error
38
+ return Console, Column, Table, Text
39
+
40
+
41
+ def _table(columns, rows, *, title=None):
42
+ Console, Column, Table, Text = _rich()
43
+ table = Table(*(Column(Text(str(c))) for c in columns), title=Text(title) if title else None)
44
+ for row in rows:
45
+ table.add_row(*(Text('—' if value is None else str(value)) for value in row))
46
+ Console().print(table)
47
+
48
+
49
+ def _query_output(cursor, format):
50
+ columns = [column[0] for column in cursor.description]
51
+ if format == 'table':
52
+ # Bound Rich's table buffer as well as DuckDB's fetched row batches.
53
+ batch = cursor.fetchmany(1000)
54
+ _table(columns, batch)
55
+ while batch := cursor.fetchmany(1000):
56
+ _table(columns, batch)
57
+ else:
58
+ # Column/value arrays retain repeated column names in joins.
59
+ sys.stdout.write('{"columns": ' + json.dumps(columns, ensure_ascii=False) + ', "rows": [')
60
+ separator = ''
61
+ while batch := cursor.fetchmany(1000):
62
+ for row in batch:
63
+ sys.stdout.write(separator + json.dumps(_json_value(row), ensure_ascii=False,
64
+ default=str, allow_nan=False))
65
+ separator = ',\n'
66
+ sys.stdout.write(']}\n')
67
+
68
+
69
+ def main(argv=None):
70
+ parser = argparse.ArgumentParser(description='Read public LGD Parquet release data')
71
+ parser.add_argument('--metadata-url', default=DEFAULT_METADATA_URL,
72
+ help='Current metadata or an immutable saved-generation URL')
73
+ commands = parser.add_subparsers(dest='command', required=True)
74
+ listing = commands.add_parser('list', help='List datasets, dates and availability')
75
+ listing.add_argument('--available-only', action='store_true')
76
+ resolve = commands.add_parser('resolve', help='Resolve a dataset to its immutable file references')
77
+ resolve.add_argument('dataset')
78
+ resolve.add_argument('--manifest', action='store_true', help='Also fetch and verify schema/source metadata')
79
+ fetching = commands.add_parser('download', help='Download and verify selected Parquet files')
80
+ fetching.add_argument('datasets', nargs='+')
81
+ fetching.add_argument('--directory', required=True)
82
+ querying = commands.add_parser('query', help='Run SQL over selected datasets')
83
+ querying.add_argument('sql')
84
+ querying.add_argument('--datasets', nargs='+', required=True)
85
+ querying.add_argument('--cache-dir', help='Use verified local files instead of remote range reads')
86
+ querying.add_argument('--params', help='SQL parameters as a JSON array or object')
87
+ for command in (listing, resolve, fetching, querying):
88
+ command.add_argument('--format', choices=('table', 'json'), default='table',
89
+ help='Output format (default: table, requires the cli extra)')
90
+ for command in (resolve, fetching, querying):
91
+ command.add_argument('--observed-on', help='Require this exact YYYY-MM-DD report date')
92
+ args = parser.parse_args(argv)
93
+ try:
94
+ if args.format == 'table':
95
+ _rich() # Fail before metadata requests, downloads or SQL work.
96
+ release = load_release(args.metadata_url)
97
+ if args.command == 'list':
98
+ result = dict(generation=release.generation, published_at=release.published_at,
99
+ immutable_url=release.immutable_url,
100
+ datasets=[dict(name=d.name, available=d.available, observed_on=d.observed_on,
101
+ uploaded_at=d.uploaded_at, rows=d.rows,
102
+ representation=d.representation, status=d.status, reason=d.reason)
103
+ for d in release.datasets.values()
104
+ if not args.available_only or d.available])
105
+ if args.format == 'json':
106
+ _print(result)
107
+ else:
108
+ _table(['Dataset', 'Observed on', 'Rows', 'Status'],
109
+ [(d['name'], d['observed_on'], d['rows'], d['status']) for d in result['datasets']],
110
+ title=f'LGD · {release.generation}')
111
+ elif args.command == 'resolve':
112
+ dataset = release.dataset(args.dataset, observed_on=args.observed_on)
113
+ result = dict(generation=release.generation, immutable_url=release.immutable_url,
114
+ dataset=dataset.name, entry=dataset.metadata)
115
+ if args.manifest:
116
+ result['manifest'] = load_manifest(dataset)
117
+ if args.format == 'json':
118
+ _print(result)
119
+ else:
120
+ _table(['Field', 'Value'], [
121
+ ('Dataset', dataset.name), ('Representation', dataset.representation),
122
+ ('Observed on', dataset.observed_on), ('Uploaded at', dataset.uploaded_at),
123
+ ('Rows', dataset.rows), ('Outcome', dataset.status), ('Reason', dataset.reason),
124
+ ('Parquet URL', dataset.parquet.url), ('Bytes', dataset.parquet.size),
125
+ ('SHA-256', dataset.parquet.sha256), ('Manifest URL', dataset.manifest.url),
126
+ ('Generation', release.generation), ('Index URL', release.immutable_url),
127
+ ])
128
+ if args.manifest:
129
+ _table(['ID', 'Column', 'Type'],
130
+ [(f['id'], f['name'], f['type']) for f in result['manifest']['schema']],
131
+ title='Canonical schema')
132
+ else:
133
+ datasets = [release.dataset(name, observed_on=args.observed_on) for name in args.datasets]
134
+ if args.command == 'download':
135
+ result = [dict(dataset=d.name, observed_on=d.observed_on,
136
+ path=str(download(d, args.directory))) for d in datasets]
137
+ if args.format == 'json':
138
+ _print(result)
139
+ else:
140
+ _table(['Dataset', 'Observed on', 'Verified file'],
141
+ [(r['dataset'], r['observed_on'], r['path']) for r in result])
142
+ else:
143
+ params = json.loads(args.params) if args.params is not None else None
144
+ if params is not None and not isinstance(params, (list, dict)):
145
+ raise ValueError('--params must be a JSON array or object')
146
+ with connect(datasets, cache_dir=args.cache_dir) as connection:
147
+ cursor = connection.execute(args.sql, params)
148
+ _query_output(cursor, args.format)
149
+ except Exception as error:
150
+ parser.exit(1, f'lgd-query: {error}\n')
151
+ return 0
lgd_query/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Errors exposed by the reader APIs."""
2
+
3
+
4
+ class MetadataError(ValueError):
5
+ """The discovery index or dataset manifest does not match its contract."""
6
+
7
+
8
+ class IntegrityError(ValueError):
9
+ """Downloaded bytes differ from their advertised size or SHA-256."""
10
+
11
+
12
+ class DatasetUnavailableError(LookupError):
13
+ """A known dataset has no published Parquet file."""
14
+
15
+
16
+ class ObservationDateError(ValueError):
17
+ """The published report does not have the requested observation date."""
lgd_query/files.py ADDED
@@ -0,0 +1,60 @@
1
+ """Verified downloads, independent of DuckDB and metadata fetching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+
10
+ from ._http import open_url
11
+ from .errors import IntegrityError
12
+ from .metadata import Dataset
13
+
14
+
15
+ def _digest(path):
16
+ digest = hashlib.sha256()
17
+ with path.open('rb') as source:
18
+ while chunk := source.read(1024 * 1024):
19
+ digest.update(chunk)
20
+ return digest.hexdigest()
21
+
22
+
23
+ def download(dataset: Dataset, directory: str | os.PathLike, *,
24
+ timeout: float = 30, retries: int = 2) -> Path:
25
+ """Return a size/SHA-256 verified Parquet file in the requested directory.
26
+
27
+ Names contain the full content hash. Valid files are reused; corrupt cached
28
+ files are replaced only after a complete replacement passes verification.
29
+ Interrupted transfers never become final cache entries.
30
+ """
31
+ dataset.require_available()
32
+ asset = dataset.parquet
33
+ directory = Path(directory).expanduser().resolve()
34
+ directory.mkdir(parents=True, exist_ok=True)
35
+ destination = directory / f'{dataset.name}.{asset.sha256}.parquet'
36
+ if (destination.exists() and destination.stat().st_size == asset.size and
37
+ _digest(destination) == asset.sha256):
38
+ return destination
39
+ temporary = None
40
+ try:
41
+ with tempfile.NamedTemporaryFile(dir=directory, prefix=f'.{dataset.name}.',
42
+ suffix='.tmp', delete=False) as target:
43
+ temporary = Path(target.name)
44
+ digest, size = hashlib.sha256(), 0
45
+ with open_url(asset.url, timeout=timeout, retries=retries) as response:
46
+ while chunk := response.read(1024 * 1024):
47
+ size += len(chunk)
48
+ if size > asset.size:
49
+ raise IntegrityError(f'{dataset.name}: download exceeds advertised size')
50
+ digest.update(chunk)
51
+ target.write(chunk)
52
+ if size != asset.size or digest.hexdigest() != asset.sha256:
53
+ raise IntegrityError(f'{dataset.name}: download differs from advertised size/SHA-256')
54
+ target.flush()
55
+ os.fsync(target.fileno())
56
+ temporary.replace(destination)
57
+ return destination
58
+ finally:
59
+ if temporary is not None:
60
+ temporary.unlink(missing_ok=True)
lgd_query/metadata.py ADDED
@@ -0,0 +1,215 @@
1
+ """Resolve the daily release index without importing DuckDB or downloader code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from datetime import date, datetime
8
+ import hashlib
9
+ import re
10
+ from types import MappingProxyType
11
+ from typing import Any, Mapping
12
+
13
+ from ._http import http_url, read_json
14
+ from .errors import DatasetUnavailableError, IntegrityError, MetadataError, ObservationDateError
15
+
16
+
17
+ DEFAULT_METADATA_URL = ('https://github.com/ramSeraph/opendata/releases/download/'
18
+ 'lgd-parquet-latest/metadata.json')
19
+
20
+
21
+ def _require(condition, message):
22
+ if not condition:
23
+ raise MetadataError(message)
24
+
25
+
26
+ def _object(value, label):
27
+ _require(isinstance(value, dict), f'{label} must be an object')
28
+ return value
29
+
30
+
31
+ def _text(value, label):
32
+ _require(isinstance(value, str) and bool(value), f'Missing/invalid {label}')
33
+ return value
34
+
35
+
36
+ def _date(value):
37
+ try:
38
+ result = date.fromisoformat(value)
39
+ if result.isoformat() == value:
40
+ return result
41
+ except (TypeError, ValueError):
42
+ pass
43
+ raise MetadataError(f'Expected a YYYY-MM-DD observation date: {value!r}')
44
+
45
+
46
+ def _timestamp(value):
47
+ try:
48
+ result = datetime.fromisoformat(value.replace('Z', '+00:00'))
49
+ if result.tzinfo is not None:
50
+ return result
51
+ except (AttributeError, TypeError, ValueError):
52
+ pass
53
+ raise MetadataError(f'Expected a timestamp with a timezone: {value!r}')
54
+
55
+
56
+ def _contract(document):
57
+ _object(document, 'Metadata')
58
+ _require(document.get('kind') == 'lgd-daily-parquet' and
59
+ type(document.get('format_version')) is int and document['format_version'] == 1,
60
+ 'Unsupported daily Parquet metadata kind/version')
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Asset:
65
+ """An immutable file reference advertised by the release metadata."""
66
+
67
+ url: str
68
+ size: int
69
+ sha256: str
70
+
71
+ def __post_init__(self):
72
+ http_url(self.url)
73
+ _require(type(self.size) is int and self.size > 0, 'Invalid asset size')
74
+ _require(isinstance(self.sha256, str) and re.fullmatch('[a-f0-9]{64}', self.sha256),
75
+ 'Invalid asset SHA-256')
76
+
77
+ @classmethod
78
+ def from_dict(cls, value: dict) -> Asset:
79
+ value = _object(value, 'Asset')
80
+ return cls(value.get('url'), value.get('size'), value.get('sha256'))
81
+
82
+ def verify(self, content: bytes) -> None:
83
+ if len(content) != self.size or hashlib.sha256(content).hexdigest() != self.sha256:
84
+ raise IntegrityError(f'Content differs from advertised size/SHA-256: {self.url}')
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Dataset:
89
+ """One dataset in a pinned index, including its actual date and outcome."""
90
+
91
+ name: str
92
+ status: str
93
+ reason: str | None
94
+ representation: str | None
95
+ observed_on: date | None
96
+ uploaded_at: datetime | None
97
+ rows: int | None
98
+ parquet: Asset | None
99
+ manifest: Asset | None
100
+ identity: str | None
101
+ _entry: dict = field(repr=False, compare=False)
102
+
103
+ @property
104
+ def available(self) -> bool:
105
+ """True even if a failed update retained an older, usable file."""
106
+ return self.parquet is not None
107
+
108
+ @property
109
+ def metadata(self) -> dict:
110
+ """A detached copy of the dataset's original index entry."""
111
+ return deepcopy(self._entry)
112
+
113
+ @property
114
+ def coverage(self) -> dict | None:
115
+ """Event date/collection coverage; snapshot reports normally have none."""
116
+ return deepcopy(self._entry.get('coverage'))
117
+
118
+ def require_available(self) -> Dataset:
119
+ if not self.available:
120
+ raise DatasetUnavailableError(f'{self.name}: {self.reason or self.status}')
121
+ return self
122
+
123
+ @classmethod
124
+ def _parse(cls, name, entry):
125
+ _require(isinstance(name, str) and re.fullmatch('[a-z][a-z0-9_]*', name),
126
+ f'Invalid dataset name: {name!r}')
127
+ entry = deepcopy(_object(entry, f'{name} entry'))
128
+ outcome = _object(entry.get('outcome'), f'{name} outcome')
129
+ status = outcome.get('status')
130
+ _require(status in ('updated', 'unchanged', 'unavailable', 'blocked', 'failed'),
131
+ f'{name}: unknown publication outcome')
132
+ reason = outcome.get('reason')
133
+ _require(reason is None or isinstance(reason, str), f'{name}: invalid failure reason')
134
+ if entry.get('daily') is None:
135
+ _require(entry.get('manifest') is None and status in ('unavailable', 'blocked', 'failed'),
136
+ f'{name}: published dataset has no daily file')
137
+ return cls(name, status, reason, None, None, None, None, None, None, None, entry)
138
+ daily = _object(entry['daily'], f'{name} daily')
139
+ representation = entry.get('representation')
140
+ _require(representation in ('snapshot', 'events'), f'{name}: unknown representation')
141
+ observed = daily.get('observed_on')
142
+ if representation == 'snapshot':
143
+ observed = _date(observed)
144
+ else:
145
+ _require(observed is None, f'{name}: event rows must not invent an observation date')
146
+ rows = daily.get('rows')
147
+ _require(type(rows) is int and rows >= 0, f'{name}: invalid row count')
148
+ coverage = entry.get('coverage')
149
+ _require(coverage is None or isinstance(coverage, dict), f'{name}: invalid coverage')
150
+ return cls(name, status, reason, representation, observed, _timestamp(daily.get('uploaded_at')),
151
+ rows, Asset.from_dict(daily.get('file')), Asset.from_dict(entry.get('manifest')),
152
+ _text(entry.get('identity'), f'{name} identity'), entry)
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Release:
157
+ """A single metadata generation; accessing it never refetches latest."""
158
+
159
+ generation: str
160
+ published_at: datetime
161
+ immutable_url: str
162
+ repository: str
163
+ datasets: Mapping[str, Dataset]
164
+ source_url: str | None
165
+ _document: dict = field(repr=False, compare=False)
166
+
167
+ @classmethod
168
+ def from_dict(cls, document: dict, *, source_url: str | None = None) -> Release:
169
+ """Resolve a previously saved JSON index without network requests."""
170
+ _contract(document)
171
+ document = deepcopy(document)
172
+ entries = _object(document.get('datasets'), 'datasets')
173
+ datasets = MappingProxyType({name: Dataset._parse(name, entry) for name, entry in entries.items()})
174
+ return cls(_text(document.get('generation'), 'generation'),
175
+ _timestamp(document.get('published_at')), http_url(document.get('immutable_url')),
176
+ _text(document.get('repository'), 'repository'), datasets, source_url, document)
177
+
178
+ @property
179
+ def metadata(self) -> dict:
180
+ """A detached JSON-serializable index, suitable for saving and reopening."""
181
+ return deepcopy(self._document)
182
+
183
+ def dataset(self, name: str, *, observed_on: date | str | None = None) -> Dataset:
184
+ """Resolve a usable dataset, optionally requiring an exact source date.
185
+
186
+ The date requirement never falls back to an older report and does not
187
+ perform a historical lookup. Cumulative events have no observation date.
188
+ """
189
+ try:
190
+ result = self.datasets[name].require_available()
191
+ except KeyError:
192
+ raise KeyError(f'Unknown dataset: {name}') from None
193
+ if observed_on is not None:
194
+ expected = observed_on if type(observed_on) is date else _date(observed_on)
195
+ if result.observed_on != expected:
196
+ raise ObservationDateError(f'{name}: requested {expected}, published observation '
197
+ f'is {result.observed_on} (outcome: {result.status})')
198
+ return result
199
+
200
+
201
+ def load_release(url: str = DEFAULT_METADATA_URL, *, timeout: float = 30, retries: int = 2) -> Release:
202
+ """Fetch the current index once, or pin an explicitly supplied immutable URL."""
203
+ return Release.from_dict(read_json(url, timeout=timeout, retries=retries), source_url=url)
204
+
205
+
206
+ def load_manifest(dataset: Dataset, *, timeout: float = 30, retries: int = 2) -> dict[str, Any]:
207
+ """Fetch schema/source details, verify bytes, and match them to the pinned index."""
208
+ dataset.require_available()
209
+ ref = dataset.manifest
210
+ manifest = read_json(ref.url, asset=ref, timeout=timeout, retries=retries)
211
+ _contract(manifest)
212
+ _require(manifest.get('dataset') == dataset.name, 'Manifest belongs to another dataset')
213
+ for key in ('identity', 'representation', 'daily', 'coverage'):
214
+ _require(manifest.get(key) == dataset._entry.get(key), f'Manifest differs from index: {key}')
215
+ return manifest
lgd_query/py.typed ADDED
File without changes
lgd_query/query.py ADDED
@@ -0,0 +1,45 @@
1
+ """DuckDB access layered on resolved datasets and optional verified downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING, Iterable, Mapping
7
+
8
+ from .files import download
9
+ from .metadata import Dataset
10
+
11
+ if TYPE_CHECKING:
12
+ from duckdb import DuckDBPyConnection
13
+
14
+
15
+ def connect(datasets: Iterable[Dataset], *, cache_dir: str | os.PathLike | None = None,
16
+ config: Mapping | None = None, timeout: float = 30,
17
+ retries: int = 2) -> DuckDBPyConnection:
18
+ """Open an in-memory DuckDB connection with a view named for each dataset.
19
+
20
+ Without cache_dir, DuckDB reads immutable remote URLs using HTTP range
21
+ requests. With cache_dir, download() verifies complete local files first.
22
+ Close the connection or use it as a context manager. No index is refetched.
23
+ timeout/retries apply to downloads; DuckDB controls remote query transport.
24
+ """
25
+ datasets = tuple(datasets)
26
+ if not datasets or any(not isinstance(d, Dataset) for d in datasets):
27
+ raise ValueError('Pass one or more resolved Dataset objects')
28
+ if len({d.name for d in datasets}) != len(datasets):
29
+ raise ValueError('Dataset view names must be unique')
30
+ for dataset in datasets:
31
+ dataset.require_available()
32
+ try:
33
+ import duckdb
34
+ except ImportError as error:
35
+ raise ImportError('SQL access requires: pip install "lgd-query[duckdb]"') from error
36
+ connection = duckdb.connect(config=dict(config or {}))
37
+ try:
38
+ for dataset in datasets:
39
+ path = (str(download(dataset, cache_dir, timeout=timeout, retries=retries))
40
+ if cache_dir is not None else dataset.parquet.url)
41
+ connection.read_parquet(path).create_view(dataset.name, replace=False)
42
+ return connection
43
+ except BaseException:
44
+ connection.close()
45
+ raise
@@ -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,14 @@
1
+ lgd_query/__init__.py,sha256=BUuJVmgvI9NAoBZv6sJe_4zffiHkduyctMnFumBhQFo,556
2
+ lgd_query/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ lgd_query/_http.py,sha256=4NvzmNU_FJETVG3yd0v-dc6UxNlsTSFaE7wy0cojNvA,2606
4
+ lgd_query/cli.py,sha256=T3c0Gv8qIbzUIVMCdRcZ4An0hEPDajUHMNAjTquIRLQ,7964
5
+ lgd_query/errors.py,sha256=gxldJyzdPw2Ivq43pzCr54nX-puM43jEN-obMtQQ_T8,486
6
+ lgd_query/files.py,sha256=_3rtK8k8auOuWqSXnAPkRBvw4rV5VmSSa7dug-JJYhA,2378
7
+ lgd_query/metadata.py,sha256=S-PJF83CbfF9NHWx8mVHImCPOd8_s-hbDGh1G0PZOgc,8644
8
+ lgd_query/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ lgd_query/query.py,sha256=OQgk_kS5VAtGUgC9LVogAeY4ZAunPbNxfjr2-NMU2aw,1861
10
+ lgd_query-0.1.0.dist-info/METADATA,sha256=lCUhp_W6xFtwvnciQmK6TK8r1mfTLc44pEUm8U3T5uA,10500
11
+ lgd_query-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ lgd_query-0.1.0.dist-info/entry_points.txt,sha256=ixtw2oTwmENJLdp0OU729SA-HYuUH3bstnFoUVVJNP4,49
13
+ lgd_query-0.1.0.dist-info/top_level.txt,sha256=HLr6qVbOD7ik5OIykqClTdCGTXgjI8XnqqVQ1P8_J3Y,10
14
+ lgd_query-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lgd-query = lgd_query.cli:main
@@ -0,0 +1 @@
1
+ lgd_query