portolan-python 0.1.1__tar.gz → 0.1.3__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.1 → portolan_python-0.1.3}/.coverage +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/PKG-INFO +1 -1
- {portolan_python-0.1.1 → portolan_python-0.1.3}/pyproject.toml +1 -1
- {portolan_python-0.1.1 → portolan_python-0.1.3}/src/portolan/registry.py +55 -10
- {portolan_python-0.1.1 → portolan_python-0.1.3}/tests/test_registry_api.py +135 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/uv.lock +1 -1
- {portolan_python-0.1.1 → portolan_python-0.1.3}/.github/workflows/release.yml +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/.gitignore +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/Makefile +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/README.md +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/docs/development.md +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/docs/distribution.md +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/docs/examples.md +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/docs/scope.md +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/src/portolan/__init__.py +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/src/portolan/catalog.py +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/src/portolan/py.typed +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/src/portolan/validation.py +0 -0
- {portolan_python-0.1.1 → portolan_python-0.1.3}/tests/test_catalog_api.py +0 -0
|
Binary file
|
|
@@ -56,7 +56,7 @@ def load_registry_entries(
|
|
|
56
56
|
entries.append(
|
|
57
57
|
RegistryCatalogEntry(
|
|
58
58
|
id=registry_id,
|
|
59
|
-
url=href,
|
|
59
|
+
url=urljoin(registry_url, href),
|
|
60
60
|
title=title if isinstance(title, str) else None,
|
|
61
61
|
status=status if isinstance(status, str) else None,
|
|
62
62
|
)
|
|
@@ -106,9 +106,20 @@ def _write_catalog_tree(
|
|
|
106
106
|
root_url: str,
|
|
107
107
|
output_root: Path,
|
|
108
108
|
fetch_json: Callable[[str], JsonObject],
|
|
109
|
+
visited: set[str] | None = None,
|
|
110
|
+
targets: dict[Path, str] | None = None,
|
|
109
111
|
) -> None:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
if visited is None:
|
|
113
|
+
visited = set()
|
|
114
|
+
if targets is None:
|
|
115
|
+
targets = {}
|
|
116
|
+
visited.add(document_url)
|
|
117
|
+
|
|
118
|
+
target = _target_document_path(root_url, document_url, output_root)
|
|
119
|
+
owner = targets.get(target)
|
|
120
|
+
if owner is not None and owner != document_url:
|
|
121
|
+
raise ValueError(f"Registry documents map to the same local path: {owner}, {document_url}")
|
|
122
|
+
targets[target] = document_url
|
|
112
123
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
113
124
|
if document.get("type") == "Collection":
|
|
114
125
|
document = _with_absolute_asset_hrefs(document_url, document)
|
|
@@ -121,9 +132,25 @@ def _write_catalog_tree(
|
|
|
121
132
|
if not isinstance(href, str):
|
|
122
133
|
continue
|
|
123
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
|
+
)
|
|
124
143
|
child = fetch_json(child_url)
|
|
125
144
|
if child.get("type") in {"Catalog", "Collection"}:
|
|
126
|
-
_write_catalog_tree(
|
|
145
|
+
_write_catalog_tree(
|
|
146
|
+
child_url,
|
|
147
|
+
child,
|
|
148
|
+
root_url,
|
|
149
|
+
output_root,
|
|
150
|
+
fetch_json,
|
|
151
|
+
visited,
|
|
152
|
+
targets,
|
|
153
|
+
)
|
|
127
154
|
|
|
128
155
|
|
|
129
156
|
def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> JsonObject:
|
|
@@ -148,13 +175,31 @@ def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> Jso
|
|
|
148
175
|
|
|
149
176
|
|
|
150
177
|
def _relative_document_path(root_url: str, document_url: str) -> Path:
|
|
151
|
-
|
|
152
|
-
|
|
178
|
+
root = urlparse(root_url)
|
|
179
|
+
document = urlparse(document_url)
|
|
180
|
+
if (document.scheme.lower(), document.netloc.lower()) != (
|
|
181
|
+
root.scheme.lower(),
|
|
182
|
+
root.netloc.lower(),
|
|
183
|
+
):
|
|
184
|
+
raise ValueError(f"Child document has a different origin: {document_url}")
|
|
185
|
+
root_path = Path(root.path).parent
|
|
186
|
+
document_path = Path(document.path)
|
|
153
187
|
try:
|
|
154
|
-
|
|
155
|
-
except ValueError:
|
|
156
|
-
|
|
157
|
-
|
|
188
|
+
relative_path = document_path.relative_to(root_path)
|
|
189
|
+
except ValueError as err:
|
|
190
|
+
raise ValueError(f"Child document is outside catalog root: {document_url}") from err
|
|
191
|
+
if ".." in relative_path.parts:
|
|
192
|
+
raise ValueError(f"Child document is outside catalog root: {document_url}")
|
|
193
|
+
return relative_path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _target_document_path(root_url: str, document_url: str, output_root: Path) -> Path:
|
|
197
|
+
relative_path = _relative_document_path(root_url, document_url)
|
|
198
|
+
resolved_root = output_root.resolve()
|
|
199
|
+
target = (output_root / relative_path).resolve()
|
|
200
|
+
if not target.is_relative_to(resolved_root):
|
|
201
|
+
raise ValueError(f"Child document escapes catalog root: {document_url}")
|
|
202
|
+
return target
|
|
158
203
|
|
|
159
204
|
|
|
160
205
|
def _fallback_catalog_id(catalog_url: str) -> str:
|
|
@@ -57,6 +57,26 @@ def test_load_registry_entries_filters_to_valid_children() -> None:
|
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_load_registry_entries_resolves_relative_child_urls() -> None:
|
|
61
|
+
registry = {
|
|
62
|
+
"links": [
|
|
63
|
+
{
|
|
64
|
+
"rel": "child",
|
|
65
|
+
"href": "../demo/catalog.json",
|
|
66
|
+
"portolan_registry:id": "demo",
|
|
67
|
+
"portolan_registry:status": "valid",
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
entries = load_registry_entries(
|
|
73
|
+
"https://registry.test/exports/catalogs.json",
|
|
74
|
+
fetch_json=lambda url: registry,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert entries[0].url == "https://registry.test/demo/catalog.json"
|
|
78
|
+
|
|
79
|
+
|
|
60
80
|
def test_load_registry_entries_supports_catalog_ids_include_stale_and_limit() -> None:
|
|
61
81
|
registry = {
|
|
62
82
|
"links": [
|
|
@@ -169,6 +189,121 @@ def test_download_registry_catalog_writes_snapshot_with_absolute_asset_hrefs(
|
|
|
169
189
|
assert collection["assets"]["data"]["href"] == "https://example.test/demo/roads/roads.parquet"
|
|
170
190
|
|
|
171
191
|
|
|
192
|
+
def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
|
|
193
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
194
|
+
child_url = "https://example.test/demo/roads/collection.json"
|
|
195
|
+
responses = {
|
|
196
|
+
root_url: {
|
|
197
|
+
"type": "Catalog",
|
|
198
|
+
"id": "demo",
|
|
199
|
+
"links": [{"rel": "child", "href": "./roads/collection.json"}],
|
|
200
|
+
},
|
|
201
|
+
child_url: {
|
|
202
|
+
**_collection("roads", {}),
|
|
203
|
+
"links": [{"rel": "child", "href": "../catalog.json"}],
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
fetched_urls: list[str] = []
|
|
207
|
+
|
|
208
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
209
|
+
fetched_urls.append(url)
|
|
210
|
+
if fetched_urls.count(url) > 1:
|
|
211
|
+
raise AssertionError(f"Fetched document twice: {url}")
|
|
212
|
+
return responses[url]
|
|
213
|
+
|
|
214
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
215
|
+
|
|
216
|
+
assert fetched_urls == [root_url, child_url]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.parametrize(
|
|
220
|
+
("child_url", "message"),
|
|
221
|
+
[
|
|
222
|
+
("https://other.test/demo/collection.json", "different origin"),
|
|
223
|
+
("https://example.test/demo/../../outside.json", "outside catalog root"),
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
def test_download_registry_catalog_rejects_unsafe_child_urls_before_fetch(
|
|
227
|
+
child_url: str,
|
|
228
|
+
message: str,
|
|
229
|
+
tmp_path: Path,
|
|
230
|
+
) -> None:
|
|
231
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
232
|
+
catalog = {
|
|
233
|
+
"type": "Catalog",
|
|
234
|
+
"id": "demo",
|
|
235
|
+
"links": [{"rel": "child", "href": child_url}],
|
|
236
|
+
}
|
|
237
|
+
fetched_urls: list[str] = []
|
|
238
|
+
|
|
239
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
240
|
+
fetched_urls.append(url)
|
|
241
|
+
if url != root_url:
|
|
242
|
+
raise AssertionError(f"Fetched unsafe child URL: {url}")
|
|
243
|
+
return catalog
|
|
244
|
+
|
|
245
|
+
with pytest.raises(ValueError, match=message):
|
|
246
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
247
|
+
|
|
248
|
+
assert fetched_urls == [root_url]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_download_registry_catalog_rejects_symlink_escape(tmp_path: Path) -> None:
|
|
252
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
253
|
+
child_url = "https://example.test/demo/linked/collection.json"
|
|
254
|
+
output_dir = tmp_path / "output"
|
|
255
|
+
outside_dir = tmp_path / "outside"
|
|
256
|
+
catalog_root = output_dir / "demo"
|
|
257
|
+
outside_dir.mkdir()
|
|
258
|
+
catalog_root.mkdir(parents=True)
|
|
259
|
+
(catalog_root / "linked").symlink_to(outside_dir, target_is_directory=True)
|
|
260
|
+
responses = {
|
|
261
|
+
root_url: {
|
|
262
|
+
"type": "Catalog",
|
|
263
|
+
"id": "demo",
|
|
264
|
+
"links": [{"rel": "child", "href": child_url}],
|
|
265
|
+
},
|
|
266
|
+
child_url: _collection("linked", {}),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
with pytest.raises(ValueError, match="escapes catalog root"):
|
|
270
|
+
download_registry_catalog(
|
|
271
|
+
root_url,
|
|
272
|
+
output_dir,
|
|
273
|
+
fetch_json=lambda url: responses[url],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
assert not (outside_dir / "collection.json").exists()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_download_registry_catalog_rejects_local_path_collisions(tmp_path: Path) -> None:
|
|
280
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
281
|
+
first_url = "https://example.test/demo/collection.json?version=1"
|
|
282
|
+
second_url = "https://example.test/demo/collection.json?version=2"
|
|
283
|
+
responses = {
|
|
284
|
+
root_url: {
|
|
285
|
+
"type": "Catalog",
|
|
286
|
+
"id": "demo",
|
|
287
|
+
"links": [
|
|
288
|
+
{"rel": "child", "href": first_url},
|
|
289
|
+
{"rel": "child", "href": second_url},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
first_url: _collection("first", {}),
|
|
293
|
+
second_url: _collection("second", {}),
|
|
294
|
+
}
|
|
295
|
+
fetched_urls: list[str] = []
|
|
296
|
+
|
|
297
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
298
|
+
fetched_urls.append(url)
|
|
299
|
+
return responses[url]
|
|
300
|
+
|
|
301
|
+
with pytest.raises(ValueError, match="same local path"):
|
|
302
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
303
|
+
|
|
304
|
+
assert fetched_urls == [root_url, first_url]
|
|
305
|
+
|
|
306
|
+
|
|
172
307
|
def test_download_registry_catalog_recurses_nested_catalogs_and_uses_fallback_id(
|
|
173
308
|
tmp_path: Path,
|
|
174
309
|
) -> None:
|
|
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
|
|
File without changes
|
|
File without changes
|