portolan-python 0.1.6__tar.gz → 0.1.8__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.6
3
+ Version: 0.1.8
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.6
12
- git push origin v0.1.6
11
+ git tag v0.1.8
12
+ git push origin v0.1.8
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.6/portolan_python-0.1.6-py3-none-any.whl
28
+ https://github.com/jemacchi/portolan-python/releases/download/v0.1.8/portolan_python-0.1.8-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.6
38
+ python -m pip install portolan-python==0.1.8
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.6/portolan_python-0.1.6-py3-none-any.whl
13
+ https://github.com/jemacchi/portolan-python/releases/download/v0.1.8/portolan_python-0.1.8-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.6"
20
+ "portolan-python @ git+https://github.com/jemacchi/portolan-python.git@v0.1.8"
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.6"
3
+ version = "0.1.8"
4
4
  description = "A lightweight Python implementation of the Portolan specification."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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(
@@ -52,18 +61,33 @@ def load_registry_entries(
52
61
  registry_id = link.get("portolan_registry:id")
53
62
  if not isinstance(href, str) or not isinstance(registry_id, str):
54
63
  continue
64
+ resolved_url = urljoin(registry_url, href)
65
+ if not _is_remote_url(resolved_url):
66
+ continue
55
67
  status = link.get("portolan_registry:status")
56
68
  if status != "valid" and not include_stale:
57
69
  continue
58
70
  if catalog_ids is not None and registry_id not in catalog_ids:
59
71
  continue
60
72
  title = link.get("title")
73
+ licenses = link.get("portolan_registry:licenses")
74
+ logo = link.get("portolan_registry:logo")
61
75
  entries.append(
62
76
  RegistryCatalogEntry(
63
77
  id=registry_id,
64
- url=urljoin(registry_url, href),
78
+ url=resolved_url,
65
79
  title=title if isinstance(title, str) else None,
66
80
  status=status if isinstance(status, str) else None,
81
+ bbox=_horizontal_bbox(link.get("bbox")),
82
+ licenses=tuple(sorted(key for key in licenses if isinstance(key, str)))
83
+ if isinstance(licenses, dict)
84
+ else (),
85
+ collection_count=_optional_int(link.get("portolan_registry:collection_count")),
86
+ feature_count=_optional_int(link.get("portolan_registry:feature_count")),
87
+ total_size_bytes=_optional_int(link.get("portolan_registry:total_size_bytes")),
88
+ updated=_optional_str(link.get("portolan_registry:updated")),
89
+ logo_url=_optional_str(logo.get("href")) if isinstance(logo, dict) else None,
90
+ failure_reason=_optional_str(link.get("portolan_registry:failure_reason")),
67
91
  )
68
92
  )
69
93
  if limit is not None and len(entries) >= limit:
@@ -71,6 +95,33 @@ def load_registry_entries(
71
95
  return entries
72
96
 
73
97
 
98
+ def _optional_int(value: object) -> int | None:
99
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
100
+
101
+
102
+ def _optional_str(value: object) -> str | None:
103
+ return value if isinstance(value, str) and value else None
104
+
105
+
106
+ def _horizontal_bbox(value: object) -> tuple[float, float, float, float] | None:
107
+ if not isinstance(value, (list, tuple)) or len(value) < 4 or len(value) % 2:
108
+ return None
109
+ if not all(
110
+ isinstance(coordinate, (int, float))
111
+ and not isinstance(coordinate, bool)
112
+ and math.isfinite(coordinate)
113
+ for coordinate in value
114
+ ):
115
+ return None
116
+ half = len(value) // 2
117
+ return (float(value[0]), float(value[1]), float(value[half]), float(value[half + 1]))
118
+
119
+
120
+ def _is_remote_url(url: str) -> bool:
121
+ parsed = urlparse(url)
122
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
123
+
124
+
74
125
  def download_registry_catalog(
75
126
  catalog_url: str,
76
127
  output_dir: Path,
@@ -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:
@@ -125,6 +178,12 @@ def test_load_registry_entries_skips_invalid_link_shapes() -> None:
125
178
  {"rel": "item", "href": "https://example.test/item.json"},
126
179
  {"rel": "child", "href": "https://example.test/missing-id/catalog.json"},
127
180
  {"rel": "child", "href": 5, "portolan_registry:id": "bad-href"},
181
+ {
182
+ "rel": "child",
183
+ "href": "javascript:alert(1)",
184
+ "portolan_registry:id": "unsafe-scheme",
185
+ "portolan_registry:status": "valid",
186
+ },
128
187
  {
129
188
  "rel": "child",
130
189
  "href": "https://example.test/no-status/catalog.json",
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "portolan-python"
471
- version = "0.1.6"
471
+ version = "0.1.8"
472
472
  source = { editable = "." }
473
473
 
474
474
  [package.optional-dependencies]