portolan-python 0.1.0__tar.gz → 0.1.2__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.0 → portolan_python-0.1.2}/.coverage +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/.github/workflows/release.yml +22 -5
- {portolan_python-0.1.0 → portolan_python-0.1.2}/PKG-INFO +1 -1
- {portolan_python-0.1.0 → portolan_python-0.1.2}/docs/distribution.md +4 -17
- {portolan_python-0.1.0 → portolan_python-0.1.2}/pyproject.toml +1 -1
- {portolan_python-0.1.0 → portolan_python-0.1.2}/src/portolan/registry.py +18 -1
- {portolan_python-0.1.0 → portolan_python-0.1.2}/tests/test_registry_api.py +43 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/uv.lock +1 -1
- {portolan_python-0.1.0 → portolan_python-0.1.2}/.gitignore +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/Makefile +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/README.md +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/docs/development.md +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/docs/examples.md +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/docs/scope.md +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/src/portolan/__init__.py +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/src/portolan/catalog.py +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/src/portolan/py.typed +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/src/portolan/validation.py +0 -0
- {portolan_python-0.1.0 → portolan_python-0.1.2}/tests/test_catalog_api.py +0 -0
|
Binary file
|
|
@@ -4,6 +4,15 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
6
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
tag:
|
|
10
|
+
description: Existing version tag to publish
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
|
7
16
|
|
|
8
17
|
jobs:
|
|
9
18
|
release:
|
|
@@ -12,6 +21,8 @@ jobs:
|
|
|
12
21
|
contents: write
|
|
13
22
|
steps:
|
|
14
23
|
- uses: actions/checkout@v6
|
|
24
|
+
with:
|
|
25
|
+
ref: ${{ env.RELEASE_TAG }}
|
|
15
26
|
|
|
16
27
|
- uses: actions/setup-python@v6
|
|
17
28
|
with:
|
|
@@ -25,7 +36,7 @@ jobs:
|
|
|
25
36
|
shell: bash
|
|
26
37
|
run: |
|
|
27
38
|
package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
|
|
28
|
-
test "${
|
|
39
|
+
test "${RELEASE_TAG}" = "v${package_version}"
|
|
29
40
|
|
|
30
41
|
- name: Test
|
|
31
42
|
run: pytest
|
|
@@ -37,7 +48,7 @@ jobs:
|
|
|
37
48
|
run: python -m twine check dist/*
|
|
38
49
|
|
|
39
50
|
- name: Preserve distributions for PyPI
|
|
40
|
-
uses: actions/upload-artifact@
|
|
51
|
+
uses: actions/upload-artifact@v7
|
|
41
52
|
with:
|
|
42
53
|
name: distributions
|
|
43
54
|
path: dist/
|
|
@@ -45,18 +56,24 @@ jobs:
|
|
|
45
56
|
- name: Create GitHub release
|
|
46
57
|
env:
|
|
47
58
|
GH_TOKEN: ${{ github.token }}
|
|
48
|
-
|
|
59
|
+
shell: bash
|
|
60
|
+
run: |
|
|
61
|
+
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
|
62
|
+
gh release upload "${RELEASE_TAG}" dist/* --clobber
|
|
63
|
+
else
|
|
64
|
+
gh release create "${RELEASE_TAG}" dist/* --generate-notes --verify-tag
|
|
65
|
+
fi
|
|
49
66
|
|
|
50
67
|
publish-pypi:
|
|
51
68
|
needs: release
|
|
52
|
-
if: vars.PUBLISH_PYPI == 'true'
|
|
69
|
+
if: vars.PUBLISH_PYPI == 'true' || github.event_name == 'workflow_dispatch'
|
|
53
70
|
runs-on: ubuntu-latest
|
|
54
71
|
environment: pypi
|
|
55
72
|
permissions:
|
|
56
73
|
id-token: write
|
|
57
74
|
steps:
|
|
58
75
|
- name: Download distributions
|
|
59
|
-
uses: actions/download-artifact@
|
|
76
|
+
uses: actions/download-artifact@v8
|
|
60
77
|
with:
|
|
61
78
|
name: distributions
|
|
62
79
|
path: dist/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Distribution
|
|
2
2
|
|
|
3
3
|
Tagged releases provide immutable Python source and wheel archives. PyPI is the
|
|
4
|
-
preferred package index
|
|
4
|
+
preferred package index for published versions.
|
|
5
5
|
|
|
6
6
|
## Release artifacts
|
|
7
7
|
|
|
@@ -30,25 +30,12 @@ python -m pip install \
|
|
|
30
30
|
|
|
31
31
|
This path needs no package index account.
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## Install from PyPI
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
token, so the repository does not store a permanent PyPI API token.
|
|
37
|
-
|
|
38
|
-
Complete these steps once:
|
|
39
|
-
|
|
40
|
-
1. Create the `portolan-python` project on PyPI, or add a pending publisher.
|
|
41
|
-
2. Add a trusted GitHub publisher for `jemacchi/portolan-python`.
|
|
42
|
-
3. Set the workflow name to `release.yml` and the environment to `pypi`.
|
|
43
|
-
4. Create the `pypi` environment in the GitHub repository.
|
|
44
|
-
5. Add the repository variable `PUBLISH_PYPI` with the value `true`.
|
|
45
|
-
|
|
46
|
-
After this setup, each valid version tag also publishes to PyPI. Consumers can
|
|
47
|
-
then use the normal command:
|
|
35
|
+
Install a published version from PyPI with:
|
|
48
36
|
|
|
49
37
|
```bash
|
|
50
38
|
python -m pip install portolan-python==0.1.0
|
|
51
39
|
```
|
|
52
40
|
|
|
53
|
-
|
|
54
|
-
you publish another release.
|
|
41
|
+
Omit the version constraint to install the latest published version.
|
|
@@ -73,15 +73,19 @@ def download_registry_catalog(
|
|
|
73
73
|
fetch_json: Callable[[str], JsonObject] | None = None,
|
|
74
74
|
) -> Path:
|
|
75
75
|
"""Download a published catalog snapshot for local workflows."""
|
|
76
|
+
_validate_remote_url(catalog_url)
|
|
76
77
|
fetch = fetch_json or _fetch_json
|
|
77
78
|
catalog = fetch(catalog_url)
|
|
78
79
|
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}")
|
|
79
82
|
catalog_root = output_dir / catalog_id
|
|
80
83
|
_write_catalog_tree(catalog_url, catalog, catalog_url, catalog_root, fetch)
|
|
81
84
|
return catalog_root
|
|
82
85
|
|
|
83
86
|
|
|
84
87
|
def _fetch_json(url: str) -> JsonObject:
|
|
88
|
+
_validate_remote_url(url)
|
|
85
89
|
request = Request(url, headers={"User-Agent": "portolan-python"})
|
|
86
90
|
with urlopen(request, timeout=30) as response:
|
|
87
91
|
data = json.loads(response.read().decode("utf-8"))
|
|
@@ -90,13 +94,24 @@ def _fetch_json(url: str) -> JsonObject:
|
|
|
90
94
|
return data
|
|
91
95
|
|
|
92
96
|
|
|
97
|
+
def _validate_remote_url(url: str) -> None:
|
|
98
|
+
parsed = urlparse(url)
|
|
99
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
100
|
+
raise ValueError(f"Registry URL must use HTTP or HTTPS: {url}")
|
|
101
|
+
|
|
102
|
+
|
|
93
103
|
def _write_catalog_tree(
|
|
94
104
|
document_url: str,
|
|
95
105
|
document: JsonObject,
|
|
96
106
|
root_url: str,
|
|
97
107
|
output_root: Path,
|
|
98
108
|
fetch_json: Callable[[str], JsonObject],
|
|
109
|
+
visited: set[str] | None = None,
|
|
99
110
|
) -> None:
|
|
111
|
+
if visited is None:
|
|
112
|
+
visited = set()
|
|
113
|
+
visited.add(document_url)
|
|
114
|
+
|
|
100
115
|
relative_path = _relative_document_path(root_url, document_url)
|
|
101
116
|
target = output_root / relative_path
|
|
102
117
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -111,9 +126,11 @@ def _write_catalog_tree(
|
|
|
111
126
|
if not isinstance(href, str):
|
|
112
127
|
continue
|
|
113
128
|
child_url = urljoin(document_url, href)
|
|
129
|
+
if child_url in visited:
|
|
130
|
+
continue
|
|
114
131
|
child = fetch_json(child_url)
|
|
115
132
|
if child.get("type") in {"Catalog", "Collection"}:
|
|
116
|
-
_write_catalog_tree(child_url, child, root_url, output_root, fetch_json)
|
|
133
|
+
_write_catalog_tree(child_url, child, root_url, output_root, fetch_json, visited)
|
|
117
134
|
|
|
118
135
|
|
|
119
136
|
def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> JsonObject:
|
|
@@ -169,6 +169,33 @@ def test_download_registry_catalog_writes_snapshot_with_absolute_asset_hrefs(
|
|
|
169
169
|
assert collection["assets"]["data"]["href"] == "https://example.test/demo/roads/roads.parquet"
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
|
|
173
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
174
|
+
child_url = "https://example.test/demo/roads/collection.json"
|
|
175
|
+
responses = {
|
|
176
|
+
root_url: {
|
|
177
|
+
"type": "Catalog",
|
|
178
|
+
"id": "demo",
|
|
179
|
+
"links": [{"rel": "child", "href": "./roads/collection.json"}],
|
|
180
|
+
},
|
|
181
|
+
child_url: {
|
|
182
|
+
**_collection("roads", {}),
|
|
183
|
+
"links": [{"rel": "child", "href": "../catalog.json"}],
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
fetched_urls: list[str] = []
|
|
187
|
+
|
|
188
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
189
|
+
fetched_urls.append(url)
|
|
190
|
+
if fetched_urls.count(url) > 1:
|
|
191
|
+
raise AssertionError(f"Fetched document twice: {url}")
|
|
192
|
+
return responses[url]
|
|
193
|
+
|
|
194
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
195
|
+
|
|
196
|
+
assert fetched_urls == [root_url, child_url]
|
|
197
|
+
|
|
198
|
+
|
|
172
199
|
def test_download_registry_catalog_recurses_nested_catalogs_and_uses_fallback_id(
|
|
173
200
|
tmp_path: Path,
|
|
174
201
|
) -> None:
|
|
@@ -280,3 +307,19 @@ def test_download_registry_catalog_handles_collections_without_asset_objects(
|
|
|
280
307
|
(catalog_root / "roads" / "collection.json").read_text(encoding="utf-8")
|
|
281
308
|
)
|
|
282
309
|
assert collection["assets"] == []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_download_registry_catalog_rejects_id_outside_output_directory(tmp_path: Path) -> None:
|
|
313
|
+
catalog = {"type": "Catalog", "id": "../../outside", "links": []}
|
|
314
|
+
|
|
315
|
+
with pytest.raises(ValueError, match="safe directory name"):
|
|
316
|
+
download_registry_catalog(
|
|
317
|
+
"https://example.test/demo/catalog.json",
|
|
318
|
+
tmp_path,
|
|
319
|
+
fetch_json=lambda url: catalog,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_download_registry_catalog_rejects_non_http_url(tmp_path: Path) -> None:
|
|
324
|
+
with pytest.raises(ValueError, match="must use HTTP or HTTPS"):
|
|
325
|
+
download_registry_catalog("file:///tmp/catalog.json", tmp_path)
|
|
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
|