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 +12 -0
- lgd_query/__main__.py +3 -0
- lgd_query/_http.py +66 -0
- lgd_query/cli.py +151 -0
- lgd_query/errors.py +17 -0
- lgd_query/files.py +60 -0
- lgd_query/metadata.py +215 -0
- lgd_query/py.typed +0 -0
- lgd_query/query.py +45 -0
- lgd_query-0.1.0.dist-info/METADATA +227 -0
- lgd_query-0.1.0.dist-info/RECORD +14 -0
- lgd_query-0.1.0.dist-info/WHEEL +5 -0
- lgd_query-0.1.0.dist-info/entry_points.txt +2 -0
- lgd_query-0.1.0.dist-info/top_level.txt +1 -0
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
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 @@
|
|
|
1
|
+
lgd_query
|