From 619df4758371d16f9516137bc5424e8c822498d2 Mon Sep 17 00:00:00 2001 From: Marton Vago Date: Fri, 24 Apr 2026 13:29:22 +0100 Subject: [PATCH 1/3] refactor: :recycle: move metadata checks to metadata class --- src/zen_do/examples.py | 6 ++-- src/zen_do/zenodo_get_deposit.py | 21 ++----------- src/zen_do/zenodo_metadata.py | 24 ++++++++++++++ tests/test_zenodo_get_deposit.py | 54 +------------------------------- tests/test_zenodo_metadata.py | 46 +++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 74 deletions(-) create mode 100644 tests/test_zenodo_metadata.py diff --git a/src/zen_do/examples.py b/src/zen_do/examples.py index 4477024..3c2ce2c 100644 --- a/src/zen_do/examples.py +++ b/src/zen_do/examples.py @@ -8,7 +8,9 @@ ) -def example_metadata(title: str = "Test Book") -> ZenodoMetadata: +def example_metadata( + title: str = "Test Book", urn: str = "urn:zenodo:my-org:project:book" +) -> ZenodoMetadata: """A set of example Zenodo metadata.""" return ZenodoMetadata( title=title, @@ -20,7 +22,7 @@ def example_metadata(title: str = "Test Book") -> ZenodoMetadata: ], related_identifiers=[ ZenodoRelatedIdentifier( - identifier="urn:zenodo:my-org:project:book", + identifier=urn, relation="isIdenticalTo", resource_type="other", scheme="urn", diff --git a/src/zen_do/zenodo_get_deposit.py b/src/zen_do/zenodo_get_deposit.py index f685be5..4d4dfa9 100644 --- a/src/zen_do/zenodo_get_deposit.py +++ b/src/zen_do/zenodo_get_deposit.py @@ -4,7 +4,7 @@ import seedcase_soil as ss from zen_do.zenodo_client import ZenodoResponse, _get_zenodo_field -from zen_do.zenodo_metadata import ZenodoMetadata, ZenodoRelatedIdentifier +from zen_do.zenodo_metadata import ZenodoMetadata, ZenodoRelatedIdentifier, _is_urn def zenodo_get_deposit(deposits: list[ZenodoResponse]) -> Optional[ZenodoResponse]: @@ -19,7 +19,7 @@ def zenodo_get_deposit(deposits: list[ZenodoResponse]) -> Optional[ZenodoRespons Returns: The Zenodo deposit for the repo if it exists, None otherwise. """ - urn = _get_urn() + urn = _load_zenodo_json().urn matching_deposits = ss.keep( deposits, @@ -46,22 +46,5 @@ def _urn_matches(id_response: ZenodoResponse, target_urn: str) -> bool: return _is_urn(id) and id.identifier == target_urn -def _is_urn(id: ZenodoRelatedIdentifier) -> bool: - return id.relation == "isIdenticalTo" and id.scheme == "urn" - - -def _get_urn() -> str: - metadata = _load_zenodo_json() - ids = ss.keep(metadata.related_identifiers, _is_urn) - if len(ids) != 1: - raise ValueError( - "Expected exactly one `isIdenticalTo` URN in `.zenodo.json` under " - f"`related_identifiers`, but found {len(ids)}. Ensure there is a single " - "unique URN, as it is used to identify the corresponding deposit on Zenodo." - ) - - return ids[0].identifier - - def _load_zenodo_json() -> ZenodoMetadata: return ZenodoMetadata.model_validate_json(Path(".zenodo.json").read_text()) diff --git a/src/zen_do/zenodo_metadata.py b/src/zen_do/zenodo_metadata.py index 9e4e410..78ccb85 100644 --- a/src/zen_do/zenodo_metadata.py +++ b/src/zen_do/zenodo_metadata.py @@ -1,6 +1,7 @@ import re from typing import Optional, Self +import seedcase_soil as ss from pydantic import BaseModel, model_validator @@ -62,3 +63,26 @@ class ZenodoMetadata(BaseModel): upload_type: str creators: list[ZenodoCreator] related_identifiers: list[ZenodoRelatedIdentifier] = [] + + @property + def urn(self) -> str: + """The URN related identifier of the deposit.""" + urns = ss.keep(self.related_identifiers, _is_urn) + return urns[0].identifier + + @model_validator(mode="after") + def _check_unique_urn(self) -> Self: + + urns = ss.keep(self.related_identifiers, _is_urn) + if len(urns) != 1: + raise ValueError( + "Expected exactly one `isIdenticalTo` URN in the Zenodo metadata file " + f"under `related_identifiers`, but found {len(urns)}. Ensure there is " + "a single unique URN, as it is used to identify the corresponding " + "deposit on Zenodo." + ) + return self + + +def _is_urn(id: ZenodoRelatedIdentifier) -> bool: + return id.relation == "isIdenticalTo" and id.scheme == "urn" diff --git a/tests/test_zenodo_get_deposit.py b/tests/test_zenodo_get_deposit.py index 16f1d46..26e986a 100644 --- a/tests/test_zenodo_get_deposit.py +++ b/tests/test_zenodo_get_deposit.py @@ -1,7 +1,6 @@ -import json from pathlib import Path -from pytest import MonkeyPatch, fixture, mark, raises +from pytest import MonkeyPatch, fixture, raises from zen_do.examples import example_deposit, example_metadata from zen_do.zenodo_get_deposit import zenodo_get_deposit @@ -77,54 +76,3 @@ def test_returns_none_if_no_matching_deposits(_zenodo_json): deposit = zenodo_get_deposit(deposits) assert deposit is None - - -def test_raises_error_if_zenodo_json_has_no_urn_id(monkeypatch, tmp_path): - metadata = example_metadata() - monkeypatch.chdir(tmp_path) - del metadata.related_identifiers[0] - (tmp_path / ".zenodo.json").write_text(metadata.model_dump_json()) - - with raises(ValueError): - zenodo_get_deposit([]) - - -def test_raises_error_if_zenodo_json_has_multiple_urn_ids(monkeypatch, tmp_path): - metadata = example_metadata() - monkeypatch.chdir(tmp_path) - metadata.related_identifiers.append( - ZenodoRelatedIdentifier( - identifier="urn:zenodo:my-org:project:poster", - relation="isIdenticalTo", - resource_type="other", - scheme="urn", - ) - ) - (tmp_path / ".zenodo.json").write_text(metadata.model_dump_json()) - - with raises(ValueError): - zenodo_get_deposit([]) - - -@mark.parametrize( - "urn", - [ - "", - "not a URN", - "urn", - "urn:", - "urn:unknown", - "urn:zenodo", - "urn:zenodo:", - "urn:zenodo:a:", - "urn:zenodo:a/b", - ], -) -def test_flags_incorrect_urn(monkeypatch, tmp_path, urn): - metadata_json = example_metadata().model_dump() - metadata_json["related_identifiers"][0]["identifier"] = urn - monkeypatch.chdir(tmp_path) - (tmp_path / ".zenodo.json").write_text(json.dumps(metadata_json)) - - with raises(ValueError): - zenodo_get_deposit([]) diff --git a/tests/test_zenodo_metadata.py b/tests/test_zenodo_metadata.py new file mode 100644 index 0000000..dece47d --- /dev/null +++ b/tests/test_zenodo_metadata.py @@ -0,0 +1,46 @@ +from pytest import mark, raises + +from zen_do.examples import example_metadata +from zen_do.zenodo_metadata import ZenodoMetadata, ZenodoRelatedIdentifier + + +def test_raises_error_if_zenodo_json_has_no_urn_id(): + metadata = example_metadata() + del metadata.related_identifiers[0] + + with raises(ValueError): + ZenodoMetadata.model_validate(metadata) + + +def test_raises_error_if_zenodo_json_has_multiple_urn_ids(): + metadata = example_metadata() + metadata.related_identifiers.append( + ZenodoRelatedIdentifier( + identifier="urn:zenodo:my-org:project:poster", + relation="isIdenticalTo", + resource_type="other", + scheme="urn", + ) + ) + + with raises(ValueError): + ZenodoMetadata.model_validate(metadata) + + +@mark.parametrize( + "urn", + [ + "", + "not a URN", + "urn", + "urn:", + "urn:unknown", + "urn:zenodo", + "urn:zenodo:", + "urn:zenodo:a:", + "urn:zenodo:a/b", + ], +) +def test_flags_incorrect_urn(urn): + with raises(ValueError): + example_metadata(urn=urn) From 909cc3008de794b469e2c9c4c2052b3cefe95967 Mon Sep 17 00:00:00 2001 From: Marton Vago Date: Fri, 24 Apr 2026 13:34:30 +0100 Subject: [PATCH 2/3] refactor: :recycle: remove extra newline --- src/zen_do/zenodo_metadata.py | 1 - uv.lock | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/zen_do/zenodo_metadata.py b/src/zen_do/zenodo_metadata.py index 78ccb85..f997f4d 100644 --- a/src/zen_do/zenodo_metadata.py +++ b/src/zen_do/zenodo_metadata.py @@ -72,7 +72,6 @@ def urn(self) -> str: @model_validator(mode="after") def _check_unique_urn(self) -> Self: - urns = ss.keep(self.related_identifiers, _is_urn) if len(urns) != 1: raise ValueError( diff --git a/uv.lock b/uv.lock index 7cabc48..19680a6 100644 --- a/uv.lock +++ b/uv.lock @@ -853,7 +853,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.12.0" +version = "9.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -863,13 +863,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, ] [[package]] From 7d88360aca59c49db0d3226e242e15a47a5a5f9b Mon Sep 17 00:00:00 2001 From: Marton Vago Date: Wed, 29 Apr 2026 12:32:43 +0100 Subject: [PATCH 3/3] feat: :sparkles: add get CLI command --- _quarto.yml | 1 + docs/design/interface/cli.qmd | 25 +------------------------ pyproject.toml | 1 + src/zen_do/__init__.py | 3 ++- src/zen_do/cli.py | 26 ++++++++++++++++++++++++++ src/zen_do/zenodo_get_deposit.py | 11 +++++++---- tests/test_cli.py | 31 ++++++++++++++++++++++++++++++- tests/test_zenodo_get_deposit.py | 20 ++++++++++++++++++++ uv.lock | 2 ++ 9 files changed, 90 insertions(+), 30 deletions(-) diff --git a/_quarto.yml b/_quarto.yml index e303249..b3c05fa 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -66,6 +66,7 @@ quartodoc: - title: "CLI" contents: - zenodo_publish + - get - title: "Zenodo API" contents: - ZenodoClient diff --git a/docs/design/interface/cli.qmd b/docs/design/interface/cli.qmd index 7416591..455ab5f 100644 --- a/docs/design/interface/cli.qmd +++ b/docs/design/interface/cli.qmd @@ -68,7 +68,7 @@ def list(sandbox: bool = False) -> None: pretty_print(deposits) ``` -## {{< var planned >}} `get` +## {{< var done >}} `get` ``` {.bash filename="Terminal"} zen-do get [METADATA_FILE] --sandbox @@ -83,29 +83,6 @@ uses the Python functions within `list()` to get the list of deposits and uses that to find the deposit that matches the content of the metadata file using the URN ID. -The Python interface, with some implementation comments and docstring, -will be: - -```python -@app.command() -def get(metadata_file: Path = Path(".zenodo.toml"), \, *, sandbox: bool = False) -> None: - """Get the Zenodo deposit JSON based on the metadata file. - - Args: - metadata_file: The path to the metadata file. - sandbox: Whether to use the Zenodo sandbox environment for testing purposes. - """ - # Read metadata file (checking if the URN exists or not). - # metadata: Metadata = read_metadata(path=metadata_file) - # Fetch token from system keyring or environment variable. - # token: Token = get_token(sandbox=sandbox) - # Get request to Zenodo servers. - # TODO: Either dict or a custom class for the deposit, e.g. `Deposit`? - # deposits: list[Deposit] = list_deposits(sandbox=sandbox, token=token) - # deposit: Deposit = find_deposit(deposits=deposits, metadata=metadata) - pretty_print(deposit) -``` - ## {{< var planned >}} `convert` ``` {.bash filename="Terminal"} diff --git a/pyproject.toml b/pyproject.toml index 14e2fcf..eafeace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "keyring>=25.7.0", "pydantic>=2.12.5", "requests>=2.32.5", + "rich>=15.0.0", "seedcase-soil>=0.10.0", "toml>=0.10.2", ] diff --git a/src/zen_do/__init__.py b/src/zen_do/__init__.py index 232bf8c..51aee63 100644 --- a/src/zen_do/__init__.py +++ b/src/zen_do/__init__.py @@ -1,6 +1,6 @@ """Module containing all source code.""" -from .cli import zenodo_publish +from .cli import get, zenodo_publish from .examples import example_deposit, example_metadata from .zenodo_client import ( ZenodoClient, @@ -9,6 +9,7 @@ from .zenodo_metadata import ZenodoCreator, ZenodoMetadata, ZenodoRelatedIdentifier __all__ = [ + "get", "example_metadata", "example_deposit", "zenodo_publish", diff --git a/src/zen_do/cli.py b/src/zen_do/cli.py index fee1af1..c3414e6 100644 --- a/src/zen_do/cli.py +++ b/src/zen_do/cli.py @@ -1,3 +1,7 @@ +from pathlib import Path + +import seedcase_soil as so +from rich import print_json from seedcase_soil import ( # print_if_verbose, run_without_tracebacks, @@ -26,6 +30,28 @@ def zenodo_publish(sandbox: bool = False) -> None: print("New Zenodo record created successfully!") +@app.command() +def get( + metadata_file: Path = Path(".zenodo.toml"), /, *, sandbox: bool = False +) -> None: + """Get the Zenodo deposit JSON based on the metadata file. + + Args: + metadata_file: The path to the metadata file. + sandbox: Whether to use the Zenodo sandbox environment for testing purposes. + """ + token = get_token(sandbox) + client = ZenodoClient(token, sandbox) + deposit = zenodo_get_deposit(client.get_deposits(), metadata_file) + + if deposit: + print_json(data=deposit) + else: + so.pretty_print( + f"No deposit found on Zenodo for metadata file '{metadata_file}'." + ) + + def main() -> None: """Create an entry point to run the cli without tracebacks.""" run_without_tracebacks(app) diff --git a/src/zen_do/zenodo_get_deposit.py b/src/zen_do/zenodo_get_deposit.py index 8fe07ff..ef9658d 100644 --- a/src/zen_do/zenodo_get_deposit.py +++ b/src/zen_do/zenodo_get_deposit.py @@ -8,7 +8,9 @@ from zen_do.zenodo_metadata import ZenodoMetadata, ZenodoRelatedIdentifier, _is_urn -def zenodo_get_deposit(deposits: list[ZenodoResponse]) -> Optional[ZenodoResponse]: +def zenodo_get_deposit( + deposits: list[ZenodoResponse], metadata_file: Path = Path(".zenodo.toml") +) -> Optional[ZenodoResponse]: """Gets the Zenodo deposit for the repository if it exists. Gets the URN identifier from the `.zenodo.toml` file. If one @@ -16,11 +18,12 @@ def zenodo_get_deposit(deposits: list[ZenodoResponse]) -> Optional[ZenodoRespons Args: deposits: All the deposits on Zenodo associated with an access token. + metadata_file: The path to the metadata file. Returns: The Zenodo deposit for the repo if it exists, None otherwise. """ - urn = _load_zenodo_toml().urn + urn = _load_zenodo_toml(metadata_file).urn matching_deposits = so.keep( deposits, @@ -47,8 +50,8 @@ def _urn_matches(id_response: ZenodoResponse, target_urn: str) -> bool: return _is_urn(id) and id.identifier == target_urn -def _load_zenodo_toml() -> ZenodoMetadata: - with open(Path(".zenodo.toml"), mode="rb") as file: +def _load_zenodo_toml(metadata_file: Path) -> ZenodoMetadata: + with open(metadata_file, mode="rb") as file: toml_file = tomllib.load(file) return ZenodoMetadata.model_validate(toml_file) diff --git a/tests/test_cli.py b/tests/test_cli.py index f1a4694..4c5c27a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from pytest import fixture, raises from zen_do.cli import app +from zen_do.examples import example_deposit @fixture @@ -9,7 +10,8 @@ def _mock_zenodo_get_deposit(mocker): @fixture -def _mock_client(mocker): +def _mock_client(mocker, monkeypatch): + monkeypatch.setenv("ZENODO_TOKEN", "token") return mocker.patch("zen_do.cli.ZenodoClient") @@ -35,3 +37,30 @@ def test_zenodo_publish_new_deposit( def test_zenodo_publish_needs_token(): with raises(RuntimeError): app("zenodo-publish", result_action="return_value") + + +def test_get_when_deposit_found( + capsys, + _mock_client, + _mock_zenodo_get_deposit, +): + deposit = example_deposit() + _mock_zenodo_get_deposit.return_value = deposit + + app("get", result_action="return_value") + out = capsys.readouterr().out + + assert str(deposit["id"]) in out + + +def test_get_when_deposit_not_found( + capsys, + _mock_client, + _mock_zenodo_get_deposit, +): + _mock_zenodo_get_deposit.return_value = None + + app("get", result_action="return_value") + out = capsys.readouterr().out + + assert "{" not in out diff --git a/tests/test_zenodo_get_deposit.py b/tests/test_zenodo_get_deposit.py index c077604..241c5a7 100644 --- a/tests/test_zenodo_get_deposit.py +++ b/tests/test_zenodo_get_deposit.py @@ -1,5 +1,6 @@ from pathlib import Path +import pydantic import toml from pytest import MonkeyPatch, fixture, raises @@ -28,6 +29,17 @@ def test_returns_deposit_if_matching_deposit_has_exactly_one_matching_identifier assert deposit["id"] == 12 +def test_can_use_non_default_file_location(tmp_path): + (tmp_path / "book").mkdir() + metadata_path = tmp_path / "book" / ".book.zenodo.toml" + metadata_path.write_text(toml.dumps(example_metadata().model_dump())) + + deposit = zenodo_get_deposit([example_deposit(id=12)], metadata_path) + + assert deposit + assert deposit["id"] == 12 + + def test_returns_deposit_if_matching_deposit_has_at_least_one_matching_identifier( _zenodo_toml, ): @@ -65,6 +77,14 @@ def test_raises_error_if_multiple_matching_deposits(_zenodo_toml): zenodo_get_deposit(deposits) +def test_raises_error_if_metadata_file_empty(tmp_path): + metadata_path = tmp_path / ".zenodo.toml" + metadata_path.touch() + + with raises(pydantic.ValidationError): + zenodo_get_deposit([example_deposit()], metadata_path) + + def test_returns_none_if_no_deposits_on_zenodo(_zenodo_toml): deposit = zenodo_get_deposit([]) diff --git a/uv.lock b/uv.lock index dbfccf7..a332537 100644 --- a/uv.lock +++ b/uv.lock @@ -2845,6 +2845,7 @@ dependencies = [ { name = "keyring" }, { name = "pydantic" }, { name = "requests" }, + { name = "rich" }, { name = "seedcase-soil" }, { name = "toml" }, ] @@ -2876,6 +2877,7 @@ requires-dist = [ { name = "keyring", specifier = ">=25.7.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=15.0.0" }, { name = "seedcase-soil", specifier = ">=0.10.0" }, { name = "toml", specifier = ">=0.10.2" }, ]