ivcap_service 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ #
2
+ # Copyright (c) 2023 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
10
+ from .service import start_batch_service
11
+ from .service_definition import create_service_definition
12
+ from .tool_definition import create_tool_definition, print_tool_definition
ivcap_service/ivcap.py ADDED
@@ -0,0 +1,145 @@
1
+ #
2
+ # Copyright (c) 2023 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 dataclasses import Field, dataclass
7
+ import json
8
+ import os
9
+ import traceback
10
+ from typing import Any, Callable, Generic, List, Optional, TypeVar, Union, BinaryIO
11
+
12
+ from time import sleep
13
+ from urllib.parse import urlparse, urlunparse
14
+ import httpx
15
+ from pydantic import BaseModel, HttpUrl
16
+ from ivcap_fastapi import getLogger
17
+
18
+ from .types import BinaryResult, ExecutionError, IvcapResult
19
+
20
+ logger = getLogger("ivcap")
21
+
22
+ # Number of attempt to deliver job result before giving up
23
+ MAX_DELIVER_RESULT_ATTEMPTS = 4
24
+
25
+
26
+ def verify_result(result: any, job_id: str, logger) -> any:
27
+ if isinstance(result, ExecutionError):
28
+ return result
29
+ if isinstance(result, BaseModel):
30
+ try:
31
+ return IvcapResult(
32
+ content=result.model_dump_json(by_alias=True),
33
+ content_type="application/json",
34
+ raw=result,
35
+ )
36
+ except Exception as ex:
37
+ msg = f"{job_id}: cannot json serialise pydantic isntance - {str(ex)}"
38
+ logger.warning(msg)
39
+ return ExecutionError(
40
+ error=msg,
41
+ type=type(ex).__name__,
42
+ traceback=traceback.format_exc()
43
+ )
44
+ if isinstance(result, BinaryResult):
45
+ return IvcapResult(content=result.content, content_type=result.content_type)
46
+ if isinstance(result, str):
47
+ return IvcapResult(content=result, content_type="text/plain", raw=result)
48
+ if isinstance(result, bytes):
49
+ # If it's a byte array, return it as is
50
+ return IvcapResult(
51
+ content=result,
52
+ content_type="application/octet-stream",
53
+ raw=result,
54
+ )
55
+ if isinstance(result, BinaryIO):
56
+ # If it's a file handler, return it as is
57
+ return IvcapResult(
58
+ content=result,
59
+ content_type="application/octet-stream",
60
+ raw=result
61
+ )
62
+ # normal model which should be serialisable
63
+ try:
64
+ result = IvcapResult(
65
+ content=json.dumps(result),
66
+ content_type="application/json"
67
+ )
68
+ except Exception as ex:
69
+ msg = f"{job_id}: cannot json serialise result - {str(ex)}"
70
+ logger.warning(msg)
71
+ result = ExecutionError(
72
+ error=msg,
73
+ type=type(ex).__name__,
74
+ )
75
+
76
+ def push_result(result: Union[IvcapResult, ExecutionError], job_id: str, authorization: Optional[str]=None):
77
+ """Actively push result to sidecar, fail quietly."""
78
+ ivcap_url = get_ivcap_url()
79
+ if ivcap_url is None:
80
+ logger.warning(f"{job_id}: no ivcap url found - cannot push result")
81
+ return
82
+ url = urlunparse(ivcap_url._replace(path=f"/results/{job_id}"))
83
+
84
+ content_type="text/plain"
85
+ content="SOMETHING WENT WRONG _ PLEASE REPORT THIS ERROR"
86
+ is_error = False
87
+ if not (isinstance(result, ExecutionError) or isinstance(result, IvcapResult)):
88
+ msg = f"{job_id}: expected 'IvcapResult' or 'ExecutionError' but got {type(result)}"
89
+ logger.warning(msg)
90
+ result = ExecutionError(
91
+ error=msg,
92
+ type='InternalError',
93
+ )
94
+
95
+ if isinstance(result, IvcapResult):
96
+ content = result.content
97
+ content_type = result.content_type
98
+ else:
99
+ is_error = True
100
+ if not isinstance(result, ExecutionError):
101
+ # this should never happen
102
+ logger.error(f"{job_id}: expected 'ExecutionError' but got {type(result)}")
103
+ result = ExecutionError(
104
+ error="please report unexpected internal error - expected 'ExecutionError' but got {type(result)}",
105
+ type="internal_error",
106
+ )
107
+ content = result.model_dump_json(by_alias=True)
108
+ content_type = "application/json"
109
+
110
+
111
+ wait_time = 1
112
+ attempt = 0
113
+ headers = {
114
+ "Content-Type": content_type,
115
+ "Is-Error": str(is_error),
116
+ }
117
+ if not (authorization == None or authorization == ""):
118
+ headers["Authorization"] = authorization
119
+
120
+ while attempt < MAX_DELIVER_RESULT_ATTEMPTS:
121
+ try:
122
+ response = httpx.post(
123
+ url=url,
124
+ headers=headers,
125
+ data=content,
126
+ )
127
+ response.raise_for_status()
128
+ return
129
+ except Exception as e:
130
+ attempt += 1
131
+ logger.info(f"{job_id}: attempt #{attempt} failed to push result - will try again in {wait_time} sec - {type(e)}: {e}")
132
+ sleep(wait_time)
133
+ wait_time *= 2
134
+
135
+ logger.warning(f"{job_id}: giving up pushing result after {attempt} attempts")
136
+
137
+
138
+ def get_ivcap_url() -> HttpUrl:
139
+ """
140
+ Returns the sidecar URL from the request headers.
141
+ """
142
+ base = os.getenv("IVCAP_BASE_URL")
143
+ if base == "" or base is None:
144
+ return None
145
+ return urlparse(base)
@@ -0,0 +1,27 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import json
7
+ import logging
8
+ from logging.config import dictConfig
9
+ import os
10
+
11
+ LOGGING_CONFIG={}
12
+
13
+ def getLogger(name: str) -> logging.Logger:
14
+ return logging.getLogger(name)
15
+
16
+ def service_log_config():
17
+ return LOGGING_CONFIG
18
+
19
+ def logging_init(cfg_path: str=None):
20
+ if not cfg_path:
21
+ script_dir = os.path.dirname(__file__)
22
+ cfg_path = os.path.join(script_dir, "logging.json")
23
+
24
+ with open(cfg_path, 'r') as file:
25
+ global LOGGING_CONFIG
26
+ LOGGING_CONFIG = json.load(file)
27
+ dictConfig(LOGGING_CONFIG)
@@ -0,0 +1,46 @@
1
+ {
2
+ "version": 1,
3
+ "formatters": {
4
+ "default": {
5
+ "format": "%(asctime)s %(levelname)s (%(name)s): %(message)s",
6
+ "datefmt": "%Y-%m-%dT%H:%M:%S%z"
7
+ },
8
+ "access": {
9
+ "()": "uvicorn.logging.AccessFormatter",
10
+ "fmt": "%(asctime)s %(levelname)s (access): \"%(request_line)s\" %(status_code)s",
11
+ "datefmt": "%Y-%m-%dT%H:%M:%S%z"
12
+ }
13
+ },
14
+ "handlers": {
15
+ "default": {
16
+ "class": "logging.StreamHandler",
17
+ "level": "DEBUG",
18
+ "formatter": "default",
19
+ "stream": "ext://sys.stderr"
20
+ },
21
+ "access": {
22
+ "formatter": "access",
23
+ "class": "logging.StreamHandler",
24
+ "stream": "ext://sys.stdout"
25
+ }
26
+ },
27
+ "root": {
28
+ "level": "INFO",
29
+ "handlers": [
30
+ "default"
31
+ ]
32
+ },
33
+ "loggers": {
34
+ "app": {
35
+ "level": "DEBUG"
36
+ },
37
+ "uvicorn.access": {
38
+ "handlers": [
39
+ "access"
40
+ ],
41
+ "level": "INFO",
42
+ "propagate": false
43
+ }
44
+ },
45
+ "disable_existing_loggers": false
46
+ }
ivcap_service/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561
@@ -0,0 +1,166 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import time
8
+ import traceback
9
+ from typing import Callable
10
+ import argparse
11
+ from logging import Logger
12
+ from typing import Any, Callable, Dict, Optional
13
+ from urllib.parse import urlunparse
14
+
15
+ import os
16
+ import sys
17
+ from pydantic import BaseModel
18
+ import requests
19
+
20
+
21
+ from .ivcap import get_ivcap_url, push_result, verify_result
22
+ from .logger import getLogger
23
+ from .types import ExecutionError
24
+ from .utils import _get_function_return_type, _get_input_type, get_version
25
+ from .tool_definition import print_tool_definition # Import the requests library
26
+
27
+ # Number of attempt to request a new job before giving up
28
+ MAX_REQUEST_JOB_ATTEMPTS = 4
29
+
30
+ def wait_for_work(worker_fn: Callable, input_model: type[BaseModel], output_model: type[BaseModel], logger: Logger):
31
+ ivcap_url = get_ivcap_url()
32
+ if ivcap_url is None:
33
+ logger.warning(f"no ivcap url found - cannot request work")
34
+ return
35
+ url = urlunparse(ivcap_url._replace(path=f"/next_job"))
36
+ logger.info(f"... checking for work at '{url}'")
37
+ try:
38
+
39
+ while True:
40
+ result = None
41
+ try:
42
+ response = fetch_job(url, logger)
43
+ job = response.json()
44
+ schema = job.get("$schema", "")
45
+ if schema.startswith("urn:ivcap:schema.service.batch.done"):
46
+ logger.info("no more jobs - we are done")
47
+ sys.exit(0)
48
+
49
+ job_id = job.get("id", "unknown_job_id") # Provide a default value if "id" is missing
50
+ result = do_job(job, worker_fn, input_model, output_model, logger)
51
+ result = verify_result(result, job_id, logger)
52
+ except Exception as e:
53
+ result = ExecutionError(
54
+ error=str(e),
55
+ type=type(e).__name__,
56
+ traceback=traceback.format_exc()
57
+ )
58
+ logger.warning(f"job {job_id} failed - {result.error}")
59
+ finally:
60
+ if result is not None:
61
+ logger.info(f"job {job_id} finished, sending result message")
62
+ push_result(result, job_id, None, logger)
63
+
64
+ except requests.exceptions.RequestException as e:
65
+ logger.warning(f"Error during request: {e}")
66
+ except Exception as e:
67
+ logger.warning(f"Error processing job: {e}")
68
+
69
+ def fetch_job(url: str, logger: Logger) -> Any:
70
+ wait_time = 1
71
+ attempt = 0
72
+ while attempt < MAX_REQUEST_JOB_ATTEMPTS:
73
+ try:
74
+ response = requests.get(url)
75
+ response.raise_for_status()
76
+ return response
77
+ except Exception as e:
78
+ attempt += 1
79
+ logger.info(f"attempt #{attempt} failed to fetch new job - will try again in {wait_time} sec - {type(e)}: {e}")
80
+ time.sleep(wait_time)
81
+ wait_time *= 2
82
+ logger.info("cannot contact sidecar - bailing out")
83
+ sys.exit(255)
84
+
85
+ def do_job(
86
+ job: Any,
87
+ worker_fn: Callable,
88
+ input_model: type[BaseModel],
89
+ output_model: type[BaseModel],
90
+ logger: Logger
91
+ ):
92
+ job_id = job.get("id", "unknown_job_id") # Provide a default value if "id" is missing
93
+ ct = job["in-content-type"]
94
+ if ct != "application/json":
95
+ raise Exception(f"cannot handle content-type '{ct}'")
96
+ jc = job["in-content"]
97
+ mreq = input_model(**jc)
98
+ logger.info(f"{job_id}: calling worker with - {mreq}")
99
+ try:
100
+ resp = worker_fn(mreq)
101
+ logger.info(f"{job_id}: worker finished with - {resp}")
102
+ if type(resp) != output_model:
103
+ logger.warning(f"{job_id}: result is of type '{type(resp)}' but expected '{output_model}'")
104
+
105
+ except BaseException as ex:
106
+ logger.warning(f"{job_id}: failed - '{ex}'")
107
+ resp = ExecutionError(
108
+ error=str(ex),
109
+ type=type(ex).__name__,
110
+ traceback=traceback.format_exc()
111
+ )
112
+ return resp
113
+
114
+ def start_batch_service(
115
+ title: str,
116
+ worker_fn: Callable,
117
+ *,
118
+ custom_args: Optional[Callable[[argparse.ArgumentParser], argparse.Namespace]] = None,
119
+ run_opts: Optional[Dict[str, Any]] = None,
120
+ with_telemetry: Optional[bool] = None,
121
+ ):
122
+ """A helper function to start a batch service
123
+
124
+ Args:
125
+ title (str): the tile
126
+ tool_fn (Callable[..., Any]): _description_
127
+ logger (Logger): _description_
128
+ custom_args (Optional[Callable[[argparse.ArgumentParser], argparse.Namespace]], optional): _description_. Defaults to None.
129
+ run_opts (Optional[Dict[str, Any]], optional): _description_. Defaults to None.
130
+ with_telemetry: (Optional[bool]): Instantiate or block use of OpenTelemetry tracing
131
+ """
132
+ logger = getLogger("server")
133
+
134
+ parser = argparse.ArgumentParser(description=title)
135
+ parser.add_argument('--with-telemetry', action="store_true", help='Initialise OpenTelemetry')
136
+ parser.add_argument('--print-service-description', action="store_true", help='Print service description to stdout')
137
+ parser.add_argument('--print-tool-description', action="store_true", help='Print tool description to stdout')
138
+ parser.add_argument('--test-file', type=str, help='path to job file for testing service')
139
+
140
+ if custom_args is not None:
141
+ args = custom_args(parser)
142
+ else:
143
+ args = parser.parse_args()
144
+
145
+ if args.print_service_description:
146
+ from .service_definition import print_batch_service_definition
147
+ print_batch_service_definition(worker_fn)
148
+ sys.exit(0)
149
+
150
+ if args.print_tool_description:
151
+ print_tool_definition(worker_fn)
152
+ sys.exit(0)
153
+
154
+ logger.info(f"{title} - {os.getenv('VERSION')} - v{get_version()}")
155
+
156
+ input_model, _ = _get_input_type(worker_fn)
157
+ output_model = _get_function_return_type(worker_fn)
158
+ # summary, description = (worker_fn.__doc__.lstrip() + "\n").split("\n", 1)
159
+
160
+ if args.test_file is not None:
161
+ from .testing import file_to_json
162
+ job = file_to_json(args.test_file)
163
+ resp = do_job(job, worker_fn, input_model, output_model, logger)
164
+ print(resp.model_dump_json(indent=2, by_alias=True))
165
+ else:
166
+ wait_for_work(worker_fn, input_model, output_model, logger)
@@ -0,0 +1,116 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import os
8
+ import sys
9
+ from typing import Callable, Dict, List, Any, Optional
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .utils import file_to_json
13
+ from .tool_definition import SERVICE_ID_PLACEHOLDER
14
+ from .logger import getLogger
15
+
16
+ SERVICE_SCHEMA = "urn:ivcap:schema.service.2"
17
+ BATCH_CONTROLLER_SCHEMA = "urn:ivcap:schema.service.batch.1"
18
+ DEF_POLICY = "urn:ivcap:policy:ivcap.open.service"
19
+ DEF_RESOURCE_FILE = "resources.json"
20
+
21
+ IMAGE_PLACEHOLDER = "#DOCKER_IMG#"
22
+
23
+ logger = getLogger("service-defintion")
24
+
25
+ class ResourceRequirements(BaseModel):
26
+ cpu: str = Field(default="500m")
27
+ memory: str = Field(default="1Gi")
28
+ ephemeral_storage: Optional[str] = None # Field(default="1Gi")
29
+
30
+ class Resources(BaseModel):
31
+ limits: ResourceRequirements = Field(default_factory=ResourceRequirements)
32
+ requests: ResourceRequirements = Field(default_factory=ResourceRequirements)
33
+
34
+ class BatchController(BaseModel):
35
+ jschema: str = Field(default=BATCH_CONTROLLER_SCHEMA, alias="$schema")
36
+ image: str
37
+ command: List[str]
38
+ resources: Resources = Field(default_factory=Resources)
39
+
40
+ class ServiceDefinition(BaseModel):
41
+ jschema: str = Field(default=SERVICE_SCHEMA, alias="$schema")
42
+ id: str = Field(alias="$id")
43
+ name: str
44
+ description: str
45
+ parameters: List[Any] = []
46
+ policy: str = Field(default=DEF_POLICY)
47
+ controller_schema: str = Field(default=BATCH_CONTROLLER_SCHEMA)
48
+ controller: Any
49
+
50
+ def print_batch_service_definition(
51
+ fn: Callable[..., Any],
52
+ service_id: Optional[str] = None,
53
+ description: Optional[str] = None,
54
+ ):
55
+ sd = create_batch_service_definition(
56
+ fn,
57
+ service_id=service_id,
58
+ description=description,
59
+ )
60
+ print(sd.model_dump_json(indent=2, by_alias=True, exclude_none=True))
61
+
62
+ def create_batch_service_definition(
63
+ fn: Callable[..., Any],
64
+ service_id: Optional[str] = None,
65
+ description: Optional[str] = None,
66
+ ) -> ServiceDefinition:
67
+ # controller
68
+ image = os.getenv("DOCKER_IMG", IMAGE_PLACEHOLDER)
69
+ service_file_name = fn.__code__.co_filename
70
+ service_dir = os.path.dirname(service_file_name)
71
+ service_file_name = os.path.basename(service_file_name)
72
+ command = ["python", f"/app/{service_file_name}"]
73
+
74
+ using_def_resource_file = False
75
+ resource_file = os.getenv("IVCAP_RESOURCES_FILE")
76
+ if resource_file == None:
77
+ using_def_resource_file = True
78
+ resource_file = os.path.join(service_dir, DEF_RESOURCE_FILE)
79
+ if os.path.exists(resource_file) and os.access(resource_file, os.R_OK):
80
+ rd = file_to_json(resource_file)
81
+ resources = Resources(**rd)
82
+ else:
83
+ if using_def_resource_file:
84
+ logger.info(f"Using default resources as I can't find resources def '{resource_file}'")
85
+ resources = Resources()
86
+ else:
87
+ logger.warning(f"Cannot open resources definition file '{resource_file}'")
88
+ sys.exit(-1)
89
+
90
+ controller = BatchController(image=image, command=command, resources=resources)
91
+ return create_service_definition(fn, controller, service_id, description)
92
+
93
+ def create_service_definition(
94
+ fn: Callable[..., Any],
95
+ controller: Any,
96
+ service_id: Optional[str] = None,
97
+ description: Optional[str] = None,
98
+ ) -> ServiceDefinition:
99
+ name = os.getenv("IVCAP_SERVICE_NAME", fn.__name__)
100
+ if service_id == None:
101
+ service_id = os.getenv("IVCAP_SERVICE_ID", SERVICE_ID_PLACEHOLDER)
102
+ if description == None:
103
+ description = fn.__doc__ or "no description available"
104
+ policy = os.getenv("IVCAP_POLICY_URN", DEF_POLICY)
105
+
106
+
107
+ sd_data = {
108
+ "$id": service_id,
109
+ "name": name,
110
+ "description": description,
111
+ "policy": policy,
112
+ "controller": controller,
113
+ }
114
+
115
+ sd = ServiceDefinition(**sd_data)
116
+ return sd
@@ -0,0 +1,63 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import os
7
+ from typing import Optional, Dict, Any
8
+ from http import HTTPStatus
9
+ import httpx
10
+ import json
11
+
12
+ def file_to_http_response(
13
+ file_path: str,
14
+ headers: Optional[Dict[str, str]] = None,
15
+ status_code: int = 200,
16
+ ) -> httpx.Response:
17
+ """
18
+ Reads data from a file and returns it as an httpx.Response object.
19
+
20
+ Args:
21
+ file_path: The path to the file to read.
22
+ headers: Optional dictionary of HTTP headers to include in the response.
23
+ status_code: The HTTP status code to return (e.g., 200, 404, 500).
24
+ status_message: Optional HTTP status message. If None, the default
25
+ message for the status_code is used.
26
+
27
+ Returns:
28
+ An httpx.Response object containing the file data and response information.
29
+ """
30
+ # Sanity checks
31
+ if not os.path.exists(file_path):
32
+ raise FileNotFoundError(f"File not found: {file_path}")
33
+ if not os.path.isfile(file_path):
34
+ raise ValueError(f"Not a file: {file_path}")
35
+ if status_code < 100 or status_code > 599:
36
+ raise ValueError(f"Invalid HTTP status code: {status_code}")
37
+
38
+ # Default headers
39
+ default_headers = {
40
+ "Content-Type": "application/octet-stream", # Default content type
41
+ "Content-Length": str(os.path.getsize(file_path)),
42
+ }
43
+
44
+ # Combine default and user-provided headers
45
+ combined_headers = default_headers.copy()
46
+ if headers:
47
+ combined_headers.update(headers) # Use update for simpler merging
48
+
49
+ # Read the file data
50
+ try:
51
+ with open(file_path, "rb") as file:
52
+ file_data = file.read()
53
+ except Exception as e:
54
+ raise IOError(f"Error reading file: {file_path}: {e}")
55
+
56
+ # Create an httpx.Response object
57
+ response = httpx.Response(
58
+ status_code=status_code,
59
+ content=file_data,
60
+ headers=combined_headers,
61
+ )
62
+
63
+ return response
@@ -0,0 +1,406 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import inspect
8
+ import os
9
+ from typing import Any, Callable, Optional, get_type_hints
10
+ from typing import (
11
+ Any,
12
+ Callable,
13
+ Optional,
14
+ Tuple,
15
+ Union,
16
+ )
17
+ from pydantic import BaseModel, Field
18
+
19
+ from .types import ExecutionContext
20
+ from .utils import _get_input_type
21
+
22
+ TOOL_SCHEMA = "urn:sd-core:schema.ai-tool.1"
23
+
24
+ SERVICE_ID_PLACEHOLDER = "#SERVICE_ID#"
25
+
26
+ class ToolDefinition(BaseModel):
27
+ jschema: str = Field(default=TOOL_SCHEMA, alias="$schema")
28
+ id: str
29
+
30
+ model_config = {
31
+ "populate_by_name": True,
32
+ }
33
+ name: str
34
+ service_id: str = Field(alias="service-id")
35
+ description: str
36
+ fn_signature: str
37
+ fn_schema: dict
38
+
39
+ def print_tool_definition(
40
+ fn: Callable[..., Any],
41
+ *,
42
+ service_id: Optional[str] = None,
43
+ description: Optional[str] = None,
44
+ id_prefix: str = "urn:sd-core:ai-tool",
45
+ ):
46
+ td = create_tool_definition(fn, service_id=service_id, description=description, id_prefix=id_prefix)
47
+ print(td.model_dump_json(indent=2, by_alias=True))
48
+
49
+ def create_tool_definition(
50
+ fn: Callable[..., Any], *,
51
+ service_id: Optional[str] = None,
52
+ description: Optional[str] = None,
53
+ id_prefix: str = "urn:sd-core:ai-tool",
54
+ ) -> ToolDefinition:
55
+ name = os.getenv("IVCAP_SERVICE_NAME", fn.__name__)
56
+ signature, description = _generate_function_description(fn, name, exclude_types=[ExecutionContext])
57
+
58
+ if service_id == None:
59
+ service_id = os.getenv("IVCAP_SERVICE_ID", SERVICE_ID_PLACEHOLDER)
60
+
61
+ #fn_sig = inspect.signature(fn)
62
+ input_type, _ = _get_input_type(fn)
63
+
64
+ td = ToolDefinition(
65
+ id=f"{id_prefix}.{name}",
66
+ name=name,
67
+ service_id=service_id,
68
+ description=description,
69
+ fn_signature=signature,
70
+ fn_schema=input_type.model_json_schema(),
71
+ )
72
+ return td
73
+
74
+ def _generate_function_description(func: Callable, name: Optional[str] = None, exclude_types: Optional[list] = None) -> Tuple[str, str]:
75
+ """Generates a function description with argument descriptions and return values from a function with a Pydantic argument.
76
+
77
+ The function description assumes that all fields in the Pydantic argument are provided as "normal" function arguments.
78
+ This is useful for generating documentation for functions that use Pydantic models, as it makes the function signature
79
+ more readable by showing the individual fields of the Pydantic model as separate arguments.
80
+
81
+ Args:
82
+ func: The function to generate a description for. The function should have at least one Pydantic model as an argument.
83
+ name: Optional name to override the function's actual name.
84
+ exclude_types: Optional list of types to exclude from the function description. Any subtypes of these types will also be excluded.
85
+
86
+ Returns:
87
+ A tuple containing:
88
+ - The function signature as a string.
89
+ - The rest of the function description, including the docstring, argument descriptions, and return type.
90
+ """
91
+ if exclude_types is None:
92
+ exclude_types = []
93
+
94
+ def is_excluded_type(param_type):
95
+ """Check if a type should be excluded based on the exclude_types list."""
96
+ if not exclude_types:
97
+ return False
98
+
99
+ # Check if the type is in the exclude_types list
100
+ if param_type in exclude_types:
101
+ return True
102
+
103
+ # Check if the type is a subclass of any type in the exclude_types list
104
+ for exclude_type in exclude_types:
105
+ # Only check issubclass if both types are classes
106
+ if (isinstance(param_type, type) and isinstance(exclude_type, type) and
107
+ issubclass(param_type, exclude_type)):
108
+ return True
109
+
110
+ return False
111
+ signature = inspect.signature(func)
112
+ type_hints = get_type_hints(func)
113
+
114
+ # Get the Pydantic model class
115
+ pydantic_model_class = None
116
+ pydantic_param_name = None
117
+ for param_name, param in signature.parameters.items():
118
+ if hasattr(param.annotation, '__mro__') and BaseModel in param.annotation.__mro__:
119
+ pydantic_model_class = param.annotation
120
+ pydantic_param_name = param_name
121
+ break
122
+
123
+ if not pydantic_model_class:
124
+ error_msg = "Function does not have a Pydantic model as an argument."
125
+ return ("", error_msg)
126
+
127
+ model_schema = pydantic_model_class.schema()
128
+
129
+ # Start with the function's docstring
130
+ description = func.__doc__ or ""
131
+
132
+ # Create a new function signature with the Pydantic fields as arguments
133
+ function_name = name if name is not None else func.__name__
134
+ new_signature = f"{function_name}("
135
+
136
+ # Collect all parameters
137
+ all_params = []
138
+
139
+ # Add non-Pydantic parameters first
140
+ for param_name, param in signature.parameters.items():
141
+ if param_name != pydantic_param_name:
142
+ param_type = type_hints.get(param_name, Any)
143
+ # Skip if the type is in the exclude_types list or is a subtype of an excluded type
144
+ if is_excluded_type(param_type) or param_name in exclude_types:
145
+ continue
146
+ param_type_name = param_type.__name__ if hasattr(param_type, '__name__') else str(param_type)
147
+ all_params.append(f"{param_name}: {param_type_name}")
148
+
149
+ # Add Pydantic fields
150
+ for field_name, field_info in model_schema['properties'].items():
151
+ # Skip if the field name is "$schema"
152
+ if field_name == "$schema":
153
+ continue
154
+ # Skip if the field name is in the exclude_types list
155
+ if field_name in exclude_types:
156
+ continue
157
+ # Get the correct type from the Pydantic model
158
+ field_type = field_info.get('type', None)
159
+
160
+ # If type is not available in the schema, try to get it from the model's __annotations__
161
+ if field_type is None and hasattr(pydantic_model_class, '__annotations__'):
162
+ annotation = pydantic_model_class.__annotations__.get(field_name)
163
+ if annotation is not None:
164
+ if hasattr(annotation, '__origin__') and annotation.__origin__ is Union:
165
+ # Handle Optional types (Union[type, NoneType])
166
+ args = annotation.__args__
167
+ if len(args) == 2 and args[1] is type(None):
168
+ field_type = args[0].__name__
169
+ is_optional = True
170
+ else:
171
+ field_type = str(annotation).replace('typing.', '')
172
+ else:
173
+ field_type = annotation.__name__ if hasattr(annotation, '__name__') else str(annotation)
174
+ else:
175
+ field_type = 'Any'
176
+ else:
177
+ # Handle optional fields
178
+ is_optional = False
179
+ if 'default' in field_info or field_name not in model_schema.get('required', []):
180
+ is_optional = True
181
+
182
+ # Map JSON schema types to Python types
183
+ if field_type == 'string':
184
+ field_type = 'str'
185
+ elif field_type == 'integer':
186
+ field_type = 'int'
187
+ elif field_type == 'number':
188
+ field_type = 'float'
189
+ elif field_type == 'boolean':
190
+ field_type = 'bool'
191
+ elif field_type == 'array':
192
+ # Handle array types with their item types
193
+ items = field_info.get('items', {})
194
+ item_type = items.get('type', 'Any')
195
+ # Map item type to Python types
196
+ if item_type == 'string':
197
+ item_type = 'str'
198
+ elif item_type == 'integer':
199
+ item_type = 'int'
200
+ elif item_type == 'number':
201
+ item_type = 'float'
202
+ elif item_type == 'boolean':
203
+ item_type = 'bool'
204
+ field_type = f"array[{item_type}]"
205
+ elif field_type is None:
206
+ field_type = 'Any'
207
+
208
+ # Add Optional[] for optional fields
209
+ if is_optional and not field_type.startswith('Optional['):
210
+ field_type = f"Optional[{field_type}]"
211
+
212
+ all_params.append(f"{field_name}: {field_type}")
213
+
214
+ # Join all parameters with commas
215
+ new_signature += ", ".join(all_params)
216
+ new_signature += ")"
217
+
218
+ # Add return type
219
+ return_type = type_hints.get('return', 'unknown')
220
+ if hasattr(return_type, '__name__'):
221
+ return_type_str = return_type.__name__
222
+ else:
223
+ return_type_str = str(return_type)
224
+ new_signature += f" -> {return_type_str}"
225
+
226
+ description = f"{new_signature}\n\n{description}\n\nArguments:\n"
227
+
228
+ # Add non-Pydantic parameters first with their descriptions
229
+ for param_name, param in signature.parameters.items():
230
+ if param_name == "$schema":
231
+ continue
232
+ if param_name != pydantic_param_name:
233
+ param_type = type_hints.get(param_name, Any)
234
+ # Skip if the type is in the exclude_types list or is a subtype of an excluded type
235
+ if is_excluded_type(param_type) or param_name in exclude_types:
236
+ continue
237
+ param_type_name = param_type.__name__ if hasattr(param_type, '__name__') else str(param_type)
238
+ description += f" {param_name}: {param_type_name}\n"
239
+
240
+ # Add Pydantic fields with their descriptions
241
+ for field_name, field_info in model_schema['properties'].items():
242
+ if field_name == "$schema":
243
+ continue
244
+ # Skip if the field name is in the exclude_types list
245
+ if field_name in exclude_types:
246
+ continue
247
+ # Get the correct type from the Pydantic model
248
+ field_type = field_info.get('type', None)
249
+
250
+ # If type is not available in the schema, try to get it from the model's __annotations__
251
+ if field_type is None and hasattr(pydantic_model_class, '__annotations__'):
252
+ annotation = pydantic_model_class.__annotations__.get(field_name)
253
+ if annotation is not None:
254
+ if hasattr(annotation, '__origin__') and annotation.__origin__ is Union:
255
+ # Handle Optional types (Union[type, NoneType])
256
+ args = annotation.__args__
257
+ if len(args) == 2 and args[1] is type(None):
258
+ field_type = args[0].__name__
259
+ is_optional = True
260
+ else:
261
+ field_type = str(annotation).replace('typing.', '')
262
+ else:
263
+ field_type = annotation.__name__ if hasattr(annotation, '__name__') else str(annotation)
264
+ else:
265
+ field_type = 'Any'
266
+ else:
267
+ # Handle optional fields
268
+ is_optional = False
269
+ if 'default' in field_info or field_name not in model_schema.get('required', []):
270
+ is_optional = True
271
+
272
+ # Map JSON schema types to Python types
273
+ if field_type == 'string':
274
+ field_type = 'str'
275
+ elif field_type == 'integer':
276
+ field_type = 'int'
277
+ elif field_type == 'number':
278
+ field_type = 'float'
279
+ elif field_type == 'boolean':
280
+ field_type = 'bool'
281
+ elif field_type == 'array':
282
+ # Handle array types with their item types
283
+ items = field_info.get('items', {})
284
+ item_type = items.get('type', 'Any')
285
+ # Map item type to Python types
286
+ if item_type == 'string':
287
+ item_type = 'str'
288
+ elif item_type == 'integer':
289
+ item_type = 'int'
290
+ elif item_type == 'number':
291
+ item_type = 'float'
292
+ elif item_type == 'boolean':
293
+ item_type = 'bool'
294
+ field_type = f"array[{item_type}]"
295
+ elif field_type is None:
296
+ field_type = 'Any'
297
+
298
+ # Add Optional[] for optional fields
299
+ if is_optional and not field_type.startswith('Optional['):
300
+ field_type = f"Optional[{field_type}]"
301
+
302
+ description += f" {field_name}: {field_type}"
303
+ if 'description' in field_info:
304
+ description += f" - {field_info['description']}"
305
+ # Add default value information if available
306
+ if 'default' in field_info:
307
+ default_value = field_info['default']
308
+ description += f" (Defaults to {default_value})"
309
+ description += "\n"
310
+
311
+ # Add a note about the return type
312
+ description += f"\nReturns: {return_type_str}"
313
+
314
+ # Split the description into signature and the rest
315
+ signature_part = new_signature
316
+ rest_part = description.replace(new_signature, "", 1).strip()
317
+
318
+ return (signature_part, rest_part)
319
+
320
+ # def create_tool_definition(
321
+ # fn: Callable[..., Any],
322
+ # name: Optional[str] = None,
323
+ # description: Optional[str] = None
324
+ # ) -> ToolDefinition:
325
+ # fn_to_parse = fn
326
+ # name = name or fn_to_parse.__name__
327
+ # description = description or fn_to_parse.__doc__
328
+
329
+ # fn_sig = inspect.signature(fn_to_parse)
330
+
331
+ # # Handle FieldInfo defaults
332
+ # def r(param):
333
+ # if isinstance(param.default, FieldInfo):
334
+ # return param.replace(default=inspect.Parameter.empty)
335
+ # else:
336
+ # return param
337
+ # fn_sig = fn_sig.replace(parameters=[r(param) for param in fn_sig.parameters.values()])
338
+
339
+ # fn_signature = f"{name}{fn_sig}"
340
+ # fn_schema = create_schema_from_function(name, fn_to_parse)
341
+ # return ToolDefinition(
342
+ # id=f"urn:sd-core:ai-tool:{name}",
343
+ # name=name,
344
+ # description=description,
345
+ # fn_signature=fn_signature,
346
+ # fn_schema=fn_schema.model_json_schema(by_alias=False))
347
+
348
+ # def create_schema_from_function(
349
+ # name: str,
350
+ # func: Union[Callable[..., Any], Callable[..., Awaitable[Any]]],
351
+ # additional_fields: Optional[
352
+ # List[Union[Tuple[str, Type, Any], Tuple[str, Type]]]
353
+ # ] = None,
354
+ # ignore_fields: Optional[List[str]] = None,
355
+ # ) -> Type[BaseModel]:
356
+ # """Create schema from function."""
357
+ # fields = {}
358
+ # ignore_fields = ignore_fields or []
359
+ # params = inspect.signature(func).parameters
360
+ # for param_name in params:
361
+ # if param_name in ignore_fields:
362
+ # continue
363
+
364
+ # param_type = params[param_name].annotation
365
+ # param_default = params[param_name].default
366
+ # description = None
367
+
368
+ # if get_origin(param_type) is typing.Annotated:
369
+ # args = get_args(param_type)
370
+ # param_type = args[0]
371
+ # if isinstance(args[1], str):
372
+ # description = args[1]
373
+
374
+ # if param_type is params[param_name].empty:
375
+ # param_type = Any
376
+
377
+ # if param_default is params[param_name].empty:
378
+ # # Required field
379
+ # fields[param_name] = (param_type, FieldInfo(description=description))
380
+ # elif isinstance(param_default, FieldInfo):
381
+ # # Field with pydantic.Field as default value
382
+ # fields[param_name] = (param_type, param_default)
383
+ # else:
384
+ # fields[param_name] = (
385
+ # param_type,
386
+ # FieldInfo(default=param_default, description=description),
387
+ # )
388
+
389
+ # additional_fields = additional_fields or []
390
+ # for field_info in additional_fields:
391
+ # if len(field_info) == 3:
392
+ # field_info = cast(Tuple[str, Type, Any], field_info)
393
+ # field_name, field_type, field_default = field_info
394
+ # fields[field_name] = (field_type, FieldInfo(default=field_default))
395
+ # elif len(field_info) == 2:
396
+ # # Required field has no default value
397
+ # field_info = cast(Tuple[str, Type], field_info)
398
+ # field_name, field_type = field_info
399
+ # fields[field_name] = (field_type, FieldInfo())
400
+ # else:
401
+ # raise ValueError(
402
+ # f"Invalid additional field info: {field_info}. "
403
+ # "Must be a tuple of length 2 or 3."
404
+ # )
405
+
406
+ # return create_model(name, **fields) # type: ignore
ivcap_service/types.py ADDED
@@ -0,0 +1,34 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import io
7
+ from typing import Any, Callable, Generic, List, Optional, TypeVar, Union, BinaryIO
8
+ from dataclasses import dataclass
9
+ from pydantic import Field, BaseModel
10
+
11
+ class ExecutionContext:
12
+ pass
13
+
14
+ @dataclass
15
+ class BinaryResult():
16
+ """If the result of the tool is a non json serialisable object, return an
17
+ instance of this class indicating the content-type and the actual
18
+ result either as a byte array or a file handle to a binary content (`open(..., "rb")`)"""
19
+ content_type: str = Field(description="Content type of result serialised")
20
+ content: Union[bytes, str, io.BufferedReader] = Field(description="Content to send, either as byte array or file handle")
21
+
22
+ @dataclass
23
+ class IvcapResult(BinaryResult):
24
+ isError: bool = False
25
+ raw: Any = None
26
+
27
+ class ExecutionError(BaseModel):
28
+ """
29
+ Pydantic model for execution errors.
30
+ """
31
+ jschema: str = Field("urn:ivcap:schema.ai-tool.error.1", alias="$schema")
32
+ error: str = Field(description="Error message")
33
+ type: str = Field(description="Error type")
34
+ traceback: Optional[str] = Field(None, description="traceback")
ivcap_service/utils.py ADDED
@@ -0,0 +1,83 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ import inspect
7
+ import json
8
+ import os
9
+ import re
10
+ from typing import Optional, Type, Callable, TypeVar, Any, get_type_hints, Union, Dict, Tuple
11
+
12
+ from pydantic import BaseModel, HttpUrl
13
+
14
+ def get_version():
15
+ return "???"
16
+
17
+ def _get_input_type(func: Callable) -> Tuple[Optional[Type[BaseModel]], Dict[str, Any]]:
18
+ """Gets the input type of a function.
19
+
20
+ Args:
21
+ func: The function to get the input type for.
22
+
23
+ Returns:
24
+ A tuple containing:
25
+ - The first function parameter which is a derived class of a pydantic BaseModel, or None if no such parameter exists.
26
+ - A dictionary of all additional parameters, where the key is the parameter name and the value is the type.
27
+ """
28
+ signature = inspect.signature(func)
29
+ type_hints = get_type_hints(func)
30
+
31
+ # Get the Pydantic model class
32
+ pydantic_model_class = None
33
+ pydantic_param_name = None
34
+ for param_name, param in signature.parameters.items():
35
+ if hasattr(param.annotation, '__mro__') and BaseModel in param.annotation.__mro__:
36
+ pydantic_model_class = param.annotation
37
+ pydantic_param_name = param_name
38
+ break
39
+
40
+ # Get all additional parameters
41
+ additional_params = {}
42
+ for param_name, param in signature.parameters.items():
43
+ if param_name != pydantic_param_name:
44
+ param_type = type_hints.get(param_name, Any)
45
+ additional_params[param_name] = param_type
46
+
47
+ return pydantic_model_class, additional_params
48
+
49
+ def _get_function_return_type(func):
50
+ """Extracts the return type from a function."""
51
+ type_hints = get_type_hints(func)
52
+ # param_types = {k: v for k, v in type_hints.items() if k != 'return'}
53
+ return_type = type_hints.get('return')
54
+ # return param_types, return_type
55
+ return return_type
56
+
57
+ def file_to_json(file_path: str) -> Dict[str, Any]:
58
+ """
59
+ Reads a file, attempts to parse it as JSON, and returns the content.
60
+
61
+ Args:
62
+ file_path: The path to the file to read.
63
+
64
+ Returns:
65
+ A dictionary containing the parsed JSON content.
66
+
67
+ Raises:
68
+ FileNotFoundError: If the file does not exist.
69
+ ValueError: If the file is not a valid JSON file.
70
+ IOError: If there is an error reading the file.
71
+ """
72
+ if not os.path.exists(file_path):
73
+ raise FileNotFoundError(f"File not found: {file_path}")
74
+
75
+ try:
76
+ with open(file_path, "r") as f:
77
+ return json.load(f)
78
+ except FileNotFoundError as e:
79
+ raise FileNotFoundError(f"Error finding file: {file_path}: {e}")
80
+ except json.JSONDecodeError as e:
81
+ raise ValueError(f"Error decoding JSON from file: {file_path}: {e}")
82
+ except Exception as e:
83
+ raise IOError(f"Error reading file: {file_path}: {e}")
@@ -0,0 +1,19 @@
1
+ #
2
+ # Copyright (c) 2023 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
+ __version__ = "???"
8
+ try: # Python > 3.8+
9
+ from importlib_metadata import version
10
+ __version__ = version("ivcap_service")
11
+ except ImportError:
12
+ try:
13
+ import pkg_resources
14
+ __version__ = pkg_resources.get_distribution('ivcap_service').version
15
+ except Exception:
16
+ pass
17
+
18
+ def get_version():
19
+ return __version__
@@ -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,129 @@
1
+ Metadata-Version: 2.1
2
+ Name: ivcap_service
3
+ Version: 0.3.0
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.8
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: cachetools (>=5.5.2,<6.0.0)
15
+ Requires-Dist: opentelemetry-distro (>=0.51b0,<0.52)
16
+ Requires-Dist: opentelemetry-exporter-otlp (>=1.30.0,<2.0.0)
17
+ Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.51b0,<0.52)
18
+ Requires-Dist: opentelemetry-instrumentation-httpx (>=0.51b0,<0.52)
19
+ Requires-Dist: opentelemetry-instrumentation-requests (>=0.51b0,<0.52)
20
+ Requires-Dist: uuid6 (==2024.7.10)
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ivcap_ai_tool: A python library for building AI tools for the IVCAP platform
24
+
25
+ <a href="https://scan.coverity.com/projects/ivcap-works-ivcap-ai-tool-sdk-python">
26
+ <img alt="Coverity Scan Build Status"
27
+ src="https://img.shields.io/coverity/scan/31491.svg"/>
28
+ </a>
29
+
30
+ A python library containing various helper and middleware functions
31
+ to simplify developing AI tools to be deployed on IVCAP.
32
+
33
+ > **Note:** A template git repositiory 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
+ ## Content
37
+
38
+ * [Register a Tool Function](#register)
39
+ * [Start the Service](#start)
40
+ * [JSON-RPC Middleware](#json-rpc)
41
+ * [Try-Later Middleware](#try-later)
42
+
43
+ ### Register a Tool Function <a name="register"></a>
44
+
45
+ ```python
46
+ class Request(BaseModel):
47
+ jschema: str = Field("urn:sd:schema:some_tool.request.1", alias="$schema")
48
+ ...
49
+
50
+ class Result(BaseModel):
51
+ jschema: str = Field("urn:sd:schema:some_tool.1", alias="$schema")
52
+ ...
53
+
54
+ def some_tool(req: Request) -> Result:
55
+ """
56
+ Here should go a quite extensive description of what the tool can be
57
+ used for so that an agent can work out if this tool is useful in
58
+ a specific context.
59
+
60
+ DO NOT ADD PARAMTER AND RETURN DECRIPTIONS -
61
+ DESCRIBE THEM IN THE `Request` MODEL
62
+ """
63
+ ...
64
+
65
+ return Result(...)
66
+
67
+ add_tool_api_route(app, "/", some_tool, opts=ToolOptions(tags=["Great Tool"]))
68
+ ```
69
+
70
+ ### Start the Service <a name="start"></a>
71
+
72
+ ```python
73
+ app = FastAPI(
74
+ ..
75
+ )
76
+
77
+ if __name__ == "__main__":
78
+ start_tool_server(app, some_tool)
79
+ ```
80
+
81
+ ### JSON-RPC Middleware <a name="json-rpc"></a>
82
+
83
+ This middleware will convert any `POST /` with a payload
84
+ following the [JSON-RPC](https://www.jsonrpc.org/specification)
85
+ specification to an internal `POST /{method}` and will return
86
+ the result formatted according to the JSON-RPC spec.
87
+
88
+ ```python
89
+ from ivcap_fastapi import use_json_rpc_middleware
90
+
91
+ app = FastAPI(
92
+ ..
93
+ )
94
+
95
+ use_json_rpc_middleware(app)
96
+ ```
97
+
98
+ ### Try-Later Middleware <a name="try-later"></a>
99
+
100
+ This middleware is supporting the use case where the execution of a
101
+ requested service is taking longer than the caller is willing to wait.
102
+ A typical use case is where the service is itself outsourcing the execution
103
+ to some other long-running service but may immediately receive a reference
104
+ to the eventual result.
105
+
106
+ In this case, raising a `TryLaterException` will return with a 204
107
+ status code and additional information on how to later check back for the
108
+ result.
109
+
110
+ ```python
111
+ from ivcap_fastapi import TryLaterException, use_try_later_middleware
112
+ use_try_later_middleware(app)
113
+
114
+ @app.post("/big_job")
115
+ def big_job(req: Request) -> Response:
116
+ jobID, expected_exec_time = scheduling_big_job(req)
117
+ raise TryLaterException(f"/big_job/jobs/{jobID}", expected_exec_time)
118
+
119
+ @app.get("/big_job/jobs/{jobID}")
120
+ def get_job(jobID: str) -> Response:
121
+ resp = find_result_for(job_id)
122
+ return resp
123
+ ```
124
+
125
+ Specifically, raising `TryLaterException(location, delay)` will
126
+ return an HTTP response with a 204 status code with the additional
127
+ HTTP headers `Location` and `Retry-Later` set to `location` and
128
+ `delay` respectively.
129
+
@@ -0,0 +1,17 @@
1
+ ivcap_service/__init__.py,sha256=X1qbtEKDWGWWmBemuYNRotRgjgq-hxsUSpQ4KXGxtJs,579
2
+ ivcap_service/ivcap.py,sha256=ci9v_-ye_Y14Wd-tQ62HH21I8O0QUQtUZAIRmFy6QLM,5036
3
+ ivcap_service/logger.py,sha256=vU_xacRTJHJLpbGBzc7w5uw2HTx78SUy3AD0f5vtuJw,804
4
+ ivcap_service/logging.json,sha256=Gsh4Xb8Cznh7h7fqaZt6riJ_jOSVYVKeY6MaUXnu91s,972
5
+ ivcap_service/py.typed,sha256=8ZJUsxZiuOy1oJeVhsTWQhTG_6pTVHVXk5hJL79ebTk,25
6
+ ivcap_service/service.py,sha256=OMW08IPTjxNkXTLla6ouab8bL20OJ_qcXnW6ocIMqGY,6430
7
+ ivcap_service/service_definition.py,sha256=Kddg7ftHEwwz_0_cdGLQNiyVcIYTJESfghlslMmHR_8,4041
8
+ ivcap_service/testing.py,sha256=L90a-T8XKVSG7USaeG2YC-AzALYrHXXMlay5DhwX-uM,2134
9
+ ivcap_service/tool_definition.py,sha256=i8XfL0JsT6RcH1NIWu66ToFVzkRQuvZYP6n_kVBqblw,16257
10
+ ivcap_service/types.py,sha256=i0scqE_jNyFy3mYS0UcfhAHIF5EsO7d_ljdcACA15Fw,1375
11
+ ivcap_service/utils.py,sha256=2wNpEg9YNZlhBRwigvENhPv4v0eg_41_us1BHLQG5Oo,3008
12
+ ivcap_service/version.py,sha256=3c3XnZLqPZ7_OmYwaMXn5Zta2IbDrJurPkMTKnpyltg,608
13
+ ivcap_service-0.3.0.dist-info/AUTHORS.md,sha256=s9xR4_HAHQgbNlj505LViebt5AtACQmhPf92aJvNYgg,88
14
+ ivcap_service-0.3.0.dist-info/LICENSE,sha256=dsQrDPPwW7iJs9pxahgJKDW8RNPf5FyXG70MFUlxcuk,1587
15
+ ivcap_service-0.3.0.dist-info/METADATA,sha256=1YwWjgg3sC8gqGhNJHzDzF4mzMR_Kubeirai3DTggvw,4148
16
+ ivcap_service-0.3.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
17
+ ivcap_service-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any