Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
e9e6344
generate prr collection
TejasMorbagal Jul 8, 2026
d9a765d
updated docs
TejasMorbagal Jul 8, 2026
3ac3d71
refactor
TejasMorbagal Jul 8, 2026
ff854a6
update CHANGES.md
TejasMorbagal Jul 8, 2026
b0a199e
cli for generate prr collection
TejasMorbagal Aug 5, 2026
f5d491e
pin ruff for now
TejasMorbagal Aug 5, 2026
9981208
check changes
konstntokas Aug 11, 2026
13252fb
check changes
konstntokas Aug 12, 2026
f6e01cc
mini error found
konstntokas Aug 12, 2026
7e9f98d
error in time extent
konstntokas Aug 13, 2026
79d3714
relative self href
konstntokas Aug 13, 2026
604f57d
remove normalize_href
konstntokas Aug 13, 2026
904d378
add absolute hrefs
konstntokas Aug 13, 2026
8de4224
link changed
konstntokas Aug 13, 2026
9352ade
links missiong
konstntokas Aug 13, 2026
a1cf6cf
allow cf spatial ref
konstntokas Aug 13, 2026
334eca5
ready for review
konstntokas Aug 13, 2026
9528fcd
ruff
konstntokas Aug 13, 2026
7edfc00
CI fixed
konstntokas Aug 14, 2026
5ac982f
add collection title
konstntokas Aug 17, 2026
9240823
Merge pull request #24 from deepesdl/konstntokas/update_prr
konstntokas Aug 18, 2026
896b741
add coord position parameter
konstntokas Sep 15, 2026
bf23ae7
change cube:dimension accoringly
konstntokas Sep 15, 2026
5452869
adjut coord names for cube extension
konstntokas Sep 16, 2026
ab39367
adjut coord names for cube extension
konstntokas Sep 16, 2026
7d6d0ad
adjut coord names for cube extension
konstntokas Sep 16, 2026
e122f67
fix tests
konstntokas Sep 16, 2026
04765d2
added tests for coverage
konstntokas Sep 16, 2026
47a3fba
Update deep_code/utils/dataset_stac_generator.py
konstntokas Sep 16, 2026
28672ac
Update deep_code/utils/dataset_stac_generator.py
konstntokas Sep 16, 2026
47c907f
Update deep_code/utils/dataset_stac_generator.py
konstntokas Sep 16, 2026
878cf00
Update deep_code/tools/prr.py
konstntokas Sep 16, 2026
3256ab2
Update deep_code/tools/new.py
konstntokas Sep 16, 2026
1a3b755
Update deep_code/utils/dataset_stac_generator.py
konstntokas Sep 16, 2026
4e21ce7
Merge pull request #25 from deepesdl/konstntokas/update_prr
konstntokas Sep 16, 2026
a52712f
backup
konstntokas Sep 16, 2026
e898b0f
add file size and extensions
konstntokas Sep 16, 2026
8146dd8
debug
konstntokas Sep 16, 2026
1fa680c
file size added
konstntokas Sep 16, 2026
496fad9
ready for review
konstntokas Sep 16, 2026
1993749
remove test files
konstntokas Sep 16, 2026
b1c94ad
adjusted relate path
konstntokas Sep 16, 2026
45dda78
extract data var names correctly
konstntokas Sep 17, 2026
7ef83f4
rename dataset_status to osc_status
konstntokas Sep 17, 2026
7c45310
remove printing
konstntokas Sep 17, 2026
ed9c065
upated docs
TejasMorbagal Sep 18, 2026
2a9a1f5
Merge pull request #26 from deepesdl/konstntokas/final_check_prr
konstntokas Sep 18, 2026
8a5bbcf
multiple fixes: osc stac item point to prr, renamed dataset_status fi…
TejasMorbagal Sep 25, 2026
be966ad
updated version
TejasMorbagal Sep 25, 2026
016459e
updated docs
TejasMorbagal Sep 25, 2026
eabf41b
fix test
TejasMorbagal Sep 25, 2026
d9a44ad
report clear message when items_config is missing entries
TejasMorbagal Sep 25, 2026
cef61cd
stac catalog s3 root is optional, PRR link is the default
TejasMorbagal Sep 25, 2026
084c164
Allow https Zarr URLs as dataset_id in publish,
TejasMorbagal Sep 28, 2026
60a3288
updated docs
TejasMorbagal Sep 28, 2026
99cf418
Fixed `publish` overwriting upstream OSC catalogs
TejasMorbagal Sep 28, 2026
de927a7
fix self link in publish
TejasMorbagal Sep 28, 2026
ff6599a
fix validation failures
TejasMorbagal Sep 28, 2026
8a272cc
Update deep_code/utils/dataset_stac_generator.py
TejasMorbagal Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/unittest-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ jobs:

- name: Lint with ruff
run: |
pip install ruff
# TODO upgrade to latest later
pip install ruff==0.9.10
ruff check

- name: Run unit tests
Expand Down
21 changes: 20 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,27 @@
- Removed redundant `via` access link from the OSC STAC collection; access is already expressed via typed assets (`zarr-data`, `zarr-consolidated-metadata`) on the STAC item.
- `osc_project` is now omitted from `OscDatasetStacGenerator` when not provided, preserving the callee's default instead of passing `None`.

## Changes in 0.1.10 (in Development)
## Changes in 0.2.0 (in Development)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log change is quiet massive. Maybe summarize a bit.


- Generated project collection now includes required STAC extensions (`osc`, `themes`, `contacts`) and OSC-mandatory fields (`osc:type`, `osc:status`, `themes`, `contacts`) to pass OSC catalog validation.
- Added optional `osc_project_url` field to the dataset config; used as the `via` link in the project collection. Falls back to `documentation_link` if omitted; defaults to the existing DeepESDL project collection when neither is provided.
- `dataset_status` now defaults to `"ongoing"` when not specified in the dataset config.
- Added a new CLI command `generate-prr-collection` that writes a Project Results Repository (PRR) STAC collection as local files, ready for submission to the ESA EarthCODE PRR endpoint. It reuses the dataset config and needs no GitHub credentials or S3 write access. See [PRR collection specification](https://eoresults.esa.int/prr_collection_specifications.html).
- The PRR command produces a self-contained `Collection → Item → Assets` tree: the Item carries the `datacube` extension (`cube:dimensions` / `cube:variables` extracted from the Zarr) with `zarr-data` and `zarr-consolidated-metadata` assets, and the Collection declares the OSC, Scientific, Processing, Themes and CF extensions.
- Added PRR-specific dataset config fields: `osc_initiative` (default `earthcode`), `osc_missions`, `osc_contract_number`, `osc_project_website`, `osc_project_description`, `thumbnail`, `thumbnail_media_type`, `sci_doi`, `sci_citation`, and `prr_output_dir`. Missing PRR-required fields produce a warning rather than a failure.
- `generate-config` templates now document the PRR fields under a dedicated section.
- `stac_catalog_s3_root` is optional again. By default the OSC collection now links to the dataset's collection in PRR (`child` → `https://eoresults.esa.int/stac/collections/{collection_id}`, `via` "Access" → the PRR STAC browser), matching how other PRR-hosted OSC products are linked; nothing is written to S3 and no S3 credentials are needed. PRR ingestion must happen before OSC publishing: publishing fails with a descriptive error if the PRR collection does not exist. Set `stac_catalog_s3_root` to publish and link a deep-code STAC catalog on S3 instead, for datasets not in PRR.
- The S3-hosted STAC item's `zarr-data` and `zarr-consolidated-metadata` assets now point to an absolute Zarr URL: the new optional `access_link` config field, or else the `zarr-data` asset looked up from the dataset's PRR item. Previously the href was relative (`./{dataset_id}`) and resolved to a non-existent path next to the item on S3.
- Fixed the S3-hosted STAC item's `self` link to match the path it is written to (`{stac_catalog_s3_root}/{collection_id}/items/{item_id}.json` instead of `.../{collection_id}/item.json`).
- Renamed the dataset config field `dataset_status` to `osc_status`, now defaulting to `"completed"` (used by both `publish` and `generate-prr-collection`). The old `dataset_status` key is still read as a fallback but logs a deprecation warning; rename it in existing dataset configs.
- `publish` and `generate-prr-collection` now report a clear error when an `items_config` entry is missing `dataset_id` or `item_id` (e.g. `dataset_id is required in items_config entry 0.`), instead of a bare `KeyError`.
- `dataset_id` in `items_config` can now be the `https://` URL of a consolidated Zarr store (e.g. the copy served by PRR), so `publish` works for datasets that are not in a DeepESDL S3 bucket. `publish` no longer computes the Zarr store's file sizes, which it did not use and which required listing every chunk of the store.
- Fixed `publish` crashing with `AttributeError: 'OscDatasetStacGenerator' object has no attribute 'variables_metadata'` when a dataset introduces a variable that has no OSC variable catalog yet. Variable metadata is now extracted once, so missing GCMD keyword URLs are prompted for only once.
- Workflow and experiment records now link to the project given by `osc_project`, instead of always linking to the DeepESDL project.
- Fixed `publish` overwriting upstream OSC catalogs (e.g. `products/catalog.json`) with the stale copies from an out-of-date fork. The fork is now synced with upstream before existing catalogs are read.
- `publish` now updates the existing `self` link of `workflows/catalog.json` and `experiments/catalog.json` in place, instead of moving it to the end of the links list.
- Fixed OSC validation failures when `publish` creates a new project collection:
- The project's title (`osc_project_title`) is now used verbatim for the project collection and for every link to it (projects catalog, product collection, workflow and experiment records). If the project already exists in OSC, its existing title is used. Before, the title was reformatted differently in each place (e.g. `ARNETLAB` became `Arnetlab`).
- New optional dataset config field `osc_project_contacts` sets the project collection's contacts. OSC requires a contact with role `technical_officer` and an email; `publish` warns if it is missing.
- The project collection now has `related` links to its themes and declares the contacts extension v0.1.1, as OSC requires.
- Extra `links` from the workflow config that OSC does not allow on experiment records (`root`, `parent`, `child` or `related` links whose type is not `application/json`) are now added to the workflow record only.
4 changes: 2 additions & 2 deletions deep_code/cli/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
help="Output directory for templates",
)
def generate_config(output_dir):
TemplateGenerator.generate_workflow_template(f"{output_dir}/workflow_config.yaml")
TemplateGenerator.generate_dataset_template(f"{output_dir}/dataset_config.yaml")
TemplateGenerator.generate_workflow_template(f"{output_dir}/workflow.yaml")
TemplateGenerator.generate_dataset_template(f"{output_dir}/dataset.yaml")
3 changes: 2 additions & 1 deletion deep_code/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@

from deep_code.cli.generate_config import generate_config
from deep_code.cli.lint import lint_dataset
from deep_code.cli.prr import generate_prr_collection_cmd
from deep_code.cli.publish import publish


@click.group()
def main():
"""Deep Code CLI."""
pass


main.add_command(publish)
main.add_command(generate_config)
main.add_command(lint_dataset)
main.add_command(generate_prr_collection_cmd)

if __name__ == "__main__":
main()
34 changes: 34 additions & 0 deletions deep_code/cli/prr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
# Copyright (c) 2025 by Brockmann Consult GmbH
# Permissions are hereby granted under the terms of the MIT License:
# https://opensource.org/licenses/MIT.

import click

from deep_code.tools.prr import generate_prr_collection


@click.command(name="generate-prr-collection")
@click.argument("dataset_config", type=click.Path(exists=True))
@click.option(
"--output-dir",
"-o",
type=click.Path(file_okay=False),
default=None,
help=(
"Directory to write the PRR collection tree into "
"(default: 'prr_output_dir' from the config, else 'prr/<collection_id>')."
),
)
def generate_prr_collection_cmd(dataset_config, output_dir):
"""Generate a PRR-style STAC Collection as local files.

Reads a dataset config (the same YAML used by 'publish') and writes a
self-contained Collection -> Item -> Assets tree for submission to the
ESA EarthCODE Project Results Repository.

Example:
deep-code generate-prr-collection dataset-config.yaml -o ./prr
"""
out_dir = generate_prr_collection(dataset_config, output_dir=output_dir)
click.echo(f"PRR collection written to: {out_dir}")
16 changes: 15 additions & 1 deletion deep_code/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
OSC_SCHEMA_URI = "https://stac-extensions.github.io/osc/v1.0.0/schema.json"
CF_SCHEMA_URI = "https://stac-extensions.github.io/cf/v0.2.0/schema.json"
THEMES_SCHEMA_URI = "https://stac-extensions.github.io/themes/v1.0.0/schema.json"
CONTACTS_SCHEMA_URI = "https://stac-extensions.github.io/contacts/v0.1.0/schema.json"
CONTACTS_SCHEMA_URI = "https://stac-extensions.github.io/contacts/v0.1.1/schema.json"
OSC_THEME_SCHEME = "https://github.com/stac-extensions/osc#theme"
OSC_REPO_OWNER = "ESA-EarthCODE"
OSC_REPO_NAME = "open-science-catalog-metadata"
Expand Down Expand Up @@ -41,3 +41,17 @@
"https://stac-extensions.github.io/application/v0.1.0/schema.json"
)
ZARR_MEDIA_TYPE = "application/vnd+zarr"
DATACUBE_SCHEMA_URI = "https://stac-extensions.github.io/datacube/v2.2.0/schema.json"
FILE_SCHEMA_URI = "https://stac-extensions.github.io/file/v2.1.0/schema.json"
PROCESSING_SCHEMA_URI = (
"https://stac-extensions.github.io/processing/v1.2.0/schema.json"
)
SCIENTIFIC_SCHEMA_URI = (
"https://stac-extensions.github.io/scientific/v1.0.0/schema.json"
)
# STAC API of the ESA Project Results Repository (PRR). The OSC item points its
# assets at the Zarr store PRR serves, looked up from this API.
PRR_STAC_API_ROOT = "https://eoresults.esa.int/stac"
PRR_STAC_BROWSER_ROOT = (
"https://eoresults.esa.int/browser/#/external/eoresults.esa.int/stac"
)
65 changes: 65 additions & 0 deletions deep_code/tests/tools/test_new.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
# Copyright (c) 2025 by Brockmann Consult GmbH
# Permissions are hereby granted under the terms of the MIT License:
# https://opensource.org/licenses/MIT.

import os
import tempfile
import unittest


from deep_code.tools.new import TemplateGenerator


class TestTemplateGenerator(unittest.TestCase):
def test_generate_dataset_template_writes_file(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
path = f.name
try:
TemplateGenerator.generate_dataset_template(output_path=path)
with open(path) as f:
content = f.read()
finally:
os.unlink(path)

# Required fields present
self.assertIn("dataset_id", content)
self.assertIn("collection_id", content)
self.assertIn("license_type", content)
self.assertIn("stac_catalog_s3_root", content)
# Optional fields present
self.assertIn("osc_themes", content)
self.assertIn("visualisation_link", content)
self.assertIn("osc_project_title", content)
# Section headers
self.assertIn("REQUIRED", content)
self.assertIn("OPTIONAL", content)

def test_generate_workflow_template_writes_file(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
path = f.name
try:
TemplateGenerator.generate_workflow_template(output_path=path)
with open(path) as f:
content = f.read()
finally:
os.unlink(path)

# Required fields present
self.assertIn("workflow_id", content)
self.assertIn("title", content)
self.assertIn("license", content)
# Optional fields present
self.assertIn("jupyter_notebook_url", content)
self.assertIn("contact", content)
# Section headers
self.assertIn("REQUIRED", content)
self.assertIn("OPTIONAL", content)

def test_generate_dataset_template_no_path_returns_none(self):
result = TemplateGenerator.generate_dataset_template()
self.assertIsNone(result)

def test_generate_workflow_template_no_path_returns_none(self):
result = TemplateGenerator.generate_workflow_template()
self.assertIsNone(result)
200 changes: 200 additions & 0 deletions deep_code/tests/tools/test_prr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
# Copyright (c) 2025 by Brockmann Consult GmbH
# Permissions are hereby granted under the terms of the MIT License:
# https://opensource.org/licenses/MIT.

import json
import os
import tempfile
import unittest
from datetime import datetime
from unittest.mock import patch

import numpy as np
import yaml
from xarray import Dataset

from deep_code.tools.prr import generate_prr_collection


def _make_dataset():
return Dataset(
coords={
"lon": ("lon", np.linspace(-20, 20, 4)),
"lat": ("lat", np.linspace(-10, 10, 3)),
"time": ("time", [np.datetime64(datetime(2021, 1, 1), "ns")]),
},
data_vars={
"sst": (
("time", "lat", "lon"),
np.random.rand(1, 3, 4),
{"units": "K", "long_name": "Sea surface temperature"},
)
},
attrs={
"description": "PRR tool test cube",
# set by open_dataset from the store's file sizes
"size": 123456,
"metadata_size": 123,
},
)


class TestGeneratePrrCollection(unittest.TestCase):
def _write_config(self, tmp, **overrides):
config = {
"collection_id": "tool-prr",
"items_config": [{"dataset_id": "test.zarr", "item_id": "tool-item"}],
"license_type": "CC-BY-4.0",
"osc_project": "tool-project",
"osc_project_url": "https://project.example.org",
"osc_status": "ongoing",
"osc_region": "Global",
"osc_themes": ["oceans"],
"documentation_link": "https://example.org",
}
config.update(overrides)
path = os.path.join(tmp, "dataset-config.yaml")
with open(path, "w") as f:
yaml.safe_dump(config, f)
return path

@patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
side_effect=lambda *args, **kwargs: _make_dataset(),
)
def test_writes_collection_tree(self, _mock_open_ds):
with tempfile.TemporaryDirectory() as tmp:
config_path = self._write_config(tmp)
out = os.path.join(tmp, "out")
result = generate_prr_collection(config_path, output_dir=out)

self.assertEqual(result, out)
coll_path = os.path.join(out, "tool-prr", "collection.json")
item_path = os.path.join(out, "tool-prr", "items", "tool-item.json")
self.assertTrue(os.path.isfile(coll_path))
self.assertTrue(os.path.isfile(item_path))
with open(coll_path) as f:
coll = json.load(f)
self.assertEqual(coll["id"], "tool-prr")
self.assertEqual(coll["type"], "Collection")
self.assertEqual(coll["osc:type"], "product")
self.assertEqual(coll["osc:status"], "ongoing")
with open(item_path) as f:
item = json.load(f)
# PRR ingests the Zarr next to the item, so asset hrefs are relative.
self.assertEqual(item["assets"]["zarr-data"]["href"], "./test.zarr")

@patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
side_effect=lambda *args, **kwargs: _make_dataset(),
)
def test_default_output_dir_from_collection_id(self, _mock_open_ds):
with tempfile.TemporaryDirectory() as tmp:
config_path = self._write_config(tmp)
cwd = os.getcwd()
os.chdir(tmp)
try:
out = generate_prr_collection(config_path)
self.assertEqual(out, "prr")
self.assertTrue(
os.path.isfile(os.path.join(out, "tool-prr", "collection.json"))
)
finally:
os.chdir(cwd)

@patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
side_effect=lambda *args, **kwargs: _make_dataset(),
)
def test_output_dir_from_config(self, _mock_open_ds):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "from-config")
config_path = self._write_config(tmp, prr_output_dir=out)
result = generate_prr_collection(config_path)
self.assertEqual(result, out)
self.assertTrue(
os.path.isfile(os.path.join(out, "tool-prr", "collection.json"))
)

@patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
side_effect=lambda *args, **kwargs: _make_dataset(),
)
def test_prr_spec_fields_plumbed_from_config(self, _mock_open_ds):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "out")
config_path = self._write_config(
tmp,
osc_missions=["sentinel-3"],
osc_project_description="Project description.",
osc_project_website="https://project.example.org",
osc_contract_number="4000114410/15/NL/BW",
thumbnail="https://example.org/logo.jpeg",
sci_doi="10.1000/xyz123",
)
generate_prr_collection(config_path, output_dir=out)
with open(os.path.join(out, "tool-prr", "collection.json")) as f:
coll = json.load(f)
self.assertEqual(coll["osc:initiative"], "earthcode")
self.assertEqual(coll["osc:project_website"], "https://project.example.org")
self.assertEqual(coll["osc:project_description"], "Project description.")
self.assertEqual(coll["osc:contract-number"], "4000114410/15/NL/BW")
self.assertEqual(coll["sci:doi"], "10.1000/xyz123")
self.assertIn("thumbnail", coll["assets"])
self.assertEqual(coll["assets"]["thumbnail"]["roles"], ["thumbnail"])

@patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
side_effect=lambda *args, **kwargs: _make_dataset(),
)
def test_deprecated_dataset_status(self, _mock_open_ds):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "out")
config_path = self._write_config(tmp, osc_status=None)
with open(config_path) as f:
config = yaml.safe_load(f)
del config["osc_status"]
config["dataset_status"] = "planned"
with open(config_path, "w") as f:
yaml.safe_dump(config, f)
generate_prr_collection(config_path, output_dir=out)
with open(os.path.join(out, "tool-prr", "collection.json")) as f:
coll = json.load(f)
self.assertEqual(coll["osc:status"], "planned")

def _assert_missing_raises(self, field: str):
with tempfile.TemporaryDirectory() as tmp:
config_path = self._write_config(tmp)
with open(config_path) as f:
config = yaml.safe_load(f)
del config[field]
with open(config_path, "w") as f:
yaml.safe_dump(config, f)
with self.assertRaisesRegex(ValueError, f"{field} is required"):
generate_prr_collection(config_path)

def test_items_config_entry_missing_dataset_id_raises(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = self._write_config(
tmp, items_config=[{"item_id": "tool-item"}]
)
with self.assertRaisesRegex(
ValueError, "dataset_id is required in items_config entry 0"
):
generate_prr_collection(config_path)

def test_missing_required_fields_raise(self):
for field in (
"collection_id",
"osc_project",
"osc_project_url",
"license_type",
"items_config",
):
with self.subTest(field=field):
self._assert_missing_raises(field)


if __name__ == "__main__":
unittest.main()
Loading
Loading