ivcap_service 0.6.8__tar.gz
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.
- ivcap_service-0.6.8/AUTHORS.md +5 -0
- ivcap_service-0.6.8/LICENSE +29 -0
- ivcap_service-0.6.8/PKG-INFO +86 -0
- ivcap_service-0.6.8/README.md +63 -0
- ivcap_service-0.6.8/ivcap_service/__init__.py +20 -0
- ivcap_service-0.6.8/ivcap_service/context.py +148 -0
- ivcap_service-0.6.8/ivcap_service/events.py +163 -0
- ivcap_service-0.6.8/ivcap_service/ivcap.py +192 -0
- ivcap_service-0.6.8/ivcap_service/logger.py +32 -0
- ivcap_service-0.6.8/ivcap_service/logging.json +35 -0
- ivcap_service-0.6.8/ivcap_service/py.typed +1 -0
- ivcap_service-0.6.8/ivcap_service/service.py +232 -0
- ivcap_service-0.6.8/ivcap_service/service_definition.py +152 -0
- ivcap_service-0.6.8/ivcap_service/testing.py +61 -0
- ivcap_service-0.6.8/ivcap_service/tool_definition.py +410 -0
- ivcap_service-0.6.8/ivcap_service/types.py +53 -0
- ivcap_service-0.6.8/ivcap_service/utils.py +89 -0
- ivcap_service-0.6.8/ivcap_service/version.py +19 -0
- ivcap_service-0.6.8/pyproject.toml +46 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023, Commonwealth Scientific and Industrial Research Organisation (CSIRO) ABN 41 687 119 230
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ivcap_service
|
|
3
|
+
Version: 0.6.8
|
|
4
|
+
Summary: SDK library for building services for the IVCAP platform
|
|
5
|
+
Author: Max Ott
|
|
6
|
+
Author-email: max.ott@csiro.au
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Dist: cachetools (>=5.5.2,<6.0.0)
|
|
14
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
15
|
+
Requires-Dist: ivcap-client (>=0.44.2,<0.45.0)
|
|
16
|
+
Requires-Dist: opentelemetry-distro (>=0.51b0,<0.52)
|
|
17
|
+
Requires-Dist: opentelemetry-exporter-otlp (>=1.30.0,<2.0.0)
|
|
18
|
+
Requires-Dist: opentelemetry-instrumentation-httpx (>=0.51b0,<0.52)
|
|
19
|
+
Requires-Dist: opentelemetry-instrumentation-requests (>=0.51b0,<0.52)
|
|
20
|
+
Requires-Dist: pydantic (>=2.11.4,<3.0.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ivcap-service: A python library for building services for the IVCAP platform
|
|
24
|
+
|
|
25
|
+
<a href="https://scan.coverity.com/projects/ivcap-service-sdk-python">
|
|
26
|
+
<img alt="Coverity Scan Build Status"
|
|
27
|
+
src="https://scan.coverity.com/projects/31773/badge.svg"/>
|
|
28
|
+
</a>
|
|
29
|
+
|
|
30
|
+
A python library containing various helper and environment functions
|
|
31
|
+
to simplify developing services to be deployed on IVCAP.
|
|
32
|
+
|
|
33
|
+
> **Note:** A template git repository using this library can be found on github
|
|
34
|
+
[ivcap-works/ivcap-python-ai-tool-template](https://github.com/ivcap-works/ivcap-python-ai-tool-template). You may clone that and start from there.
|
|
35
|
+
|
|
36
|
+
## Describe the service <a name="register"></a>
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
logging_init()
|
|
40
|
+
logger = getLogger("app")
|
|
41
|
+
|
|
42
|
+
service = Service(
|
|
43
|
+
name="Some service",
|
|
44
|
+
contact={
|
|
45
|
+
"name": "Mary Doe",
|
|
46
|
+
"email": "mary.doe@acme.au",
|
|
47
|
+
},
|
|
48
|
+
license_info={
|
|
49
|
+
"name": "MIT",
|
|
50
|
+
"url": "https://opensource.org/license/MIT",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
class Request(BaseModel):
|
|
58
|
+
jschema: str = Field("urn:sd:schema:some_tool.request.1", alias="$schema")
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
class Result(BaseModel):
|
|
62
|
+
jschema: str = Field("urn:sd:schema:some_tool.1", alias="$schema")
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def some_service(req: Request) -> Result:
|
|
66
|
+
"""
|
|
67
|
+
Here should go a quite extensive description of what the service can be
|
|
68
|
+
used for so that an agent can work out if this service is useful in
|
|
69
|
+
a specific context.
|
|
70
|
+
|
|
71
|
+
DO NOT ADD PARAMTER AND RETURN DECRIPTIONS -
|
|
72
|
+
DESCRIBE THEM IN THE `Request` MODEL
|
|
73
|
+
"""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
return Result(...)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Start the Service <a name="start"></a>
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
from ivcap_service import start_batch_service
|
|
84
|
+
some_service(service, consume_compute)
|
|
85
|
+
```
|
|
86
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ivcap-service: A python library for building services for the IVCAP platform
|
|
2
|
+
|
|
3
|
+
<a href="https://scan.coverity.com/projects/ivcap-service-sdk-python">
|
|
4
|
+
<img alt="Coverity Scan Build Status"
|
|
5
|
+
src="https://scan.coverity.com/projects/31773/badge.svg"/>
|
|
6
|
+
</a>
|
|
7
|
+
|
|
8
|
+
A python library containing various helper and environment functions
|
|
9
|
+
to simplify developing services to be deployed on IVCAP.
|
|
10
|
+
|
|
11
|
+
> **Note:** A template git repository using this library can be found on github
|
|
12
|
+
[ivcap-works/ivcap-python-ai-tool-template](https://github.com/ivcap-works/ivcap-python-ai-tool-template). You may clone that and start from there.
|
|
13
|
+
|
|
14
|
+
## Describe the service <a name="register"></a>
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
logging_init()
|
|
18
|
+
logger = getLogger("app")
|
|
19
|
+
|
|
20
|
+
service = Service(
|
|
21
|
+
name="Some service",
|
|
22
|
+
contact={
|
|
23
|
+
"name": "Mary Doe",
|
|
24
|
+
"email": "mary.doe@acme.au",
|
|
25
|
+
},
|
|
26
|
+
license_info={
|
|
27
|
+
"name": "MIT",
|
|
28
|
+
"url": "https://opensource.org/license/MIT",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
class Request(BaseModel):
|
|
36
|
+
jschema: str = Field("urn:sd:schema:some_tool.request.1", alias="$schema")
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
class Result(BaseModel):
|
|
40
|
+
jschema: str = Field("urn:sd:schema:some_tool.1", alias="$schema")
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def some_service(req: Request) -> Result:
|
|
44
|
+
"""
|
|
45
|
+
Here should go a quite extensive description of what the service can be
|
|
46
|
+
used for so that an agent can work out if this service is useful in
|
|
47
|
+
a specific context.
|
|
48
|
+
|
|
49
|
+
DO NOT ADD PARAMTER AND RETURN DECRIPTIONS -
|
|
50
|
+
DESCRIBE THEM IN THE `Request` MODEL
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
return Result(...)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Start the Service <a name="start"></a>
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
from ivcap_service import start_batch_service
|
|
62
|
+
some_service(service, consume_compute)
|
|
63
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2025 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
""" A library for building services for the IVCAP platform"""
|
|
7
|
+
|
|
8
|
+
from .version import __version__
|
|
9
|
+
from .logger import getLogger, logging_init, service_log_config, set_service_log_config
|
|
10
|
+
from .service import start_batch_service, Service
|
|
11
|
+
from .service_definition import create_service_definition, find_resources_file, find_command, IMAGE_PLACEHOLDER, Resources, ServiceDefinition
|
|
12
|
+
from .tool_definition import create_tool_definition, print_tool_definition, ToolDefinition
|
|
13
|
+
from .utils import get_function_return_type, get_input_type
|
|
14
|
+
from .ivcap import get_ivcap_url, verify_result, push_result, set_result_callback, OnResultF, SidecarReporter
|
|
15
|
+
from .types import IvcapResult, BinaryResult, ExecutionError, JobContext
|
|
16
|
+
from .context import otel_instrument, set_context
|
|
17
|
+
from .events import (
|
|
18
|
+
EventReporter, set_event_reporter_factory, EventFactoryF, create_event_reporter,
|
|
19
|
+
BaseEvent, GenericEvent, GenericErrorEvent,
|
|
20
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2025 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
# Various "patches" to maiontain context between incoming requests
|
|
8
|
+
# and calls to external services within a "session"
|
|
9
|
+
#
|
|
10
|
+
import functools
|
|
11
|
+
from logging import Logger
|
|
12
|
+
from ivcap_service import getLogger
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Callable, Literal, Optional
|
|
15
|
+
from httpx import URL as URLx
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
from .types import JobContext
|
|
19
|
+
|
|
20
|
+
ExecContextF = Callable[[], JobContext]
|
|
21
|
+
|
|
22
|
+
def otel_instrument(
|
|
23
|
+
with_telemetry: Optional[Literal[True]],
|
|
24
|
+
extension: Optional[Callable[[str], None]],
|
|
25
|
+
logger: Logger,
|
|
26
|
+
):
|
|
27
|
+
if with_telemetry == False:
|
|
28
|
+
return
|
|
29
|
+
endpoint = os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT')
|
|
30
|
+
if endpoint == None:
|
|
31
|
+
if with_telemetry == True:
|
|
32
|
+
logger.warning("requested --with-telemetry but exporter is not defined")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if os.environ.get("PYTHONPATH") == None:
|
|
36
|
+
os.environ["PYTHONPATH"] = ""
|
|
37
|
+
import opentelemetry.instrumentation.auto_instrumentation.sitecustomize # force internal settings
|
|
38
|
+
logger.info(f"instrumenting for endpoint {endpoint}")
|
|
39
|
+
if extension != None:
|
|
40
|
+
extension(endpoint)
|
|
41
|
+
# Also instrumemt
|
|
42
|
+
try:
|
|
43
|
+
from opentelemetry.instrumentation.requests import RequestsInstrumentor
|
|
44
|
+
RequestsInstrumentor().instrument()
|
|
45
|
+
except ImportError:
|
|
46
|
+
pass
|
|
47
|
+
try:
|
|
48
|
+
import httpx # checks if httpx library is even used by this tool
|
|
49
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
50
|
+
HTTPXClientInstrumentor().instrument()
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def extend_requests(context_f: ExecContextF):
|
|
55
|
+
from requests import Session, PreparedRequest
|
|
56
|
+
|
|
57
|
+
logger = getLogger("app.request")
|
|
58
|
+
|
|
59
|
+
# Save original function
|
|
60
|
+
wrapped_send = Session.send
|
|
61
|
+
|
|
62
|
+
@functools.wraps(wrapped_send)
|
|
63
|
+
def _send(
|
|
64
|
+
self: Session, request: PreparedRequest, **kwargs: Any
|
|
65
|
+
):
|
|
66
|
+
ctxt = context_f()
|
|
67
|
+
logger.debug(f"{ctxt.job_id if ctxt else '???'}: Instrumenting 'requests' request to {request.url}")
|
|
68
|
+
_modify_request(request, ctxt, logger)
|
|
69
|
+
# Call original method
|
|
70
|
+
return wrapped_send(self, request, **kwargs)
|
|
71
|
+
|
|
72
|
+
# Apply wrapper
|
|
73
|
+
Session.send = _send
|
|
74
|
+
|
|
75
|
+
def _modify_request(request, ctxt: JobContext, logger):
|
|
76
|
+
headers = request.headers
|
|
77
|
+
url = request.url
|
|
78
|
+
hostname = _get_hostname(url)
|
|
79
|
+
is_local_url = hostname.endswith(".local") or hostname.endswith(".minikube") or hostname.endswith(".ivcap.net")
|
|
80
|
+
if not is_local_url:
|
|
81
|
+
request.url = _wrap_proxy_url(url, headers)
|
|
82
|
+
job_id = ctxt.job_id if ctxt != None else None
|
|
83
|
+
if job_id != None: # OTEL messages won't have a jobID
|
|
84
|
+
headers["Ivcap-Job-Id"] = job_id
|
|
85
|
+
auth = ctxt.job_authorization if ctxt != None else None
|
|
86
|
+
if auth != None and is_local_url:
|
|
87
|
+
logger.debug(f"Adding 'Authorization' header")
|
|
88
|
+
headers["Authorization"] = auth
|
|
89
|
+
|
|
90
|
+
def _get_hostname(url):
|
|
91
|
+
try:
|
|
92
|
+
if isinstance(url, URLx):
|
|
93
|
+
return url.host
|
|
94
|
+
if isinstance(url, str):
|
|
95
|
+
return urlparse(url).hostname
|
|
96
|
+
except Exception:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
ivcap_proxy_url = os.getenv('IVCAP_PROXY_URL')
|
|
100
|
+
|
|
101
|
+
def _wrap_proxy_url(url, headers):
|
|
102
|
+
global ivcap_proxy_url
|
|
103
|
+
if ivcap_proxy_url == None:
|
|
104
|
+
return url
|
|
105
|
+
|
|
106
|
+
if isinstance(url, URLx):
|
|
107
|
+
# ensuring that any 'unset' query parameters are not included
|
|
108
|
+
# in the final URL
|
|
109
|
+
query_params = [(k, v) for k, v in url.params.items() if v is not None]
|
|
110
|
+
url2 = URLx(url).copy_with(params=query_params)
|
|
111
|
+
forward_url = str(url2)
|
|
112
|
+
proxy_url = URLx(ivcap_proxy_url)
|
|
113
|
+
if isinstance(url, str):
|
|
114
|
+
forward_url = url # urllib.parse.quote_plus(url)
|
|
115
|
+
proxy_url = ivcap_proxy_url
|
|
116
|
+
headers["Ivcap-Forward-Url"] = forward_url
|
|
117
|
+
return proxy_url
|
|
118
|
+
|
|
119
|
+
def extend_httpx(context_f: ExecContextF):
|
|
120
|
+
try:
|
|
121
|
+
import httpx
|
|
122
|
+
except ImportError:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
logger = getLogger("app.httpx")
|
|
126
|
+
|
|
127
|
+
# Save original function
|
|
128
|
+
wrapped_send = httpx.Client.send
|
|
129
|
+
def _send(self, request, **kwargs):
|
|
130
|
+
ctxt = context_f()
|
|
131
|
+
logger.debug(f"{ctxt.job_id if ctxt else '???'}: Instrumenting 'httpx' request to {request.url}")
|
|
132
|
+
_modify_request(request, ctxt, logger)
|
|
133
|
+
# Call original method
|
|
134
|
+
return wrapped_send(self, request, **kwargs)
|
|
135
|
+
# Apply wrapper
|
|
136
|
+
httpx.Client.send = _send
|
|
137
|
+
|
|
138
|
+
wrapped_asend = httpx.AsyncClient.send
|
|
139
|
+
def _asend(self, request, **kwargs):
|
|
140
|
+
ctxt = context_f()
|
|
141
|
+
logger.debug(f"{ctxt.job_id if ctxt else '???'}: Instrumenting 'httpx' async request to {request.url}")
|
|
142
|
+
_modify_request(request, ctxt, logger)
|
|
143
|
+
return wrapped_asend(self, request, **kwargs)
|
|
144
|
+
httpx.AsyncClient.send = _asend
|
|
145
|
+
|
|
146
|
+
def set_context(context_f: ExecContextF):
|
|
147
|
+
extend_requests(context_f)
|
|
148
|
+
extend_httpx(context_f)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2025 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
|
|
3
|
+
# Use of this source code is governed by a BSD-style license that can be
|
|
4
|
+
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
|
+
#
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any, Callable, ClassVar, Generator, Optional, Type
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from .logger import getLogger
|
|
13
|
+
|
|
14
|
+
class BaseEvent(BaseModel):
|
|
15
|
+
def __init_subclass__(cls, **kwargs):
|
|
16
|
+
super().__init_subclass__(**kwargs)
|
|
17
|
+
if 'SCHEMA' not in getattr(cls, '__annotations__', {}):
|
|
18
|
+
raise TypeError(f"{cls.__name__} must annotate SCHEMA as 'ClassVar[str]'")
|
|
19
|
+
if not hasattr(cls, 'SCHEMA'):
|
|
20
|
+
raise TypeError(f"{cls.__name__} must define a class constant 'SCHEMA'")
|
|
21
|
+
|
|
22
|
+
def model_dump(self, *args, **kwargs):
|
|
23
|
+
d = super().model_dump(*args, **kwargs)
|
|
24
|
+
d["$schema"] = self.__class__.SCHEMA
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
def model_dump_json(self, *args, **kwargs):
|
|
28
|
+
# Only pass *args, **kwargs to model_dump, not to json.dumps
|
|
29
|
+
return json.dumps(self.model_dump(*args, **kwargs))
|
|
30
|
+
|
|
31
|
+
class GenericEvent(BaseEvent):
|
|
32
|
+
SCHEMA: ClassVar[str] = "urn:ivcap:schema:service.event.generic.1"
|
|
33
|
+
name: str = Field(description="Name of event")
|
|
34
|
+
options: Optional[dict[str, Any]] = Field(None, description="Optional list of options")
|
|
35
|
+
|
|
36
|
+
class GenericErrorEvent(BaseEvent):
|
|
37
|
+
SCHEMA: ClassVar[str]= "urn:ivcap:schema:service.event.error.1"
|
|
38
|
+
error: str = Field(description="Error description")
|
|
39
|
+
context: Optional[str] = Field(None, description="Optional description of context")
|
|
40
|
+
stacktrace: Optional[list[str]] = Field(None, description="Optional stacktrace")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StepStartEvent(GenericEvent):
|
|
44
|
+
SCHEMA: ClassVar[str] = "urn:ivcap:schema:service.event.step.start.1"
|
|
45
|
+
|
|
46
|
+
class StepInfoEvent(GenericEvent):
|
|
47
|
+
SCHEMA: ClassVar[str] = "urn:ivcap:schema:service.event.step.info.1"
|
|
48
|
+
|
|
49
|
+
class StepErrorEvent(GenericErrorEvent):
|
|
50
|
+
SCHEMA: ClassVar[str] = "urn:ivcap:schema:service.event.step.error.1"
|
|
51
|
+
|
|
52
|
+
class StepFinishEvent(GenericEvent):
|
|
53
|
+
SCHEMA: ClassVar[str] = "urn:ivcap:schema:service.event.step.finish.1"
|
|
54
|
+
|
|
55
|
+
logger = getLogger("event")
|
|
56
|
+
|
|
57
|
+
event_reporter_factory = None
|
|
58
|
+
|
|
59
|
+
EventFactoryF = Callable[[str, Optional[str]], 'EventReporter']
|
|
60
|
+
def set_event_reporter_factory(factory: EventFactoryF):
|
|
61
|
+
"""
|
|
62
|
+
Set afactory function for creating specialised EventReporter instances.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
factory (EventFactoryF): A factory function that takes a job ID and an optional job authorization token,
|
|
66
|
+
and returns an instance of EventReporter. If None, the default EventReporter will be used.
|
|
67
|
+
"""
|
|
68
|
+
global event_reporter_factory
|
|
69
|
+
if factory is not None and not callable(factory):
|
|
70
|
+
raise ValueError("Factory must be a callable that returns an EventReporter instance.")
|
|
71
|
+
event_reporter_factory = factory
|
|
72
|
+
|
|
73
|
+
def create_event_reporter(job_id: str, job_authorization: Optional[str] = None) -> 'EventReporter':
|
|
74
|
+
"""
|
|
75
|
+
Create an EventReporter instance for the given job ID.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
job_id (str): The unique identifier for the job.
|
|
80
|
+
job_authorization (Optional[str]): Optional authorization token for the job.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
EventReporter: An instance of EventReporter initialized with the job ID.
|
|
84
|
+
"""
|
|
85
|
+
if event_reporter_factory is not None:
|
|
86
|
+
return event_reporter_factory(job_id, job_authorization)
|
|
87
|
+
return EventReporter(job_id, job_authorization)
|
|
88
|
+
|
|
89
|
+
class EventContext:
|
|
90
|
+
def __init__(self,
|
|
91
|
+
event_name:str,
|
|
92
|
+
reporter:'EventReporter',
|
|
93
|
+
finishEventClass: Optional[Type[BaseEvent]],
|
|
94
|
+
errorEventClass: Optional[Type[GenericErrorEvent]]
|
|
95
|
+
):
|
|
96
|
+
self._event_name = event_name
|
|
97
|
+
self._reporter = reporter
|
|
98
|
+
self._finishEventClass = finishEventClass
|
|
99
|
+
self._errorEventClass = errorEventClass
|
|
100
|
+
self._finished_sent = False
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def name(self):
|
|
104
|
+
return self._event_name
|
|
105
|
+
|
|
106
|
+
def finished(self, **kwargs):
|
|
107
|
+
if self._finishEventClass:
|
|
108
|
+
event = self._finishEventClass(name=self._event_name, **kwargs)
|
|
109
|
+
else:
|
|
110
|
+
event = GenericEvent(name=self._event_name, options=kwargs)
|
|
111
|
+
self._reporter.emit(event)
|
|
112
|
+
self._finished_sent = True
|
|
113
|
+
|
|
114
|
+
def info(self, event: BaseEvent | dict):
|
|
115
|
+
self._reporter.emit(event)
|
|
116
|
+
|
|
117
|
+
def error(self, err: Exception, context: Optional[str]=None):
|
|
118
|
+
evc = self._errorEventClass if self._errorEventClass is not None else GenericErrorEvent
|
|
119
|
+
stacktrace = traceback.format_tb(err.__traceback__)
|
|
120
|
+
if not context:
|
|
121
|
+
context = self._event_name
|
|
122
|
+
event = evc(error=str(err), stacktrace=stacktrace, context=context)
|
|
123
|
+
self._reporter.emit(event)
|
|
124
|
+
|
|
125
|
+
EventCtxtGenerator = Generator[EventContext, None, None]
|
|
126
|
+
|
|
127
|
+
class EventReporter:
|
|
128
|
+
def __init__(self, job_id: str, job_authorization: str):
|
|
129
|
+
self.job_id = job_id
|
|
130
|
+
self.job_authorization = job_authorization
|
|
131
|
+
|
|
132
|
+
def _send(self, event: BaseEvent):
|
|
133
|
+
logger.debug(f"{self.job_id}: {event.model_dump_json(exclude_none=True)}")
|
|
134
|
+
|
|
135
|
+
def emit(self, event: BaseEvent):
|
|
136
|
+
self._send(event)
|
|
137
|
+
|
|
138
|
+
def step_started(self, step_name: str, **kwargs):
|
|
139
|
+
self.emit(StepStartEvent(name=step_name, options=kwargs))
|
|
140
|
+
|
|
141
|
+
def step_finished(self, step_name: str, **kwargs):
|
|
142
|
+
self.emit(StepFinishEvent(name=step_name, options=kwargs))
|
|
143
|
+
|
|
144
|
+
def step(self, step_name, **kwargs):
|
|
145
|
+
self.step_started(step_name, options=kwargs)
|
|
146
|
+
return self._event_scope(step_name, StepFinishEvent(name=step_name), StepErrorEvent)
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def _event_scope(
|
|
150
|
+
self,
|
|
151
|
+
event_name: str,
|
|
152
|
+
defaultFinishEvent: Optional[BaseEvent] = None,
|
|
153
|
+
errorEventClass: Optional[Type[GenericErrorEvent]] = None,
|
|
154
|
+
) -> EventCtxtGenerator:
|
|
155
|
+
fevc = defaultFinishEvent.__class__ if defaultFinishEvent else None
|
|
156
|
+
ctxt = EventContext(event_name, self, fevc, errorEventClass)
|
|
157
|
+
try:
|
|
158
|
+
yield ctxt
|
|
159
|
+
except Exception as e:
|
|
160
|
+
ctxt.error(e, event_name)
|
|
161
|
+
raise e
|
|
162
|
+
if not ctxt._finished_sent:
|
|
163
|
+
self.emit(defaultFinishEvent)
|