Skip to content

Commit 7e6e0da

Browse files
Viktor GalPanaetius
andauthored
feat(core): add support for template variables for workflow parameters (#2704)
* add template variable support for workflow parameters * wrap KeyError with renku ParameterError Co-authored-by: Ralf Grubenmann <[email protected]>
1 parent 5e93aa5 commit 7e6e0da

File tree

6 files changed

+117
-17
lines changed

6 files changed

+117
-17
lines changed

renku/core/util/template_vars.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright 2017-2022 - Swiss Data Science Center (SDSC)
4+
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
5+
# Eidgenössische Technische Hochschule Zürich (ETHZ).
6+
#
7+
# Licensed under the Apache License, Version 2.0 (the "License");
8+
# you may not use this file except in compliance with the License.
9+
# You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
"""Template variable utility methods."""
19+
20+
import datetime
21+
from string import Formatter
22+
from typing import Any, Iterable, Mapping, Tuple, Union
23+
24+
from renku.core.errors import ParameterError
25+
from renku.domain_model.workflow.parameter import CommandParameterBase
26+
27+
28+
class TemplateVariableFormatter(Formatter):
29+
"""Template variable formatter for `CommandParameterBase`."""
30+
31+
RESERVED_KEYS = ["iter_index"]
32+
33+
def __init__(self):
34+
super(TemplateVariableFormatter, self).__init__()
35+
36+
def apply(self, param: str, parameters: Mapping[str, Any] = {}) -> str:
37+
"""Renders the parameter template into its final value."""
38+
try:
39+
return super().vformat(param, args=[datetime.datetime.now()], kwargs=parameters)
40+
except KeyError as e:
41+
raise ParameterError(f"Could not resolve the variable {str(e)}")
42+
43+
def get_value(self, key, args, kwargs):
44+
"""Ignore some special keys when formatting the variable."""
45+
if key in self.RESERVED_KEYS:
46+
return key
47+
return super().get_value(key, args, kwargs)
48+
49+
@staticmethod
50+
def to_map(parameters: Iterable[Union[CommandParameterBase, Tuple[str, str]]]) -> Mapping[str, str]:
51+
"""Converts a list of `CommandParameterBase` into parameter name-value dictionary."""
52+
return dict(
53+
map(
54+
lambda x: (x.name, x.actual_value) if isinstance(x, CommandParameterBase) else (x[1], str(x[0])),
55+
parameters,
56+
)
57+
)

renku/core/workflow/value_resolution.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from typing import Any, Dict, Optional, Set
2323

2424
from renku.core import errors
25+
from renku.core.util.template_vars import TemplateVariableFormatter
2526
from renku.domain_model.workflow.composite_plan import CompositePlan
2627
from renku.domain_model.workflow.parameter import ParameterMapping
2728
from renku.domain_model.workflow.plan import AbstractPlan, Plan
@@ -34,6 +35,7 @@ def __init__(self, plan: AbstractPlan, values: Optional[Dict[str, Any]]):
3435
self._values = values
3536
self.missing_parameters: Set[str] = set()
3637
self._plan = plan
38+
self._template_engine = TemplateVariableFormatter()
3739

3840
@abstractmethod
3941
def apply(self) -> AbstractPlan:
@@ -77,11 +79,18 @@ def apply(self) -> AbstractPlan:
7779
return self._plan
7880

7981
values_keys = set(self._values.keys())
80-
for param in chain(self._plan.inputs, self._plan.outputs, self._plan.parameters):
82+
for param in chain(self._plan.inputs, self._plan.parameters, self._plan.outputs):
8183
if param.name in self._values:
8284
param.actual_value = self._values[param.name]
8385
values_keys.discard(param.name)
8486

87+
# NOTE: we need 2-pass the plan parameters as values can be overridden
88+
# that should be reflected in the params_map
89+
params_map = TemplateVariableFormatter.to_map(chain(self._plan.inputs, self._plan.parameters))
90+
for param in chain(self._plan.inputs, self._plan.parameters, self._plan.outputs):
91+
if isinstance(param.actual_value, str):
92+
param.actual_value = self._template_engine.apply(param.actual_value, params_map)
93+
8594
self.missing_parameters = values_keys
8695

8796
return self._plan

tests/cli/test_output_option.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ def test_explicit_inputs_can_be_in_inputs(renku_cli, client, subdirectory):
224224

225225
exit_code, activity = renku_cli("run", "--input", foo, "--no-output", "ls", foo)
226226

227-
plan = activity.association.plan
228227
assert 0 == exit_code
228+
plan = activity.association.plan
229229
assert 1 == len(plan.inputs)
230230

231231
assert "foo" == str(plan.inputs[0].default_value)

tests/cli/test_status.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def test_status(runner, project, subdirectory):
3333

3434
write_and_commit_file(repo, source, "content")
3535

36-
assert 0 == runner.invoke(cli, ["run", "cp", source, output]).exit_code
36+
result = runner.invoke(cli, ["run", "cp", source, output])
37+
assert 0 == result.exit_code, format_result_exception(result)
3738

3839
result = runner.invoke(cli, ["status"])
3940
assert 0 == result.exit_code, format_result_exception(result)
@@ -149,7 +150,8 @@ def test_status_with_path_all_generation(runner, project):
149150

150151
write_and_commit_file(repo, source, "content")
151152

152-
assert 0 == runner.invoke(cli, ["run", "--input", source, "touch", output1, output2]).exit_code
153+
result = runner.invoke(cli, ["run", "--input", source, "touch", output1, output2])
154+
assert 0 == result.exit_code, format_result_exception(result)
153155

154156
write_and_commit_file(repo, source, "new content")
155157

tests/cli/test_update.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,8 @@ def test_update_no_args(runner, project, no_lfs_warning, provider):
329329

330330
write_and_commit_file(repo, source, "content")
331331

332-
assert 0 == runner.invoke(cli, ["run", "cp", source, output]).exit_code
332+
result = runner.invoke(cli, ["run", "cp", source, output])
333+
assert 0 == result.exit_code, format_result_exception(result)
333334

334335
write_and_commit_file(repo, source, "changed content")
335336

tests/cli/test_workflow.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# limitations under the License.
1818
"""Test ``workflow`` commands."""
1919

20+
import datetime
2021
import itertools
2122
import logging
2223
import os
@@ -38,6 +39,17 @@
3839
from tests.utils import format_result_exception, write_and_commit_file
3940

4041

42+
def _execute(capsys, runner, args):
43+
with capsys.disabled():
44+
try:
45+
cli.main(
46+
args=args,
47+
prog_name=runner.get_default_prog_name(cli),
48+
)
49+
except SystemExit as e:
50+
assert e.code in {None, 0}
51+
52+
4153
def test_workflow_list(runner, project, run_shell, client):
4254
"""Test listing of workflows."""
4355
# Run a shell command with pipe.
@@ -541,16 +553,6 @@ def test_workflow_execute_command(runner, run_shell, project, capsys, client, pr
541553
result = runner.invoke(cli, cmd)
542554
assert 0 == result.exit_code, format_result_exception(result)
543555

544-
def _execute(args):
545-
with capsys.disabled():
546-
try:
547-
cli.main(
548-
args=args,
549-
prog_name=runner.get_default_prog_name(cli),
550-
)
551-
except SystemExit as e:
552-
assert e.code in {None, 0}
553-
554556
def _flatten_dict(obj, key_string=""):
555557
if type(obj) == dict:
556558
key_string = key_string + "." if key_string else key_string
@@ -563,7 +565,7 @@ def _flatten_dict(obj, key_string=""):
563565

564566
if not parameters:
565567
execute_cmd = ["workflow", "execute", "-p", provider, workflow_name]
566-
_execute(execute_cmd)
568+
_execute(capsys, runner, execute_cmd)
567569
else:
568570
database = Database.from_path(client.database_path)
569571
plan = database["plans-by-name"][workflow_name]
@@ -600,7 +602,7 @@ def _flatten_dict(obj, key_string=""):
600602

601603
execute_cmd.append(workflow_name)
602604

603-
_execute(execute_cmd)
605+
_execute(capsys, runner, execute_cmd)
604606

605607
# check whether parameters setting was effective
606608
for o in outputs:
@@ -1135,3 +1137,32 @@ def test_workflow_execute_docker_toil_stderr(runner, client, run_shell):
11351137

11361138
assert 1 == result.exit_code, format_result_exception(result)
11371139
assert "Cannot run workflows that have stdin or stderr redirection with Docker" in result.output
1140+
1141+
1142+
@pytest.mark.parametrize("provider", available_workflow_providers())
1143+
@pytest.mark.parametrize(
1144+
"workflow, parameters, outputs",
1145+
[
1146+
(
1147+
"touch foo",
1148+
{"output-1": "{:%Y-%m-%d}"},
1149+
[datetime.datetime.now().strftime("%Y-%m-%d")],
1150+
)
1151+
],
1152+
)
1153+
def test_workflow_templated_params(runner, run_shell, client, capsys, workflow, parameters, provider, outputs):
1154+
workflow_name = "foobar"
1155+
1156+
# Run a shell command with pipe.
1157+
output = run_shell(f"renku run --name {workflow_name} {workflow}")
1158+
# Assert expected empty stdout.
1159+
assert b"" == output[0]
1160+
# Assert not allocated stderr.
1161+
assert output[1] is None
1162+
1163+
execute_cmd = ["workflow", "execute", "-p", provider, workflow_name]
1164+
[execute_cmd.extend(["--set", f"{k}={v}"]) for k, v in parameters.items()]
1165+
_execute(capsys, runner, execute_cmd)
1166+
1167+
for o in outputs:
1168+
assert Path(o).resolve().exists()

0 commit comments

Comments
 (0)