Skip to content

Commit 2e94789

Browse files
authored
Merge pull request #4 from xcube-dev/pont-3-port-option
Add port option, and remove run functionality from build subcommand
2 parents e09dbd6 + 1395291 commit 2e94789

File tree

7 files changed

+107
-78
lines changed

7 files changed

+107
-78
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2024 Brockmann Consult GmbH
3+
Copyright (c) 2024-2025 Brockmann Consult GmbH
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

examples/environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ channels:
33
- conda-forge
44
dependencies:
55
- python >=3.10
6-
- xcube >=1.7.1
6+
- xcube >=1.8.3
77
- matplotlib-base

test/test_cli.py

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
from unittest.mock import patch
2-
1+
from unittest.mock import patch, ANY
32
import pytest
43
from click.testing import CliRunner
54

@@ -10,35 +9,64 @@
109
@patch("xcengine.core.ScriptCreator.convert_notebook_to_script")
1110
@patch("subprocess.run")
1211
@pytest.mark.parametrize("verbose_arg", [[], ["--verbose"]])
13-
def test_make_script(run_mock, convert_mock, init_mock, tmp_path, verbose_arg):
12+
@pytest.mark.parametrize("batch_arg", [[], ["--batch"]])
13+
@pytest.mark.parametrize("server_arg", [[], ["--server"]])
14+
@pytest.mark.parametrize("from_saved_arg", [[], ["--from-saved"]])
15+
def test_make_script(
16+
run_mock,
17+
convert_mock,
18+
init_mock,
19+
tmp_path,
20+
verbose_arg,
21+
batch_arg,
22+
server_arg,
23+
from_saved_arg,
24+
):
25+
from xcengine.cli import logging
26+
27+
logging.getLogger().setLevel(logging.WARN)
1428
nb_path = tmp_path / "foo.ipynb"
1529
nb_path.touch()
1630
output_dir = tmp_path / "bar"
1731
init_mock.return_value = None
1832
runner = CliRunner()
1933
result = runner.invoke(
2034
cli,
21-
verbose_arg +
22-
[
23-
"make-script",
24-
"--batch",
25-
"--server",
26-
"--from-saved",
35+
verbose_arg
36+
+ ["make-script"]
37+
+ batch_arg
38+
+ server_arg
39+
+ from_saved_arg
40+
+ [
2741
str(nb_path),
2842
str(output_dir),
2943
],
3044
)
31-
convert_mock.assert_called()
32-
init_mock.assert_called()
33-
run_mock.assert_called_with(
34-
[
35-
"python3",
36-
output_dir / "execute.py",
37-
"--batch",
38-
"--server",
39-
"--from-saved",
40-
]
45+
convert_mock.assert_called_once_with(
46+
output_dir=output_dir, clear_output=False
47+
)
48+
init_mock.assert_called_once_with(nb_path)
49+
if batch_arg or server_arg:
50+
run_mock.assert_called_once_with(
51+
["python3", output_dir / "execute.py"]
52+
+ batch_arg
53+
+ server_arg
54+
+ from_saved_arg
55+
)
56+
assert logging.getLogger().getEffectiveLevel() == (
57+
logging.DEBUG if "--verbose" in verbose_arg else logging.WARNING
4158
)
42-
from xcengine.cli import logging
43-
assert logging.getLogger().getEffectiveLevel() == (logging.DEBUG if "--verbose" in verbose_arg else logging.WARNING)
4459
assert result.exit_code == 0
60+
61+
62+
@patch("xcengine.core.ImageBuilder.__init__")
63+
@patch("xcengine.core.ImageBuilder.build")
64+
def test_image_build(build_mock, init_mock, tmp_path):
65+
nb_path = tmp_path / "foo.ipynb"
66+
nb_path.touch()
67+
runner = CliRunner()
68+
tag = "foo"
69+
result = runner.invoke(cli, ["image", "build", "--tag", tag, str(nb_path)])
70+
init_mock.assert_called_once_with(
71+
notebook=nb_path, environment=None, tag=tag, build_dir=ANY
72+
)

xcengine/cli.py

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright (c) 2024 by Brockmann Consult GmbH
3+
# Copyright (c) 2024-2025 by Brockmann Consult GmbH
44
# Permissions are hereby granted under the terms of the MIT License:
55
# https://opensource.org/licenses/MIT.
66

@@ -12,6 +12,7 @@
1212

1313
import click
1414
import yaml
15+
from click.core import ParameterSource
1516

1617
from .core import ScriptCreator, ImageBuilder, ContainerRunner
1718

@@ -30,13 +31,6 @@ def cli(verbose):
3031
"-b", "--batch", is_flag=True, help="Run as batch script after creating"
3132
)
3233

33-
server_option = click.option(
34-
"-s",
35-
"--server",
36-
is_flag=True,
37-
help="Run as xcube server script after creating",
38-
)
39-
4034
output_option = click.option(
4135
"-o",
4236
"--output",
@@ -66,6 +60,13 @@ def cli(verbose):
6660
),
6761
)
6862

63+
server_option = click.option(
64+
"-s",
65+
"--server",
66+
is_flag=True,
67+
help="Run the script as an xcube server after creating it.",
68+
)
69+
6970

7071
@cli.command(help="Create a compute engine script on the host system")
7172
@batch_option
@@ -113,25 +114,20 @@ def image_cli():
113114
@image_cli.command(
114115
help="Build, and optionally run, a compute engine as a Docker image"
115116
)
116-
@batch_option
117-
@server_option
118-
@from_saved_option
119117
@click.option(
120118
"-b",
121119
"--build-dir",
122120
type=click.Path(path_type=pathlib.Path, dir_okay=True, file_okay=False),
123121
help="Build directory to use for preparing the Docker image. If not "
124122
"specified, an automatically created temporary directory will be used.",
125123
)
126-
@keep_option
127124
@click.option(
128125
"-e",
129126
"--environment",
130127
type=click.Path(path_type=pathlib.Path, dir_okay=False, file_okay=True),
131128
help="Conda environment file to use in Docker image. "
132129
"If not specified, try to reproduce the current environment.",
133130
)
134-
@output_option
135131
@click.option(
136132
"-t",
137133
"--tag",
@@ -150,55 +146,65 @@ def image_cli():
150146
)
151147
@notebook_argument
152148
def build(
153-
batch: bool,
154-
server: bool,
155-
from_saved: bool,
156-
keep: bool,
157149
build_dir: pathlib.Path,
158150
notebook: pathlib.Path,
159-
output: pathlib.Path,
160151
environment: pathlib.Path,
161152
tag: str,
162153
eoap: pathlib.Path,
163154
) -> None:
164-
init_args = dict(
165-
notebook=notebook, output_dir=output, environment=environment, tag=tag
166-
)
167-
build_args = dict(
168-
run_batch=batch, run_server=server, from_saved=from_saved, keep=keep
169-
)
155+
init_args = dict(notebook=notebook, environment=environment, tag=tag)
170156
if build_dir:
171157
image_builder = ImageBuilder(build_dir=build_dir, **init_args)
172158
os.makedirs(build_dir, exist_ok=True)
173-
image_builder.build(**build_args)
159+
image = image_builder.build()
174160
else:
175161
with tempfile.TemporaryDirectory() as temp_dir:
176162
image_builder = ImageBuilder(
177163
build_dir=pathlib.Path(temp_dir), **init_args
178164
)
179-
image_builder.build(**build_args)
165+
image = image_builder.build()
180166
if eoap:
181167
eoap.write_text(yaml.dump(image_builder.create_cwl()))
168+
print(f"Built image with tags {image.tags}")
182169

183170

184171
@image_cli.command(help="Run a compute engine image as a Docker container")
185172
@batch_option
186173
@server_option
174+
@click.option(
175+
"-p",
176+
"--port",
177+
is_flag=False,
178+
type=int,
179+
default=8080,
180+
help="Host port for xcube server (default: 8080). Implies --server.",
181+
)
187182
@from_saved_option
188183
@output_option
189184
@keep_option
190185
@click.argument("image", type=str)
186+
@click.pass_context
191187
def run(
188+
ctx: click.Context,
192189
batch: bool,
193-
server: bool,
190+
server: False,
191+
port: int,
194192
from_saved: bool,
195193
keep: bool,
196194
image: str,
197195
output: pathlib.Path,
198196
) -> None:
199197
runner = ContainerRunner(image=image, output_dir=output)
198+
port_specified_explicitly = (
199+
ctx.get_parameter_source("port")
200+
is not click.core.ParameterSource.DEFAULT
201+
)
202+
actual_port = port if server or port_specified_explicitly else None
200203
runner.run(
201-
run_batch=batch, run_server=server, from_saved=from_saved, keep=keep
204+
run_batch=batch,
205+
host_port=actual_port,
206+
from_saved=from_saved,
207+
keep=keep,
202208
)
203209

204210

xcengine/core.py

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2024 by Brockmann Consult GmbH
1+
# Copyright (c) 2024-2025 by Brockmann Consult GmbH
22
# Permissions are hereby granted under the terms of the MIT License:
33
# https://opensource.org/licenses/MIT.
44

@@ -153,13 +153,11 @@ class ImageBuilder:
153153
def __init__(
154154
self,
155155
notebook: pathlib.Path,
156-
output_dir: pathlib.Path,
157156
environment: pathlib.Path,
158157
build_dir: pathlib.Path,
159158
tag: str | None,
160159
):
161160
self.notebook = notebook
162-
self.output_dir = output_dir
163161
self.environment = environment
164162
self.build_dir = build_dir
165163
if tag is None:
@@ -171,13 +169,7 @@ def __init__(
171169
self.tag = tag
172170
self.script_creator = ScriptCreator(self.notebook)
173171

174-
def build(
175-
self,
176-
run_batch: bool,
177-
run_server: bool,
178-
from_saved: bool,
179-
keep: bool,
180-
) -> None:
172+
def build(self) -> Image:
181173
self.script_creator.convert_notebook_to_script(self.build_dir)
182174
if self.environment:
183175
with open(self.environment, "r") as fh:
@@ -192,10 +184,7 @@ def build(
192184
self.add_packages_to_environment(env_def, ["xcube", "pystac"])
193185
with open(self.build_dir / "environment.yml", "w") as fh:
194186
fh.write(yaml.safe_dump(env_def))
195-
image: Image = self.build_image()
196-
if run_batch or run_server:
197-
runner = ContainerRunner(image, self.output_dir)
198-
runner.run(run_batch, run_server, from_saved, keep)
187+
return self._build_image()
199188

200189
@staticmethod
201190
def export_conda_env() -> dict:
@@ -253,7 +242,7 @@ def ensure_present(pkg: str):
253242
ensure_present(package)
254243
return conda_env
255244

256-
def build_image(self) -> docker.models.images.Image:
245+
def _build_image(self) -> docker.models.images.Image:
257246
client = docker.from_env()
258247
dockerfile = textwrap.dedent(
259248
"""
@@ -317,23 +306,26 @@ def client(self):
317306
return self._client
318307

319308
def run(
320-
self, run_batch: bool, run_server: bool, from_saved: bool, keep: bool
309+
self,
310+
run_batch: bool,
311+
host_port: int | None,
312+
from_saved: bool,
313+
keep: bool,
321314
):
322315
LOGGER.info(f"Running container from image {self.image.short_id}")
323316
LOGGER.info(f"Image tags: {' '.join(self.image.tags)}")
324317
command = (
325318
["python", "execute.py"]
326319
+ (["--batch"] if run_batch else [])
327-
+ (["--server"] if run_server else [])
320+
+ (["--server"] if host_port is not None else [])
328321
+ (["--from-saved"] if from_saved else [])
329322
)
330-
container: Container = self.client.containers.run(
331-
image=self.image,
332-
command=command,
333-
ports={"8080": 8080},
334-
remove=False,
335-
detach=True,
323+
run_args = dict(
324+
image=self.image, command=command, remove=False, detach=True
336325
)
326+
if host_port is not None:
327+
run_args["ports"] = {"8080": host_port}
328+
container: Container = self.client.containers.run(**run_args)
337329
LOGGER.info(f"Waiting for container {container.short_id} to complete.")
338330
while container.status in {"created", "running"}:
339331
LOGGER.debug(
@@ -349,7 +341,7 @@ def run(
349341
)
350342
self.extract_output_from_container(container)
351343
LOGGER.info(f"Results copied.")
352-
if not run_server and not keep:
344+
if host_port is None and not keep:
353345
LOGGER.info(f"Removing container {container.short_id}...")
354346
container.remove(force=True)
355347
LOGGER.info(f"Container {container.short_id} removed.")

xcengine/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2024 by Brockmann Consult GmbH
1+
# Copyright (c) 2024-2025 by Brockmann Consult GmbH
22
# Permissions are hereby granted under the terms of the MIT License:
33
# https://opensource.org/licenses/MIT.
44
from collections import namedtuple

xcengine/wrapper.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright (c) 2024 by Brockmann Consult GmbH
3+
# Copyright (c) 2024-2025 by Brockmann Consult GmbH
44
# Permissions are hereby granted under the terms of the MIT License:
55
# https://opensource.org/licenses/MIT.
66

@@ -25,10 +25,13 @@ def __xce_set_params():
2525
)
2626
globals().update(params.read_params_combined(sys.argv))
2727

28+
2829
if "XC_USER_CODE_PATH" in os.environ:
2930
__user_code_path = pathlib.Path(os.environ["XC_USER_CODE_PATH"])
3031
else:
31-
__user_code_path = pathlib.Path(__file__).with_name("user_code.py").resolve()
32+
__user_code_path = (
33+
pathlib.Path(__file__).with_name("user_code.py").resolve()
34+
)
3235
with __user_code_path.open() as fh:
3336
user_code = fh.read()
3437

0 commit comments

Comments
 (0)