portolan-python 0.1.3__tar.gz → 0.1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: portolan-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A lightweight Python implementation of the Portolan specification.
5
5
  Author: Portolan contributors
6
6
  License: Apache-2.0
@@ -8,8 +8,8 @@ preferred package index for published versions.
8
8
  Push a tag that matches the version in `pyproject.toml`:
9
9
 
10
10
  ```bash
11
- git tag v0.1.0
12
- git push origin v0.1.0
11
+ git tag v0.1.5
12
+ git push origin v0.1.5
13
13
  ```
14
14
 
15
15
  The release workflow runs the tests, builds both distributions, checks their
@@ -25,7 +25,7 @@ Install the wheel attached to a release:
25
25
 
26
26
  ```bash
27
27
  python -m pip install \
28
- https://github.com/jemacchi/portolan-python/releases/download/v0.1.0/portolan_python-0.1.0-py3-none-any.whl
28
+ https://github.com/jemacchi/portolan-python/releases/download/v0.1.5/portolan_python-0.1.5-py3-none-any.whl
29
29
  ```
30
30
 
31
31
  This path needs no package index account.
@@ -35,7 +35,7 @@ This path needs no package index account.
35
35
  Install a published version from PyPI with:
36
36
 
37
37
  ```bash
38
- python -m pip install portolan-python==0.1.0
38
+ python -m pip install portolan-python==0.1.5
39
39
  ```
40
40
 
41
41
  Omit the version constraint to install the latest published version.
@@ -10,14 +10,14 @@ GitHub:
10
10
 
11
11
  ```bash
12
12
  python -m pip install \
13
- https://github.com/jemacchi/portolan-python/releases/download/v0.1.0/portolan_python-0.1.0-py3-none-any.whl
13
+ https://github.com/jemacchi/portolan-python/releases/download/v0.1.5/portolan_python-0.1.5-py3-none-any.whl
14
14
  ```
15
15
 
16
16
  You can also install the source at a specific tag:
17
17
 
18
18
  ```bash
19
19
  python -m pip install \
20
- "portolan-python @ git+https://github.com/jemacchi/portolan-python.git@v0.1.0"
20
+ "portolan-python @ git+https://github.com/jemacchi/portolan-python.git@v0.1.5"
21
21
  ```
22
22
 
23
23
  ## List collections and assets
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "portolan-python"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "A lightweight Python implementation of the Portolan specification."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,12 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
7
+ import shutil
8
+ import tempfile
6
9
  from collections.abc import Callable
10
+ from contextlib import contextmanager
7
11
  from dataclasses import dataclass
8
12
  from pathlib import Path
9
13
  from typing import Any
14
+ from urllib.error import HTTPError
10
15
  from urllib.parse import urljoin, urlparse
11
- from urllib.request import Request, urlopen
16
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
12
17
 
13
18
  JsonObject = dict[str, Any]
14
19
 
@@ -70,6 +75,7 @@ def download_registry_catalog(
70
75
  catalog_url: str,
71
76
  output_dir: Path,
72
77
  *,
78
+ expected_catalog_id: str | None = None,
73
79
  fetch_json: Callable[[str], JsonObject] | None = None,
74
80
  ) -> Path:
75
81
  """Download a published catalog snapshot for local workflows."""
@@ -77,17 +83,34 @@ def download_registry_catalog(
77
83
  fetch = fetch_json or _fetch_json
78
84
  catalog = fetch(catalog_url)
79
85
  catalog_id = str(catalog.get("id") or _fallback_catalog_id(catalog_url))
80
- if catalog_id in {"", ".", ".."} or Path(catalog_id).name != catalog_id or "\\" in catalog_id:
81
- raise ValueError(f"Catalog id must be a safe directory name: {catalog_id}")
86
+ _validate_catalog_id(catalog_id)
87
+ if expected_catalog_id is not None:
88
+ _validate_catalog_id(expected_catalog_id)
89
+ if catalog_id != expected_catalog_id:
90
+ raise ValueError(
91
+ f"Catalog id '{catalog_id}' does not match registry id '{expected_catalog_id}'"
92
+ )
82
93
  catalog_root = output_dir / catalog_id
83
- _write_catalog_tree(catalog_url, catalog, catalog_url, catalog_root, fetch)
94
+ output_dir.mkdir(parents=True, exist_ok=True)
95
+ with _catalog_lock(output_dir, catalog_id):
96
+ _validate_catalog_root(output_dir, catalog_root)
97
+ staging_root = Path(
98
+ tempfile.mkdtemp(prefix=f".{catalog_id}.staging-", dir=output_dir.resolve())
99
+ )
100
+ try:
101
+ _write_catalog_tree(catalog_url, catalog, catalog_url, staging_root, fetch)
102
+ _publish_snapshot(staging_root, catalog_root, output_dir)
103
+ except BaseException:
104
+ shutil.rmtree(staging_root, ignore_errors=True)
105
+ raise
84
106
  return catalog_root
85
107
 
86
108
 
87
109
  def _fetch_json(url: str) -> JsonObject:
88
110
  _validate_remote_url(url)
89
111
  request = Request(url, headers={"User-Agent": "portolan-python"})
90
- with urlopen(request, timeout=30) as response:
112
+ opener = build_opener(_SameOriginRedirectHandler(url))
113
+ with opener.open(request, timeout=30) as response:
91
114
  data = json.loads(response.read().decode("utf-8"))
92
115
  if not isinstance(data, dict):
93
116
  raise TypeError(f"Expected JSON object from {url}")
@@ -100,6 +123,75 @@ def _validate_remote_url(url: str) -> None:
100
123
  raise ValueError(f"Registry URL must use HTTP or HTTPS: {url}")
101
124
 
102
125
 
126
+ class _SameOriginRedirectHandler(HTTPRedirectHandler):
127
+ def __init__(self, original_url: str) -> None:
128
+ self._origin = _url_origin(original_url)
129
+ super().__init__()
130
+
131
+ def redirect_request(
132
+ self,
133
+ req: Request,
134
+ fp: Any,
135
+ code: int,
136
+ msg: str,
137
+ headers: Any,
138
+ newurl: str,
139
+ ) -> Request | None:
140
+ if _url_origin(newurl) != self._origin:
141
+ raise HTTPError(newurl, code, f"Redirect changed origin: {newurl}", headers, fp)
142
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
143
+
144
+
145
+ def _url_origin(url: str) -> tuple[str, str]:
146
+ parsed = urlparse(url)
147
+ return parsed.scheme.lower(), parsed.netloc.lower()
148
+
149
+
150
+ def _validate_catalog_id(catalog_id: str) -> None:
151
+ if catalog_id in {"", ".", ".."} or Path(catalog_id).name != catalog_id or "\\" in catalog_id:
152
+ raise ValueError(f"Catalog id must be a safe directory name: {catalog_id}")
153
+
154
+
155
+ @contextmanager
156
+ def _catalog_lock(output_dir: Path, catalog_id: str) -> Any:
157
+ lock_path = output_dir.resolve() / f".{catalog_id}.lock"
158
+ try:
159
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
160
+ except FileExistsError as err:
161
+ raise RuntimeError(f"Catalog download is already in progress: {catalog_id}") from err
162
+ os.close(descriptor)
163
+ try:
164
+ yield
165
+ finally:
166
+ lock_path.unlink(missing_ok=True)
167
+
168
+
169
+ def _validate_catalog_root(output_dir: Path, catalog_root: Path) -> None:
170
+ if catalog_root.is_symlink():
171
+ raise ValueError(f"Catalog directory must not be a symlink: {catalog_root}")
172
+ if not catalog_root.resolve().is_relative_to(output_dir.resolve()):
173
+ raise ValueError(f"Catalog directory escapes output directory: {catalog_root}")
174
+
175
+
176
+ def _publish_snapshot(staging_root: Path, catalog_root: Path, output_dir: Path) -> None:
177
+ _validate_catalog_root(output_dir, catalog_root)
178
+ backup_root = Path(
179
+ tempfile.mkdtemp(prefix=f".{catalog_root.name}.backup-", dir=output_dir.resolve())
180
+ )
181
+ backup_root.rmdir()
182
+ had_previous = catalog_root.exists()
183
+ if had_previous:
184
+ catalog_root.rename(backup_root)
185
+ try:
186
+ staging_root.rename(catalog_root)
187
+ except BaseException:
188
+ if had_previous:
189
+ backup_root.rename(catalog_root)
190
+ raise
191
+ if had_previous:
192
+ shutil.rmtree(backup_root)
193
+
194
+
103
195
  def _write_catalog_tree(
104
196
  document_url: str,
105
197
  document: JsonObject,
@@ -123,34 +215,54 @@ def _write_catalog_tree(
123
215
  target.parent.mkdir(parents=True, exist_ok=True)
124
216
  if document.get("type") == "Collection":
125
217
  document = _with_absolute_asset_hrefs(document_url, document)
218
+
219
+ links = document.get("links")
220
+ if isinstance(links, list):
221
+ rewritten_links: list[Any] = []
222
+ for link in links:
223
+ rewritten_link = link
224
+ if isinstance(link, dict) and link.get("rel") == "child":
225
+ href = link.get("href")
226
+ if isinstance(href, str):
227
+ child_url = urljoin(document_url, href)
228
+ child_target = _target_document_path(root_url, child_url, output_root)
229
+ owner = targets.get(child_target)
230
+ if owner is not None and owner != child_url:
231
+ raise ValueError(
232
+ "Registry documents map to the same local path: "
233
+ f"{owner}, {child_url}"
234
+ )
235
+ if child_url in visited:
236
+ if owner == child_url:
237
+ rewritten_link = {
238
+ **link,
239
+ "href": _relative_local_href(target, child_target),
240
+ }
241
+ else:
242
+ child = fetch_json(child_url)
243
+ if child.get("type") in {"Catalog", "Collection"}:
244
+ _write_catalog_tree(
245
+ child_url,
246
+ child,
247
+ root_url,
248
+ output_root,
249
+ fetch_json,
250
+ visited,
251
+ targets,
252
+ )
253
+ rewritten_link = {
254
+ **link,
255
+ "href": _relative_local_href(target, child_target),
256
+ }
257
+ rewritten_links.append(rewritten_link)
258
+ document = {**document, "links": rewritten_links}
259
+
126
260
  target.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
127
261
 
128
- for link in document.get("links", []):
129
- if not isinstance(link, dict) or link.get("rel") != "child":
130
- continue
131
- href = link.get("href")
132
- if not isinstance(href, str):
133
- continue
134
- child_url = urljoin(document_url, href)
135
- if child_url in visited:
136
- continue
137
- child_target = _target_document_path(root_url, child_url, output_root)
138
- owner = targets.get(child_target)
139
- if owner is not None and owner != child_url:
140
- raise ValueError(
141
- f"Registry documents map to the same local path: {owner}, {child_url}"
142
- )
143
- child = fetch_json(child_url)
144
- if child.get("type") in {"Catalog", "Collection"}:
145
- _write_catalog_tree(
146
- child_url,
147
- child,
148
- root_url,
149
- output_root,
150
- fetch_json,
151
- visited,
152
- targets,
153
- )
262
+
263
+ def _relative_local_href(parent_target: Path, child_target: Path) -> str:
264
+ relative_path = os.path.relpath(child_target, start=parent_target.parent)
265
+ return Path(relative_path).as_posix()
154
266
 
155
267
 
156
268
  def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> JsonObject:
@@ -3,12 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from email.message import Message
6
7
  from pathlib import Path
7
8
  from typing import Any
9
+ from urllib.error import HTTPError
10
+ from urllib.request import Request
8
11
 
9
12
  import pytest
10
13
 
11
14
  from portolan import download_registry_catalog, load_registry_entries
15
+ from portolan.registry import _SameOriginRedirectHandler
12
16
 
13
17
  pytestmark = pytest.mark.unit
14
18
 
@@ -185,10 +189,51 @@ def test_download_registry_catalog_writes_snapshot_with_absolute_asset_hrefs(
185
189
  collection = json.loads(
186
190
  (catalog_root / "roads" / "collection.json").read_text(encoding="utf-8")
187
191
  )
188
- assert catalog["links"][0]["href"] == "./roads/collection.json"
192
+ assert catalog["links"][0]["href"] == "roads/collection.json"
189
193
  assert collection["assets"]["data"]["href"] == "https://example.test/demo/roads/roads.parquet"
190
194
 
191
195
 
196
+ def test_download_registry_catalog_rewrites_downloaded_children_to_local_hrefs(
197
+ tmp_path: Path,
198
+ ) -> None:
199
+ root_url = "https://example.test/demo/catalog.json"
200
+ child_url = "https://example.test/demo/roads/collection.json"
201
+ item_url = "https://example.test/demo/roads/item.json"
202
+ responses = {
203
+ root_url: {
204
+ "type": "Catalog",
205
+ "id": "demo",
206
+ "links": [
207
+ {"rel": "self", "href": root_url},
208
+ {"rel": "child", "href": child_url},
209
+ {"rel": "child", "href": item_url},
210
+ ],
211
+ },
212
+ child_url: {
213
+ **_collection("roads", {}),
214
+ "links": [{"rel": "child", "href": root_url}],
215
+ },
216
+ item_url: {"type": "Feature", "id": "road-1"},
217
+ }
218
+
219
+ catalog_root = download_registry_catalog(
220
+ root_url,
221
+ tmp_path,
222
+ fetch_json=lambda url: responses[url],
223
+ )
224
+
225
+ catalog = json.loads((catalog_root / "catalog.json").read_text(encoding="utf-8"))
226
+ collection = json.loads(
227
+ (catalog_root / "roads" / "collection.json").read_text(encoding="utf-8")
228
+ )
229
+ assert catalog["links"] == [
230
+ {"rel": "self", "href": root_url},
231
+ {"rel": "child", "href": "roads/collection.json"},
232
+ {"rel": "child", "href": item_url},
233
+ ]
234
+ assert collection["links"] == [{"rel": "child", "href": "../catalog.json"}]
235
+
236
+
192
237
  def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
193
238
  root_url = "https://example.test/demo/catalog.json"
194
239
  child_url = "https://example.test/demo/roads/collection.json"
@@ -266,14 +311,82 @@ def test_download_registry_catalog_rejects_symlink_escape(tmp_path: Path) -> Non
266
311
  child_url: _collection("linked", {}),
267
312
  }
268
313
 
269
- with pytest.raises(ValueError, match="escapes catalog root"):
314
+ download_registry_catalog(
315
+ root_url,
316
+ output_dir,
317
+ fetch_json=lambda url: responses[url],
318
+ )
319
+
320
+ assert not (outside_dir / "collection.json").exists()
321
+ assert (catalog_root / "linked" / "collection.json").exists()
322
+
323
+
324
+ def test_download_registry_catalog_rejects_catalog_root_symlink(tmp_path: Path) -> None:
325
+ output_dir = tmp_path / "output"
326
+ outside_dir = tmp_path / "outside"
327
+ output_dir.mkdir()
328
+ outside_dir.mkdir()
329
+ (output_dir / "demo").symlink_to(outside_dir, target_is_directory=True)
330
+
331
+ with pytest.raises(ValueError, match="Catalog directory must not be a symlink"):
270
332
  download_registry_catalog(
271
- root_url,
333
+ "https://example.test/demo/catalog.json",
272
334
  output_dir,
273
- fetch_json=lambda url: responses[url],
335
+ fetch_json=lambda url: {"type": "Catalog", "id": "demo", "links": []},
274
336
  )
275
337
 
276
- assert not (outside_dir / "collection.json").exists()
338
+ assert list(outside_dir.iterdir()) == []
339
+
340
+
341
+ def test_download_registry_catalog_rejects_registry_id_mismatch(tmp_path: Path) -> None:
342
+ with pytest.raises(ValueError, match="does not match registry id"):
343
+ download_registry_catalog(
344
+ "https://example.test/demo/catalog.json",
345
+ tmp_path,
346
+ expected_catalog_id="selected-catalog",
347
+ fetch_json=lambda url: {"type": "Catalog", "id": "other-catalog", "links": []},
348
+ )
349
+
350
+ assert not (tmp_path / "selected-catalog").exists()
351
+
352
+
353
+ def test_download_registry_catalog_preserves_snapshot_when_child_fetch_fails(
354
+ tmp_path: Path,
355
+ ) -> None:
356
+ catalog_root = tmp_path / "demo"
357
+ catalog_root.mkdir()
358
+ previous = catalog_root / "catalog.json"
359
+ previous.write_text('{"id": "previous"}\n', encoding="utf-8")
360
+ root_url = "https://example.test/demo/catalog.json"
361
+
362
+ def fetch_json(url: str) -> dict[str, Any]:
363
+ if url == root_url:
364
+ return {
365
+ "type": "Catalog",
366
+ "id": "demo",
367
+ "links": [{"rel": "child", "href": "./missing.json"}],
368
+ }
369
+ raise HTTPError(url, 503, "Unavailable", hdrs=Message(), fp=None)
370
+
371
+ with pytest.raises(HTTPError):
372
+ download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
373
+
374
+ assert previous.read_text(encoding="utf-8") == '{"id": "previous"}\n'
375
+ assert not list(tmp_path.glob(".demo.staging-*"))
376
+
377
+
378
+ def test_same_origin_redirect_handler_rejects_cross_origin_redirect() -> None:
379
+ handler = _SameOriginRedirectHandler("https://example.test/demo/catalog.json")
380
+
381
+ with pytest.raises(HTTPError, match="Redirect changed origin"):
382
+ handler.redirect_request(
383
+ Request("https://example.test/demo/child.json"),
384
+ fp=None,
385
+ code=302,
386
+ msg="Found",
387
+ headers={},
388
+ newurl="http://127.0.0.1/private",
389
+ )
277
390
 
278
391
 
279
392
  def test_download_registry_catalog_rejects_local_path_collisions(tmp_path: Path) -> None:
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "portolan-python"
471
- version = "0.1.3"
471
+ version = "0.1.5"
472
472
  source = { editable = "." }
473
473
 
474
474
  [package.optional-dependencies]