From 65f92fa965a9dedb4e7292464a8f07ec59c29b96 Mon Sep 17 00:00:00 2001 From: Tom Durrant Date: Thu, 23 Apr 2026 21:53:16 +1000 Subject: [PATCH] Extend CLI extension loader to support multiple target types Add support for three extension styles in the oceanum CLI host: - Legacy module side effects (existing behaviour, unchanged) - Direct click.Command/Group export - Registrar callable (receives parent group, registers commands) Broken plugins are caught and logged to stderr without aborting startup. An OCEANUM_CLI_DEBUG env var enables verbose load tracing. Also normalise the token-gated test bootstrap: conftest.py uses pytest hooks to skip requires_datamesh_token tests when the env var is absent, and test_cli_storage.py is marked accordingly. Add unit tests covering all four dispatch paths plus broken-plugin resilience and debug-mode safety. --- README.md | 11 ++++ pyproject.toml | 1 + src/oceanum/__main__.py | 49 ++++++++++++---- tests/conftest.py | 20 ++++--- tests/test_cli_extension_loader.py | 91 ++++++++++++++++++++++++++++++ tests/test_cli_storage.py | 2 + 6 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 tests/test_cli_extension_loader.py diff --git a/README.md b/README.md index fa1785f..696317f 100644 --- a/README.md +++ b/README.md @@ -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)`. diff --git a/pyproject.toml b/pyproject.toml index a75e4d8..716ae82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/oceanum/__main__.py b/src/oceanum/__main__.py index f25ea36..93f2d4b 100644 --- a/src/oceanum/__main__.py +++ b/src/oceanum/__main__.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index ef535f8..1d1edf5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_cli_extension_loader.py b/tests/test_cli_extension_loader.py new file mode 100644 index 0000000..5e13b1d --- /dev/null +++ b/tests/test_cli_extension_loader.py @@ -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 diff --git a/tests/test_cli_storage.py b/tests/test_cli_storage.py index e2bbedc..5f3a6a7 100644 --- a/tests/test_cli_storage.py +++ b/tests/test_cli_storage.py @@ -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"])