Skip to content

Commit b8f5653

Browse files
committed
Made osc_project a configurable parameter
1 parent 9246556 commit b8f5653

4 files changed

Lines changed: 298 additions & 11 deletions

File tree

deep_code/tests/tools/test_publish.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,80 @@ def test_publish_writes_zarr_stac_to_s3_when_configured(
285285
# Two S3 files written: catalog.json + item.json
286286
self.assertEqual(mock_fsspec_open.call_count, 2)
287287

288+
# ------------------------------------------------------------------
289+
# Project collection create-vs-update branching
290+
# ------------------------------------------------------------------
291+
292+
@patch("deep_code.tools.publish.OscDatasetStacGenerator")
293+
def test_publish_dataset_creates_project_collection_when_missing(
294+
self, MockGenerator
295+
):
296+
"""When the project collection does not exist, build_project_collection is
297+
called and projects/catalog.json is updated via _update_and_add_to_file_dict."""
298+
mock_gen = MagicMock()
299+
mock_gen.osc_project = "test-project"
300+
mock_gen.get_variable_ids.return_value = []
301+
mock_gen.build_dataset_stac_collection.return_value.to_dict.return_value = {}
302+
mock_gen.build_project_collection.return_value = {
303+
"type": "Collection",
304+
"id": "test-project",
305+
}
306+
MockGenerator.return_value = mock_gen
307+
308+
self.publisher.dataset_config = {
309+
"dataset_id": "test-dataset",
310+
"collection_id": "test-collection",
311+
"license_type": "CC-BY-4.0",
312+
}
313+
self.publisher.collection_id = "test-collection"
314+
315+
# Project collection is missing; all other file_exists calls return True
316+
self.publisher.gh_publisher.github_automation.file_exists.return_value = False
317+
318+
with patch.object(self.publisher, "_update_and_add_to_file_dict") as mock_update, \
319+
patch.object(self.publisher, "_update_variable_catalogs"):
320+
file_dict = self.publisher.publish_dataset(write_to_file=False)
321+
322+
mock_gen.build_project_collection.assert_called_once()
323+
self.assertIn("projects/test-project/collection.json", file_dict)
324+
mock_gen.update_deepesdl_collection.assert_not_called()
325+
326+
# projects/catalog.json must be updated
327+
updated_paths = [call.args[1] for call in mock_update.call_args_list]
328+
self.assertIn("projects/catalog.json", updated_paths)
329+
330+
@patch("deep_code.tools.publish.OscDatasetStacGenerator")
331+
def test_publish_dataset_updates_project_collection_when_exists(
332+
self, MockGenerator
333+
):
334+
"""When the project collection exists, update_deepesdl_collection is called
335+
via _update_and_add_to_file_dict and build_project_collection is not called."""
336+
mock_gen = MagicMock()
337+
mock_gen.osc_project = "test-project"
338+
mock_gen.get_variable_ids.return_value = []
339+
mock_gen.build_dataset_stac_collection.return_value.to_dict.return_value = {}
340+
MockGenerator.return_value = mock_gen
341+
342+
self.publisher.dataset_config = {
343+
"dataset_id": "test-dataset",
344+
"collection_id": "test-collection",
345+
"license_type": "CC-BY-4.0",
346+
}
347+
self.publisher.collection_id = "test-collection"
348+
349+
# Project collection already exists
350+
self.publisher.gh_publisher.github_automation.file_exists.return_value = True
351+
352+
with patch.object(self.publisher, "_update_and_add_to_file_dict") as mock_update, \
353+
patch.object(self.publisher, "_update_variable_catalogs"):
354+
self.publisher.publish_dataset(write_to_file=False)
355+
356+
mock_gen.build_project_collection.assert_not_called()
357+
358+
# update_deepesdl_collection passed to _update_and_add_to_file_dict
359+
update_methods = [call.args[2] for call in mock_update.call_args_list]
360+
self.assertIn(mock_gen.update_deepesdl_collection, update_methods)
361+
288362
@patch.object(Publisher, "publish_dataset", return_value={"github_file.json": {}})
289363
def test_publish_skips_zarr_stac_when_not_configured(self, mock_publish_ds):
290364
# No stac_catalog_s3_root in config

deep_code/tests/utils/test_dataset_stac_generator.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,145 @@ def test_update_variable_base_catalog(self):
214214
# self link must remain in place
215215
self.assertEqual(result["links"][0]["rel"], "self")
216216

217+
# ------------------------------------------------------------------
218+
# osc_project parameter
219+
# ------------------------------------------------------------------
220+
221+
def test_osc_project_default(self):
222+
"""Default osc_project is 'deep-earth-system-data-lab'."""
223+
self.assertEqual(self.generator.osc_project, "deep-earth-system-data-lab")
224+
225+
@patch("deep_code.utils.dataset_stac_generator.open_dataset")
226+
def test_osc_project_custom(self, mock_open_ds):
227+
"""A custom osc_project is stored on the generator."""
228+
mock_open_ds.return_value = self.mock_dataset
229+
gen = OscDatasetStacGenerator(
230+
dataset_id="mock-dataset-id",
231+
collection_id="mock-collection-id",
232+
workflow_id="dummy",
233+
workflow_title="test",
234+
license_type="proprietary",
235+
osc_project="my-custom-project",
236+
)
237+
self.assertEqual(gen.osc_project, "my-custom-project")
238+
239+
def test_build_dataset_stac_collection_osc_project_in_related_link(self):
240+
"""The project-related link in the collection uses the configured osc_project."""
241+
collection = self.generator.build_dataset_stac_collection(mode="dataset")
242+
project_links = [
243+
lnk
244+
for lnk in collection.links
245+
if lnk.rel == "related" and "projects" in str(lnk.target)
246+
]
247+
self.assertEqual(len(project_links), 1)
248+
self.assertIn("deep-earth-system-data-lab", project_links[0].target)
249+
250+
# ------------------------------------------------------------------
251+
# build_project_collection
252+
# ------------------------------------------------------------------
253+
254+
def test_build_project_collection_structure(self):
255+
"""build_project_collection returns a minimal valid STAC Collection dict."""
256+
result = self.generator.build_project_collection()
257+
258+
self.assertIsInstance(result, dict)
259+
self.assertEqual(result["type"], "Collection")
260+
self.assertEqual(result["id"], "deep-earth-system-data-lab")
261+
self.assertEqual(result["stac_version"], "1.0.0")
262+
self.assertIn("extent", result)
263+
264+
rels = [lnk["rel"] for lnk in result["links"]]
265+
self.assertIn("self", rels)
266+
self.assertIn("root", rels)
267+
self.assertIn("parent", rels)
268+
269+
self_link = next(lnk for lnk in result["links"] if lnk["rel"] == "self")
270+
self.assertIn("deep-earth-system-data-lab", self_link["href"])
271+
self.assertTrue(self_link["href"].endswith("collection.json"))
272+
273+
@patch("deep_code.utils.dataset_stac_generator.open_dataset")
274+
def test_build_project_collection_custom_project(self, mock_open_ds):
275+
"""build_project_collection reflects a custom osc_project."""
276+
mock_open_ds.return_value = self.mock_dataset
277+
gen = OscDatasetStacGenerator(
278+
dataset_id="mock-dataset-id",
279+
collection_id="mock-collection-id",
280+
workflow_id="dummy",
281+
workflow_title="test",
282+
license_type="proprietary",
283+
osc_project="my-project",
284+
)
285+
result = gen.build_project_collection()
286+
287+
self.assertEqual(result["id"], "my-project")
288+
self_link = next(lnk for lnk in result["links"] if lnk["rel"] == "self")
289+
self.assertIn("my-project", self_link["href"])
290+
291+
# ------------------------------------------------------------------
292+
# update_project_base_catalog
293+
# ------------------------------------------------------------------
294+
295+
def test_update_project_base_catalog(self):
296+
"""Child link for the project is appended to the projects base catalog."""
297+
import json as _json, os, tempfile
298+
299+
base = {
300+
"type": "Catalog",
301+
"id": "projects",
302+
"stac_version": "1.0.0",
303+
"description": "Projects",
304+
"links": [
305+
{
306+
"rel": "self",
307+
"href": "https://esa-earthcode.github.io/open-science-catalog-metadata/projects/catalog.json",
308+
"type": "application/json",
309+
}
310+
],
311+
}
312+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
313+
_json.dump(base, tmp)
314+
tmp_path = tmp.name
315+
316+
result = self.generator.update_project_base_catalog(tmp_path)
317+
os.unlink(tmp_path)
318+
319+
self.assertIsInstance(result, dict)
320+
child_links = [lnk for lnk in result["links"] if lnk["rel"] == "child"]
321+
self.assertEqual(len(child_links), 1)
322+
self.assertIn("deep-earth-system-data-lab", child_links[0]["href"])
323+
self.assertTrue(child_links[0]["href"].endswith("collection.json"))
324+
# existing self link is preserved
325+
self.assertEqual(result["links"][0]["rel"], "self")
326+
327+
def test_update_project_base_catalog_no_duplicate(self):
328+
"""Calling update_project_base_catalog when the child link already exists
329+
does not produce a duplicate."""
330+
import json as _json, os, tempfile
331+
332+
base = {
333+
"type": "Catalog",
334+
"id": "projects",
335+
"stac_version": "1.0.0",
336+
"description": "Projects",
337+
"links": [
338+
{
339+
"rel": "child",
340+
"href": "./deep-earth-system-data-lab/collection.json",
341+
"type": "application/json",
342+
"title": "Deep Earth System Data Lab",
343+
}
344+
],
345+
}
346+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
347+
_json.dump(base, tmp)
348+
tmp_path = tmp.name
349+
350+
result = self.generator.update_project_base_catalog(tmp_path)
351+
os.unlink(tmp_path)
352+
353+
child_links = [lnk for lnk in result["links"] if lnk["rel"] == "child"]
354+
self.assertEqual(len(child_links), 1)
355+
217356
def test_update_deepesdl_collection(self):
218357
"""Child and theme-related links are appended; existing links kept."""
219358
base = {

deep_code/tools/publish.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,21 @@ def publish_dataset(
318318
file_dict, product_catalog_path, generator.update_product_base_catalog
319319
)
320320

321-
# Update DeepESDL collection
322-
deepesdl_collection_path = "projects/deep-earth-system-data-lab/collection.json"
323-
self._update_and_add_to_file_dict(
324-
file_dict, deepesdl_collection_path, generator.update_deepesdl_collection
325-
)
321+
# Update or create project collection
322+
project_collection_path = f"projects/{generator.osc_project}/collection.json"
323+
if not self.gh_publisher.github_automation.file_exists(project_collection_path):
324+
logger.info(
325+
f"Project collection for {generator.osc_project} does not exist. Creating..."
326+
)
327+
file_dict[project_collection_path] = generator.build_project_collection()
328+
# Add child link in the projects base catalog
329+
self._update_and_add_to_file_dict(
330+
file_dict, "projects/catalog.json", generator.update_project_base_catalog
331+
)
332+
else:
333+
self._update_and_add_to_file_dict(
334+
file_dict, project_collection_path, generator.update_deepesdl_collection
335+
)
326336

327337
# Write to files if testing
328338
if write_to_file:

deep_code/utils/dataset_stac_generator.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@
1111
from pystac import Catalog, Collection, Extent, Item, Asset, Link, SpatialExtent, TemporalExtent
1212

1313
from deep_code.constants import (
14-
DEEPESDL_COLLECTION_SELF_HREF,
1514
OSC_THEME_SCHEME,
16-
PRODUCT_BASE_CATALOG_SELF_HREF,
17-
VARIABLE_BASE_CATALOG_SELF_HREF,
1815
ZARR_MEDIA_TYPE,
1916
)
2017
from deep_code.utils.helper import open_dataset
@@ -35,6 +32,7 @@ class OscDatasetStacGenerator:
3532
osc_themes: List of themes related to the dataset (e.g., ["climate"]).
3633
osc_missions: List of satellite missions associated with the dataset.
3734
cf_params: CF metadata parameters for the dataset.
35+
osc_project: OSC project identifier (default: "deep-earth-system-data-lab").
3836
"""
3937

4038
def __init__(
@@ -51,12 +49,14 @@ def __init__(
5149
osc_themes: list[str] | None = None,
5250
osc_missions: list[str] | None = None,
5351
cf_params: list[dict[str]] | None = None,
52+
osc_project: str = "deep-earth-system-data-lab",
5453
):
5554
self.dataset_id = dataset_id
5655
self.collection_id = collection_id
5756
self.workflow_id = workflow_id
5857
self.workflow_title = workflow_title
5958
self.license_type = license_type
59+
self.osc_project = osc_project
6060
self.access_link = access_link or f"s3://deep-esdl-public/{dataset_id}"
6161
self.documentation_link = documentation_link
6262
self.osc_status = osc_status
@@ -330,6 +330,70 @@ def add_themes_as_related_links_var_catalog(self, var_catalog):
330330
)
331331
)
332332

333+
def build_project_collection(self) -> dict:
334+
"""Build a minimal STAC Collection JSON dict for the OSC project.
335+
336+
Used when the project collection does not yet exist in the catalog.
337+
338+
Returns:
339+
A plain dict representing the STAC Collection.
340+
"""
341+
now_iso = datetime.now(timezone.utc).isoformat()
342+
self_href = (
343+
"https://esa-earthcode.github.io/open-science-catalog-metadata"
344+
f"/projects/{self.osc_project}/collection.json"
345+
)
346+
return {
347+
"type": "Collection",
348+
"id": self.osc_project,
349+
"stac_version": "1.0.0",
350+
"stac_extensions": [],
351+
"title": self.format_string(self.osc_project),
352+
"description": self.format_string(self.osc_project),
353+
"keywords": [],
354+
"license": "various",
355+
"extent": {
356+
"spatial": {"bbox": [[-180, -90, 180, 90]]},
357+
"temporal": {"interval": [[None, None]]},
358+
},
359+
"created": now_iso,
360+
"updated": now_iso,
361+
"links": [
362+
{
363+
"rel": "self",
364+
"href": self_href,
365+
"type": "application/json",
366+
},
367+
{
368+
"rel": "root",
369+
"href": "../../catalog.json",
370+
"type": "application/json",
371+
"title": "Open Science Catalog",
372+
},
373+
{
374+
"rel": "parent",
375+
"href": "../catalog.json",
376+
"type": "application/json",
377+
"title": "Projects",
378+
},
379+
],
380+
}
381+
382+
def update_project_base_catalog(self, project_base_catalog_path) -> dict:
383+
"""Append a child link for the project to the projects base catalog."""
384+
with open(project_base_catalog_path, encoding="utf-8") as f:
385+
data = json.load(f)
386+
self._append_link_if_absent(
387+
data.setdefault("links", []),
388+
{
389+
"rel": "child",
390+
"href": f"./{self.osc_project}/collection.json",
391+
"type": "application/json",
392+
"title": self.format_string(self.osc_project),
393+
},
394+
)
395+
return data
396+
333397
def update_deepesdl_collection(self, deepesdl_collection_full_path) -> dict:
334398
"""Append child and theme-related links to the DeepESDL collection."""
335399
with open(deepesdl_collection_full_path, encoding="utf-8") as f:
@@ -546,7 +610,7 @@ def build_dataset_stac_collection(self, mode: str, stac_catalog_s3_root: str | N
546610
# Add OSC extension metadata
547611
osc_extension = OscExtension.add_to(collection)
548612
# osc_project and osc_type are fixed constant values
549-
osc_extension.osc_project = "deep-earth-system-data-lab"
613+
osc_extension.osc_project = self.osc_project
550614
osc_extension.osc_type = "product"
551615
osc_extension.osc_status = self.osc_status
552616
osc_extension.osc_region = self.osc_region
@@ -623,9 +687,9 @@ def build_dataset_stac_collection(self, mode: str, stac_catalog_s3_root: str | N
623687
collection.add_link(
624688
Link(
625689
rel="related",
626-
target="../../projects/deep-earth-system-data-lab/collection.json",
690+
target=f"../../projects/{self.osc_project}/collection.json",
627691
media_type="application/json",
628-
title="Project: DeepESDL",
692+
title=f"Project: {self.format_string(self.osc_project)}",
629693
)
630694
)
631695

0 commit comments

Comments
 (0)