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.
- changelog/.gitempty +0 -0
- dkist_processing_core/__init__.py +13 -0
- dkist_processing_core/build_utils.py +139 -0
- dkist_processing_core/config.py +82 -0
- dkist_processing_core/failure_callback.py +96 -0
- dkist_processing_core/node.py +169 -0
- dkist_processing_core/resource_queue.py +9 -0
- dkist_processing_core/task.py +250 -0
- dkist_processing_core/tests/__init__.py +1 -0
- dkist_processing_core/tests/conftest.py +172 -0
- dkist_processing_core/tests/invalid_workflow_cyclic/__init__.py +1 -0
- dkist_processing_core/tests/invalid_workflow_cyclic/workflow.py +21 -0
- dkist_processing_core/tests/invalid_workflow_for_docker_multi_category/__init__.py +0 -0
- dkist_processing_core/tests/invalid_workflow_for_docker_multi_category/workflow.py +21 -0
- dkist_processing_core/tests/task_example.py +45 -0
- dkist_processing_core/tests/test_build_utils.py +128 -0
- dkist_processing_core/tests/test_export.py +71 -0
- dkist_processing_core/tests/test_failure_callback.py +90 -0
- dkist_processing_core/tests/test_node.py +156 -0
- dkist_processing_core/tests/test_task.py +82 -0
- dkist_processing_core/tests/test_workflow.py +212 -0
- dkist_processing_core/tests/valid_workflow_package/__init__.py +1 -0
- dkist_processing_core/tests/valid_workflow_package/workflow.py +21 -0
- dkist_processing_core/tests/zero_node_workflow_package/__init__.py +1 -0
- dkist_processing_core/tests/zero_node_workflow_package/workflow.py +9 -0
- dkist_processing_core/workflow.py +294 -0
- dkist_processing_core-4.3.0.dist-info/METADATA +249 -0
- dkist_processing_core-4.3.0.dist-info/RECORD +41 -0
- dkist_processing_core-4.3.0.dist-info/WHEEL +5 -0
- dkist_processing_core-4.3.0.dist-info/top_level.txt +4 -0
- docs/Makefile +134 -0
- docs/auto-proc-concept-model.png +0 -0
- docs/auto_proc_brick.png +0 -0
- docs/automated-processing-deployed.png +0 -0
- docs/changelog.rst +6 -0
- docs/conf.py +50 -0
- docs/index.rst +9 -0
- docs/landing_page.rst +34 -0
- docs/make.bat +170 -0
- docs/requirements.txt +1 -0
- 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 @@
|
|
|
1
|
+
"""init."""
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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)
|