Skip to content

Commit dc6e821

Browse files
authored
Merge pull request #10 from xcube-dev/pont-9-param-extraction
Improve automated parameter extraction (closes #9)
2 parents ab307c1 + 2f38444 commit dc6e821

File tree

10 files changed

+131
-21
lines changed

10 files changed

+131
-21
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Changes in 0.1.1 (in development)
22

3+
* Improve automated parameter extraction (#9)
4+
35
## Changes in 0.1.0
46

57
* Initial release

examples/dynamic.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@
212212
"name": "python",
213213
"nbconvert_exporter": "python",
214214
"pygments_lexer": "ipython3",
215-
"version": "3.12.5"
215+
"version": "3.12.8"
216216
}
217217
},
218218
"nbformat": 4,

test/data/paramtest.ipynb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "39da67c4-94a7-4f13-8bda-87a9a3812694",
7+
"metadata": {
8+
"editable": true,
9+
"slideshow": {
10+
"slide_type": ""
11+
},
12+
"tags": []
13+
},
14+
"outputs": [],
15+
"source": [
16+
"import math\n",
17+
"\n",
18+
"my_constant = math.floor(3.5)"
19+
]
20+
},
21+
{
22+
"cell_type": "code",
23+
"execution_count": null,
24+
"id": "bae7e3db-0886-4d33-b982-4374452f62d0",
25+
"metadata": {
26+
"editable": true,
27+
"slideshow": {
28+
"slide_type": ""
29+
},
30+
"tags": [
31+
"parameters"
32+
]
33+
},
34+
"outputs": [],
35+
"source": [
36+
"parameter_1 = my_constant * 2\n",
37+
"parameter_2 = \"default value\""
38+
]
39+
}
40+
],
41+
"metadata": {
42+
"kernelspec": {
43+
"display_name": "Python 3 (ipykernel)",
44+
"language": "python",
45+
"name": "python3"
46+
},
47+
"language_info": {
48+
"codemirror_mode": {
49+
"name": "ipython",
50+
"version": 3
51+
},
52+
"file_extension": ".py",
53+
"mimetype": "text/x-python",
54+
"name": "python",
55+
"nbconvert_exporter": "python",
56+
"pygments_lexer": "ipython3",
57+
"version": "3.12.8"
58+
}
59+
},
60+
"nbformat": 4,
61+
"nbformat_minor": 5
62+
}

test/test_cli.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,12 @@ def test_image_build(builder_mock, tmp_path):
7474
)
7575
instance_mock.build.assert_called_once_with()
7676

77+
7778
@patch("xcengine.cli.ContainerRunner")
7879
def test_image_run(runner_mock):
7980
cli_runner = CliRunner()
8081
instance_mock = runner_mock.return_value = MagicMock()
81-
result = cli_runner.invoke(
82-
cli,
83-
["image", "run", "foo"]
84-
)
82+
result = cli_runner.invoke(cli, ["image", "run", "foo"])
8583
runner_mock.assert_called_once_with(image="foo", output_dir=None)
8684
assert result.exit_code == 0
8785
instance_mock.run.assert_called_once_with(

test/test_core.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from unittest.mock import MagicMock, patch
1616

17-
from xcengine.core import ChunkStream, ImageBuilder
17+
from xcengine.core import ChunkStream, ImageBuilder, ScriptCreator
1818

1919

2020
@patch("xcengine.core.ScriptCreator.__init__")
@@ -47,13 +47,13 @@ def test_image_builder_init(init_mock, tmp_path, tag):
4747
init_mock.assert_called_once_with(nb_path)
4848

4949

50-
def test_init_runner_invalid_image_type():
50+
def test_runner_init_invalid_image_type():
5151
with pytest.raises(ValueError, match='Invalid type "int"'):
5252
# noinspection PyTypeChecker
5353
xcengine.core.ContainerRunner(666, pathlib.Path("/foo"))
5454

5555

56-
def test_init_runner_with_string():
56+
def test_runner_init_with_string():
5757
image_name = "foo"
5858
image_mock = Mock(docker.models.images.Image)
5959
client_mock = Mock(docker.client.DockerClient)
@@ -69,7 +69,7 @@ def get_mock(name):
6969
assert image_mock == runner.image
7070

7171

72-
def test_init_runner_with_image():
72+
def test_runner_init_with_image():
7373
runner = xcengine.core.ContainerRunner(
7474
image := Mock(docker.models.images.Image), pathlib.Path("/foo")
7575
)
@@ -117,3 +117,13 @@ def test_chunk_stream():
117117
chunk_stream = ChunkStream(bytegen)
118118
assert chunk_stream.readable()
119119
assert BufferedReader(chunk_stream).read() == expected
120+
121+
122+
def test_script_creator_init():
123+
script_creator = ScriptCreator(
124+
pathlib.Path(__file__).parent / "data" / "paramtest.ipynb"
125+
)
126+
assert script_creator.nb_params.params == {
127+
"parameter_1": (int, 6),
128+
"parameter_2": (str, "default value"),
129+
}

test/test_parameters.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,25 @@ def test_parameters_from_code(expected_vars):
156156
)
157157

158158

159+
def test_parameters_from_code_with_setup(expected_vars):
160+
assert (
161+
xcengine.parameters.NotebookParameters.from_code(
162+
"""
163+
some_int = 2 * half_of_some_int
164+
some_float = 3.14159
165+
some_string = some_uppercase_string.lower()
166+
some_bool = not not_some_bool
167+
""",
168+
setup_code="""
169+
half_of_some_int = 21
170+
some_uppercase_string = "FOO"
171+
not_some_bool = True
172+
""",
173+
).params
174+
== expected_vars
175+
)
176+
177+
159178
def test_parameters_get_workflow_inputs(notebook_parameters):
160179
assert notebook_parameters.get_cwl_workflow_inputs() == {
161180
"some_int": {

test/test_wrapper.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
@patch("sys.argv", ["wrapper.py", "--verbose"])
77
def test_wrapper(tmp_path, monkeypatch):
88
import xcengine
9+
910
for path in xcengine.__path__:
1011
monkeypatch.syspath_prepend(path)
11-
user_code_path = (tmp_path / "user_code.py")
12+
user_code_path = tmp_path / "user_code.py"
1213
user_code_path.touch()
1314
os.environ["XC_USER_CODE_PATH"] = str(user_code_path)
1415
from xcengine import wrapper
16+
1517
xcengine.wrapper.main()

xcengine/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def cli(verbose):
3131
"--from-saved",
3232
is_flag=True,
3333
help="If --batch and --server both used, serve datasets from saved Zarrs "
34-
"rather than computing them on the fly.",
34+
"rather than computing them on the fly.",
3535
)
3636

3737
notebook_argument = click.argument(
@@ -160,7 +160,7 @@ def build(
160160
"--batch",
161161
is_flag=True,
162162
help="Run the compute engine as a batch script. Use with the --output "
163-
"option to copy output out of the container.",
163+
"option to copy output out of the container.",
164164
)
165165
@click.option(
166166
"-s",

xcengine/core.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
# Permissions are hereby granted under the terms of the MIT License:
33
# https://opensource.org/licenses/MIT.
44

5+
import functools
56
import io
67
import json
8+
import operator
79
import os
810
import shutil
911
import sys
@@ -71,8 +73,15 @@ def process_params_cell(self) -> None:
7173
params_cell_index = i
7274
break
7375
if params_cell_index is not None:
76+
setup_code = "\n".join(
77+
map(
78+
operator.attrgetter("source"),
79+
self.notebook.cells[:params_cell_index],
80+
)
81+
)
7482
self.nb_params = NotebookParameters.from_code(
75-
self.notebook.cells[params_cell_index].source
83+
self.notebook.cells[params_cell_index].source,
84+
setup_code=setup_code,
7685
)
7786
self.notebook.cells.insert(
7887
params_cell_index + 1,

xcengine/parameters.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ def make_cwl_params(self):
3535
self.cwl_params["product"] = "Directory", None
3636

3737
@classmethod
38-
def from_code(cls, code: str) -> "NotebookParameters":
38+
def from_code(
39+
cls, code: str, setup_code: str | None = None
40+
) -> "NotebookParameters":
3941
# TODO run whole notebook up to params cell, not just the params cell!
4042
# (Because it might use imports etc. from earlier in the notebook.)
4143
# This will need some tweaking of the parameter extraction -- see
4244
# comment therein.
43-
return cls(cls.extract_variables(code))
45+
return cls(cls.extract_variables(code, setup_code))
4446

4547
@classmethod
4648
def from_yaml(cls, yaml_content: str | typing.IO) -> "NotebookParameters":
@@ -61,12 +63,18 @@ def from_yaml_file(cls, path: str | os.PathLike) -> "NotebookParameters":
6163
return cls.from_yaml(fh)
6264

6365
@classmethod
64-
def extract_variables(cls, code: str) -> dict[str, tuple[type, Any]]:
65-
# TODO: just working on a single code block is insufficient:
66-
# We should execute everything up to the params cell, but only extract
67-
# variables defined in the params cell.
68-
exec(code, globals(), locals_ := {})
69-
return {k: cls.make_param_tuple(locals_[k]) for k in (locals_.keys())}
66+
def extract_variables(
67+
cls, code: str, setup_code: str | None = None
68+
) -> dict[str, tuple[type, Any]]:
69+
if setup_code is None:
70+
locals_ = {}
71+
old_locals = {}
72+
else:
73+
exec(setup_code, globals(), locals_ := {})
74+
old_locals = locals_.copy()
75+
exec(code, globals(), locals_)
76+
new_vars = locals_.keys() - old_locals.keys()
77+
return {k: cls.make_param_tuple(locals_[k]) for k in new_vars}
7078

7179
@staticmethod
7280
def make_param_tuple(value: Any) -> tuple[type, Any]:

0 commit comments

Comments
 (0)