pinexq-procon 2.1.0.dev3__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.
- pinexq/procon/__init__.py +0 -0
- pinexq/procon/core/__init__.py +0 -0
- pinexq/procon/core/cli.py +442 -0
- pinexq/procon/core/exceptions.py +64 -0
- pinexq/procon/core/helpers.py +61 -0
- pinexq/procon/core/logconfig.py +48 -0
- pinexq/procon/core/naming.py +36 -0
- pinexq/procon/core/types.py +15 -0
- pinexq/procon/dataslots/__init__.py +19 -0
- pinexq/procon/dataslots/abstractionlayer.py +215 -0
- pinexq/procon/dataslots/annotation.py +389 -0
- pinexq/procon/dataslots/dataslots.py +369 -0
- pinexq/procon/dataslots/datatypes.py +50 -0
- pinexq/procon/dataslots/default_reader_writer.py +26 -0
- pinexq/procon/dataslots/filebackend.py +126 -0
- pinexq/procon/dataslots/metadata.py +137 -0
- pinexq/procon/jobmanagement/__init__.py +9 -0
- pinexq/procon/jobmanagement/api_helpers.py +287 -0
- pinexq/procon/remote/__init__.py +0 -0
- pinexq/procon/remote/messages.py +250 -0
- pinexq/procon/remote/rabbitmq.py +420 -0
- pinexq/procon/runtime/__init__.py +3 -0
- pinexq/procon/runtime/foreman.py +128 -0
- pinexq/procon/runtime/job.py +384 -0
- pinexq/procon/runtime/settings.py +12 -0
- pinexq/procon/runtime/tool.py +16 -0
- pinexq/procon/runtime/worker.py +437 -0
- pinexq/procon/step/__init__.py +3 -0
- pinexq/procon/step/introspection.py +234 -0
- pinexq/procon/step/schema.py +99 -0
- pinexq/procon/step/step.py +119 -0
- pinexq/procon/step/versioning.py +84 -0
- pinexq_procon-2.1.0.dev3.dist-info/METADATA +83 -0
- pinexq_procon-2.1.0.dev3.dist-info/RECORD +35 -0
- pinexq_procon-2.1.0.dev3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, Protocol, Sequence, TypeVar
|
|
5
|
+
|
|
6
|
+
from ..dataslots.datatypes import Metadata, SlotDescription
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
LOG = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def metadata_to_json(meta: Metadata) -> str:
|
|
13
|
+
return meta.model_dump_json()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def json_to_metadata(data: str) -> Metadata:
|
|
17
|
+
return Metadata(**json.loads(data))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MetadataProxy:
|
|
21
|
+
"""
|
|
22
|
+
Getter/setter methods for controlled user access to a Metadata object.
|
|
23
|
+
|
|
24
|
+
If no Metadata object is provided for initialization, this class will assign a default
|
|
25
|
+
on first access of any metadata property.
|
|
26
|
+
"""
|
|
27
|
+
__slots__ = ('_metadata', '_readonly')
|
|
28
|
+
|
|
29
|
+
_metadata: Metadata
|
|
30
|
+
_readonly: bool
|
|
31
|
+
|
|
32
|
+
def __init__(self, metadata: Metadata, _readonly: bool = False):
|
|
33
|
+
self._metadata = metadata
|
|
34
|
+
self._readonly = _readonly
|
|
35
|
+
|
|
36
|
+
def __bool__(self):
|
|
37
|
+
"""Return True if Metadata was set."""
|
|
38
|
+
return len(self._metadata.comment) > 0 or len(self._metadata.tags) > 0
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def comment(self) -> str:
|
|
42
|
+
"""Comments assigned to the Workdata of this slot."""
|
|
43
|
+
return self._metadata.comment
|
|
44
|
+
|
|
45
|
+
@comment.setter
|
|
46
|
+
def comment(self, s: str):
|
|
47
|
+
if self._readonly:
|
|
48
|
+
raise AttributeError("Metadata on this slot is read-only! Can not set 'comment'.")
|
|
49
|
+
self._metadata.comment = s
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def tags(self) -> tuple[str, ...] | list[str]:
|
|
53
|
+
"""Tags assigned to the Workdata of this slot.
|
|
54
|
+
|
|
55
|
+
Returns a list of tags if the metadata is writable (e.g. OUTPUT-slot)
|
|
56
|
+
or an immutable tuple if metadata is read-only (e.g. INPUT slots)
|
|
57
|
+
"""
|
|
58
|
+
if self._readonly:
|
|
59
|
+
return tuple(self._metadata.tags)
|
|
60
|
+
return self._metadata.tags
|
|
61
|
+
|
|
62
|
+
@tags.setter
|
|
63
|
+
def tags(self, tag_list: Sequence[str]):
|
|
64
|
+
if self._readonly:
|
|
65
|
+
raise AttributeError("Metadata on this slot is read-only! Can not set 'tags'.")
|
|
66
|
+
self._metadata.tags = list(tag_list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def filename(self) -> str:
|
|
70
|
+
"""Original filename that was uploaded as Workdata. This is independent of the
|
|
71
|
+
filename accessed by the backend of the Slot, which can vary depending on the implementation."""
|
|
72
|
+
return self._metadata.filename
|
|
73
|
+
|
|
74
|
+
@filename.setter
|
|
75
|
+
def filename(self, s: str):
|
|
76
|
+
if self._readonly:
|
|
77
|
+
raise AttributeError("Metadata on this slot is read-only! Can not set 'filename'.")
|
|
78
|
+
self._metadata.filename = s
|
|
79
|
+
|
|
80
|
+
def __repr__(self):
|
|
81
|
+
return self._metadata.__repr__()
|
|
82
|
+
|
|
83
|
+
def is_readonly(self) -> bool:
|
|
84
|
+
"""True if the metadata is readonly."""
|
|
85
|
+
return self._readonly
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MetadataHandler(Protocol):
|
|
89
|
+
"""Defines the expected interface how to read/write metadata."""
|
|
90
|
+
|
|
91
|
+
def get(self, slot: SlotDescription) -> Metadata:
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def set(self, slot: SlotDescription, metadata: Metadata):
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LocalFileMetadataStore(MetadataHandler):
|
|
99
|
+
"""Store metadata as a '.meta' sidecar file next to file defined in the slot's description uri."""
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _metadata_path_from_description(slot: SlotDescription) -> Path:
|
|
103
|
+
slot_path = Path(slot.uri)
|
|
104
|
+
return slot_path.with_name(f"{slot_path.name}.meta")
|
|
105
|
+
|
|
106
|
+
def get(self, slot: SlotDescription) -> Metadata | None:
|
|
107
|
+
meta_path = self._metadata_path_from_description(slot)
|
|
108
|
+
if meta_path.exists():
|
|
109
|
+
return Metadata.model_validate_json(meta_path.read_text())
|
|
110
|
+
else:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def set(self, slot: SlotDescription, metadata: Metadata):
|
|
114
|
+
meta_path = self._metadata_path_from_description(slot)
|
|
115
|
+
meta_json = metadata.model_dump_json()
|
|
116
|
+
meta_path.write_text(meta_json)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
TSetCb = TypeVar('TSetCb', bound=Callable[[SlotDescription, Metadata], None])
|
|
120
|
+
TGetCb = TypeVar('TGetCb', bound=Callable[[SlotDescription,], Metadata])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CallbackMetadataHandler(MetadataHandler):
|
|
124
|
+
"""Generic call/callback-interface to get and set metadata"""
|
|
125
|
+
|
|
126
|
+
_setter_callback: TSetCb
|
|
127
|
+
_getter_callback: TGetCb
|
|
128
|
+
|
|
129
|
+
def __init__(self, setter: TSetCb, getter: TGetCb):
|
|
130
|
+
self._setter_callback = setter
|
|
131
|
+
self._getter_callback = getter
|
|
132
|
+
|
|
133
|
+
def get(self, slot: SlotDescription) -> Metadata:
|
|
134
|
+
return self._getter_callback(slot)
|
|
135
|
+
|
|
136
|
+
def set(self, slot: SlotDescription, metadata: Metadata):
|
|
137
|
+
self._setter_callback(slot, metadata)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import warnings
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import jwt
|
|
9
|
+
from httpx_caching import CachingClient
|
|
10
|
+
from pinexq_client.job_management import Job, enter_jma
|
|
11
|
+
from pinexq_client.job_management.hcos import EntryPointHco
|
|
12
|
+
|
|
13
|
+
from ..core.exceptions import ProConException
|
|
14
|
+
from ..runtime.job import RemoteExecutionContext
|
|
15
|
+
from ..step import Step
|
|
16
|
+
from ..step.step import ExecutionContextType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JmaApiHeaders(StrEnum):
|
|
23
|
+
api_key = "x-api-key"
|
|
24
|
+
access_token = "x-access-token"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PREFIX = "JMC_"
|
|
28
|
+
API_HOST_URL = f"{PREFIX}API_HOST_URL"
|
|
29
|
+
API_KEY = f"{PREFIX}API_KEY"
|
|
30
|
+
ACCESS_TOKEN = f"{PREFIX}ACCESS_TOKEN"
|
|
31
|
+
|
|
32
|
+
# use a client with local cache
|
|
33
|
+
USE_CLIENT_WITH_CACHE = True
|
|
34
|
+
|
|
35
|
+
# Environment variables that will directly translate to HTTP headers
|
|
36
|
+
# 'header-name': 'ENVIRONMENT_VARIABLE'
|
|
37
|
+
HEADERS_FROM_ENV = {
|
|
38
|
+
JmaApiHeaders.api_key: API_KEY,
|
|
39
|
+
JmaApiHeaders.access_token: ACCESS_TOKEN,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def _create_client_instance(api_endpoint: str, headers: dict) -> httpx.Client:
|
|
43
|
+
"""
|
|
44
|
+
Will create a httpx client, optional with caching, to be used with the API objects.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
api_endpoint: The endpoint for the pinexq API.
|
|
48
|
+
headers: headers to be passed to httpx.Client.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
client = httpx.Client(base_url=api_endpoint, headers=headers)
|
|
52
|
+
if USE_CLIENT_WITH_CACHE:
|
|
53
|
+
# for now, we use the persistent cache, which is also shared between instances
|
|
54
|
+
# use if you need each client to have a own cache storage=InMemoryStorage()
|
|
55
|
+
# broken, will cache SSE stream
|
|
56
|
+
#return SyncCacheClient(
|
|
57
|
+
# base_url=pinexq_api_endpoint,
|
|
58
|
+
# headers=headers,
|
|
59
|
+
# timeout=timeout)
|
|
60
|
+
return CachingClient(client)
|
|
61
|
+
else:
|
|
62
|
+
return client
|
|
63
|
+
|
|
64
|
+
def client_from_env_vars() -> httpx.Client:
|
|
65
|
+
"""Initializes a httpx.Client from environment variables.
|
|
66
|
+
|
|
67
|
+
The following variables are used:
|
|
68
|
+
JMC_API_KEY: (header, optional) The api-key you get from the login portal.
|
|
69
|
+
JMC_ACCESS_TOKEN: (header, optional) The JWT access token for the JM.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
api_url = os.environ[API_HOST_URL]
|
|
73
|
+
except KeyError:
|
|
74
|
+
raise ProConException(
|
|
75
|
+
f'Environment variable "{API_HOST_URL}" not found!'
|
|
76
|
+
f" Can not configure job management api access!"
|
|
77
|
+
)
|
|
78
|
+
headers = {hdr: os.environ[env] for hdr, env in HEADERS_FROM_ENV.items() if env in os.environ}
|
|
79
|
+
return _create_client_instance(api_endpoint=api_url, headers=headers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def client_from_job_execution_context(exec_context: ExecutionContextType) -> httpx.Client:
|
|
83
|
+
# This function will be deprecated and replaced by the function below!
|
|
84
|
+
"""Initializes a httpx.Client from connection info embedded in the job.offer.
|
|
85
|
+
|
|
86
|
+
Tries to get the JMApi-host and headers from the environment variables first.
|
|
87
|
+
If not available, the host is extracted from the job-url in the job.offer. Headers
|
|
88
|
+
are set by the information provided job.offer.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
warnings.warn(
|
|
92
|
+
"Calling `client_from_job_execution_context()` directly will be deprecated "
|
|
93
|
+
"in future releases. Please use the equivalent `get_client()` function instead.",
|
|
94
|
+
DeprecationWarning
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not exec_context.current_job_offer:
|
|
98
|
+
raise ProConException(
|
|
99
|
+
"Current execution context provides no information to create client (no job offer)."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
job_context = exec_context.current_job_offer.job_execution_context
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# fixme: Redundant, as there is an explicit get_from_env_vars function
|
|
106
|
+
api_url = os.environ[API_HOST_URL]
|
|
107
|
+
except KeyError:
|
|
108
|
+
# Return only the host-url, cutoff path and query
|
|
109
|
+
api_url = httpx.URL(job_context.job_url).join("/")
|
|
110
|
+
|
|
111
|
+
# Create header from environment variables
|
|
112
|
+
# fixme: Redundant, as there is an explicit get_from_env_vars function
|
|
113
|
+
env_var_headers = {
|
|
114
|
+
hdr: os.environ[env] for hdr, env in HEADERS_FROM_ENV.items() if env in os.environ
|
|
115
|
+
}
|
|
116
|
+
# Create headers from the settings in the job.offer
|
|
117
|
+
if job_context.access_token is not None:
|
|
118
|
+
job_offer_headers = {
|
|
119
|
+
JmaApiHeaders.access_token.value: job_context.access_token,
|
|
120
|
+
}
|
|
121
|
+
else:
|
|
122
|
+
job_offer_headers = {}
|
|
123
|
+
|
|
124
|
+
# Update headers from env-vars with info from the job.offer
|
|
125
|
+
headers = env_var_headers | job_offer_headers
|
|
126
|
+
return _create_client_instance(api_endpoint=api_url, headers=headers)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _client_from_job_execution_context(context: ExecutionContextType) -> httpx.Client:
|
|
130
|
+
# This function will replace the deprecated one above!
|
|
131
|
+
"""Initializes a httpx.Client from connection info embedded in the job.offer.
|
|
132
|
+
|
|
133
|
+
The host is extracted from the job-url in the job.offer. Headers are set by the
|
|
134
|
+
information provided job.offer.
|
|
135
|
+
"""
|
|
136
|
+
if not context.current_job_offer:
|
|
137
|
+
raise ProConException(
|
|
138
|
+
"Current execution context provides no information to create client (no job offer)."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
job_context = context.current_job_offer.job_execution_context
|
|
142
|
+
|
|
143
|
+
# Return only the host-url, cutoff path and query
|
|
144
|
+
api_url = httpx.URL(job_context.job_url).join("/")
|
|
145
|
+
|
|
146
|
+
# Create headers from the settings in the job.offer
|
|
147
|
+
job_offer_headers = {}
|
|
148
|
+
if job_context.access_token:
|
|
149
|
+
job_offer_headers[JmaApiHeaders.access_token.value] = job_context.access_token
|
|
150
|
+
|
|
151
|
+
# Update headers from env-vars with info from the job.offer
|
|
152
|
+
return _create_client_instance(api_endpoint=str(api_url), headers=job_offer_headers)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_client(context: ExecutionContextType | None = None) -> httpx.Client:
|
|
156
|
+
"""Trys to get the client fom job offer context if exec_context is provided.
|
|
157
|
+
If not possible it trys to get it from environment variables (intended for testing).
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
context: The execution context of the current Step container.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
An initialized HTTPX client with base_url and headers set.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
...
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
if context:
|
|
170
|
+
try:
|
|
171
|
+
return _client_from_job_execution_context(context)
|
|
172
|
+
except ProConException as ex:
|
|
173
|
+
logger.warning(
|
|
174
|
+
f"Unable to initialize client from execution context: {str(ex)}. "
|
|
175
|
+
f"Falling back to configure client from environment variables."
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
return client_from_env_vars()
|
|
179
|
+
except ProConException as ex:
|
|
180
|
+
raise ProConException(
|
|
181
|
+
"Unable to determine API host and credentials from neither execution "
|
|
182
|
+
"context (if running as 'remote') or environment variables!"
|
|
183
|
+
) from ex
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _job_from_context(context: ExecutionContextType, client: httpx.Client) -> Job:
|
|
187
|
+
"""Create a PinexQ client `Job` object from a given step-function's
|
|
188
|
+
execution context and a httpx client.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
context: The execution context of the current Step container.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
An initialized HTTPX client with base_url and headers set.
|
|
195
|
+
"""
|
|
196
|
+
job_url = httpx.URL(context.current_job_offer.job_execution_context.job_url)
|
|
197
|
+
return Job.from_url(client=client, job_url=job_url)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def job_from_step_context(step: Step, client: httpx.Client | None = None) -> Job:
|
|
201
|
+
"""Initialize an API Job object with the job_id of the current Job.
|
|
202
|
+
|
|
203
|
+
Meant to be called inside a Step-container during execution of a Step-function.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
step: The current Step container (referenced by `self`)
|
|
207
|
+
client: A httpx.Client initialized with the API-url.
|
|
208
|
+
"""
|
|
209
|
+
# Todo: Mark this function deprecated and refer to `get_job()` instead?
|
|
210
|
+
context: RemoteExecutionContext | None = step.step_context
|
|
211
|
+
if not client:
|
|
212
|
+
client = get_client(context)
|
|
213
|
+
|
|
214
|
+
return _job_from_context(context, client)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_job(context: ExecutionContextType) -> Job:
|
|
218
|
+
"""Create a PinexQ client `Job` object from a step-function's execution context.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
context: The execution context of the current Step container.
|
|
222
|
+
Returns:
|
|
223
|
+
A pinexq_client.job_management.tool.Job object initialized as the Job executing the given context.
|
|
224
|
+
"""
|
|
225
|
+
client = get_client(context)
|
|
226
|
+
return _job_from_context(context, client)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_entrypoint_hco(context: ExecutionContextType) -> EntryPointHco:
|
|
230
|
+
"""Create a PinexQ client `Entrypoint` object from a step-function's execution context.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
context: The execution context of the current Step container.
|
|
234
|
+
Returns:
|
|
235
|
+
An entrypoint hco object to the JMA where the current Job is running.
|
|
236
|
+
"""
|
|
237
|
+
client = get_client(context)
|
|
238
|
+
return enter_jma(client)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_grants(context: ExecutionContextType) -> list[str]:
|
|
242
|
+
"""Try to access grants for the current user
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
context: Execution context of the current step function
|
|
246
|
+
Returns:
|
|
247
|
+
A list of grants that could be determined or an empty list.
|
|
248
|
+
"""
|
|
249
|
+
access_token = _get_access_token(context)
|
|
250
|
+
if (access_token is None) or (access_token.get("grants") is None):
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
grants = access_token["grants"]
|
|
255
|
+
return grants
|
|
256
|
+
except Exception as ex:
|
|
257
|
+
raise ProConException("Can not extract Grants from access token.") from ex
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _get_access_token(context: ExecutionContextType) -> Any | None:
|
|
261
|
+
"""Trys to get access token. If one is contained in the job offer it is taken.
|
|
262
|
+
else it trys to get an AccessToken from the environment variables.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
context: Execution context of the current step function
|
|
266
|
+
Returns:
|
|
267
|
+
A access token raw string or None.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
access_token: str | None = None
|
|
271
|
+
try:
|
|
272
|
+
# This will fail, if we're not in a RemoteExecutionContext, there's no job offer or no token
|
|
273
|
+
access_token = context.current_job_offer.job_execution_context.access_token
|
|
274
|
+
except AttributeError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
# Fall back to environment variables, if the previous step failed
|
|
278
|
+
access_token = access_token or os.environ.get(HEADERS_FROM_ENV[JmaApiHeaders.access_token])
|
|
279
|
+
|
|
280
|
+
if access_token is None:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
token_content = jwt.decode(access_token, options={"verify_signature": False})
|
|
285
|
+
return token_content
|
|
286
|
+
except jwt.InvalidTokenError as ex:
|
|
287
|
+
raise ProConException("Access token could not be decoded!") from ex
|
|
File without changes
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message type definitions for the RabbitMQ communication.
|
|
3
|
+
|
|
4
|
+
https://dev.azure.com/data-cybernetics/Rymax-One/_wiki/wikis/General/105/Processing-communication-design
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Annotated, Any, Literal, Optional, Union
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
|
|
11
|
+
from pydantic import AnyUrl, BaseModel, Field, constr
|
|
12
|
+
|
|
13
|
+
from ..dataslots import Metadata
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessageModel(BaseModel):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HeartbeatContent(MessageModel):
|
|
21
|
+
type_: Literal['heartbeat'] = Field('heartbeat', alias='$type')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# class JobQuota(MessageModel):
|
|
25
|
+
# type_: Literal['quota'] = Field('quota', const=True, alias='$type')
|
|
26
|
+
# runtime: Optional[timedelta]
|
|
27
|
+
# cpu_time: Optional[float]
|
|
28
|
+
# qpu_time: Optional[float]
|
|
29
|
+
# max_storage: Optional[int]
|
|
30
|
+
|
|
31
|
+
class Header(BaseModel):
|
|
32
|
+
Key: str
|
|
33
|
+
Value: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SlotInfo(BaseModel):
|
|
37
|
+
uri: AnyUrl
|
|
38
|
+
prebuildheaders: list[Header] | None
|
|
39
|
+
mediatype: str
|
|
40
|
+
metadata: Metadata | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DataslotInput(BaseModel):
|
|
44
|
+
name: str
|
|
45
|
+
sources: list[SlotInfo]
|
|
46
|
+
# metadata: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DataslotOutput(BaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
destinations: list[SlotInfo]
|
|
52
|
+
# metadata: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JobExecutionContext(BaseModel):
|
|
56
|
+
job_url: str
|
|
57
|
+
access_token: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JobOfferContent(MessageModel):
|
|
61
|
+
type_: Literal['job.offer'] = Field('job.offer', alias='$type')
|
|
62
|
+
job_id: UUID
|
|
63
|
+
algorithm: str
|
|
64
|
+
algorithm_version: Optional[str] = None
|
|
65
|
+
parameters: Optional[Any] = None
|
|
66
|
+
# quota: Optional[JobQuota]
|
|
67
|
+
priority: Optional[int] = None
|
|
68
|
+
input_dataslots: list[DataslotInput]
|
|
69
|
+
output_dataslots: list[DataslotOutput]
|
|
70
|
+
job_execution_context: JobExecutionContext
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class JobStatus(Enum):
|
|
74
|
+
starting = 'starting'
|
|
75
|
+
running = 'running'
|
|
76
|
+
finished = 'finished'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class JobStatusContent(MessageModel):
|
|
80
|
+
type_: Literal['job.status'] = Field('job.status', alias='$type')
|
|
81
|
+
job_id: UUID
|
|
82
|
+
status: JobStatus
|
|
83
|
+
content: Optional[Any] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class JobProgressContent(MessageModel):
|
|
87
|
+
type_: Literal['job.progress'] = Field('job.progress', alias='$type')
|
|
88
|
+
job_id: UUID
|
|
89
|
+
status: JobStatus
|
|
90
|
+
content: Optional[Any] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class JobCommand(Enum):
|
|
94
|
+
status = 'status'
|
|
95
|
+
abort = 'abort'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class JobCommandContent(MessageModel):
|
|
99
|
+
type_: Literal['job.command'] = Field('job.command', alias='$type')
|
|
100
|
+
job_id: UUID
|
|
101
|
+
command: JobCommand
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class JobResultContent(MessageModel):
|
|
105
|
+
type_: Literal['job.result'] = Field('job.result', alias='$type')
|
|
106
|
+
job_id: UUID
|
|
107
|
+
result: Any
|
|
108
|
+
# quota: Optional[JobQuota]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MetadataUpdate(BaseModel):
|
|
112
|
+
dataslot_name: str
|
|
113
|
+
slot_index: Annotated[int, Field(ge=0)]
|
|
114
|
+
metadata: Metadata
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class JobResultMetadataContent(MessageModel):
|
|
118
|
+
type_: Literal['job.dataslot.metadata'] = Field('job.dataslot.metadata', alias='$type')
|
|
119
|
+
updates: list[MetadataUpdate]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WorkerStatus(Enum):
|
|
123
|
+
starting = 'starting'
|
|
124
|
+
idle = 'idle'
|
|
125
|
+
running = 'running'
|
|
126
|
+
stopped = 'stopped'
|
|
127
|
+
exiting = 'exiting'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class WorkerStatusContent(MessageModel):
|
|
131
|
+
type_: Literal['worker.status'] = Field('worker.status', alias='$type')
|
|
132
|
+
worker_id: str
|
|
133
|
+
status: WorkerStatus
|
|
134
|
+
content: Optional[Any] = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WorkerCommand(Enum):
|
|
138
|
+
start = 'start'
|
|
139
|
+
stop = 'stop'
|
|
140
|
+
status = 'status'
|
|
141
|
+
abort = 'abort'
|
|
142
|
+
restart = 'restart'
|
|
143
|
+
shutdown = 'shutdown'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class WorkerCommandContent(MessageModel):
|
|
147
|
+
type_: Literal['worker.command'] = Field('worker.command', alias='$type')
|
|
148
|
+
worker_id: str
|
|
149
|
+
command: WorkerCommand
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ErrorContent(MessageModel):
|
|
153
|
+
type_: Literal['error'] = Field('error', alias='$type')
|
|
154
|
+
type: str = Field('/procon/error')
|
|
155
|
+
title: str
|
|
156
|
+
detail: str
|
|
157
|
+
instance: str
|
|
158
|
+
metadata: Optional[dict] = None
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_exception(cls, *args, exception: Exception, **kwargs) -> 'ErrorContent':
|
|
162
|
+
"""Generates the error message from an exception"""
|
|
163
|
+
return cls(
|
|
164
|
+
*args,
|
|
165
|
+
detail=f'{type(exception).__name__}: {exception}',
|
|
166
|
+
**kwargs
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ID string of a service (worker, job, api) in the header;
|
|
171
|
+
# format: <service>:<instance id>:<sub id>
|
|
172
|
+
# SenderID = pydantic.constr(regex=r'^[\w-]+:[\w-]+(:[\w-]+)?$')
|
|
173
|
+
SenderID = str # don't validate the id format for now
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MessageHeader(MessageModel):
|
|
177
|
+
type_: Literal['header'] = Field('header', alias='$type')
|
|
178
|
+
sender_id: SenderID
|
|
179
|
+
msg_id: UUID = Field(default_factory=uuid4)
|
|
180
|
+
date: datetime = Field(default_factory=datetime.utcnow)
|
|
181
|
+
version: Literal['1.0'] = '1.0'
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MessageBody(MessageModel):
|
|
185
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
186
|
+
header: MessageHeader
|
|
187
|
+
content: Union[
|
|
188
|
+
JobOfferContent, JobStatusContent, JobProgressContent,
|
|
189
|
+
JobCommandContent, JobResultContent,
|
|
190
|
+
JobResultMetadataContent,
|
|
191
|
+
WorkerStatusContent, WorkerCommandContent,
|
|
192
|
+
HeartbeatContent, ErrorContent
|
|
193
|
+
] = Field(..., discriminator='type_')
|
|
194
|
+
metadata: Optional[Any] = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class JobOfferMessageBody(MessageModel):
|
|
198
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
199
|
+
header: MessageHeader
|
|
200
|
+
content: Union[JobOfferContent, ErrorContent] = Field(..., discriminator='type_')
|
|
201
|
+
metadata: Optional[Any] = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class WorkerCommandMessageBody(MessageModel):
|
|
205
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
206
|
+
header: MessageHeader
|
|
207
|
+
content: Union[WorkerCommandContent, ErrorContent] = Field(..., discriminator='type_')
|
|
208
|
+
metadata: Optional[Any] = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class WorkerStatusMessageBody(MessageModel):
|
|
212
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
213
|
+
header: MessageHeader
|
|
214
|
+
content: Union[WorkerStatusContent, ErrorContent] = Field(..., discriminator='type_')
|
|
215
|
+
metadata: Optional[Any] = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class JobCommandMessageBody(MessageModel):
|
|
219
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
220
|
+
header: MessageHeader
|
|
221
|
+
content: Union[JobCommandContent, ErrorContent] = Field(..., discriminator='type_')
|
|
222
|
+
metadata: Optional[Any] = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class JobStatusMessageBody(MessageModel):
|
|
226
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
227
|
+
header: MessageHeader
|
|
228
|
+
content: Union[JobStatusContent, ErrorContent] = Field(..., discriminator='type_')
|
|
229
|
+
metadata: Optional[Any] = None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class JobResultMessageBody(MessageModel):
|
|
233
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
234
|
+
header: MessageHeader
|
|
235
|
+
content: JobResultContent
|
|
236
|
+
metadata: Optional[Any] = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class JobResultMetadataMessageBody(MessageModel):
|
|
240
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
241
|
+
header: MessageHeader
|
|
242
|
+
content: JobResultMetadataContent
|
|
243
|
+
metadata: Optional[Any] = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class JobProgressMessageBody(MessageModel):
|
|
247
|
+
type_: Literal['message'] = Field('message', alias='$type')
|
|
248
|
+
header: MessageHeader
|
|
249
|
+
content: JobProgressContent
|
|
250
|
+
metadata: Optional[Any] = None
|