Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,14 @@ Documentation is at: (https://oceanum-python.readthedocs.io/)
## Testing

The default test account has no datamesh write access. To test the write functionality, you will need to replace the DATAMESH_TOKEN in tox.ini with a token from an account with write access.

Unit tests and storage tests can be run via pytest. Tests requiring valid credentials are marked with `requires_datamesh_token` and expect the `OCEANUM_TEST_DATAMESH_TOKEN` environment variable to be set.

## Plugin Authoring

The `oceanum` CLI can be extended via entry points. To add a new command or group, register it in your `pyproject.toml` under the `[project.entry-points."oceanum.cli.extensions"]` group.

Supported extension targets:
- **Module**: Importing the module registers commands via side effects (legacy).
- **Direct Click Object**: A `click.Command` or `click.Group` object.
- **Registrar Callable**: A function with signature `register_cli(parent_group: click.Group)`.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,4 @@ universal = true
required_plugins = "pytest-env"
pythonpath = ["src"]
env = ["STORAGE_SERVICE = https://storage.oceanum.io"]
markers = ["requires_datamesh_token: marks tests that need OCEANUM_TEST_DATAMESH_TOKEN"]
49 changes: 38 additions & 11 deletions src/oceanum/__main__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
import os
import sys
import types
import click
from importlib.metadata import entry_points
from oceanum.cli import main
from oceanum.cli import main as cli_main

CLI_DEBUG = bool(os.getenv('OCEANUM_CLI_DEBUG'))

for run_ep in entry_points(group='oceanum.cli.extensions'):
try:
if CLI_DEBUG:
print(f"Loading entry point: {run_ep.name}...")
ep_module = run_ep.load()
if CLI_DEBUG:
print(f"Imported module '{ep_module.__name__}' successfully.")
except ModuleNotFoundError as e:
print(f"Error loading entry point {run_ep.name}: {e}", file=sys.stderr)
pass

def load_cli_extensions(parent_group=None):
# Load CLI extension entry points at call time to avoid side effects on import
if parent_group is None:
parent_group = cli_main
for run_ep in entry_points(group='oceanum.cli.extensions'):
try:
if CLI_DEBUG:
print(f"Loading entry point: {run_ep.name}...")
ep_obj = run_ep.load()
# Module object: legacy behaviour (side effects only)
if isinstance(ep_obj, types.ModuleType):
if CLI_DEBUG:
print(f"Loaded module '{getattr(ep_obj, '__name__', run_ep.name)}' successfully.")
# Command/Group exported directly
elif isinstance(ep_obj, click.Command):
name = getattr(ep_obj, 'name', None) or run_ep.name
parent_group.add_command(ep_obj, name=name)
if CLI_DEBUG:
print(f"Registered command '{name}' from entry point '{run_ep.name}'.")
# Registrar callable
elif callable(ep_obj):
ep_obj(parent_group)
if CLI_DEBUG:
print(f"Called registrar from entry point '{run_ep.name}'.")
else:
if CLI_DEBUG:
print(f"Unknown entry point target type for '{run_ep.name}': {type(ep_obj)}", file=sys.stderr)
except Exception as e:
print(f"Error loading entry point {run_ep.name}: {e}", file=sys.stderr)


def main():
load_cli_extensions(cli_main)
cli_main()

if __name__ == "__main__":
main()
20 changes: 13 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import os
import pytest
import dotenv


dotenv.load_dotenv()

test_token = os.getenv("OCEANUM_TEST_DATAMESH_TOKEN", None)

if not test_token:
raise ValueError(
"Environment variable 'OCEANUM_TEST_DATAMESH_TOKEN' is not set. "
"Please set it to a valid token for testing."
def pytest_configure(config):
config.addinivalue_line(
"markers", "requires_datamesh_token: marks tests that need OCEANUM_TEST_DATAMESH_TOKEN"
)

os.environ["DATAMESH_TOKEN"] = test_token
def pytest_collection_modifyitems(config, items):
token = os.getenv("OCEANUM_TEST_DATAMESH_TOKEN")
if token:
os.environ["DATAMESH_TOKEN"] = token
return
skip_marker = pytest.mark.skip(reason="OCEANUM_TEST_DATAMESH_TOKEN not set; skipping token-gated tests")
for item in items:
if item.get_closest_marker("requires_datamesh_token"):
item.add_marker(skip_marker)
91 changes: 91 additions & 0 deletions tests/test_cli_extension_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import click
import types
from unittest.mock import patch

from oceanum.__main__ import load_cli_extensions


class FakeEntryPoint:
"""Minimal stand-in for importlib.metadata.EntryPoint-like object."""
def __init__(self, name, obj):
self.name = name
self._obj = obj

def load(self):
if isinstance(self._obj, Exception):
raise self._obj
return self._obj


def test_registrar_extension():
"""Registrar callable: load() returns a function that registers commands."""
parent = click.Group("test-parent")

def register_cli(parent_group):
parent_group.add_command(click.Command("registered-cmd"), name="registered-cmd")

ep = FakeEntryPoint("registrar-ext", register_cli)
with patch("oceanum.__main__.entry_points", return_value=[ep]):
load_cli_extensions(parent)
assert "registered-cmd" in parent.commands


def test_broken_plugin_non_fatal(capsys):
"""Broken plugin raises exception but later plugins still load."""
parent = click.Group("test-parent")

class BrokenEP:
name = "broken"
def load(self):
raise ImportError("missing dependency")

good_group = click.Group(name="good")
good_ep = FakeEntryPoint("good-ext", good_group)

with patch("oceanum.__main__.entry_points", return_value=[BrokenEP(), good_ep]):
load_cli_extensions(parent)

captured = capsys.readouterr()
assert "Error loading entry point broken" in captured.err
assert "good" in parent.commands


def test_debug_safety(monkeypatch, capsys):
"""Debug mode with non-module target does not raise AttributeError."""
monkeypatch.setenv("OCEANUM_CLI_DEBUG", "1")
# Reload CLI_DEBUG after env change
import importlib
import oceanum.__main__ as m
importlib.reload(m)
from oceanum.__main__ import load_cli_extensions as lce

parent = click.Group("test-parent")
demo_group = click.Group(name="debug-demo")
ep = FakeEntryPoint("debug-ext", demo_group)

with patch("oceanum.__main__.entry_points", return_value=[ep]):
lce(parent)

captured = capsys.readouterr()
assert "AttributeError" not in captured.out
assert "AttributeError" not in captured.err
assert "debug-demo" in parent.commands


def test_legacy_module_extension():
"""Legacy extension path: entry point returns a ModuleType, no commands added."""
parent = click.Group("test-parent")
ep = FakeEntryPoint("legacy-ext", types.ModuleType("legacy_module"))
with patch("oceanum.__main__.entry_points", return_value=[ep]):
load_cli_extensions(parent)
assert len(parent.commands) == 0


def test_direct_command_extension():
"""Direct command extension path: entry point returns a click.Group, should be added."""
parent = click.Group("test-parent")
demo_group = click.Group(name="demo-group")
ep = FakeEntryPoint("direct-ext", demo_group)
with patch("oceanum.__main__.entry_points", return_value=[ep]):
load_cli_extensions(parent)
assert "demo-group" in parent.commands
2 changes: 2 additions & 0 deletions tests/test_cli_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

REMOTE_PATH = "test_storage_cli"

pytestmark = pytest.mark.requires_datamesh_token

@pytest.fixture
def fs():
return FileSystem(os.environ["DATAMESH_TOKEN"])
Expand Down
Loading