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
29 changes: 16 additions & 13 deletions src/simdb/cli/commands/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import urllib.parse
from itertools import chain
from pathlib import Path
from typing import Any, List, Optional, Tuple, Type
from typing import Any, List, Optional, Tuple

import appdirs
import click
Expand Down Expand Up @@ -200,19 +200,22 @@ def simulation_ingest(config: Config, manifest_file: str, alias: str):
click.echo("ALIAS: " + simulation.alias + "\nUUID: " + str(simulation.uuid))


def n_required_args_adaptor(n) -> Type[click.Command]:
class NRequiredArgs(click.Command):
NArgs = n
class OptionalRemoteCommand(click.Command):
"""A command declared as `[REMOTE] ARG...` whose REMOTE may be left out."""

def parse_args(self, ctx, args):
if len(args) == self.NArgs:
args.insert(0, "")
super().parse_args(ctx, args)
def parse_args(self, ctx, args):
arguments = [p for p in self.get_params(ctx) if isinstance(p, click.Argument)]
if self._count_values_given(ctx, args, arguments) < len(arguments):
args = ["", *args]
super().parse_args(ctx, args)

return NRequiredArgs
def _count_values_given(self, ctx, args, arguments) -> int:
"""Count how many of the ARGUMENTS the command line provides a value for."""
values = self.make_parser(ctx).parse_args(list(args))[0]
return sum(1 for argument in arguments if values.get(argument.name) is not None)


@simulation.command("push", cls=n_required_args_adaptor(1))
@simulation.command("push", cls=OptionalRemoteCommand)
@pass_config
@click.argument("remote", required=False)
@click.argument("sim_id")
Expand Down Expand Up @@ -257,7 +260,7 @@ def simulation_push(
click.echo(f"Successfully pushed simulation {simulation.uuid}")


@simulation.command("pull", cls=n_required_args_adaptor(2))
@simulation.command("pull", cls=OptionalRemoteCommand)
@pass_config
@click.argument("remote", required=False)
@click.argument("sim_id")
Expand Down Expand Up @@ -380,7 +383,7 @@ def simulation_query(
)


@simulation.command("data", cls=n_required_args_adaptor(2))
@simulation.command("data", cls=OptionalRemoteCommand)
@pass_config
@click.argument("remote", required=False)
@click.argument("sim_id")
Expand Down Expand Up @@ -447,7 +450,7 @@ def simulation_data(
print_quantity(coord, label=f"coord {coord.name}", show_stats=False)


@simulation.command("validate", cls=n_required_args_adaptor(1))
@simulation.command("validate", cls=OptionalRemoteCommand)
@pass_config
@click.argument("remote", required=False)
@click.argument("sim_id")
Expand Down
34 changes: 34 additions & 0 deletions tests/cli/cli_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Helpers shared by the CLI tests.

Kept out of ``conftest.py`` because pytest imports every ``conftest`` under
the same module name, so importing from it directly picks up whichever one
was loaded first.
"""

from typing import Optional
from unittest import mock


def make_simulation(
alias: str,
uuid: str = "0123456789abcdef0123456789abcdef",
datetime: str = "2000-01-01 00:00:00",
status: str = "not validated",
meta: Optional[dict] = None,
) -> mock.Mock:
"""Build a stand-in for a :class:`~simdb.database.models.Simulation`.

Only the attributes the CLI display code touches are set; ``find_meta``
answers from ``meta`` the same way the real model does (a list of objects
with a ``value``, empty when the name is unknown).
"""
simulation = mock.Mock()
simulation.alias = alias
simulation.uuid = uuid
simulation.datetime = datetime
simulation.status = status
meta = meta or {}
simulation.find_meta.side_effect = lambda name: (
[mock.Mock(value=meta[name])] if name in meta else []
)
return simulation
159 changes: 159 additions & 0 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Shared fixtures for the ``simdb`` command line interface tests.

Every test here drives the CLI through :class:`click.testing.CliRunner`, so the
fixtures below take care of the two things that would otherwise leak between
tests and into the developer's machine: the configuration that the CLI reads at
startup, and the handshake :class:`~simdb.cli.remote_api.RemoteAPI` performs
against a remote when it is constructed.
"""

import os
from pathlib import Path
from types import SimpleNamespace
from unittest import mock

import pytest
from click.testing import CliRunner, Result

from simdb.cli.remote_api import RemoteAPI
from simdb.cli.simdb import cli

REMOTE_NAME = "test"
REMOTE_URL = "http://0.0.0.0:5000/"
REMOTE_TOKEN = "123ABC"

SERVER_ENDPOINTS = ["v1", "v1.1", "v1.1.1", "v1.2", "v1.3"]
"""API versions the fake remote advertises."""


@pytest.fixture(autouse=True)
def isolated_config_environment(tmp_path, monkeypatch):
"""Point the CLI at throw-away site and user configuration files.

:class:`~simdb.config.config.Config` reads ``simdb.cfg`` from the platform
config directories unless ``SIMDB_SITE_CONFIG_PATH``/
``SIMDB_USER_CONFIG_PATH`` say otherwise. Without this fixture the outcome of
a test depends on whether the machine running it happens to have a real
SimDB configuration, which is exactly the kind of difference that makes a
suite pass locally and fail in CI.
"""
for variable in [name for name in os.environ if name.startswith("SIMDB_")]:
monkeypatch.delenv(variable)
monkeypatch.setenv("SIMDB_SITE_CONFIG_PATH", str(tmp_path / "site-simdb.cfg"))
monkeypatch.setenv("SIMDB_USER_CONFIG_PATH", str(tmp_path / "user-simdb.cfg"))


@pytest.fixture
def config_file(tmp_path) -> Path:
"""A configuration file declaring a single, default, token-authenticated remote."""
config_path = tmp_path / "simdb.cfg"
config_path.write_text(
f'[remote "{REMOTE_NAME}"]\n'
f"url = {REMOTE_URL}\n"
"default = True\n"
f"token = {REMOTE_TOKEN}\n"
"\n"
"[db]\n"
# Keep any command that reaches the real database away from the local
# one in the user's data directory.
f"file = {tmp_path / 'sim.db'}\n"
)
return config_path


@pytest.fixture
def runner() -> CliRunner:
return CliRunner()


@pytest.fixture
def invoke(runner, config_file):
"""Invoke the ``simdb`` CLI against the throw-away :func:`config_file`.

``invoke("simulation", "list")`` runs ``simdb --config-file=... simulation
list``. Any keyword argument is forwarded to
:meth:`click.testing.CliRunner.invoke`, so ``input=`` can be used to answer
prompts.
"""

def _invoke(*args: str, **kwargs) -> Result:
return runner.invoke(cli, [f"--config-file={config_file}", *args], **kwargs)

return _invoke


@pytest.fixture
def remote_handshake():
"""Stub the requests :class:`RemoteAPI` makes while it is being constructed.

``RemoteAPI.__init__`` asks the remote for its authentication scheme, its
endpoints, and its server version before any command specific request is
made. Tests that only care about the command itself get all three stubbed
here, and can still assert on them through the returned namespace::

def test_something(invoke, remote_handshake):
...
assert remote_handshake.get_endpoints.called
"""
with mock.patch.object(
RemoteAPI, "get_server_authentication", return_value="None"
) as get_server_authentication, mock.patch.object(
RemoteAPI, "get_endpoints", return_value=list(SERVER_ENDPOINTS)
) as get_endpoints, mock.patch.object(
RemoteAPI, "get_server_version", return_value="0.11"
) as get_server_version:
yield SimpleNamespace(
get_server_authentication=get_server_authentication,
get_endpoints=get_endpoints,
get_server_version=get_server_version,
)


@pytest.fixture
def local_db():
"""Replace the local database with a mock in every module that looks it up.

``get_local_db`` is imported into each command module, so patching a single
import site silently leaves the other commands talking to the real database
in the user's data directory.
"""
db = mock.Mock()
with mock.patch(
"simdb.cli.commands.alias.get_local_db", return_value=db
), mock.patch("simdb.cli.commands.simulation.get_local_db", return_value=db):
yield db


@pytest.fixture
def data_file(tmp_path) -> Path:
"""A small file a manifest can reference as an input or output."""
path = tmp_path / "data.txt"
path.write_text("simulation data\n")
return path


@pytest.fixture
def manifest_file(tmp_path, data_file) -> Path:
"""A minimal, valid manifest referencing only local files."""
manifest_path = tmp_path / "manifest.yaml"
manifest_path.write_text(
f"""\
manifest_version: 2
alias: simulation-alias

inputs:
- uri: file://{data_file}

outputs:
- uri: file://{data_file}

metadata:
- values:
workflow:
name: Workflow Name
git: ssh://git@git.iter.org/wf/workflow.git
branch: master
commit: 079e84d5ae8a0eec6dcf3819c98f3c05f48e952f
"""
)
return manifest_path
Loading
Loading