Skip to content

Commit e12cfbd

Browse files
authored
Merge pull request #72 from xcube-dev/pont-57-container-args
Make 'xcengine image run' pass extra args to container
2 parents d02af45 + 27f669c commit e12cfbd

8 files changed

Lines changed: 95 additions & 12 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* Improve type annotations and checks (#68)
88
* Include Dockerfile in built images (#55)
99
* Look for environment.yml automatically (#41)
10+
* Allow 'xcengine image run' to pass arguments to the container (#57)
1011

1112
## Changes in 0.1.1
1213

docs/xcetool.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Options:
3939

4040
### `xcetool image run`
4141

42-
Usage: `xcetool image run [OPTIONS] IMAGE`
42+
Usage: `xcetool image run [OPTIONS] IMAGE [CONTAINER_ARGUMENT]...`
4343

4444
Options:
4545

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

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

6668
If you use the `--server` option with `xcetool image run`, the image will be
6769
run in xcube server mode: after the code from the input notebook is used to

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies:
1818
- cwltool
1919
- pytest
2020
- pytest-cov
21+
- pytz
2122

2223
# Note: xcube is not required for the conversion itself, but is required
2324
# to run generated scripts outside containers ("create" mode). xcube is

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ xcetool = "xcengine.cli:cli"
5656
dev = [
5757
"cwltool",
5858
"pytest",
59-
"pytest-cov"
59+
"pytest-cov",
60+
"pytz"
6061
]
6162
doc = [
6263
"mkdocs",

test/test_cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def test_image_run(runner_mock):
102102
host_port=None,
103103
from_saved=False,
104104
keep=False,
105+
script_args=[],
105106
)
106107

107108

@@ -120,10 +121,30 @@ def test_image_run_print_urls(runner_mock):
120121
host_port=port,
121122
from_saved=False,
122123
keep=False,
124+
script_args=[],
123125
)
124126
assert re.search(
125127
f"server.*http://localhost:{port}", result.stdout, re.IGNORECASE
126128
)
127129
assert re.search(
128130
f"viewer.*http://localhost:{port}/viewer", result.stdout, re.IGNORECASE
129131
)
132+
133+
134+
@patch("xcengine.cli.ContainerRunner")
135+
def test_image_run_script_args(runner_mock):
136+
cli_runner = CliRunner()
137+
instance_mock = runner_mock.return_value = MagicMock()
138+
port = 32168
139+
result = cli_runner.invoke(
140+
cli, ["image", "run", "--server", "--port", str(port), "foo", "--bar"]
141+
)
142+
runner_mock.assert_called_once_with(image="foo", output_dir=None)
143+
assert result.exit_code == 0
144+
instance_mock.run.assert_called_once_with(
145+
run_batch=False,
146+
host_port=port,
147+
from_saved=False,
148+
keep=False,
149+
script_args=["--bar"],
150+
)

test/test_core.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929

3030
@patch("xcengine.core.ScriptCreator.__init__")
3131
@pytest.mark.parametrize("tag", [None, "bar"])
32-
@pytest.mark.parametrize("env_file_name", ["environment.yml", "foo.yaml", None])
32+
@pytest.mark.parametrize(
33+
"env_file_name", ["environment.yml", "foo.yaml", None]
34+
)
3335
@pytest.mark.parametrize("use_env_file_param", [False, True])
3436
def test_image_builder_init(
3537
init_mock,
@@ -56,7 +58,11 @@ def test_image_builder_init(
5658
)
5759
assert ib.notebook == nb_path
5860
assert ib.build_dir == build_path
59-
expected_env = environment_path if (use_env_file_param or env_file_name == "environment.yml") else None
61+
expected_env = (
62+
environment_path
63+
if (use_env_file_param or env_file_name == "environment.yml")
64+
else None
65+
)
6066
assert ib.environment == expected_env
6167
if tag is None:
6268
assert abs(
@@ -123,6 +129,28 @@ def test_runner_run_keep(keep: bool):
123129
container.remove.assert_called_once_with(force=True)
124130

125131

132+
def test_runner_extra_args():
133+
runner = xcengine.core.ContainerRunner(
134+
image := Mock(docker.models.images.Image),
135+
None,
136+
client := Mock(DockerClient),
137+
)
138+
image.tags = []
139+
client.containers.run.return_value = (container := MagicMock(Container))
140+
container.status = "exited"
141+
script_args = ["--foo", "--bar", "42", "--baz", "somestring"]
142+
runner.run(
143+
run_batch=False,
144+
host_port=None,
145+
from_saved=False,
146+
keep=False,
147+
script_args=script_args,
148+
)
149+
run_args = client.containers.run.call_args
150+
command = run_args[1]["command"]
151+
assert command == ["python", "execute.py"] + script_args
152+
153+
126154
def test_runner_sigint():
127155
runner = xcengine.core.ContainerRunner(
128156
image := Mock(docker.models.images.Image),

xcengine/cli.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def image_cli():
103103

104104
@image_cli.command(
105105
help="Build a compute engine as a Docker image, optionally generating an "
106-
"Application Package"
106+
"Application Package"
107107
)
108108
@click.option(
109109
"-b",
@@ -119,7 +119,7 @@ def image_cli():
119119
help="Conda environment file to use in Docker image. "
120120
"If no environment file is specified here or in the notebook, and if "
121121
"there is no file named environment.yml in the notebook's directory, "
122-
"xcetool will try to reproduce the current environment."
122+
"xcetool will try to reproduce the current environment.",
123123
)
124124
@click.option(
125125
"-t",
@@ -147,10 +147,12 @@ def build(
147147
) -> None:
148148
if environment is None:
149149
LOGGER.info("No environment file specified on command line.")
150+
150151
class InitArgs(TypedDict):
151152
notebook: pathlib.Path
152153
environment: pathlib.Path
153154
tag: str
155+
154156
init_args = InitArgs(notebook=notebook, environment=environment, tag=tag)
155157
if build_dir:
156158
image_builder = ImageBuilder(build_dir=build_dir, **init_args)
@@ -163,9 +165,11 @@ class InitArgs(TypedDict):
163165
)
164166
image = image_builder.build()
165167
if eoap:
168+
166169
class IndentDumper(yaml.Dumper):
167170
def increase_indent(self, flow=False, indentless=False):
168171
return super(IndentDumper, self).increase_indent(flow, False)
172+
169173
eoap.write_text(
170174
yaml.dump(
171175
image_builder.create_cwl(),
@@ -176,7 +180,14 @@ def increase_indent(self, flow=False, indentless=False):
176180
print(f"Built image with tags {image.tags}")
177181

178182

179-
@image_cli.command(help="Run a compute engine image as a Docker container.")
183+
@image_cli.command(
184+
help="Run a compute engine image as a Docker container. "
185+
"Any arguments provided after IMAGE will be passed on to the command "
186+
"executed inside the container.",
187+
context_settings=dict(
188+
ignore_unknown_options=True,
189+
),
190+
)
180191
@click.option(
181192
"-b",
182193
"--batch",
@@ -213,6 +224,12 @@ def increase_indent(self, flow=False, indentless=False):
213224
help="Keep container after it has finished running.",
214225
)
215226
@click.argument("image", type=str)
227+
@click.argument(
228+
"script_args",
229+
nargs=-1,
230+
type=click.UNPROCESSED,
231+
metavar="[CONTAINER_ARGUMENT]...",
232+
)
216233
@click.pass_context
217234
def run(
218235
ctx: click.Context,
@@ -223,6 +240,7 @@ def run(
223240
keep: bool,
224241
image: str,
225242
output: pathlib.Path,
243+
script_args,
226244
) -> None:
227245
runner = ContainerRunner(image=image, output_dir=output)
228246
port_specified_explicitly = (
@@ -243,4 +261,5 @@ def run(
243261
host_port=actual_port,
244262
from_saved=from_saved,
245263
keep=keep,
264+
script_args=list(script_args),
246265
)

xcengine/core.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
LOGGER = logging.getLogger(__name__)
3434
logging.basicConfig(level=logging.INFO)
3535

36+
3637
class ScriptCreator:
3738
"""Turn a Jupyter notebook into a set of scripts"""
3839

@@ -221,7 +222,7 @@ def __init__(
221222
self.environment = notebook.parent / nb_env
222223
else:
223224
LOGGER.info(f"No environment specified in notebook.")
224-
LOGGER.info(f"Looking for a file named \"environment.yml\".")
225+
LOGGER.info(f'Looking for a file named "environment.yml".')
225226
notebook_sibling = notebook.parent / "environment.yml"
226227
if notebook_sibling.is_file():
227228
self.environment = notebook_sibling
@@ -375,9 +376,11 @@ def run(
375376
host_port: int | None,
376377
from_saved: bool,
377378
keep: bool,
379+
script_args: list[str] | None = None,
378380
):
379381
LOGGER.info(f"Running container from image {self.image.short_id}")
380382
LOGGER.info(f"Image tags: {' '.join(self.image.tags)}")
383+
assert isinstance(script_args, list) or script_args is None
381384
command = (
382385
["python", "execute.py"]
383386
+ (["--batch"] if run_batch else [])
@@ -391,6 +394,9 @@ def run(
391394
else []
392395
)
393396
+ (["--from-saved"] if from_saved else [])
397+
+ script_args
398+
if script_args is not None
399+
else []
394400
)
395401
run_args: dict[str, Any] = dict(
396402
image=self.image, command=command, remove=False, detach=True
@@ -400,10 +406,14 @@ def run(
400406
container: Container = self.client.containers.run(**run_args)
401407
LOGGER.info(f"Waiting for container {container.short_id} to complete.")
402408
default_sigint_handler = signal.getsignal(signal.SIGINT)
409+
403410
def signal_hander(signum, frame):
404411
signal.signal(signal.SIGINT, default_sigint_handler)
405-
LOGGER.info(f"Caught SIGINT. Stopping container {container.short_id}")
412+
LOGGER.info(
413+
f"Caught SIGINT. Stopping container {container.short_id}"
414+
)
406415
container.stop()
416+
407417
signal.signal(signal.SIGINT, signal_hander)
408418
while container.status in {"created", "running"}:
409419
LOGGER.debug(

0 commit comments

Comments
 (0)