Skip to content

Commit 9246556

Browse files
committed
No pystac serialization — avoids all pystac link-reordering and href-transformation side effects
1 parent 74ead07 commit 9246556

6 files changed

Lines changed: 237 additions & 129 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@
7373
`zarr-consolidated-metadata` (`.zmetadata`).
7474
- The S3 STAC catalog and item are written directly to S3 via `fsspec`/`s3fs`
7575
independently of the GitHub PR.
76-
- The OSC STAC Collection gains a `child` link pointing to the S3 catalog root,
77-
connecting the two levels of the hierarchy.
76+
- The OSC STAC Collection gains a `via` link pointing to the S3 catalog root,
77+
connecting the two levels of the hierarchy. (`child` is intentionally avoided
78+
because the OSC validator requires every `child` link to resolve to a file inside
79+
the metadata repository.)
7880
- Opt-in via the new `stac_catalog_s3_root` field in `dataset_config.yaml`
7981
(e.g. `stac_catalog_s3_root: s3://my-bucket/stac/my-collection/`).
8082
- S3 write credentials are resolved from `S3_USER_STORAGE_KEY`/`S3_USER_STORAGE_SECRET`,

deep_code/tests/tools/test_publish.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,10 @@ def test_get_stac_s3_storage_options_prefers_xcube_env_vars(self):
190190
opts = self.publisher._get_stac_s3_storage_options()
191191
self.assertEqual(opts["key"], "xcube-key")
192192
self.assertEqual(opts["secret"], "xcube-secret")
193+
self.assertEqual(opts["s3_additional_kwargs"], {"ACL": ""})
193194

194195
def test_get_stac_s3_storage_options_falls_back_to_aws_env_vars(self):
195196
env = {"AWS_ACCESS_KEY_ID": "aws-key", "AWS_SECRET_ACCESS_KEY": "aws-secret"}
196-
# Ensure xcube vars are absent
197197
patched_env = {
198198
k: v
199199
for k, v in os.environ.items()
@@ -204,8 +204,9 @@ def test_get_stac_s3_storage_options_falls_back_to_aws_env_vars(self):
204204
opts = self.publisher._get_stac_s3_storage_options()
205205
self.assertEqual(opts["key"], "aws-key")
206206
self.assertEqual(opts["secret"], "aws-secret")
207+
self.assertEqual(opts["s3_additional_kwargs"], {"ACL": ""})
207208

208-
def test_get_stac_s3_storage_options_returns_empty_for_boto3_chain(self):
209+
def test_get_stac_s3_storage_options_returns_acl_suppression_for_boto3_chain(self):
209210
no_cred_env = {
210211
k: v
211212
for k, v in os.environ.items()
@@ -219,7 +220,7 @@ def test_get_stac_s3_storage_options_returns_empty_for_boto3_chain(self):
219220
}
220221
with patch.dict(os.environ, no_cred_env, clear=True):
221222
opts = self.publisher._get_stac_s3_storage_options()
222-
self.assertEqual(opts, {})
223+
self.assertEqual(opts, {"s3_additional_kwargs": {"ACL": ""}})
223224

224225
# ------------------------------------------------------------------
225226
# S3 write helper

deep_code/tests/utils/test_dataset_stac_generator.py

Lines changed: 128 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from unittest.mock import MagicMock, patch
99

1010
import numpy as np
11-
from pystac import Catalog, Collection, Item
11+
from pystac import Catalog, Item
1212
from xarray import DataArray, Dataset
1313

1414
from deep_code.constants import (
@@ -141,42 +141,115 @@ def test_build_variable_catalog(self, mock_add_themes, mock_add_gcmd):
141141
# Self href ends with var1/catalog.json
142142
self.assertTrue(catalog.self_href.endswith("/var1/catalog.json"))
143143

144-
@patch("pystac.Catalog.from_file")
145-
def test_update_product_base_catalog(self, mock_from_file):
146-
"""Test linking product catalog."""
147-
mock_cat = MagicMock(spec=Catalog)
148-
mock_from_file.return_value = mock_cat
149-
150-
result = self.generator.update_product_base_catalog("path.json")
151-
self.assertIs(result, mock_cat)
152-
mock_cat.add_link.assert_called_once()
153-
mock_cat.set_self_href.assert_called_once_with(PRODUCT_BASE_CATALOG_SELF_HREF)
154-
155-
@patch("pystac.Catalog.from_file")
156-
def test_update_variable_base_catalog(self, mock_from_file):
157-
"""Test linking variable base catalog."""
158-
mock_cat = MagicMock(spec=Catalog)
159-
mock_from_file.return_value = mock_cat
144+
def test_update_product_base_catalog(self):
145+
"""Child link is appended; existing links (including self) are untouched."""
146+
base = {
147+
"type": "Catalog",
148+
"id": "products",
149+
"stac_version": "1.0.0",
150+
"description": "Products",
151+
"links": [
152+
{
153+
"rel": "self",
154+
"href": PRODUCT_BASE_CATALOG_SELF_HREF,
155+
"type": "application/json",
156+
}
157+
],
158+
}
159+
import tempfile, json as _json
160+
161+
with tempfile.NamedTemporaryFile(
162+
mode="w", suffix=".json", delete=False
163+
) as tmp:
164+
_json.dump(base, tmp)
165+
tmp_path = tmp.name
166+
167+
result = self.generator.update_product_base_catalog(tmp_path)
168+
169+
import os
170+
171+
os.unlink(tmp_path)
172+
173+
self.assertIsInstance(result, dict)
174+
rels = [lnk["rel"] for lnk in result["links"]]
175+
# self link must still be present and still first
176+
self.assertEqual(result["links"][0]["rel"], "self")
177+
self.assertEqual(result["links"][0]["href"], PRODUCT_BASE_CATALOG_SELF_HREF)
178+
self.assertIn("child", rels)
179+
child = next(lnk for lnk in result["links"] if lnk["rel"] == "child")
180+
self.assertIn("mock-collection-id", child["href"])
181+
182+
def test_update_variable_base_catalog(self):
183+
"""Child links for each variable are appended."""
184+
base = {
185+
"type": "Catalog",
186+
"id": "variables",
187+
"stac_version": "1.0.0",
188+
"description": "Variables",
189+
"links": [
190+
{
191+
"rel": "self",
192+
"href": VARIABLE_BASE_CATALOG_SELF_HREF,
193+
"type": "application/json",
194+
}
195+
],
196+
}
197+
import tempfile, json as _json, os
198+
199+
with tempfile.NamedTemporaryFile(
200+
mode="w", suffix=".json", delete=False
201+
) as tmp:
202+
_json.dump(base, tmp)
203+
tmp_path = tmp.name
160204

161205
vars_ = ["v1", "v2"]
162-
result = self.generator.update_variable_base_catalog("vars.json", vars_)
163-
self.assertIs(result, mock_cat)
164-
# Expect one add_link per variable
165-
self.assertEqual(mock_cat.add_link.call_count, len(vars_))
166-
mock_cat.set_self_href.assert_called_once_with(VARIABLE_BASE_CATALOG_SELF_HREF)
167-
168-
@patch("pystac.Collection.from_file")
169-
def test_update_deepesdl_collection(self, mock_from_file):
170-
"""Test updating DeepESDL collection."""
171-
mock_coll = MagicMock(spec=Collection)
172-
mock_from_file.return_value = mock_coll
173-
174-
result = self.generator.update_deepesdl_collection("deep.json")
175-
self.assertIs(result, mock_coll)
176-
# Expect child and theme related links for each theme
177-
calls = mock_coll.add_link.call_count
178-
self.assertGreaterEqual(calls, 1 + len(self.generator.osc_themes))
179-
mock_coll.set_self_href.assert_called_once_with(DEEPESDL_COLLECTION_SELF_HREF)
206+
result = self.generator.update_variable_base_catalog(tmp_path, vars_)
207+
os.unlink(tmp_path)
208+
209+
self.assertIsInstance(result, dict)
210+
child_hrefs = [
211+
lnk["href"] for lnk in result["links"] if lnk["rel"] == "child"
212+
]
213+
self.assertEqual(len(child_hrefs), len(vars_))
214+
# self link must remain in place
215+
self.assertEqual(result["links"][0]["rel"], "self")
216+
217+
def test_update_deepesdl_collection(self):
218+
"""Child and theme-related links are appended; existing links kept."""
219+
base = {
220+
"type": "Collection",
221+
"id": "deep-esdl",
222+
"stac_version": "1.0.0",
223+
"description": "DeepESDL",
224+
"extent": {},
225+
"links": [
226+
{
227+
"rel": "self",
228+
"href": DEEPESDL_COLLECTION_SELF_HREF,
229+
"type": "application/json",
230+
}
231+
],
232+
}
233+
import tempfile, json as _json, os
234+
235+
with tempfile.NamedTemporaryFile(
236+
mode="w", suffix=".json", delete=False
237+
) as tmp:
238+
_json.dump(base, tmp)
239+
tmp_path = tmp.name
240+
241+
result = self.generator.update_deepesdl_collection(tmp_path)
242+
os.unlink(tmp_path)
243+
244+
self.assertIsInstance(result, dict)
245+
rels = [lnk["rel"] for lnk in result["links"]]
246+
# child link added
247+
self.assertIn("child", rels)
248+
# one related link per theme
249+
related = [lnk for lnk in result["links"] if lnk["rel"] == "related"]
250+
self.assertGreaterEqual(len(related), len(self.generator.osc_themes))
251+
# self link still present
252+
self.assertEqual(result["links"][0]["rel"], "self")
180253

181254
# ------------------------------------------------------------------
182255
# Zarr STAC Item / Catalog generation
@@ -280,31 +353,39 @@ def test_build_zarr_stac_catalog_file_dict_content(self):
280353
self.assertIn("zarr-data", item_dict["assets"])
281354
self.assertIn("zarr-consolidated-metadata", item_dict["assets"])
282355

283-
def test_build_dataset_stac_collection_adds_s3_catalog_child_link(self):
284-
"""Child link to S3 catalog is added when stac_catalog_s3_root is provided."""
356+
def test_build_dataset_stac_collection_adds_s3_catalog_via_link(self):
357+
"""A 'via' link to the S3 catalog is added when stac_catalog_s3_root is provided.
358+
359+
rel='via' is used (not 'child') because the OSC validator requires every
360+
'child' link to resolve to a file inside the metadata repository.
361+
"""
285362
s3_root = "s3://test-bucket/stac/my-collection/"
286363
collection = self.generator.build_dataset_stac_collection(
287364
mode="dataset", stac_catalog_s3_root=s3_root
288365
)
289-
child_links = [lnk for lnk in collection.links if lnk.rel == "child"]
290-
s3_child = next(
291-
(lnk for lnk in child_links if "s3://" in str(lnk.target)), None
366+
s3_via = next(
367+
(
368+
lnk
369+
for lnk in collection.links
370+
if lnk.rel == "via" and "catalog.json" in str(lnk.target)
371+
),
372+
None,
292373
)
293-
self.assertIsNotNone(s3_child, "Expected a child link pointing to S3 catalog")
374+
self.assertIsNotNone(s3_via, "Expected a 'via' link pointing to S3 catalog")
294375
self.assertEqual(
295-
s3_child.target,
376+
s3_via.target,
296377
"s3://test-bucket/stac/my-collection/catalog.json",
297378
)
298379

299-
def test_build_dataset_stac_collection_no_s3_child_link_by_default(self):
300-
"""No S3 child link is added when stac_catalog_s3_root is absent."""
380+
def test_build_dataset_stac_collection_no_s3_via_link_by_default(self):
381+
"""No S3 catalog 'via' link is added when stac_catalog_s3_root is absent."""
301382
collection = self.generator.build_dataset_stac_collection(mode="dataset")
302-
s3_child_links = [
383+
s3_catalog_links = [
303384
lnk
304385
for lnk in collection.links
305-
if lnk.rel == "child" and "s3://" in str(getattr(lnk, "target", ""))
386+
if lnk.rel == "via" and "catalog.json" in str(getattr(lnk, "target", ""))
306387
]
307-
self.assertEqual(len(s3_child_links), 0)
388+
self.assertEqual(len(s3_catalog_links), 0)
308389

309390

310391
class TestFormatString(unittest.TestCase):

deep_code/tools/publish.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,7 @@ def _update_and_add_to_file_dict(
215215
full_path = (
216216
Path(self.gh_publisher.github_automation.local_clone_dir) / catalog_path
217217
)
218-
updated_catalog = update_method(full_path, *args)
219-
file_dict[full_path] = updated_catalog.to_dict()
218+
file_dict[full_path] = update_method(full_path, *args)
220219

221220
def _update_variable_catalogs(self, generator, file_dict, variable_ids):
222221
"""Update or create variable catalogs and add them to file_dict.
@@ -243,10 +242,9 @@ def _update_variable_catalogs(self, generator, file_dict, variable_ids):
243242
Path(self.gh_publisher.github_automation.local_clone_dir)
244243
/ var_file_path
245244
)
246-
updated_catalog = generator.update_existing_variable_catalog(
245+
file_dict[var_file_path] = generator.update_existing_variable_catalog(
247246
full_path, var_id
248247
)
249-
file_dict[var_file_path] = updated_catalog.to_dict()
250248

251249
def publish_dataset(
252250
self,
@@ -545,10 +543,14 @@ def _get_stac_s3_storage_options(self) -> dict:
545543
secret = os.environ.get("S3_USER_STORAGE_SECRET") or os.environ.get(
546544
"AWS_SECRET_ACCESS_KEY"
547545
)
546+
# s3_additional_kwargs={"ACL": ""} suppresses the ACL header that s3fs
547+
# adds by default; required when Object Ownership is set to
548+
# BucketOwnerEnforced (ACLs disabled) to avoid AccessDenied errors.
549+
base = {"s3_additional_kwargs": {"ACL": ""}}
548550
if key and secret:
549-
return {"key": key, "secret": secret}
551+
return {"key": key, "secret": secret, **base}
550552
# Fall through to boto3 chain (IAM role / ~/.aws/credentials)
551-
return {}
553+
return base
552554

553555
def _write_stac_catalog_to_s3(
554556
self, file_dict: dict[str, dict], storage_options: dict

0 commit comments

Comments
 (0)