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.
- {portolan_python-0.1.3 → portolan_python-0.1.5}/.coverage +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/PKG-INFO +1 -1
- {portolan_python-0.1.3 → portolan_python-0.1.5}/docs/distribution.md +4 -4
- {portolan_python-0.1.3 → portolan_python-0.1.5}/docs/examples.md +2 -2
- {portolan_python-0.1.3 → portolan_python-0.1.5}/pyproject.toml +1 -1
- {portolan_python-0.1.3 → portolan_python-0.1.5}/src/portolan/registry.py +143 -31
- {portolan_python-0.1.3 → portolan_python-0.1.5}/tests/test_registry_api.py +118 -5
- {portolan_python-0.1.3 → portolan_python-0.1.5}/uv.lock +1 -1
- {portolan_python-0.1.3 → portolan_python-0.1.5}/.github/workflows/release.yml +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/.gitignore +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/Makefile +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/README.md +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/docs/development.md +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/docs/scope.md +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/src/portolan/__init__.py +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/src/portolan/catalog.py +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/src/portolan/py.typed +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/src/portolan/validation.py +0 -0
- {portolan_python-0.1.3 → portolan_python-0.1.5}/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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
20
|
+
"portolan-python @ git+https://github.com/jemacchi/portolan-python.git@v0.1.5"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## List collections and assets
|
|
@@ -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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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"] == "
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
"https://example.test/demo/catalog.json",
|
|
272
334
|
output_dir,
|
|
273
|
-
fetch_json=lambda url:
|
|
335
|
+
fetch_json=lambda url: {"type": "Catalog", "id": "demo", "links": []},
|
|
274
336
|
)
|
|
275
337
|
|
|
276
|
-
assert
|
|
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:
|
|
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
|