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.
@@ -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 "${GITHUB_REF_NAME}" = "v${package_version}"
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@v4
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
- run: gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes --verify-tag
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@v4
76
+ uses: actions/download-artifact@v8
60
77
  with:
61
78
  name: distributions
62
79
  path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: portolan-python
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A lightweight Python implementation of the Portolan specification.
5
5
  Author: Portolan contributors
6
6
  License: Apache-2.0
@@ -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 once its trusted publisher is configured.
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
- ## Publish to PyPI
33
+ ## Install from PyPI
34
34
 
35
- The workflow supports PyPI trusted publishing. It uses a short-lived identity
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
- PyPI does not permit replacing a published version. Increase the version before
54
- you publish another release.
41
+ Omit the version constraint to install the latest published version.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "portolan-python"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "A lightweight Python implementation of the Portolan specification."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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)
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "portolan-python"
471
- version = "0.1.0"
471
+ version = "0.1.2"
472
472
  source = { editable = "." }
473
473
 
474
474
  [package.optional-dependencies]