Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* Improve type annotations and checks (#68)
* Include Dockerfile in built images (#55)
* Look for environment.yml automatically (#41)
* Allow 'xcengine image run' to pass arguments to the container (#57)

## Changes in 0.1.1

Expand Down
10 changes: 6 additions & 4 deletions docs/xcetool.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Options:

### `xcetool image run`

Usage: `xcetool image run [OPTIONS] IMAGE`
Usage: `xcetool image run [OPTIONS] IMAGE [CONTAINER_ARGUMENT]...`

Options:

Expand All @@ -59,9 +59,11 @@ Options:
running.
- `--help`: Show a help message for this subcommand and exit.

This subcommand runs an xcengine container image. An image can also be run
using the `docker run` command, but `xcetool image run` provides some
additional convenience (e.g. easy configuration of a server HTTP port).
This subcommand runs an xcengine container image. Any arguments provided
after IMAGE will be passed on to the command executed inside the container.
An image can also be run using the `docker run` command, but
`xcetool image run` provides some additional convenience (e.g. easy
configuration of a server HTTP port).

If you use the `--server` option with `xcetool image run`, the image will be
run in xcube server mode: after the code from the input notebook is used to
Expand Down
1 change: 1 addition & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies:
- cwltool
- pytest
- pytest-cov
- pytz

# Note: xcube is not required for the conversion itself, but is required
# to run generated scripts outside containers ("create" mode). xcube is
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ xcetool = "xcengine.cli:cli"
dev = [
"cwltool",
"pytest",
"pytest-cov"
"pytest-cov",
"pytz"
]
doc = [
"mkdocs",
Expand Down
21 changes: 21 additions & 0 deletions test/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def test_image_run(runner_mock):
host_port=None,
from_saved=False,
keep=False,
script_args=[],
)


Expand All @@ -120,10 +121,30 @@ def test_image_run_print_urls(runner_mock):
host_port=port,
from_saved=False,
keep=False,
script_args=[],
)
assert re.search(
f"server.*http://localhost:{port}", result.stdout, re.IGNORECASE
)
assert re.search(
f"viewer.*http://localhost:{port}/viewer", result.stdout, re.IGNORECASE
)


@patch("xcengine.cli.ContainerRunner")
def test_image_run_script_args(runner_mock):
cli_runner = CliRunner()
instance_mock = runner_mock.return_value = MagicMock()
port = 32168
result = cli_runner.invoke(
cli, ["image", "run", "--server", "--port", str(port), "foo", "--bar"]
)
runner_mock.assert_called_once_with(image="foo", output_dir=None)
assert result.exit_code == 0
instance_mock.run.assert_called_once_with(
run_batch=False,
host_port=port,
from_saved=False,
keep=False,
script_args=["--bar"],
)
32 changes: 30 additions & 2 deletions test/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@

@patch("xcengine.core.ScriptCreator.__init__")
@pytest.mark.parametrize("tag", [None, "bar"])
@pytest.mark.parametrize("env_file_name", ["environment.yml", "foo.yaml", None])
@pytest.mark.parametrize(
"env_file_name", ["environment.yml", "foo.yaml", None]
)
@pytest.mark.parametrize("use_env_file_param", [False, True])
def test_image_builder_init(
init_mock,
Expand All @@ -56,7 +58,11 @@ def test_image_builder_init(
)
assert ib.notebook == nb_path
assert ib.build_dir == build_path
expected_env = environment_path if (use_env_file_param or env_file_name == "environment.yml") else None
expected_env = (
environment_path
if (use_env_file_param or env_file_name == "environment.yml")
else None
)
assert ib.environment == expected_env
if tag is None:
assert abs(
Expand Down Expand Up @@ -123,6 +129,28 @@ def test_runner_run_keep(keep: bool):
container.remove.assert_called_once_with(force=True)


def test_runner_extra_args():
runner = xcengine.core.ContainerRunner(
image := Mock(docker.models.images.Image),
None,
client := Mock(DockerClient),
)
image.tags = []
client.containers.run.return_value = (container := MagicMock(Container))
container.status = "exited"
script_args = ["--foo", "--bar", "42", "--baz", "somestring"]
runner.run(
run_batch=False,
host_port=None,
from_saved=False,
keep=False,
script_args=script_args,
)
run_args = client.containers.run.call_args
command = run_args[1]["command"]
assert command == ["python", "execute.py"] + script_args


def test_runner_sigint():
runner = xcengine.core.ContainerRunner(
image := Mock(docker.models.images.Image),
Expand Down
25 changes: 22 additions & 3 deletions xcengine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def image_cli():

@image_cli.command(
help="Build a compute engine as a Docker image, optionally generating an "
"Application Package"
"Application Package"
)
@click.option(
"-b",
Expand All @@ -119,7 +119,7 @@ def image_cli():
help="Conda environment file to use in Docker image. "
"If no environment file is specified here or in the notebook, and if "
"there is no file named environment.yml in the notebook's directory, "
"xcetool will try to reproduce the current environment."
"xcetool will try to reproduce the current environment.",
)
@click.option(
"-t",
Expand Down Expand Up @@ -147,10 +147,12 @@ def build(
) -> None:
if environment is None:
LOGGER.info("No environment file specified on command line.")

class InitArgs(TypedDict):
notebook: pathlib.Path
environment: pathlib.Path
tag: str

init_args = InitArgs(notebook=notebook, environment=environment, tag=tag)
if build_dir:
image_builder = ImageBuilder(build_dir=build_dir, **init_args)
Expand All @@ -163,9 +165,11 @@ class InitArgs(TypedDict):
)
image = image_builder.build()
if eoap:

class IndentDumper(yaml.Dumper):
def increase_indent(self, flow=False, indentless=False):
return super(IndentDumper, self).increase_indent(flow, False)

eoap.write_text(
yaml.dump(
image_builder.create_cwl(),
Expand All @@ -176,7 +180,14 @@ def increase_indent(self, flow=False, indentless=False):
print(f"Built image with tags {image.tags}")


@image_cli.command(help="Run a compute engine image as a Docker container.")
@image_cli.command(
help="Run a compute engine image as a Docker container. "
"Any arguments provided after IMAGE will be passed on to the command "
"executed inside the container.",
context_settings=dict(
ignore_unknown_options=True,
),
)
@click.option(
"-b",
"--batch",
Expand Down Expand Up @@ -213,6 +224,12 @@ def increase_indent(self, flow=False, indentless=False):
help="Keep container after it has finished running.",
)
@click.argument("image", type=str)
@click.argument(
"script_args",
nargs=-1,
type=click.UNPROCESSED,
metavar="[CONTAINER_ARGUMENT]...",
)
@click.pass_context
def run(
ctx: click.Context,
Expand All @@ -223,6 +240,7 @@ def run(
keep: bool,
image: str,
output: pathlib.Path,
script_args,
) -> None:
runner = ContainerRunner(image=image, output_dir=output)
port_specified_explicitly = (
Expand All @@ -243,4 +261,5 @@ def run(
host_port=actual_port,
from_saved=from_saved,
keep=keep,
script_args=list(script_args),
)
14 changes: 12 additions & 2 deletions xcengine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
LOGGER = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)


class ScriptCreator:
"""Turn a Jupyter notebook into a set of scripts"""

Expand Down Expand Up @@ -221,7 +222,7 @@ def __init__(
self.environment = notebook.parent / nb_env
else:
LOGGER.info(f"No environment specified in notebook.")
LOGGER.info(f"Looking for a file named \"environment.yml\".")
LOGGER.info(f'Looking for a file named "environment.yml".')
notebook_sibling = notebook.parent / "environment.yml"
if notebook_sibling.is_file():
self.environment = notebook_sibling
Expand Down Expand Up @@ -375,9 +376,11 @@ def run(
host_port: int | None,
from_saved: bool,
keep: bool,
script_args: list[str] | None = None,
):
LOGGER.info(f"Running container from image {self.image.short_id}")
LOGGER.info(f"Image tags: {' '.join(self.image.tags)}")
assert isinstance(script_args, list) or script_args is None
command = (
["python", "execute.py"]
+ (["--batch"] if run_batch else [])
Expand All @@ -391,6 +394,9 @@ def run(
else []
)
+ (["--from-saved"] if from_saved else [])
+ script_args
if script_args is not None
else []
)
run_args: dict[str, Any] = dict(
image=self.image, command=command, remove=False, detach=True
Expand All @@ -400,10 +406,14 @@ def run(
container: Container = self.client.containers.run(**run_args)
LOGGER.info(f"Waiting for container {container.short_id} to complete.")
default_sigint_handler = signal.getsignal(signal.SIGINT)

def signal_hander(signum, frame):
signal.signal(signal.SIGINT, default_sigint_handler)
LOGGER.info(f"Caught SIGINT. Stopping container {container.short_id}")
LOGGER.info(
f"Caught SIGINT. Stopping container {container.short_id}"
)
container.stop()

signal.signal(signal.SIGINT, signal_hander)
while container.status in {"created", "running"}:
LOGGER.debug(
Expand Down