dkist-processing-core 4.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. changelog/.gitempty +0 -0
  2. dkist_processing_core/__init__.py +13 -0
  3. dkist_processing_core/build_utils.py +139 -0
  4. dkist_processing_core/config.py +82 -0
  5. dkist_processing_core/failure_callback.py +96 -0
  6. dkist_processing_core/node.py +169 -0
  7. dkist_processing_core/resource_queue.py +9 -0
  8. dkist_processing_core/task.py +250 -0
  9. dkist_processing_core/tests/__init__.py +1 -0
  10. dkist_processing_core/tests/conftest.py +172 -0
  11. dkist_processing_core/tests/invalid_workflow_cyclic/__init__.py +1 -0
  12. dkist_processing_core/tests/invalid_workflow_cyclic/workflow.py +21 -0
  13. dkist_processing_core/tests/invalid_workflow_for_docker_multi_category/__init__.py +0 -0
  14. dkist_processing_core/tests/invalid_workflow_for_docker_multi_category/workflow.py +21 -0
  15. dkist_processing_core/tests/task_example.py +45 -0
  16. dkist_processing_core/tests/test_build_utils.py +128 -0
  17. dkist_processing_core/tests/test_export.py +71 -0
  18. dkist_processing_core/tests/test_failure_callback.py +90 -0
  19. dkist_processing_core/tests/test_node.py +156 -0
  20. dkist_processing_core/tests/test_task.py +82 -0
  21. dkist_processing_core/tests/test_workflow.py +212 -0
  22. dkist_processing_core/tests/valid_workflow_package/__init__.py +1 -0
  23. dkist_processing_core/tests/valid_workflow_package/workflow.py +21 -0
  24. dkist_processing_core/tests/zero_node_workflow_package/__init__.py +1 -0
  25. dkist_processing_core/tests/zero_node_workflow_package/workflow.py +9 -0
  26. dkist_processing_core/workflow.py +294 -0
  27. dkist_processing_core-4.3.0.dist-info/METADATA +249 -0
  28. dkist_processing_core-4.3.0.dist-info/RECORD +41 -0
  29. dkist_processing_core-4.3.0.dist-info/WHEEL +5 -0
  30. dkist_processing_core-4.3.0.dist-info/top_level.txt +4 -0
  31. docs/Makefile +134 -0
  32. docs/auto-proc-concept-model.png +0 -0
  33. docs/auto_proc_brick.png +0 -0
  34. docs/automated-processing-deployed.png +0 -0
  35. docs/changelog.rst +6 -0
  36. docs/conf.py +50 -0
  37. docs/index.rst +9 -0
  38. docs/landing_page.rst +34 -0
  39. docs/make.bat +170 -0
  40. docs/requirements.txt +1 -0
  41. licenses/LICENSE.rst +11 -0
@@ -0,0 +1,250 @@
1
+ """
2
+ Base class that is used to wrap the various DAG task methods.
3
+
4
+ It provides support for user-defined setup and cleanup, task monitoring using Elastic APM,
5
+ standardized logging and exception handling.
6
+ """
7
+ import logging
8
+ from abc import ABC
9
+ from abc import abstractmethod
10
+ from contextlib import contextmanager
11
+
12
+ import elasticapm
13
+
14
+ from dkist_processing_core.config import core_configurations
15
+
16
+
17
+ __all__ = ["TaskBase"]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ApmTransaction:
23
+ """
24
+ Elastic APM transaction manager for a DAG Task.
25
+
26
+ Without configuration, it disables itself.
27
+ """
28
+
29
+ @property
30
+ def apm_service_name(self) -> str:
31
+ """Format the service name for Elastic APM."""
32
+ name = f"{self._workflow_name}-{self._workflow_version}"
33
+ name = name.replace("_", "-")
34
+ name = name.replace(".", "-")
35
+ return name
36
+
37
+ @property
38
+ def apm_config(self) -> dict:
39
+ """Override the Elastic APM configuration with the workflow specific service name."""
40
+ core_config = core_configurations.apm_config
41
+ core_config["SERVICE_NAME"] = self.apm_service_name
42
+ return core_config
43
+
44
+ def __init__(self, transaction_name: str, workflow_name: str, workflow_version: str) -> None:
45
+ self._workflow_name = workflow_name
46
+ self._workflow_version = workflow_version
47
+ self.transaction_name = transaction_name
48
+
49
+ if core_configurations.elastic_apm_enabled:
50
+ self.client = elasticapm.Client(self.apm_config)
51
+ self.instrument()
52
+ self.client.begin_transaction(transaction_type="Task")
53
+ logger.info(f"APM Configured: {self=} {self.apm_config=}")
54
+ else:
55
+ logger.warning(f"APM Not Configured")
56
+
57
+ @contextmanager
58
+ def capture_span(self, name: str, *args, **kwargs):
59
+ if core_configurations.elastic_apm_enabled:
60
+ try:
61
+ with elasticapm.capture_span(name, *args, **kwargs):
62
+ yield
63
+ finally:
64
+ pass
65
+ else:
66
+ try:
67
+ yield
68
+ finally:
69
+ pass
70
+
71
+ def close(self, exc_type=None):
72
+ if core_configurations.elastic_apm_enabled:
73
+ result = "Success"
74
+ if exc_type is not None:
75
+ result = "Error" # pragma: no cover
76
+ self.client.capture_exception(handled=False) # pragma: no cover
77
+ self.client.end_transaction(name=self.transaction_name, result=result)
78
+ self.client.close()
79
+
80
+ @staticmethod
81
+ def instrument():
82
+ """Vendored implementation of elasticapm.instrumentation.control.instrument changed to omit certain frameworks."""
83
+ omit_frameworks = {
84
+ "elasticapm.instrumentation.packages.redis.RedisInstrumentation",
85
+ "elasticapm.instrumentation.packages.redis.RedisPipelineInstrumentation",
86
+ "elasticapm.instrumentation.packages.redis.RedisConnectionInstrumentation",
87
+ "elasticapm.instrumentation.packages.asyncio.aioredis.RedisConnectionPoolInstrumentation",
88
+ "elasticapm.instrumentation.packages.asyncio.aioredis.RedisPipelineInstrumentation",
89
+ "elasticapm.instrumentation.packages.asyncio.aioredis.RedisConnectionInstrumentation",
90
+ }
91
+
92
+ from elasticapm.instrumentation.control import _lock
93
+ from elasticapm.instrumentation.register import _cls_register
94
+ from elasticapm.instrumentation.register import _instrumentation_singletons
95
+ from elasticapm.instrumentation.register import import_string
96
+
97
+ # from elasticapm.instrumentation.control.instrument
98
+ with _lock:
99
+ # update to vendored code
100
+ filtered_cls_register = _cls_register.difference(omit_frameworks)
101
+ # from elasticapm.instrumentation.register.get_instrumentation_objects
102
+ for cls_str in filtered_cls_register:
103
+ if cls_str not in _instrumentation_singletons:
104
+ cls = import_string(cls_str)
105
+ _instrumentation_singletons[cls_str] = cls()
106
+ obj = _instrumentation_singletons[cls_str]
107
+ # from elasticapm.instrumentation.control.instrument
108
+ obj.instrument()
109
+
110
+ def __repr__(self):
111
+ return f"{self.__class__.__name__}(transaction_name={self.transaction_name}, workflow_name={self._workflow_name}, workflow_version={self._workflow_version})"
112
+
113
+
114
+ class TaskBase(ABC):
115
+ """
116
+ A Task is the interface between processing code and its execution. Processing code can follow this interface through subclassing remain agnostic to the execution environment.
117
+
118
+ Each DAG task must implement its own subclass of this abstract wrapper class.
119
+
120
+ Intended instantiation is as a context manager
121
+
122
+ >>> class RealTask(TaskBase):
123
+ >>> def run(self):
124
+ >>> pass
125
+ >>>
126
+ >>> with RealTask(1, "a", "b") as task:
127
+ >>> task()
128
+
129
+ Task names in airflow are the same as the class name
130
+ Additional methods can be added but will only be called if they are referenced via run,
131
+ pre_run, post_run, or __exit__
132
+
133
+ overriding methods other than run, pre_run, post_run, and in special cases __exit__ is
134
+ discouraged as they are used internally to support the abstraction.
135
+ e.g. __init__ is called by the core api without user involvement so adding parameters will not
136
+ result in them being passed in as there is no client interface to __init__.
137
+
138
+ To use the apm infrastructure in subclass code one would do the following:
139
+
140
+ >>> def foo(self):
141
+ >>> with self.apm_step("do detailed work"):
142
+ >>> pass # do work
143
+
144
+ Parameters
145
+ ----------
146
+ recipe_run_id : int
147
+ id of the recipe run used to identify the workflow run this task is part of
148
+ workflow_name : str
149
+ name of the workflow to which this instance of the task belongs
150
+ workflow_version : str
151
+ version of the workflow to which this instance of the task belongs
152
+
153
+ """
154
+
155
+ retries = 0
156
+ retry_delay_seconds = 60
157
+
158
+ def __init__(
159
+ self,
160
+ recipe_run_id: int,
161
+ workflow_name: str,
162
+ workflow_version: str,
163
+ ):
164
+ """
165
+ Instantiate a Task.
166
+
167
+ The details of instantiation may vary based upon the export target but this signature is what is expected by the intantiation transformation (Node) code.
168
+ """
169
+ self.recipe_run_id = int(recipe_run_id)
170
+ self.workflow_name = workflow_name
171
+ self.workflow_version = workflow_version
172
+ self.task_name = self.__class__.__name__
173
+ logger.info(f"Task {self.task_name} initialized")
174
+ self.apm = ApmTransaction(
175
+ transaction_name=self.task_name,
176
+ workflow_name=self.workflow_name,
177
+ workflow_version=self.workflow_version,
178
+ )
179
+ self.apm_step = self.apm.capture_span # abbreviated syntax for capture span context mgr
180
+
181
+ def pre_run(self) -> None:
182
+ """Intended to be overridden and will execute prior to run() with Elastic APM span capturing."""
183
+
184
+ @abstractmethod
185
+ def run(self) -> None:
186
+ """Abstract method that must be overridden to execute the desired DAG task."""
187
+
188
+ def post_run(self) -> None:
189
+ """Intended to be overridden and will execute after run() with Elastic APM span capturing."""
190
+
191
+ def rollback(self) -> None:
192
+ """Rollback any changes to persistent stores performed by the task."""
193
+
194
+ def __call__(self) -> None:
195
+ """
196
+ DAG task wrapper. Execution is instrumented with Application Performance Monitoring if configured.
197
+
198
+ The standard execution sequence is:
199
+
200
+ 1 run
201
+
202
+ 2 record provenance
203
+
204
+ Returns
205
+ -------
206
+ None
207
+
208
+ """
209
+ logger.info(f"Task {self.task_name} started")
210
+ with self.apm_step("Pre Run", span_type="code.core", labels={"type": "core"}):
211
+ self.pre_run()
212
+ with self.apm_step("Run", span_type="code.core", labels={"type": "core"}):
213
+ self.run()
214
+ with self.apm_step("Post Run", span_type="code.core", labels={"type": "core"}):
215
+ self.post_run()
216
+ logger.info(f"Task {self.task_name} complete")
217
+
218
+ def __enter__(self):
219
+ """
220
+ Override to execute setup tasks before task execution.
221
+
222
+ Only override this method with tasks that need to happen
223
+ regardless of tasks having an exception, ensure that no additional exception
224
+ will be raised, and always call super().__enter__
225
+ """
226
+ return self
227
+
228
+ def __exit__(self, exc_type, exc_val, exc_tb):
229
+ """
230
+ Override to execute teardown tasks after task execution regardless of task execution success.
231
+
232
+ Only override this method with tasks that need to happen
233
+ regardless of tasks having an exception, ensure that no additional exception
234
+ will be raised, and always call super().__exit__
235
+ """
236
+ self.apm.close(exc_type=exc_type)
237
+
238
+ def __repr__(self):
239
+ """Return the representation of the task."""
240
+ return (
241
+ f"{self.__class__.__name__}("
242
+ f"recipe_run_id={self.recipe_run_id}, "
243
+ f"workflow_name={self.workflow_name}, "
244
+ f"workflow_version={self.workflow_version}, "
245
+ f")"
246
+ )
247
+
248
+ def __str__(self):
249
+ """Return a string representation of the task."""
250
+ return repr(self)
@@ -0,0 +1 @@
1
+ """init."""
@@ -0,0 +1,172 @@
1
+ """Global test fixtures."""
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ from shutil import rmtree
5
+ from typing import Any
6
+ from unittest.mock import MagicMock
7
+
8
+ import pytest
9
+ from talus import DurableProducer
10
+
11
+ from dkist_processing_core import ResourceQueue
12
+ from dkist_processing_core import TaskBase
13
+ from dkist_processing_core import Workflow
14
+ from dkist_processing_core.node import Node
15
+ from dkist_processing_core.node import task_type_hint
16
+ from dkist_processing_core.tests.task_example import Task
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def export_path() -> str:
21
+ """Export path object that will be removed on teardown."""
22
+ path = Path("export/")
23
+ yield str(path)
24
+ rmtree(path, ignore_errors=True)
25
+
26
+
27
+ @pytest.fixture(scope="session")
28
+ def task_subclass():
29
+ """Sub class of the abstract task base class implementing methods that are expected to be subclassed with inspect-able metadata."""
30
+ return Task
31
+
32
+
33
+ @pytest.fixture(scope="session")
34
+ def error_task_subclass():
35
+ """Subclass of the abstract task base class implementing methods that are expected to be subclassed with inspect-able metadata."""
36
+
37
+ class Task(TaskBase):
38
+ def __init__(self, *args, **kwargs):
39
+ self.run_was_called = False
40
+ self.post_run_was_called = False
41
+ super().__init__(*args, **kwargs)
42
+
43
+ def run(self):
44
+ self.run_was_called = True
45
+
46
+ def post_run(self) -> None:
47
+ self.post_run_was_called = True
48
+ raise RuntimeError("error recording provenance")
49
+
50
+ return Task
51
+
52
+
53
+ @pytest.fixture()
54
+ def task_instance(task_subclass):
55
+ """Create an instance of the task subclass defined in task_subclass."""
56
+ with task_subclass(
57
+ recipe_run_id=1, workflow_name="workflow_name", workflow_version="version"
58
+ ) as task:
59
+ yield task
60
+
61
+
62
+ @pytest.fixture()
63
+ def workflow():
64
+ """Create an instance of the Workflow abstraction without tasks."""
65
+ input_data = "input"
66
+ output_data = "output"
67
+ category = "instrument"
68
+ detail = "workflow_information"
69
+ version = "V6-12342"
70
+ tags = ["tag1", "tag2"]
71
+ workflow_instance = Workflow(
72
+ input_data=input_data,
73
+ output_data=output_data,
74
+ category=category,
75
+ detail=detail,
76
+ workflow_version=version,
77
+ workflow_package=__package__,
78
+ tags=tags,
79
+ )
80
+ return (
81
+ workflow_instance,
82
+ input_data,
83
+ output_data,
84
+ category,
85
+ detail,
86
+ version,
87
+ tags,
88
+ )
89
+
90
+
91
+ @pytest.fixture()
92
+ def workflow_tasks(task_subclass) -> list[task_type_hint]:
93
+ """List of Tasks that can be composed into a workflow."""
94
+
95
+ class TaskA(task_subclass):
96
+ pass
97
+
98
+ class TaskB(task_subclass):
99
+ pass
100
+
101
+ class TaskC(task_subclass):
102
+ pass
103
+
104
+ class TaskD(task_subclass):
105
+ pass
106
+
107
+ return [TaskA, TaskB, TaskC, TaskD]
108
+
109
+
110
+ @pytest.fixture(params=["default", "non_default"])
111
+ def queue_name(request):
112
+ """Name of the queue on the Node"""
113
+ if request.param == "default":
114
+ return ResourceQueue.DEFAULT
115
+ return ResourceQueue.HIGH_MEMORY
116
+
117
+
118
+ @pytest.fixture(params=["default", "non_default"])
119
+ def pip_extras(request):
120
+ """Extra pip requirements for Node initialization"""
121
+ if request.param == "default":
122
+ return None
123
+ return ["asdf"]
124
+
125
+
126
+ @pytest.fixture(params=["0_upstream", "1_upstream", "2_upstream"])
127
+ def node(
128
+ workflow_tasks, request, queue_name, pip_extras
129
+ ) -> tuple[Node, task_type_hint, Any, str, str]:
130
+ """Node instance and its component parts."""
131
+ version = "V6-123"
132
+ name = f"{request.param}_{version}"
133
+ TaskA, TaskB, TaskC, _ = workflow_tasks
134
+ upstreams = {
135
+ "0_upstream": (None, []),
136
+ "1_upstream": (TaskB, [TaskB]),
137
+ "2_upstream": ([TaskB, TaskC], [TaskB, TaskC]),
138
+ }
139
+ upstream = upstreams[request.param]
140
+ package = __package__
141
+ return (
142
+ Node(
143
+ workflow_name=name,
144
+ workflow_version=version,
145
+ workflow_package=package,
146
+ task=TaskA,
147
+ upstreams=upstream[0],
148
+ resource_queue=queue_name,
149
+ pip_extras=pip_extras,
150
+ ),
151
+ TaskA,
152
+ upstream[1],
153
+ name,
154
+ version,
155
+ )
156
+
157
+
158
+ @pytest.fixture()
159
+ def fake_producer():
160
+ return MagicMock(spec=DurableProducer)
161
+
162
+
163
+ @pytest.fixture()
164
+ def fake_producer_factory(fake_producer):
165
+ @contextmanager
166
+ def fake_factory():
167
+ try:
168
+ yield fake_producer
169
+ finally:
170
+ pass
171
+
172
+ return fake_factory
@@ -0,0 +1,21 @@
1
+ """Example invalid workflow."""
2
+ from dkist_processing_core import Workflow
3
+ from dkist_processing_core.tests.task_example import Task
4
+ from dkist_processing_core.tests.task_example import Task2
5
+ from dkist_processing_core.tests.task_example import Task3
6
+
7
+
8
+ # |<--------------------|
9
+ # |-->Task -> Task2 --> |
10
+
11
+
12
+ example = Workflow(
13
+ input_data="test-data",
14
+ output_data="invalid",
15
+ category="core",
16
+ workflow_package=__package__,
17
+ )
18
+ example.add_node(task=Task, upstreams=None)
19
+ example.add_node(task=Task2, upstreams=Task)
20
+ example.add_node(task=Task3, upstreams=Task2)
21
+ example.add_node(task=Task2, upstreams=Task3)
@@ -0,0 +1,21 @@
1
+ """Example invalid workflow for """
2
+ from dkist_processing_core import Workflow
3
+ from dkist_processing_core.tests.task_example import Task
4
+
5
+
6
+ category_a = Workflow(
7
+ input_data="test-data",
8
+ output_data="invalid",
9
+ category="A",
10
+ workflow_package=__package__,
11
+ )
12
+ category_a.add_node(task=Task, upstreams=None)
13
+
14
+
15
+ category_b = Workflow(
16
+ input_data="test-data",
17
+ output_data="invalid",
18
+ category="B",
19
+ workflow_package=__package__,
20
+ )
21
+ category_b.add_node(task=Task, upstreams=None)
@@ -0,0 +1,45 @@
1
+ """Example task subclass used in the tests."""
2
+ from dkist_processing_core import TaskBase
3
+
4
+
5
+ class Task(TaskBase):
6
+ """Example task for testing."""
7
+
8
+ log_url = "http://localhost:8080/log?execution_date=2021-01-07T18%3A19%3A38.214767%2B00%3A00&task_id=task_a&dag_id=test_dag"
9
+
10
+ def __init__(self, *args, **kwargs):
11
+ """Task base construction."""
12
+ self.run_was_called = False
13
+ self.pre_run_was_called = False
14
+ self.post_run_was_called = False
15
+ super().__init__(*args, **kwargs)
16
+
17
+ def run(self):
18
+ """Override base class run method."""
19
+ self.run_was_called = True
20
+
21
+ def pre_run(self) -> None:
22
+ """Override base class pre-run method."""
23
+ self.pre_run_was_called = True
24
+
25
+ def post_run(self) -> None:
26
+ """Override base class post-run method."""
27
+ self.post_run_was_called = True
28
+
29
+
30
+ class Task2(Task):
31
+ """Test task class."""
32
+
33
+ pass
34
+
35
+
36
+ class Task3(Task):
37
+ """Test task class."""
38
+
39
+ pass
40
+
41
+
42
+ class Task4(Task):
43
+ """Test task class."""
44
+
45
+ pass
@@ -0,0 +1,128 @@
1
+ """Tests for the build utils."""
2
+ import os
3
+ import subprocess
4
+ from pathlib import Path
5
+ from shutil import rmtree
6
+
7
+ import pytest
8
+ from airflow.exceptions import AirflowException
9
+ from airflow.exceptions import DuplicateTaskIdFound
10
+
11
+ from dkist_processing_core.build_utils import export_dags
12
+ from dkist_processing_core.build_utils import export_notebook_dockerfile
13
+ from dkist_processing_core.build_utils import export_notebooks
14
+ from dkist_processing_core.build_utils import validate_workflows
15
+ from dkist_processing_core.tests import invalid_workflow_cyclic
16
+ from dkist_processing_core.tests import invalid_workflow_for_docker_multi_category
17
+ from dkist_processing_core.tests import valid_workflow_package
18
+ from dkist_processing_core.tests import zero_node_workflow_package
19
+
20
+
21
+ def test_validate_workflow_valid():
22
+ """
23
+ Given: A workflow package with a valid workflow.
24
+ When: validating the workflow.
25
+ Then: No errors raised.
26
+ """
27
+ validate_workflows(valid_workflow_package)
28
+
29
+
30
+ @pytest.mark.parametrize(
31
+ "workflow_package",
32
+ [
33
+ invalid_workflow_cyclic,
34
+ zero_node_workflow_package,
35
+ ],
36
+ )
37
+ def test_validate_workflow_invalid(workflow_package):
38
+ """
39
+ Given: A workflow package with an invalid workflow.
40
+ When: validating the workflow.
41
+ Then: Errors raised.
42
+ """
43
+ exceptions = (ValueError, DuplicateTaskIdFound)
44
+ with pytest.raises(exceptions):
45
+ validate_workflows(workflow_package)
46
+
47
+
48
+ def test_validate_workflow_zero_nodes():
49
+ """
50
+ Given: A workflow package with an invalid workflow of zero nodes.
51
+ When: validating the workflow.
52
+ Then: Errors raised.
53
+ """
54
+ exceptions = (ValueError, AirflowException)
55
+ with pytest.raises(exceptions):
56
+ validate_workflows(zero_node_workflow_package)
57
+
58
+
59
+ def test_export_dag(export_path):
60
+ """
61
+ Given: A path to export to and a package containing a valid workflow.
62
+ When: Workflows in the package are exported.
63
+ Then: Expected export file exists.
64
+ """
65
+ export_dags(valid_workflow_package, export_path)
66
+ path = export_path / Path("test-data_to_valid_core_dev.py")
67
+ assert path.exists()
68
+
69
+
70
+ def test_export_notebook(export_path):
71
+ """
72
+ Given: A path to export to and a package containing a valid workflow.
73
+ When: Workflows in the package are exported as ipynb.
74
+ Then: Expected export files exists.
75
+ """
76
+ paths = export_notebooks(valid_workflow_package, export_path)
77
+ assert len(paths) >= 1
78
+ assert all([p.exists() for p in paths])
79
+
80
+
81
+ @pytest.fixture()
82
+ def repository_root_path() -> Path:
83
+ """Return a directory relative to repository root"""
84
+ repo_root_parts = []
85
+ cwd = Path.cwd() # expecting to be 2 levels below repo root
86
+ for part in cwd.parts:
87
+ repo_root_parts.append(part)
88
+ if part == "dkist-processing-core":
89
+ break
90
+ return Path(*repo_root_parts)
91
+
92
+
93
+ @pytest.fixture()
94
+ def notebook_export_path(repository_root_path) -> Path:
95
+ """Return a directory relative to repository root"""
96
+ export_path = Path("notebooks/")
97
+ yield Path("notebooks/")
98
+ rmtree(export_path, ignore_errors=True)
99
+
100
+
101
+ @pytest.mark.long()
102
+ def test_export_notebook_dockerfile(repository_root_path, notebook_export_path):
103
+ """
104
+ Given: A path to export to and a package containing a valid workflow.
105
+ When: Workflows in the package are exported as a valid Dockerfile.
106
+ Then: Expected export file exists.
107
+ """
108
+ os.chdir(str(repository_root_path))
109
+ print(Path.cwd())
110
+ dockerfile_path = export_notebook_dockerfile(valid_workflow_package, notebook_export_path)
111
+ assert dockerfile_path.exists()
112
+ image_name = "test_export_notebook_dockerfile:latest"
113
+ subprocess.run(["docker", "build", "-t", image_name, dockerfile_path.parent], check=True)
114
+ dockerfile_path.unlink()
115
+
116
+
117
+ @pytest.mark.long()
118
+ def test_export_notebook_dockerfile_invalid_workflow_package(
119
+ repository_root_path, notebook_export_path
120
+ ):
121
+ """
122
+ Given: A path to export to and a package containing a valid workflow.
123
+ When: Workflows in the package are exported as a valid Dockerfile.
124
+ Then: Expected export file exists.
125
+ """
126
+ os.chdir(str(repository_root_path))
127
+ with pytest.raises(ValueError):
128
+ export_notebook_dockerfile(invalid_workflow_for_docker_multi_category, notebook_export_path)