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.
@@ -0,0 +1,5 @@
1
+ # Initial version authors
2
+
3
+ * Max Ott <max.ott@csiro.au>
4
+
5
+ # Partial list of contributors
@@ -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)