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.
@@ -0,0 +1 @@
1
+ include tools/check_dist.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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