portolan-python 0.1.5__tar.gz → 0.1.7__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.
- {portolan_python-0.1.5 → portolan_python-0.1.7}/.coverage +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/PKG-INFO +1 -1
- {portolan_python-0.1.5 → portolan_python-0.1.7}/docs/distribution.md +4 -4
- {portolan_python-0.1.5 → portolan_python-0.1.7}/docs/examples.md +2 -2
- {portolan_python-0.1.5 → portolan_python-0.1.7}/pyproject.toml +1 -1
- {portolan_python-0.1.5 → portolan_python-0.1.7}/src/portolan/registry.py +60 -6
- {portolan_python-0.1.5 → portolan_python-0.1.7}/tests/test_registry_api.py +125 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/uv.lock +1 -1
- {portolan_python-0.1.5 → portolan_python-0.1.7}/.github/workflows/release.yml +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/.gitignore +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/Makefile +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/README.md +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/docs/development.md +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/docs/scope.md +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/src/portolan/__init__.py +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/src/portolan/catalog.py +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/src/portolan/py.typed +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/src/portolan/validation.py +0 -0
- {portolan_python-0.1.5 → portolan_python-0.1.7}/tests/test_catalog_api.py +0 -0
|
Binary file
|
|
@@ -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.
|
|
12
|
-
git push origin v0.1.
|
|
11
|
+
git tag v0.1.7
|
|
12
|
+
git push origin v0.1.7
|
|
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.
|
|
28
|
+
https://github.com/jemacchi/portolan-python/releases/download/v0.1.7/portolan_python-0.1.7-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.
|
|
38
|
+
python -m pip install portolan-python==0.1.7
|
|
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.
|
|
13
|
+
https://github.com/jemacchi/portolan-python/releases/download/v0.1.7/portolan_python-0.1.7-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.
|
|
20
|
+
"portolan-python @ git+https://github.com/jemacchi/portolan-python.git@v0.1.7"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## List collections and assets
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
import os
|
|
7
8
|
import shutil
|
|
8
9
|
import tempfile
|
|
@@ -31,6 +32,14 @@ class RegistryCatalogEntry:
|
|
|
31
32
|
url: str
|
|
32
33
|
title: str | None = None
|
|
33
34
|
status: str | None = None
|
|
35
|
+
bbox: tuple[float, float, float, float] | None = None
|
|
36
|
+
licenses: tuple[str, ...] = ()
|
|
37
|
+
collection_count: int | None = None
|
|
38
|
+
feature_count: int | None = None
|
|
39
|
+
total_size_bytes: int | None = None
|
|
40
|
+
updated: str | None = None
|
|
41
|
+
logo_url: str | None = None
|
|
42
|
+
failure_reason: str | None = None
|
|
34
43
|
|
|
35
44
|
|
|
36
45
|
def load_registry_entries(
|
|
@@ -58,12 +67,24 @@ def load_registry_entries(
|
|
|
58
67
|
if catalog_ids is not None and registry_id not in catalog_ids:
|
|
59
68
|
continue
|
|
60
69
|
title = link.get("title")
|
|
70
|
+
licenses = link.get("portolan_registry:licenses")
|
|
71
|
+
logo = link.get("portolan_registry:logo")
|
|
61
72
|
entries.append(
|
|
62
73
|
RegistryCatalogEntry(
|
|
63
74
|
id=registry_id,
|
|
64
75
|
url=urljoin(registry_url, href),
|
|
65
76
|
title=title if isinstance(title, str) else None,
|
|
66
77
|
status=status if isinstance(status, str) else None,
|
|
78
|
+
bbox=_horizontal_bbox(link.get("bbox")),
|
|
79
|
+
licenses=tuple(sorted(key for key in licenses if isinstance(key, str)))
|
|
80
|
+
if isinstance(licenses, dict)
|
|
81
|
+
else (),
|
|
82
|
+
collection_count=_optional_int(link.get("portolan_registry:collection_count")),
|
|
83
|
+
feature_count=_optional_int(link.get("portolan_registry:feature_count")),
|
|
84
|
+
total_size_bytes=_optional_int(link.get("portolan_registry:total_size_bytes")),
|
|
85
|
+
updated=_optional_str(link.get("portolan_registry:updated")),
|
|
86
|
+
logo_url=_optional_str(logo.get("href")) if isinstance(logo, dict) else None,
|
|
87
|
+
failure_reason=_optional_str(link.get("portolan_registry:failure_reason")),
|
|
67
88
|
)
|
|
68
89
|
)
|
|
69
90
|
if limit is not None and len(entries) >= limit:
|
|
@@ -71,6 +92,28 @@ def load_registry_entries(
|
|
|
71
92
|
return entries
|
|
72
93
|
|
|
73
94
|
|
|
95
|
+
def _optional_int(value: object) -> int | None:
|
|
96
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _optional_str(value: object) -> str | None:
|
|
100
|
+
return value if isinstance(value, str) and value else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _horizontal_bbox(value: object) -> tuple[float, float, float, float] | None:
|
|
104
|
+
if not isinstance(value, (list, tuple)) or len(value) < 4 or len(value) % 2:
|
|
105
|
+
return None
|
|
106
|
+
if not all(
|
|
107
|
+
isinstance(coordinate, (int, float))
|
|
108
|
+
and not isinstance(coordinate, bool)
|
|
109
|
+
and math.isfinite(coordinate)
|
|
110
|
+
for coordinate in value
|
|
111
|
+
):
|
|
112
|
+
return None
|
|
113
|
+
half = len(value) // 2
|
|
114
|
+
return (float(value[0]), float(value[1]), float(value[half]), float(value[half + 1]))
|
|
115
|
+
|
|
116
|
+
|
|
74
117
|
def download_registry_catalog(
|
|
75
118
|
catalog_url: str,
|
|
76
119
|
output_dir: Path,
|
|
@@ -221,16 +264,27 @@ def _write_catalog_tree(
|
|
|
221
264
|
rewritten_links: list[Any] = []
|
|
222
265
|
for link in links:
|
|
223
266
|
rewritten_link = link
|
|
224
|
-
if isinstance(link, dict) and link.get("
|
|
225
|
-
href = link
|
|
226
|
-
|
|
227
|
-
|
|
267
|
+
if isinstance(link, dict) and isinstance(link.get("href"), str):
|
|
268
|
+
href = link["href"]
|
|
269
|
+
linked_url = urljoin(document_url, href)
|
|
270
|
+
rewritten_link = {**link, "href": linked_url}
|
|
271
|
+
if link.get("rel") in {"root", "parent"}:
|
|
272
|
+
linked_target = next(
|
|
273
|
+
(path for path, owner_url in targets.items() if owner_url == linked_url),
|
|
274
|
+
None,
|
|
275
|
+
)
|
|
276
|
+
if linked_target is not None:
|
|
277
|
+
rewritten_link = {
|
|
278
|
+
**link,
|
|
279
|
+
"href": _relative_local_href(target, linked_target),
|
|
280
|
+
}
|
|
281
|
+
if link.get("rel") == "child":
|
|
282
|
+
child_url = linked_url
|
|
228
283
|
child_target = _target_document_path(root_url, child_url, output_root)
|
|
229
284
|
owner = targets.get(child_target)
|
|
230
285
|
if owner is not None and owner != child_url:
|
|
231
286
|
raise ValueError(
|
|
232
|
-
"Registry documents map to the same local path: "
|
|
233
|
-
f"{owner}, {child_url}"
|
|
287
|
+
f"Registry documents map to the same local path: {owner}, {child_url}"
|
|
234
288
|
)
|
|
235
289
|
if child_url in visited:
|
|
236
290
|
if owner == child_url:
|
|
@@ -40,8 +40,19 @@ def test_load_registry_entries_filters_to_valid_children() -> None:
|
|
|
40
40
|
"rel": "child",
|
|
41
41
|
"href": "https://example.test/a/catalog.json",
|
|
42
42
|
"title": "A",
|
|
43
|
+
"bbox": [-71.0, -35.0, -70.0, -34.0],
|
|
43
44
|
"portolan_registry:id": "catalog-a",
|
|
44
45
|
"portolan_registry:status": "valid",
|
|
46
|
+
"portolan_registry:licenses": {"ODbL-1.0": 2, "CC-BY-4.0": 1},
|
|
47
|
+
"portolan_registry:collection_count": 3,
|
|
48
|
+
"portolan_registry:feature_count": 42,
|
|
49
|
+
"portolan_registry:total_size_bytes": 2048,
|
|
50
|
+
"portolan_registry:updated": "2026-10-08T00:00:00Z",
|
|
51
|
+
"portolan_registry:logo": {
|
|
52
|
+
"href": "https://example.test/a/logo.png",
|
|
53
|
+
"type": "image/png",
|
|
54
|
+
},
|
|
55
|
+
"portolan_registry:failure_reason": None,
|
|
45
56
|
},
|
|
46
57
|
{
|
|
47
58
|
"rel": "child",
|
|
@@ -59,6 +70,48 @@ def test_load_registry_entries_filters_to_valid_children() -> None:
|
|
|
59
70
|
assert [(entry.id, entry.url, entry.title, entry.status) for entry in entries] == [
|
|
60
71
|
("catalog-a", "https://example.test/a/catalog.json", "A", "valid")
|
|
61
72
|
]
|
|
73
|
+
assert entries[0].bbox == (-71.0, -35.0, -70.0, -34.0)
|
|
74
|
+
assert entries[0].licenses == ("CC-BY-4.0", "ODbL-1.0")
|
|
75
|
+
assert entries[0].collection_count == 3
|
|
76
|
+
assert entries[0].feature_count == 42
|
|
77
|
+
assert entries[0].total_size_bytes == 2048
|
|
78
|
+
assert entries[0].updated == "2026-10-08T00:00:00Z"
|
|
79
|
+
assert entries[0].logo_url == "https://example.test/a/logo.png"
|
|
80
|
+
assert entries[0].failure_reason is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_load_registry_entries_ignores_malformed_optional_metadata() -> None:
|
|
84
|
+
registry = {
|
|
85
|
+
"links": [
|
|
86
|
+
{
|
|
87
|
+
"rel": "child",
|
|
88
|
+
"href": "https://example.test/a/catalog.json",
|
|
89
|
+
"bbox": [True, -35.0, -70.0, -34.0],
|
|
90
|
+
"portolan_registry:id": "catalog-a",
|
|
91
|
+
"portolan_registry:status": "valid",
|
|
92
|
+
"portolan_registry:licenses": ["not", "a", "mapping"],
|
|
93
|
+
"portolan_registry:collection_count": True,
|
|
94
|
+
"portolan_registry:feature_count": "many",
|
|
95
|
+
"portolan_registry:total_size_bytes": 1.5,
|
|
96
|
+
"portolan_registry:updated": 7,
|
|
97
|
+
"portolan_registry:logo": {"href": 9},
|
|
98
|
+
"portolan_registry:failure_reason": [],
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
(entry,) = load_registry_entries(
|
|
104
|
+
"https://registry.test/catalogs.json", fetch_json=lambda url: registry
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
assert entry.bbox is None
|
|
108
|
+
assert entry.licenses == ()
|
|
109
|
+
assert entry.collection_count is None
|
|
110
|
+
assert entry.feature_count is None
|
|
111
|
+
assert entry.total_size_bytes is None
|
|
112
|
+
assert entry.updated is None
|
|
113
|
+
assert entry.logo_url is None
|
|
114
|
+
assert entry.failure_reason is None
|
|
62
115
|
|
|
63
116
|
|
|
64
117
|
def test_load_registry_entries_resolves_relative_child_urls() -> None:
|
|
@@ -234,6 +287,78 @@ def test_download_registry_catalog_rewrites_downloaded_children_to_local_hrefs(
|
|
|
234
287
|
assert collection["links"] == [{"rel": "child", "href": "../catalog.json"}]
|
|
235
288
|
|
|
236
289
|
|
|
290
|
+
def test_download_registry_catalog_makes_non_downloaded_relative_links_absolute(
|
|
291
|
+
tmp_path: Path,
|
|
292
|
+
) -> None:
|
|
293
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
294
|
+
collection_url = "https://example.test/demo/roads/collection.json"
|
|
295
|
+
feature_url = "https://example.test/demo/roads/features/road-1.json"
|
|
296
|
+
responses = {
|
|
297
|
+
root_url: {
|
|
298
|
+
"type": "Catalog",
|
|
299
|
+
"id": "demo",
|
|
300
|
+
"links": [{"rel": "child", "href": "./roads/collection.json"}],
|
|
301
|
+
},
|
|
302
|
+
collection_url: {
|
|
303
|
+
**_collection("roads", {}),
|
|
304
|
+
"links": [
|
|
305
|
+
{"rel": "item", "href": "./items/road-1.json"},
|
|
306
|
+
{"rel": "child", "href": "./features/road-1.json"},
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
feature_url: {"type": "Feature", "id": "road-1"},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
catalog_root = download_registry_catalog(
|
|
313
|
+
root_url,
|
|
314
|
+
tmp_path,
|
|
315
|
+
fetch_json=lambda url: responses[url],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
collection = json.loads(
|
|
319
|
+
(catalog_root / "roads" / "collection.json").read_text(encoding="utf-8")
|
|
320
|
+
)
|
|
321
|
+
assert collection["links"] == [
|
|
322
|
+
{"rel": "item", "href": "https://example.test/demo/roads/items/road-1.json"},
|
|
323
|
+
{"rel": "child", "href": feature_url},
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_download_registry_catalog_keeps_downloaded_root_and_parent_links_local(
|
|
328
|
+
tmp_path: Path,
|
|
329
|
+
) -> None:
|
|
330
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
331
|
+
collection_url = "https://example.test/demo/roads/collection.json"
|
|
332
|
+
responses = {
|
|
333
|
+
root_url: {
|
|
334
|
+
"type": "Catalog",
|
|
335
|
+
"id": "demo",
|
|
336
|
+
"links": [{"rel": "child", "href": "./roads/collection.json"}],
|
|
337
|
+
},
|
|
338
|
+
collection_url: {
|
|
339
|
+
**_collection("roads", {}),
|
|
340
|
+
"links": [
|
|
341
|
+
{"rel": "root", "href": root_url},
|
|
342
|
+
{"rel": "parent", "href": root_url},
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
catalog_root = download_registry_catalog(
|
|
348
|
+
root_url,
|
|
349
|
+
tmp_path,
|
|
350
|
+
fetch_json=lambda url: responses[url],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
collection = json.loads(
|
|
354
|
+
(catalog_root / "roads" / "collection.json").read_text(encoding="utf-8")
|
|
355
|
+
)
|
|
356
|
+
assert collection["links"] == [
|
|
357
|
+
{"rel": "root", "href": "../catalog.json"},
|
|
358
|
+
{"rel": "parent", "href": "../catalog.json"},
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
|
|
237
362
|
def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
|
|
238
363
|
root_url = "https://example.test/demo/catalog.json"
|
|
239
364
|
child_url = "https://example.test/demo/roads/collection.json"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|