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.
- ivcap_service/__init__.py +12 -0
- ivcap_service/ivcap.py +145 -0
- ivcap_service/logger.py +27 -0
- ivcap_service/logging.json +46 -0
- ivcap_service/py.typed +1 -0
- ivcap_service/service.py +166 -0
- ivcap_service/service_definition.py +116 -0
- ivcap_service/testing.py +63 -0
- ivcap_service/tool_definition.py +406 -0
- ivcap_service/types.py +34 -0
- ivcap_service/utils.py +83 -0
- ivcap_service/version.py +19 -0
- ivcap_service-0.3.0.dist-info/AUTHORS.md +5 -0
- ivcap_service-0.3.0.dist-info/LICENSE +29 -0
- ivcap_service-0.3.0.dist-info/METADATA +129 -0
- ivcap_service-0.3.0.dist-info/RECORD +17 -0
- ivcap_service-0.3.0.dist-info/WHEEL +4 -0
|
@@ -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)
|
ivcap_service/logger.py
ADDED
|
@@ -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
|
ivcap_service/service.py
ADDED
|
@@ -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
|
ivcap_service/testing.py
ADDED
|
@@ -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}")
|
ivcap_service/version.py
ADDED
|
@@ -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,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,,
|