From ef61b51368373df8b527f2c4737cb7a07c8ca0bd Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Wed, 10 Nov 2021 16:59:52 -0800 Subject: [PATCH 1/6] [wip] Shell Task Signed-off-by: Ketan Umare --- flytekit/extras/tasks/__init__.py | 0 flytekit/extras/tasks/shell.py | 208 ++++++++++++++++++ flytekit/types/directory/types.py | 9 +- flytekit/types/file/file.py | 9 +- tests/flytekit/unit/extras/tasks/__init__.py | 0 .../flytekit/unit/extras/tasks/test_shell.py | 76 +++++++ .../unit/extras/tasks/testdata/test.csv | 0 7 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 flytekit/extras/tasks/__init__.py create mode 100644 flytekit/extras/tasks/shell.py create mode 100644 tests/flytekit/unit/extras/tasks/__init__.py create mode 100644 tests/flytekit/unit/extras/tasks/test_shell.py create mode 100644 tests/flytekit/unit/extras/tasks/testdata/test.csv diff --git a/flytekit/extras/tasks/__init__.py b/flytekit/extras/tasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py new file mode 100644 index 0000000000..d964173fe9 --- /dev/null +++ b/flytekit/extras/tasks/shell.py @@ -0,0 +1,208 @@ +import datetime +import logging +import os +import re +import subprocess +import typing +from dataclasses import dataclass + +from flytekit import PythonInstanceTask, ExecutionParameters +from flytekit.core.interface import Interface +from flytekit.core.task import TaskPlugins +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile + + +@dataclass +class OutputLocation: + """ + Args: + var: str The name of the output variable + var_type: typing.Type The type of output variable + location: os.PathLike The location where this output variable will be written to or a regex that accepts input + vars and generates the path. Of the form ``"{{ .inputs.v }}.tmp.md"``. + This example for a given input v, at path `/tmp/abc.csv` will resolve to `/tmp/abc.csv.tmp.md` + """ + var: str + var_type: typing.Type + location: typing.Union[os.PathLike, str] + + +def _stringify(v: typing.Any) -> str: + """ + Special cased return for the given value. Given the type returns the string version for the type. + Handles FlyteFile and FlyteDirectory specially. Downloads and returns the downloaded filepath + """ + if isinstance(v, FlyteFile): + v.download() + return v.path + if isinstance(v, FlyteDirectory): + v.download() + return v.path + if isinstance(v, datetime.datetime): + return v.isoformat() + return str(v) + + +def _interpolate(tmpl: str, regex: re.Pattern, validate_all_match: bool = True, **kwargs) -> str: + """ + Substitutes all templates that match the input regex - ``r"({{\s*.inputs.(\w+)\s*}})", re.IGNORECASE`` with the + given inputs and returns the substituted string. The result is non destructive towards the given string. + """ + modified = tmpl + matched = set() + for match in regex.finditer(tmpl): + expr = match.groups()[0] + var = match.groups()[1] + if var not in kwargs: + raise ValueError(f"Variable {var} in Query (part of {expr}) not found in inputs {kwargs.keys()}") + matched.add(var) + val = kwargs[var] + # str conversion should be deliberate, with right conversion for each type + modified = modified.replace(expr, _stringify(val)) + + if validate_all_match: + if len(matched) < len(kwargs.keys()): + diff = set(kwargs.keys()).difference(matched) + raise ValueError(f"Extra Inputs have no matches in query template - missing {diff}") + return modified + + +def _dummy_task_func(): + """ + A Fake function to satisfy the inner PythonTask requirements + """ + return None + + +T = typing.TypeVar("T") + + +class ShellTask(PythonInstanceTask[T]): + """ + """ + + _INPUT_REGEX = re.compile(r"({{\s*.inputs.(\w+)\s*}})", re.IGNORECASE) + _OUTPUT_REGEX = re.compile(r"({{\s*.outputs.(\w+)\s*}})", re.IGNORECASE) + + def __init__( + self, + name: str, + debug: bool = False, + script: typing.Optional[str] = None, + script_file: typing.Optional[os.PathLike] = None, + task_config: T = None, + inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, + output_locs: typing.Optional[typing.List[OutputLocation]] = None, + **kwargs, + ): + """ + Args: + name: str Name of the Task. Should be unique in the project + debug: bool Print the generated script and other debugging information + script: The actual script specified as a string + script_file: A path to the file that contains the script (Only script or script_file) can be provided + task_config: T Configuration for the task, can be either a Pod (or coming soon, BatchJob) config + inputs: A Dictionary of input names to types + output_locs: A list of :ref:class:`OutputLocations` + **kwargs: Other arguments that can be passed to :ref:class:`PythonInstanceTask` + """ + if script and script_file: + raise ValueError("Only either of script or script_file can be provided") + if not script and not script_file: + raise ValueError("Either a script or script_file is needed") + if script_file: + if not os.path.exists(script_file): + raise ValueError(f"FileNotFound: the specified Script file at path {script_file} cannot be loaded") + + # Each instance of NotebookTask instantiates an underlying task with a dummy function that will only be used + # to run pre- and post- execute functions using the corresponding task plugin. + # We rename the function name here to ensure the generated task has a unique name and avoid duplicate task name + # errors. + # This seem like a hack. We should use a plugin_class that doesn't require a fake-function to make work. + plugin_class = TaskPlugins.find_pythontask_plugin(type(task_config)) + self._config_task_instance = plugin_class(task_config=task_config, task_function=_dummy_task_func) + # Rename the internal task so that there are no conflicts at serialization time. Technically these internal + # tasks should not be serialized at all, but we don't currently have a mechanism for skipping Flyte entities + # at serialization time. + self._config_task_instance._name = f"_bash.{name}" + self._script = script + self._script_file = script_file + self._debug = debug + self._output_locs = output_locs if output_locs else [] + outputs = self._validate_output_locs() + super().__init__( + name, task_config, task_type=self._config_task_instance.task_type, + interface=Interface(inputs=inputs, outputs=outputs), **kwargs + ) + + def _validate_output_locs(self) -> typing.Dict[str, typing.Type]: + outputs = {} + for v in self._output_locs: + if v is None: + raise ValueError("OutputLocation cannot be none") + if not isinstance(v, OutputLocation): + raise ValueError("Every output type should be an output location on the file-system") + if v.location is None: + raise ValueError(f"Output Location not provided for output var {v.var}") + if not issubclass(v.var_type, FlyteFile) and not issubclass(v.var_type, FlyteDirectory): + raise ValueError(f"Currently only outputs of type FlyteFile/FlyteDirectory and their derived types" + f" are supported") + outputs[v.var] = v.var_type + return outputs + + @property + def script(self) -> typing.Optional[str]: + return self._script + + @property + def script_file(self) -> typing.Optional[os.PathLike]: + return self._script_file + + def pre_execute(self, user_params: ExecutionParameters) -> ExecutionParameters: + return self._config_task_instance.pre_execute(user_params) + + def execute(self, **kwargs) -> typing.Any: + """ + Executes the given script by substituting the inputs and outputs and extracts the outputs from the filesystem + """ + logging.info(f"Running shell script as type {self.task_type}") + if self.script_file: + with open(self.script_file) as f: + self._script = f.read() + + outputs: typing.Dict[str, str] = {} + if self._output_locs: + for v in self._output_locs: + outputs[v.var] = _interpolate(v.location, self._INPUT_REGEX, validate_all_match=False, **kwargs) + + gen_script = _interpolate(self._script, self._INPUT_REGEX, **kwargs) + # For outputs it is not necessary that all outputs are used in the script, some are implicit outputs + # for example gcc main.c will generate a.out automatically + gen_script = _interpolate(gen_script, self._OUTPUT_REGEX, validate_all_match=False, **outputs) + if self._debug: + print("\n==============================================\n") + print(gen_script) + print("\n==============================================\n") + + try: + subprocess.check_call(gen_script, shell=True) + except subprocess.CalledProcessError as e: + files = os.listdir("./") + fstr = "\n-".join(files) + logging.error(f"Failed to Execute Script, return-code {e.returncode} \n" + f"StdErr: {e.stderr}\n" + f"StdOut: {e.stdout}\n" + f" Current directory contents: .\n-{fstr}") + raise + + final_outputs = [] + for v in self._output_locs: + if issubclass(v.var_type, FlyteFile): + final_outputs.append(FlyteFile(outputs[v.var])) + if issubclass(v.var_type, FlyteDirectory): + final_outputs.append(FlyteDirectory(outputs[v.var])) + return tuple(final_outputs) + + def post_execute(self, user_params: ExecutionParameters, rval: typing.Any) -> typing.Any: + return self._config_task_instance.post_execute(user_params, rval) diff --git a/flytekit/types/directory/types.py b/flytekit/types/directory/types.py index d2ef84f2d2..a0112c1eea 100644 --- a/flytekit/types/directory/types.py +++ b/flytekit/types/directory/types.py @@ -172,14 +172,7 @@ def remote_source(self) -> str: return typing.cast(str, self._remote_source) def download(self) -> str: - if self._downloaded: - return self.path - if self._downloader is not noop: - self._downloader() - self._downloaded = True - return self.path - else: - raise ValueError(f"Attempting to trigger download on non-downloadable folder {self}") + return self.__fspath__() def __repr__(self): return self._path diff --git a/flytekit/types/file/file.py b/flytekit/types/file/file.py index 9ebcc8e9a5..1fbcee049a 100644 --- a/flytekit/types/file/file.py +++ b/flytekit/types/file/file.py @@ -206,14 +206,7 @@ def remote_source(self) -> str: return typing.cast(str, self._remote_source) def download(self) -> str: - if self._downloaded: - return self.path - if self._downloader is not noop: - self._downloader() - self._downloaded = True - return self.path - else: - raise ValueError(f"Attempting to trigger download on non-downloadable file {self}") + return self.__fspath__() def __repr__(self): return self._path diff --git a/tests/flytekit/unit/extras/tasks/__init__.py b/tests/flytekit/unit/extras/tasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py new file mode 100644 index 0000000000..18289bcee9 --- /dev/null +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -0,0 +1,76 @@ +import datetime + +import pytest + +from flytekit import kwtypes +from flytekit.extras.tasks.shell import ShellTask, OutputLocation +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import CSVFile, FlyteFile + + +def test_shell_task_no_io(): + t = ShellTask( + name="test", + script=""" + echo "Hello World!" + """, + ) + + t() + + +def test_shell_task_fail(): + t = ShellTask( + name="test", + script=""" + non-existent blah + """, + ) + + with pytest.raises(Exception): + t() + + +def test_input_substitution_primitive(): + t = ShellTask( + name="test", + script=""" + cat {{ .inputs.f }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }}" + """, + inputs=kwtypes(f=str, y=int, j=datetime.datetime), + ) + + t(f="__init__.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f="test_shell.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + with pytest.raises(Exception): + t(f="non_exist.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + + +def test_input_substitution_files(): + t = ShellTask( + name="test", + script=""" + cat {{ .inputs.f }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }}" + """, + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + ) + + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + + +def test_input_output_substitution_files(): + t = ShellTask( + name="test", + debug=True, + script=""" + cat {{ .inputs.f }} >> {{ .outputs.y }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" + """, + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + ) + + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) \ No newline at end of file diff --git a/tests/flytekit/unit/extras/tasks/testdata/test.csv b/tests/flytekit/unit/extras/tasks/testdata/test.csv new file mode 100644 index 0000000000..e69de29bb2 From 6ae9446ddd804a96e14ab9eb9b9398d92989ca58 Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Thu, 11 Nov 2021 17:16:34 -0800 Subject: [PATCH 2/6] Tests updated Signed-off-by: Ketan Umare --- flytekit/extras/tasks/shell.py | 3 +- .../flytekit/unit/extras/tasks/test_shell.py | 48 ++++++++++++++++--- .../unit/extras/tasks/testdata/script.sh | 4 ++ 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 tests/flytekit/unit/extras/tasks/testdata/script.sh diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index d964173fe9..aae55112c7 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -90,7 +90,7 @@ def __init__( name: str, debug: bool = False, script: typing.Optional[str] = None, - script_file: typing.Optional[os.PathLike] = None, + script_file: typing.Optional[str] = None, task_config: T = None, inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, output_locs: typing.Optional[typing.List[OutputLocation]] = None, @@ -114,6 +114,7 @@ def __init__( if script_file: if not os.path.exists(script_file): raise ValueError(f"FileNotFound: the specified Script file at path {script_file} cannot be loaded") + script_file = os.path.abspath(script_file) # Each instance of NotebookTask instantiates an underlying task with a dummy function that will only be used # to run pre- and post- execute functions using the corresponding task plugin. diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py index 18289bcee9..ce4964cab5 100644 --- a/tests/flytekit/unit/extras/tasks/test_shell.py +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -1,4 +1,6 @@ import datetime +import os +from subprocess import CalledProcessError import pytest @@ -35,6 +37,7 @@ def test_input_substitution_primitive(): t = ShellTask( name="test", script=""" + set -ex cat {{ .inputs.f }} echo "Hello World {{ .inputs.y }} on {{ .inputs.j }}" """, @@ -42,9 +45,9 @@ def test_input_substitution_primitive(): ) t(f="__init__.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) - t(f="test_shell.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) - with pytest.raises(Exception): - t(f="non_exist.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f="test_shell.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + with pytest.raises(CalledProcessError): + t(f="non_exist.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) def test_input_substitution_files(): @@ -57,15 +60,33 @@ def test_input_substitution_files(): inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), ) - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) def test_input_output_substitution_files(): + s = """ + cat {{ .inputs.f }} >> {{ .outputs.y }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" + """ + t = ShellTask( + name="test", + debug=True, + script=s, + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + ) + + assert t.script == s + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + + +def test_input_output_missing_var(): t = ShellTask( name="test", debug=True, script=""" - cat {{ .inputs.f }} >> {{ .outputs.y }} + cat {{ .inputs.f }} {{ .inputs.missing }} >> {{ .outputs.y }} echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" """, inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), @@ -73,4 +94,19 @@ def test_input_output_substitution_files(): OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] ) - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) \ No newline at end of file + with pytest.raises(ValueError): + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + + +def test_shell_script(): + t = ShellTask( + name="test", + debug=True, + script_file="testdata/script.sh", + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + ) + + assert t.script_file == os.path.abspath("testdata/script.sh") + t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) diff --git a/tests/flytekit/unit/extras/tasks/testdata/script.sh b/tests/flytekit/unit/extras/tasks/testdata/script.sh new file mode 100644 index 0000000000..ebe5aaf670 --- /dev/null +++ b/tests/flytekit/unit/extras/tasks/testdata/script.sh @@ -0,0 +1,4 @@ +set -ex + +cat {{ .inputs.f }} >> {{ .outputs.y }} +echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" \ No newline at end of file From 6beda393ac6b11edcae84c1f612773c8d062a36f Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Fri, 12 Nov 2021 14:05:40 -0800 Subject: [PATCH 3/6] lint fixed Signed-off-by: Ketan Umare --- flytekit/extras/tasks/shell.py | 56 +++++++++++-------- .../flytekit/unit/extras/tasks/test_shell.py | 44 ++++++++++++--- .../unit/extras/tasks/testdata/script.sh | 6 +- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index aae55112c7..bbcd11e4bc 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -6,7 +6,7 @@ import typing from dataclasses import dataclass -from flytekit import PythonInstanceTask, ExecutionParameters +from flytekit import ExecutionParameters, PythonInstanceTask from flytekit.core.interface import Interface from flytekit.core.task import TaskPlugins from flytekit.types.directory import FlyteDirectory @@ -23,6 +23,7 @@ class OutputLocation: vars and generates the path. Of the form ``"{{ .inputs.v }}.tmp.md"``. This example for a given input v, at path `/tmp/abc.csv` will resolve to `/tmp/abc.csv.tmp.md` """ + var: str var_type: typing.Type location: typing.Union[os.PathLike, str] @@ -46,8 +47,8 @@ def _stringify(v: typing.Any) -> str: def _interpolate(tmpl: str, regex: re.Pattern, validate_all_match: bool = True, **kwargs) -> str: """ - Substitutes all templates that match the input regex - ``r"({{\s*.inputs.(\w+)\s*}})", re.IGNORECASE`` with the - given inputs and returns the substituted string. The result is non destructive towards the given string. + Substitutes all templates that match the supplied regex + with the given inputs and returns the substituted string. The result is non destructive towards the given string. """ modified = tmpl matched = set() @@ -79,22 +80,21 @@ def _dummy_task_func(): class ShellTask(PythonInstanceTask[T]): - """ - """ + """ """ _INPUT_REGEX = re.compile(r"({{\s*.inputs.(\w+)\s*}})", re.IGNORECASE) _OUTPUT_REGEX = re.compile(r"({{\s*.outputs.(\w+)\s*}})", re.IGNORECASE) def __init__( - self, - name: str, - debug: bool = False, - script: typing.Optional[str] = None, - script_file: typing.Optional[str] = None, - task_config: T = None, - inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, - output_locs: typing.Optional[typing.List[OutputLocation]] = None, - **kwargs, + self, + name: str, + debug: bool = False, + script: typing.Optional[str] = None, + script_file: typing.Optional[str] = None, + task_config: T = None, + inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, + output_locs: typing.Optional[typing.List[OutputLocation]] = None, + **kwargs, ): """ Args: @@ -133,8 +133,11 @@ def __init__( self._output_locs = output_locs if output_locs else [] outputs = self._validate_output_locs() super().__init__( - name, task_config, task_type=self._config_task_instance.task_type, - interface=Interface(inputs=inputs, outputs=outputs), **kwargs + name, + task_config, + task_type=self._config_task_instance.task_type, + interface=Interface(inputs=inputs, outputs=outputs), + **kwargs, ) def _validate_output_locs(self) -> typing.Dict[str, typing.Type]: @@ -147,8 +150,9 @@ def _validate_output_locs(self) -> typing.Dict[str, typing.Type]: if v.location is None: raise ValueError(f"Output Location not provided for output var {v.var}") if not issubclass(v.var_type, FlyteFile) and not issubclass(v.var_type, FlyteDirectory): - raise ValueError(f"Currently only outputs of type FlyteFile/FlyteDirectory and their derived types" - f" are supported") + raise ValueError( + "Currently only outputs of type FlyteFile/FlyteDirectory and their derived types are supported" + ) outputs[v.var] = v.var_type return outputs @@ -191,10 +195,12 @@ def execute(self, **kwargs) -> typing.Any: except subprocess.CalledProcessError as e: files = os.listdir("./") fstr = "\n-".join(files) - logging.error(f"Failed to Execute Script, return-code {e.returncode} \n" - f"StdErr: {e.stderr}\n" - f"StdOut: {e.stdout}\n" - f" Current directory contents: .\n-{fstr}") + logging.error( + f"Failed to Execute Script, return-code {e.returncode} \n" + f"StdErr: {e.stderr}\n" + f"StdOut: {e.stdout}\n" + f" Current directory contents: .\n-{fstr}" + ) raise final_outputs = [] @@ -203,7 +209,11 @@ def execute(self, **kwargs) -> typing.Any: final_outputs.append(FlyteFile(outputs[v.var])) if issubclass(v.var_type, FlyteDirectory): final_outputs.append(FlyteDirectory(outputs[v.var])) - return tuple(final_outputs) + if len(final_outputs) == 1: + return final_outputs[0] + if len(final_outputs) > 1: + return tuple(final_outputs) + return None def post_execute(self, user_params: ExecutionParameters, rval: typing.Any) -> typing.Any: return self._config_task_instance.post_execute(user_params, rval) diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py index ce4964cab5..3c6f8f87f0 100644 --- a/tests/flytekit/unit/extras/tasks/test_shell.py +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -5,7 +5,7 @@ import pytest from flytekit import kwtypes -from flytekit.extras.tasks.shell import ShellTask, OutputLocation +from flytekit.extras.tasks.shell import OutputLocation, ShellTask from flytekit.types.directory import FlyteDirectory from flytekit.types.file import CSVFile, FlyteFile @@ -60,7 +60,7 @@ def test_input_substitution_files(): inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), ) - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + assert t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) is None def test_input_output_substitution_files(): @@ -73,12 +73,34 @@ def test_input_output_substitution_files(): debug=True, script=s, inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), - output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), - OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + output_locs=[ + OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc"), + ], ) assert t.script == s - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + x, y = t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + assert x is not None + assert y.path[-4:] == ".pyc" + + +def test_input_single_output_substitution_files(): + s = """ + cat {{ .inputs.f }} >> {{ .outputs.y }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }}" + """ + t = ShellTask( + name="test", + debug=True, + script=s, + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + output_locs=[OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")], + ) + + assert t.script == s + y = t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + assert y.path[-4:] == ".pyc" def test_input_output_missing_var(): @@ -90,8 +112,10 @@ def test_input_output_missing_var(): echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" """, inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), - output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), - OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + output_locs=[ + OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc"), + ], ) with pytest.raises(ValueError): @@ -104,8 +128,10 @@ def test_shell_script(): debug=True, script_file="testdata/script.sh", inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), - output_locs=[OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), - OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc")] + output_locs=[ + OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc"), + ], ) assert t.script_file == os.path.abspath("testdata/script.sh") diff --git a/tests/flytekit/unit/extras/tasks/testdata/script.sh b/tests/flytekit/unit/extras/tasks/testdata/script.sh index ebe5aaf670..22012ec3ae 100644 --- a/tests/flytekit/unit/extras/tasks/testdata/script.sh +++ b/tests/flytekit/unit/extras/tasks/testdata/script.sh @@ -1,4 +1,6 @@ +#!/bin/bash + set -ex -cat {{ .inputs.f }} >> {{ .outputs.y }} -echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" \ No newline at end of file +cat "{{ .inputs.f }}" >> "{{ .outputs.y }}" +echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" From e383e7c174ab27776a8a9c7fbc0defa88bbfa6bf Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Fri, 12 Nov 2021 14:23:59 -0800 Subject: [PATCH 4/6] tests updated Signed-off-by: Ketan Umare --- .../flytekit/unit/extras/tasks/test_shell.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py index 3c6f8f87f0..002a037695 100644 --- a/tests/flytekit/unit/extras/tasks/test_shell.py +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -9,6 +9,10 @@ from flytekit.types.directory import FlyteDirectory from flytekit.types.file import CSVFile, FlyteFile +testdata = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testdata") +script_sh = os.path.join(testdata, "script.sh") +test_csv = os.path.join(testdata, "test.csv") + def test_shell_task_no_io(): t = ShellTask( @@ -60,7 +64,7 @@ def test_input_substitution_files(): inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), ) - assert t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) is None + assert t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) is None def test_input_output_substitution_files(): @@ -80,7 +84,7 @@ def test_input_output_substitution_files(): ) assert t.script == s - x, y = t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + x, y = t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) assert x is not None assert y.path[-4:] == ".pyc" @@ -99,7 +103,7 @@ def test_input_single_output_substitution_files(): ) assert t.script == s - y = t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + y = t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) assert y.path[-4:] == ".pyc" @@ -119,14 +123,14 @@ def test_input_output_missing_var(): ) with pytest.raises(ValueError): - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) def test_shell_script(): t = ShellTask( - name="test", + name="test2", debug=True, - script_file="testdata/script.sh", + script_file=script_sh, inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), output_locs=[ OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), @@ -134,5 +138,5 @@ def test_shell_script(): ], ) - assert t.script_file == os.path.abspath("testdata/script.sh") - t(f="testdata/test.csv", y="testdata", j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + assert t.script_file == script_sh + t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) From 2f5edc07f8df6bc0c1a3c9f9750148796f16fe40 Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Tue, 23 Nov 2021 21:47:30 -0800 Subject: [PATCH 5/6] updated Signed-off-by: Ketan Umare --- flytekit/extras/tasks/shell.py | 2 +- .../flytekit/unit/extras/tasks/test_shell.py | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index bbcd11e4bc..c6b0d20078 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -65,7 +65,7 @@ def _interpolate(tmpl: str, regex: re.Pattern, validate_all_match: bool = True, if validate_all_match: if len(matched) < len(kwargs.keys()): diff = set(kwargs.keys()).difference(matched) - raise ValueError(f"Extra Inputs have no matches in query template - missing {diff}") + raise ValueError(f"Extra Inputs have no matches in script template - missing {diff}") return modified diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py index 002a037695..a8079baa1a 100644 --- a/tests/flytekit/unit/extras/tasks/test_shell.py +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -9,7 +9,8 @@ from flytekit.types.directory import FlyteDirectory from flytekit.types.file import CSVFile, FlyteFile -testdata = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testdata") +test_file_path = os.path.dirname(os.path.realpath(__file__)) +testdata = os.path.join(test_file_path, "testdata") script_sh = os.path.join(testdata, "script.sh") test_csv = os.path.join(testdata, "test.csv") @@ -48,8 +49,8 @@ def test_input_substitution_primitive(): inputs=kwtypes(f=str, y=int, j=datetime.datetime), ) - t(f="__init__.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) - t(f="test_shell.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f=os.path.join(test_file_path, "__init__.py"), y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + t(f=os.path.join(test_file_path, "test_shell.py"), y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) with pytest.raises(CalledProcessError): t(f="non_exist.py", y=5, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) @@ -107,7 +108,7 @@ def test_input_single_output_substitution_files(): assert y.path[-4:] == ".pyc" -def test_input_output_missing_var(): +def test_input_output_extra_var_in_template(): t = ShellTask( name="test", debug=True, @@ -126,6 +127,25 @@ def test_input_output_missing_var(): t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) +def test_input_output_extra_input(): + t = ShellTask( + name="test", + debug=True, + script=""" + cat {{ .inputs.missing }} >> {{ .outputs.y }} + echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" + """, + inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + output_locs=[ + OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc"), + ], + ) + + with pytest.raises(ValueError): + t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) + + def test_shell_script(): t = ShellTask( name="test2", From d5b43902b9481bdcf0b5bfa137d016495a4c97e6 Mon Sep 17 00:00:00 2001 From: Ketan Umare Date: Sun, 28 Nov 2021 12:20:42 -0800 Subject: [PATCH 6/6] better unit test Signed-off-by: Ketan Umare --- flytekit/extras/tasks/shell.py | 9 +++++-- .../flytekit/unit/extras/tasks/test_shell.py | 25 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/flytekit/extras/tasks/shell.py b/flytekit/extras/tasks/shell.py index c6b0d20078..6e8dbcc21b 100644 --- a/flytekit/extras/tasks/shell.py +++ b/flytekit/extras/tasks/shell.py @@ -6,8 +6,9 @@ import typing from dataclasses import dataclass -from flytekit import ExecutionParameters, PythonInstanceTask +from flytekit.core.context_manager import ExecutionParameters from flytekit.core.interface import Interface +from flytekit.core.python_function_task import PythonInstanceTask from flytekit.core.task import TaskPlugins from flytekit.types.directory import FlyteDirectory from flytekit.types.file import FlyteFile @@ -104,7 +105,7 @@ def __init__( script_file: A path to the file that contains the script (Only script or script_file) can be provided task_config: T Configuration for the task, can be either a Pod (or coming soon, BatchJob) config inputs: A Dictionary of input names to types - output_locs: A list of :ref:class:`OutputLocations` + output_locs: A list of :py:class:`OutputLocations` **kwargs: Other arguments that can be passed to :ref:class:`PythonInstanceTask` """ if script and script_file: @@ -116,6 +117,10 @@ def __init__( raise ValueError(f"FileNotFound: the specified Script file at path {script_file} cannot be loaded") script_file = os.path.abspath(script_file) + if task_config is not None: + if str(type(task_config)) != "flytekitplugins.pod.task.Pod": + raise ValueError("TaskConfig can either be empty - indicating simple container task or a PodConfig.") + # Each instance of NotebookTask instantiates an underlying task with a dummy function that will only be used # to run pre- and post- execute functions using the corresponding task plugin. # We rename the function name here to ensure the generated task has a unique name and avoid duplicate task name diff --git a/tests/flytekit/unit/extras/tasks/test_shell.py b/tests/flytekit/unit/extras/tasks/test_shell.py index a8079baa1a..39f114a9c7 100644 --- a/tests/flytekit/unit/extras/tasks/test_shell.py +++ b/tests/flytekit/unit/extras/tasks/test_shell.py @@ -1,5 +1,6 @@ import datetime import os +import tempfile from subprocess import CalledProcessError import pytest @@ -70,24 +71,32 @@ def test_input_substitution_files(): def test_input_output_substitution_files(): s = """ - cat {{ .inputs.f }} >> {{ .outputs.y }} - echo "Hello World {{ .inputs.y }} on {{ .inputs.j }} - output {{.outputs.x}}" + cat {{ .inputs.f }} > {{ .outputs.y }} """ t = ShellTask( name="test", debug=True, script=s, - inputs=kwtypes(f=CSVFile, y=FlyteDirectory, j=datetime.datetime), + inputs=kwtypes(f=CSVFile), output_locs=[ - OutputLocation(var="x", var_type=FlyteDirectory, location="{{ .inputs.y }}"), - OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.pyc"), + OutputLocation(var="y", var_type=FlyteFile, location="{{ .inputs.f }}.mod"), ], ) assert t.script == s - x, y = t(f=test_csv, y=testdata, j=datetime.datetime(2021, 11, 10, 12, 15, 0)) - assert x is not None - assert y.path[-4:] == ".pyc" + + contents = "1,2,3,4\n" + with tempfile.TemporaryDirectory() as tmp: + csv = os.path.join(tmp, "abc.csv") + print(csv) + with open(csv, "w") as f: + f.write(contents) + y = t(f=csv) + assert y.path[-4:] == ".mod" + assert os.path.exists(y.path) + with open(y.path) as f: + s = f.read() + assert s == contents def test_input_single_output_substitution_files():