ivcap_fastapi 0.2.0__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- 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)
|