ivcap_fastapi 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ivcap_fastapi-0.2.0/AUTHORS.md +5 -0
- ivcap_fastapi-0.2.0/LICENSE +29 -0
- ivcap_fastapi-0.2.0/PKG-INFO +74 -0
- ivcap_fastapi-0.2.0/README.md +53 -0
- ivcap_fastapi-0.2.0/pyproject.toml +39 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/__init__.py +20 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/json_rpc.py +120 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/logger.py +26 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/logging.json +46 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/py.typed +1 -0
- ivcap_fastapi-0.2.0/src/ivcap_fastapi/try_later.py +29 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
BSD 3-Clause License
|
2
|
+
|
3
|
+
Copyright (c) 2023, Commonwealth Scientific and Industrial Research Organisation (CSIRO) ABN 41 687 119 230
|
4
|
+
All rights reserved.
|
5
|
+
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
8
|
+
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
10
|
+
list of conditions and the following disclaimer.
|
11
|
+
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
14
|
+
and/or other materials provided with the distribution.
|
15
|
+
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
17
|
+
contributors may be used to endorse or promote products derived from
|
18
|
+
this software without specific prior written permission.
|
19
|
+
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@@ -0,0 +1,74 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: ivcap_fastapi
|
3
|
+
Version: 0.2.0
|
4
|
+
Summary: Helper functions for building FastAPI based IVCAP services
|
5
|
+
Author: Max Ott
|
6
|
+
Author-email: max.ott@csiro.au
|
7
|
+
Classifier: Programming Language :: Python :: 2
|
8
|
+
Classifier: Programming Language :: Python :: 2.7
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
10
|
+
Classifier: Programming Language :: Python :: 3.4
|
11
|
+
Classifier: Programming Language :: Python :: 3.5
|
12
|
+
Classifier: Programming Language :: Python :: 3.6
|
13
|
+
Classifier: Programming Language :: Python :: 3.7
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
19
|
+
Description-Content-Type: text/markdown
|
20
|
+
|
21
|
+
# ivcap_fastapi: Python helpers for building FastAPI based IVCAP services
|
22
|
+
|
23
|
+
A python library containing various helper and middleware functions
|
24
|
+
to support converting FastAPI based tools into IVCAP services.
|
25
|
+
|
26
|
+
## Content
|
27
|
+
|
28
|
+
* [Try-Later Middleware](#try-later)
|
29
|
+
* [JSON-RPC Middleware](#json-rpc)
|
30
|
+
|
31
|
+
### Try-Later Middleware <a name="try-later"></a>
|
32
|
+
|
33
|
+
This middleware is supporting the use case where the execution of a
|
34
|
+
requested service is taking longer than the caller is willing to wait.
|
35
|
+
A typical use case is where the service is itself outsourcing the execution
|
36
|
+
to some other long-running service but may immediately receive a reference
|
37
|
+
to the eventual result.
|
38
|
+
|
39
|
+
In this case, raising a `TryLaterException` will return with a 204
|
40
|
+
status code and additional information on how to later check back for the
|
41
|
+
result.
|
42
|
+
|
43
|
+
```python
|
44
|
+
from ivcap_fastapi import TryLaterException, use_try_later_middleware
|
45
|
+
use_try_later_middleware(app)
|
46
|
+
|
47
|
+
@app.post("/big_job")
|
48
|
+
def big_job(req: Request) -> Response:
|
49
|
+
jobID, expected_exec_time = scheduling_big_job(req)
|
50
|
+
raise TryLaterException(f"/jobs/{jobID}", expected_exec_time)
|
51
|
+
|
52
|
+
@app.get("/jobs/{jobID}")
|
53
|
+
def get_job(jobID: str) -> Response:
|
54
|
+
resp = find_result_for(job_id)
|
55
|
+
return resp
|
56
|
+
```
|
57
|
+
|
58
|
+
Specifically, raising `TryLaterException(location, delay)` will
|
59
|
+
return an HTTP response with a 204 status code with the additional
|
60
|
+
HTTP headers `Location` and `Retry-Later` set to `location` and
|
61
|
+
`delay` respectively.
|
62
|
+
|
63
|
+
### JSON-RPC Middleware <a name="json-rpc"></a>
|
64
|
+
|
65
|
+
This middleware will convert any `POST /` with a payload
|
66
|
+
following the [JSON-RPC](https://www.jsonrpc.org/specification)
|
67
|
+
specification to an internal `POST /{method}` and will return
|
68
|
+
the result formatted according to the JSON-RPC spec.
|
69
|
+
|
70
|
+
```python
|
71
|
+
from ivcap_fastapi import use_json_rpc_middleware
|
72
|
+
use_json_rpc_middleware(app)
|
73
|
+
```
|
74
|
+
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# ivcap_fastapi: Python helpers for building FastAPI based IVCAP services
|
2
|
+
|
3
|
+
A python library containing various helper and middleware functions
|
4
|
+
to support converting FastAPI based tools into IVCAP services.
|
5
|
+
|
6
|
+
## Content
|
7
|
+
|
8
|
+
* [Try-Later Middleware](#try-later)
|
9
|
+
* [JSON-RPC Middleware](#json-rpc)
|
10
|
+
|
11
|
+
### Try-Later Middleware <a name="try-later"></a>
|
12
|
+
|
13
|
+
This middleware is supporting the use case where the execution of a
|
14
|
+
requested service is taking longer than the caller is willing to wait.
|
15
|
+
A typical use case is where the service is itself outsourcing the execution
|
16
|
+
to some other long-running service but may immediately receive a reference
|
17
|
+
to the eventual result.
|
18
|
+
|
19
|
+
In this case, raising a `TryLaterException` will return with a 204
|
20
|
+
status code and additional information on how to later check back for the
|
21
|
+
result.
|
22
|
+
|
23
|
+
```python
|
24
|
+
from ivcap_fastapi import TryLaterException, use_try_later_middleware
|
25
|
+
use_try_later_middleware(app)
|
26
|
+
|
27
|
+
@app.post("/big_job")
|
28
|
+
def big_job(req: Request) -> Response:
|
29
|
+
jobID, expected_exec_time = scheduling_big_job(req)
|
30
|
+
raise TryLaterException(f"/jobs/{jobID}", expected_exec_time)
|
31
|
+
|
32
|
+
@app.get("/jobs/{jobID}")
|
33
|
+
def get_job(jobID: str) -> Response:
|
34
|
+
resp = find_result_for(job_id)
|
35
|
+
return resp
|
36
|
+
```
|
37
|
+
|
38
|
+
Specifically, raising `TryLaterException(location, delay)` will
|
39
|
+
return an HTTP response with a 204 status code with the additional
|
40
|
+
HTTP headers `Location` and `Retry-Later` set to `location` and
|
41
|
+
`delay` respectively.
|
42
|
+
|
43
|
+
### JSON-RPC Middleware <a name="json-rpc"></a>
|
44
|
+
|
45
|
+
This middleware will convert any `POST /` with a payload
|
46
|
+
following the [JSON-RPC](https://www.jsonrpc.org/specification)
|
47
|
+
specification to an internal `POST /{method}` and will return
|
48
|
+
the result formatted according to the JSON-RPC spec.
|
49
|
+
|
50
|
+
```python
|
51
|
+
from ivcap_fastapi import use_json_rpc_middleware
|
52
|
+
use_json_rpc_middleware(app)
|
53
|
+
```
|
@@ -0,0 +1,39 @@
|
|
1
|
+
[tool.poetry]
|
2
|
+
name = "ivcap_fastapi"
|
3
|
+
version = "0.2.0"
|
4
|
+
description = "Helper functions for building FastAPI based IVCAP services"
|
5
|
+
|
6
|
+
authors = ["Max Ott <max.ott@csiro.au>"]
|
7
|
+
|
8
|
+
readme = "README.md"
|
9
|
+
|
10
|
+
include = ["src/ivcap_fastapi/py.typed"]
|
11
|
+
|
12
|
+
[tool.poetry.dependencies]
|
13
|
+
|
14
|
+
[tool.poetry.dev-dependencies]
|
15
|
+
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
17
|
+
fastapi = { version = "^0.111.1", extras = ["standard"] }
|
18
|
+
pytest = "^7.1.3"
|
19
|
+
pytest-cov = "^4.1.0"
|
20
|
+
Sphinx = "^5.2.3"
|
21
|
+
myst-nb = "^0.17.1"
|
22
|
+
autoapi = "^2.0.1"
|
23
|
+
sphinx-autoapi = "^2.0.0"
|
24
|
+
sphinx-rtd-theme = "^1.0.0"
|
25
|
+
licenseheaders = "^0.8.8"
|
26
|
+
|
27
|
+
[tool.semantic_release]
|
28
|
+
version_variable = "pyproject.toml:version" # version location
|
29
|
+
branch = "main" # branch to make releases of
|
30
|
+
build_command = "poetry build" # build dists
|
31
|
+
dist_path = "dist/" # where to put dists
|
32
|
+
upload_to_release = true # auto-create GitHub release
|
33
|
+
upload_to_pypi = false # don't auto-upload to PyPI
|
34
|
+
remove_dist = false # don't remove dists
|
35
|
+
patch_without_tag = true # patch release by default
|
36
|
+
|
37
|
+
[build-system]
|
38
|
+
requires = ["poetry-core>=1.0.0"]
|
39
|
+
build-backend = "poetry.core.masonry.api"
|
@@ -0,0 +1,20 @@
|
|
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 client library for accessing IVCAP """
|
7
|
+
|
8
|
+
# read version from installed package
|
9
|
+
try: # Python < 3.10 (backport)
|
10
|
+
from importlib_metadata import version
|
11
|
+
except ImportError:
|
12
|
+
from importlib.metadata import version
|
13
|
+
try:
|
14
|
+
__version__ = version("ivcap_client")
|
15
|
+
except Exception:
|
16
|
+
__version__ = "unknown" # should only happen when running the local examples
|
17
|
+
|
18
|
+
from .json_rpc import use_json_rpc_middleware
|
19
|
+
from .try_later import TryLaterException, use_try_later_middleware
|
20
|
+
from .logger import getLogger, service_log_config, logging_init
|
@@ -0,0 +1,120 @@
|
|
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
|
+
|
8
|
+
from starlette.datastructures import Headers
|
9
|
+
from starlette.responses import Response
|
10
|
+
from starlette.requests import ClientDisconnect
|
11
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
12
|
+
|
13
|
+
class JsonRPCMiddleware:
|
14
|
+
def __init__(self, app: ASGIApp) -> None:
|
15
|
+
self.app = app
|
16
|
+
|
17
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
18
|
+
if scope["type"] != "http":
|
19
|
+
await self.app(scope, receive, send)
|
20
|
+
return
|
21
|
+
|
22
|
+
if scope["path"] == "/":
|
23
|
+
headers = Headers(scope=scope)
|
24
|
+
ct = headers.get("Content-Type", "")
|
25
|
+
if scope["method"] == "POST" and ct == "application/json":
|
26
|
+
await self.call_rpc(scope, receive, send)
|
27
|
+
return
|
28
|
+
|
29
|
+
await self.app(scope, receive, send)
|
30
|
+
|
31
|
+
async def call_rpc(self, scope: Scope, receive: Receive, send: Send) -> None:
|
32
|
+
message = await self.get_full_message(receive)
|
33
|
+
async def mod_receive():
|
34
|
+
nonlocal message
|
35
|
+
return message
|
36
|
+
|
37
|
+
s = message["body"].decode('utf-8')
|
38
|
+
pyld = json.loads(s)
|
39
|
+
if pyld.get("jsonrpc") != "2.0":
|
40
|
+
await self.app(scope, mod_receive, send)
|
41
|
+
return
|
42
|
+
|
43
|
+
method = pyld.get("method")
|
44
|
+
params = pyld.get("params")
|
45
|
+
id = pyld.get("id")
|
46
|
+
if method == None or params == None:
|
47
|
+
response = Response(status_code=400)
|
48
|
+
await response(scope, receive, send)
|
49
|
+
return
|
50
|
+
|
51
|
+
scope["path"] = "/" + method
|
52
|
+
ps = json.dumps(params)
|
53
|
+
message["body"] = ps.encode('utf-8')
|
54
|
+
|
55
|
+
|
56
|
+
startMsg = None
|
57
|
+
async def mod_send(message):
|
58
|
+
if message["type"] == "http.response.start":
|
59
|
+
nonlocal startMsg
|
60
|
+
startMsg = message # fix content-length later
|
61
|
+
|
62
|
+
elif message["type"] == "http.response.body":
|
63
|
+
body = self.create_reply(message["body"], id)
|
64
|
+
message["body"] = body
|
65
|
+
startMsg["headers"] = self.set_content_length(len(body), startMsg["headers"])
|
66
|
+
|
67
|
+
await send(message)
|
68
|
+
|
69
|
+
await self.app(scope, mod_receive, mod_send)
|
70
|
+
|
71
|
+
def create_reply(self, body, id):
|
72
|
+
#{"jsonrpc": "2.0", "result": 19, "id": 3}
|
73
|
+
s = body.decode('utf-8')
|
74
|
+
result = json.loads(s)
|
75
|
+
reply = {
|
76
|
+
"jsonrpc": "2.0",
|
77
|
+
"result": result,
|
78
|
+
"id": id,
|
79
|
+
}
|
80
|
+
return json.dumps(reply).encode('utf-8')
|
81
|
+
|
82
|
+
def set_content_length(self, length, headers):
|
83
|
+
def f(t):
|
84
|
+
k = t[0].decode('utf-8')
|
85
|
+
if k == "content-length":
|
86
|
+
return (t[0], f"{length}".encode('utf-8'))
|
87
|
+
return t
|
88
|
+
|
89
|
+
l = list(map(f, headers))
|
90
|
+
return l
|
91
|
+
|
92
|
+
async def get_full_message(self, receive: Receive):
|
93
|
+
message = None
|
94
|
+
chunks: list[bytes] = []
|
95
|
+
done = False
|
96
|
+
|
97
|
+
async def g():
|
98
|
+
nonlocal message, chunks, done
|
99
|
+
while not done:
|
100
|
+
m = await receive()
|
101
|
+
if m["type"] == "http.request":
|
102
|
+
if message == None:
|
103
|
+
message = m
|
104
|
+
body = m.get("body", b"")
|
105
|
+
chunks.append(body)
|
106
|
+
if not m.get("more_body", False):
|
107
|
+
message[body] = chunks
|
108
|
+
done = True
|
109
|
+
yield message
|
110
|
+
elif message["type"] == "http.disconnect":
|
111
|
+
raise ClientDisconnect()
|
112
|
+
yield None
|
113
|
+
|
114
|
+
async for _ in g():
|
115
|
+
pass
|
116
|
+
|
117
|
+
return message
|
118
|
+
|
119
|
+
def use_json_rpc_middleware(app):
|
120
|
+
app.add_middleware(JsonRPCMiddleware)
|
@@ -0,0 +1,26 @@
|
|
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
|
+
LOGGING_CONFIG = json.load(file)
|
26
|
+
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
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
# Marker file for PEP 561
|
@@ -0,0 +1,29 @@
|
|
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 fastapi import Response, Request
|
7
|
+
|
8
|
+
class TryLaterException(Exception):
|
9
|
+
"""An exception to raise if a computation will take longer and the result
|
10
|
+
should be collected later at a different method."""
|
11
|
+
def __init__(self, location, wait_time):
|
12
|
+
super().__init__(location)
|
13
|
+
self.location = location
|
14
|
+
self.wait_time = wait_time
|
15
|
+
|
16
|
+
def response(self):
|
17
|
+
r = Response(status_code=204) # No Content
|
18
|
+
r.headers["Location"] = self.location
|
19
|
+
r.headers["Retry-Later"] = f"{self.wait_time}"
|
20
|
+
return r
|
21
|
+
|
22
|
+
async def _try_later(request: Request, call_next) -> Response:
|
23
|
+
try:
|
24
|
+
return await call_next(request)
|
25
|
+
except TryLaterException as e:
|
26
|
+
return e.response()
|
27
|
+
|
28
|
+
def use_try_later_middleware(app):
|
29
|
+
app.middleware("http")(_try_later)
|