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.
@@ -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,9 @@
1
+ from .api_helpers import (
2
+ client_from_env_vars,
3
+ client_from_job_execution_context,
4
+ job_from_step_context,
5
+ get_client,
6
+ get_entrypoint_hco,
7
+ get_job,
8
+ get_grants,
9
+ )
@@ -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