rsgiadapter 0.0.3__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.
- rsgiadapter-0.0.3/PKG-INFO +82 -0
- rsgiadapter-0.0.3/README.md +50 -0
- rsgiadapter-0.0.3/pyproject.toml +92 -0
- rsgiadapter-0.0.3/src/rsgiadapter/__init__.py +5 -0
- rsgiadapter-0.0.3/src/rsgiadapter/asgi.py +216 -0
- rsgiadapter-0.0.3/src/rsgiadapter/constant.py +22 -0
- rsgiadapter-0.0.3/src/rsgiadapter/protocol.py +141 -0
- rsgiadapter-0.0.3/src/rsgiadapter/py.typed +0 -0
- rsgiadapter-0.0.3/src/rsgiadapter/response.py +51 -0
- rsgiadapter-0.0.3/tests/__init__.py +0 -0
- rsgiadapter-0.0.3/tests/test_asgi.py +180 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: rsgiadapter
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: An adapter for asgi to rsgi
|
|
5
|
+
Keywords: asgi,rsgi,adapter,asgi adapter,rsgi adapter,asgi to rsgi,rsgi to asgi,asyncio
|
|
6
|
+
Author-Email: belingud <im.victor@qq.com>
|
|
7
|
+
License: BSD-3-Clause
|
|
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
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Development Status :: 3 - Alpha
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Intended Audience :: Information Technology
|
|
19
|
+
Classifier: Intended Audience :: System Administrators
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Topic :: Software Development
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Project-URL: Homepage, https://github.com/belingud/rsgiadapter
|
|
29
|
+
Requires-Python: <4.0,>=3.8
|
|
30
|
+
Requires-Dist: asgiref>=3.8.1
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# rsgiadapter
|
|
34
|
+
|
|
35
|
+
An Asgi to rsgi adapter.
|
|
36
|
+
|
|
37
|
+
RSGI Specification ref: https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md
|
|
38
|
+
|
|
39
|
+
`rsgiadapter` is an adapter for [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md) server run [ASGI](https://asgi.readthedocs.io) application like FastAPI and BlackSheep.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
|
|
43
|
+
`app.py`
|
|
44
|
+
```python
|
|
45
|
+
import granian
|
|
46
|
+
from granian.constants import Interfaces
|
|
47
|
+
from rsgiadapter import ASGIToRSGI
|
|
48
|
+
|
|
49
|
+
app = None # Define your asgi application here
|
|
50
|
+
|
|
51
|
+
rsgi_app = ASGIToRSGI(app)
|
|
52
|
+
|
|
53
|
+
serve = granian.Granian("app:rsgi_app", interface=Interfaces.RSGI)
|
|
54
|
+
serve.serve()
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Supported Feature:
|
|
59
|
+
|
|
60
|
+
- [x] HTTP Request Response
|
|
61
|
+
- [x] ASGI scope
|
|
62
|
+
- [x] ASGI receive
|
|
63
|
+
- [x] ASGI send
|
|
64
|
+
- [x] Extensions
|
|
65
|
+
- [x] http.response.pathsend
|
|
66
|
+
- [ ] websocket.http.response
|
|
67
|
+
- [ ] http.response.push
|
|
68
|
+
- [ ] http.response.zerocopysend
|
|
69
|
+
- [ ] http.response.early_hint
|
|
70
|
+
- [ ] http.response.trailers
|
|
71
|
+
- [ ] http.response.debug
|
|
72
|
+
- [ ] Lifespan
|
|
73
|
+
- [ ] lifespan.startup
|
|
74
|
+
- [ ] lifespan.startup.complete
|
|
75
|
+
- [ ] lifespan.startup.failed
|
|
76
|
+
- [ ] lifespan.shutdown
|
|
77
|
+
- [ ] lifespan.shutdown.complete
|
|
78
|
+
- [ ] lifespan.shutdown.failed
|
|
79
|
+
|
|
80
|
+
Ref:
|
|
81
|
+
|
|
82
|
+
- Granian: https://github.com/emmett-framework/granian
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# rsgiadapter
|
|
2
|
+
|
|
3
|
+
An Asgi to rsgi adapter.
|
|
4
|
+
|
|
5
|
+
RSGI Specification ref: https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md
|
|
6
|
+
|
|
7
|
+
`rsgiadapter` is an adapter for [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md) server run [ASGI](https://asgi.readthedocs.io) application like FastAPI and BlackSheep.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
|
|
11
|
+
`app.py`
|
|
12
|
+
```python
|
|
13
|
+
import granian
|
|
14
|
+
from granian.constants import Interfaces
|
|
15
|
+
from rsgiadapter import ASGIToRSGI
|
|
16
|
+
|
|
17
|
+
app = None # Define your asgi application here
|
|
18
|
+
|
|
19
|
+
rsgi_app = ASGIToRSGI(app)
|
|
20
|
+
|
|
21
|
+
serve = granian.Granian("app:rsgi_app", interface=Interfaces.RSGI)
|
|
22
|
+
serve.serve()
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Supported Feature:
|
|
27
|
+
|
|
28
|
+
- [x] HTTP Request Response
|
|
29
|
+
- [x] ASGI scope
|
|
30
|
+
- [x] ASGI receive
|
|
31
|
+
- [x] ASGI send
|
|
32
|
+
- [x] Extensions
|
|
33
|
+
- [x] http.response.pathsend
|
|
34
|
+
- [ ] websocket.http.response
|
|
35
|
+
- [ ] http.response.push
|
|
36
|
+
- [ ] http.response.zerocopysend
|
|
37
|
+
- [ ] http.response.early_hint
|
|
38
|
+
- [ ] http.response.trailers
|
|
39
|
+
- [ ] http.response.debug
|
|
40
|
+
- [ ] Lifespan
|
|
41
|
+
- [ ] lifespan.startup
|
|
42
|
+
- [ ] lifespan.startup.complete
|
|
43
|
+
- [ ] lifespan.startup.failed
|
|
44
|
+
- [ ] lifespan.shutdown
|
|
45
|
+
- [ ] lifespan.shutdown.complete
|
|
46
|
+
- [ ] lifespan.shutdown.failed
|
|
47
|
+
|
|
48
|
+
Ref:
|
|
49
|
+
|
|
50
|
+
- Granian: https://github.com/emmett-framework/granian
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rsgiadapter"
|
|
3
|
+
dynamic = []
|
|
4
|
+
description = "An adapter for asgi to rsgi"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "belingud", email = "im.victor@qq.com" },
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"asgiref>=3.8.1",
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.8,<4.0"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
keywords = [
|
|
14
|
+
"asgi",
|
|
15
|
+
"rsgi",
|
|
16
|
+
"adapter",
|
|
17
|
+
"asgi adapter",
|
|
18
|
+
"rsgi adapter",
|
|
19
|
+
"asgi to rsgi",
|
|
20
|
+
"rsgi to asgi",
|
|
21
|
+
"asyncio",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.8",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"License :: OSI Approved :: BSD License",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Development Status :: 3 - Alpha",
|
|
33
|
+
"Intended Audience :: Developers",
|
|
34
|
+
"Intended Audience :: Information Technology",
|
|
35
|
+
"Intended Audience :: System Administrators",
|
|
36
|
+
"Topic :: Internet",
|
|
37
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
38
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
39
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
40
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
41
|
+
"Topic :: Software Development :: Libraries",
|
|
42
|
+
"Topic :: Software Development",
|
|
43
|
+
"Typing :: Typed",
|
|
44
|
+
]
|
|
45
|
+
version = "0.0.3"
|
|
46
|
+
|
|
47
|
+
[project.license]
|
|
48
|
+
text = "BSD-3-Clause"
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
Homepage = "https://github.com/belingud/rsgiadapter"
|
|
52
|
+
|
|
53
|
+
[build-system]
|
|
54
|
+
requires = [
|
|
55
|
+
"pdm-backend",
|
|
56
|
+
]
|
|
57
|
+
build-backend = "pdm.backend"
|
|
58
|
+
|
|
59
|
+
[tool.pdm]
|
|
60
|
+
distribution = true
|
|
61
|
+
|
|
62
|
+
[tool.pdm.build]
|
|
63
|
+
excludes = [
|
|
64
|
+
"./**/.git",
|
|
65
|
+
]
|
|
66
|
+
package-dir = "src"
|
|
67
|
+
includes = [
|
|
68
|
+
"src/rsgiadapter",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.pdm.version]
|
|
72
|
+
source = "file"
|
|
73
|
+
path = "src/rsgiadapter/__init__.py"
|
|
74
|
+
|
|
75
|
+
[tool.pdm.dev-dependencies]
|
|
76
|
+
dev = [
|
|
77
|
+
"fastapi>=0.111.0",
|
|
78
|
+
"black>=24.4.2",
|
|
79
|
+
"isort>=5.13.2",
|
|
80
|
+
"granian>=1.4.1",
|
|
81
|
+
"uvicorn>=0.30.1",
|
|
82
|
+
"bump2version>=1.0.1",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
addopts = "-rsxX -l --tb=short --strict"
|
|
87
|
+
testpaths = [
|
|
88
|
+
"tests/",
|
|
89
|
+
]
|
|
90
|
+
python_files = [
|
|
91
|
+
"tests/*/test*.py",
|
|
92
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from os import PathLike, environ
|
|
4
|
+
from typing import AsyncGenerator, TYPE_CHECKING, Union
|
|
5
|
+
|
|
6
|
+
from asgiref.compatibility import guarantee_single_callable
|
|
7
|
+
|
|
8
|
+
from .constant import EventTypeEnum
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .protocol import (
|
|
12
|
+
ASGIScope,
|
|
13
|
+
RSGIHTTPProtocol,
|
|
14
|
+
RSGIHTTPScope,
|
|
15
|
+
RSGIWebsocketProtocol,
|
|
16
|
+
RSGIWebsocketScope,
|
|
17
|
+
)
|
|
18
|
+
from .response import BodyIter, Response
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("rsgiadapter")
|
|
21
|
+
if environ.get("RSGI_ADAPTER_DEBUG", "0") == "1":
|
|
22
|
+
logger.setLevel(logging.DEBUG)
|
|
23
|
+
|
|
24
|
+
DEFAULT_ASGI_VERSION = "3.0"
|
|
25
|
+
DEFAULT_SPEC_VERSION = "2.3"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ASGIToRSGI:
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
asgi_application,
|
|
33
|
+
asgi_version=DEFAULT_ASGI_VERSION,
|
|
34
|
+
spec_version=DEFAULT_SPEC_VERSION,
|
|
35
|
+
):
|
|
36
|
+
self.asgi_application = asgi_application
|
|
37
|
+
self.asgi_version = asgi_version
|
|
38
|
+
self.spec_version = spec_version
|
|
39
|
+
|
|
40
|
+
async def __call__(self, scope, protocol):
|
|
41
|
+
await ASGIToRSGIAdapter(
|
|
42
|
+
self.asgi_application, self.asgi_version, self.spec_version
|
|
43
|
+
)(scope, protocol)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ASGIToRSGIAdapter:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
asgi_app,
|
|
50
|
+
asgi_version=DEFAULT_ASGI_VERSION,
|
|
51
|
+
spec_version=DEFAULT_SPEC_VERSION,
|
|
52
|
+
):
|
|
53
|
+
self.asgi_app = asgi_app
|
|
54
|
+
self.asgi_version = asgi_version
|
|
55
|
+
self.spec_version = spec_version
|
|
56
|
+
self.event_status = EventTypeEnum.HTTP_REQUEST
|
|
57
|
+
|
|
58
|
+
self.state = {}
|
|
59
|
+
self.response_started = False
|
|
60
|
+
self.response_content_length = None
|
|
61
|
+
|
|
62
|
+
async def yield_body(
|
|
63
|
+
self, protocol: Union["RSGIHTTPProtocol", "RSGIWebsocketProtocol"]
|
|
64
|
+
) -> AsyncGenerator:
|
|
65
|
+
"""
|
|
66
|
+
Asynchronously yields messages from the given rsgi `protocol`.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
protocol (RSGIHTTPProtocol | RSGIWebsocketProtocol): RSGIHTTPProtocol or RSGIWebsocketProtocol instance.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
Any: The next message from the rsgi protocol.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
async for msg in protocol:
|
|
76
|
+
yield msg
|
|
77
|
+
|
|
78
|
+
def make_asgi_scope(
|
|
79
|
+
self, scope: Union["RSGIHTTPScope", "RSGIWebsocketScope"]
|
|
80
|
+
) -> "ASGIScope":
|
|
81
|
+
"""
|
|
82
|
+
Generates an ASGI scope based on RSGI scope, extracting relevant information,
|
|
83
|
+
such as versions, protocol, HTTP version, server details, client details, scheme,
|
|
84
|
+
method, path, query string, headers, and state.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
scope (Union["RSGIHTTPScope", "RSGIWebsocketScope"]): The scope object containing the necessary information.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
dict: A dictionary representing the ASGI scope with version details
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If the scope is None
|
|
94
|
+
"""
|
|
95
|
+
if not scope:
|
|
96
|
+
raise ValueError("Scope cannot be None")
|
|
97
|
+
|
|
98
|
+
asgi_version = self.asgi_version
|
|
99
|
+
spec_version = self.spec_version
|
|
100
|
+
proto = scope.proto
|
|
101
|
+
http_version = scope.http_version
|
|
102
|
+
server = scope.server.split(":") if scope.server else []
|
|
103
|
+
client = scope.client.split(":") if scope.client else []
|
|
104
|
+
scheme = scope.scheme
|
|
105
|
+
method = scope.method
|
|
106
|
+
path = scope.path
|
|
107
|
+
raw_path = path.encode("latin-1") if path else b""
|
|
108
|
+
query_string = (
|
|
109
|
+
scope.query_string.encode("latin-1") if scope.query_string else b""
|
|
110
|
+
)
|
|
111
|
+
headers = (
|
|
112
|
+
[
|
|
113
|
+
(k.encode("latin-1"), v.encode("latin-1"))
|
|
114
|
+
for k, v in scope.headers.items()
|
|
115
|
+
]
|
|
116
|
+
if scope.headers
|
|
117
|
+
else []
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"asgi": {"version": asgi_version, "spec_version": spec_version},
|
|
122
|
+
"extensions": {"http.response.pathsend": {}},
|
|
123
|
+
"type": proto,
|
|
124
|
+
"http_version": http_version,
|
|
125
|
+
"server": server,
|
|
126
|
+
"client": client,
|
|
127
|
+
"scheme": scheme,
|
|
128
|
+
"method": method,
|
|
129
|
+
"path": path,
|
|
130
|
+
"raw_path": raw_path,
|
|
131
|
+
"query_string": query_string,
|
|
132
|
+
"headers": headers,
|
|
133
|
+
"root_path": "",
|
|
134
|
+
"state": self.state,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async def __call__(
|
|
138
|
+
self,
|
|
139
|
+
scope: Union["RSGIHTTPScope", "RSGIWebsocketScope"],
|
|
140
|
+
protocol: Union["RSGIHTTPProtocol", "RSGIWebsocketProtocol"],
|
|
141
|
+
):
|
|
142
|
+
asgi_scope = self.make_asgi_scope(scope)
|
|
143
|
+
send_queue = asyncio.Queue()
|
|
144
|
+
asgi_body = self.yield_body(protocol)
|
|
145
|
+
|
|
146
|
+
async def receive():
|
|
147
|
+
try:
|
|
148
|
+
return {
|
|
149
|
+
"type": self.event_status,
|
|
150
|
+
"body": await anext(asgi_body),
|
|
151
|
+
"more_body": True,
|
|
152
|
+
}
|
|
153
|
+
except StopAsyncIteration:
|
|
154
|
+
return {
|
|
155
|
+
"type": self.event_status,
|
|
156
|
+
"body": b"",
|
|
157
|
+
"more_body": False,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async def send(msg):
|
|
161
|
+
if msg.get("more_body", None) is False:
|
|
162
|
+
self.event_status = EventTypeEnum.HTTP_DISCONNECT
|
|
163
|
+
await send_queue.put(msg)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
await guarantee_single_callable(self.asgi_app)(asgi_scope, receive, send)
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
logger.debug("ASGI app cancelled")
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.debug("ASGI app raised an exception", exc_info=True)
|
|
171
|
+
response = await self.get_response(send_queue)
|
|
172
|
+
|
|
173
|
+
await self.perform_response(protocol, response)
|
|
174
|
+
|
|
175
|
+
async def get_response(self, send_queue: asyncio.Queue):
|
|
176
|
+
response = Response(
|
|
177
|
+
status=None, headers=[], body=BodyIter(), path=None, stream=None, type=None
|
|
178
|
+
)
|
|
179
|
+
while not send_queue.empty():
|
|
180
|
+
message = await send_queue.get()
|
|
181
|
+
if message["type"] == EventTypeEnum.HTTP_RESP_START:
|
|
182
|
+
response.status = message["status"]
|
|
183
|
+
response.headers = [
|
|
184
|
+
(k.decode(), v.decode()) for k, v in message["headers"]
|
|
185
|
+
]
|
|
186
|
+
elif message["type"] == EventTypeEnum.HTTP_RESP_BODY:
|
|
187
|
+
response.body.append(message["body"])
|
|
188
|
+
elif message["type"] == EventTypeEnum.PATH_SEND:
|
|
189
|
+
response.path = message["path"]
|
|
190
|
+
response.type = EventTypeEnum.PATH_SEND
|
|
191
|
+
return response
|
|
192
|
+
|
|
193
|
+
async def perform_response(
|
|
194
|
+
self,
|
|
195
|
+
protocol: Union["RSGIHTTPProtocol", "RSGIWebsocketProtocol"],
|
|
196
|
+
response: Response,
|
|
197
|
+
) -> None:
|
|
198
|
+
if response.path is not None and isinstance(response.path, (str, PathLike)):
|
|
199
|
+
protocol.response_file(
|
|
200
|
+
status=response.status, headers=response.headers, file=response.path
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
if len(response.body) > 1:
|
|
204
|
+
trx = protocol.response_stream(
|
|
205
|
+
status=response.status,
|
|
206
|
+
headers=response.headers,
|
|
207
|
+
)
|
|
208
|
+
async for chunk in response.body:
|
|
209
|
+
await trx.send_bytes(chunk)
|
|
210
|
+
else:
|
|
211
|
+
protocol.response_bytes(
|
|
212
|
+
status=response.status,
|
|
213
|
+
headers=response.headers,
|
|
214
|
+
body=b"".join(response.get_body()),
|
|
215
|
+
)
|
|
216
|
+
response.clear_body()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EventTypeEnum(StrEnum):
|
|
5
|
+
"""
|
|
6
|
+
ASGI event types
|
|
7
|
+
"""
|
|
8
|
+
# http
|
|
9
|
+
HTTP_REQUEST = "http.request"
|
|
10
|
+
HTTP_DISCONNECT = "http.disconnect"
|
|
11
|
+
HTTP_RESP_START = "http.response.start"
|
|
12
|
+
HTTP_RESP_END = "http.response.end"
|
|
13
|
+
HTTP_RESP_BODY = "http.response.body"
|
|
14
|
+
|
|
15
|
+
# websocket
|
|
16
|
+
WEBSOCKET_CONNECT = "websocket.connect"
|
|
17
|
+
WEBSOCKET_DISCONNECT = "websocket.disconnect"
|
|
18
|
+
WEBSOCKET_RECEIVE = "websocket.receive"
|
|
19
|
+
WEBSOCKET_SEND = "websocket.send"
|
|
20
|
+
|
|
21
|
+
# extensions
|
|
22
|
+
PATH_SEND = "http.response.pathsend"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, TypedDict, Union
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ASGIScope(TypedDict):
|
|
5
|
+
asgi: Dict[str, str]
|
|
6
|
+
extensions: Dict[str, Dict[str, Any]]
|
|
7
|
+
type: str
|
|
8
|
+
http_version: str
|
|
9
|
+
server: Iterable[Union[str, int, None]]
|
|
10
|
+
client: Iterable[Union[str, int, None]]
|
|
11
|
+
scheme: str
|
|
12
|
+
method: str
|
|
13
|
+
path: str
|
|
14
|
+
raw_path: bytes
|
|
15
|
+
query_string: bytes
|
|
16
|
+
headers: Iterable[Tuple[Union[str, bytes], Union[str, bytes]]]
|
|
17
|
+
root_path: str
|
|
18
|
+
state: Optional[Dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RSGIHTTPScope(object):
|
|
22
|
+
"""RSGI HTTP Scope template, for type hinting"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def __new__(cls, *args, **kwargs):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
authority = property(
|
|
32
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
33
|
+
) # default
|
|
34
|
+
|
|
35
|
+
client = property(
|
|
36
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
37
|
+
) # default
|
|
38
|
+
|
|
39
|
+
headers = property(
|
|
40
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
41
|
+
) # default
|
|
42
|
+
|
|
43
|
+
http_version = property(
|
|
44
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
45
|
+
) # default
|
|
46
|
+
|
|
47
|
+
method = property(
|
|
48
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
49
|
+
) # default
|
|
50
|
+
|
|
51
|
+
path = property(
|
|
52
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
53
|
+
) # default
|
|
54
|
+
|
|
55
|
+
proto = property(
|
|
56
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
57
|
+
) # default
|
|
58
|
+
|
|
59
|
+
query_string = property(
|
|
60
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
61
|
+
) # default
|
|
62
|
+
|
|
63
|
+
rsgi_version = property(
|
|
64
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
65
|
+
) # default
|
|
66
|
+
|
|
67
|
+
scheme = property(
|
|
68
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
69
|
+
) # default
|
|
70
|
+
|
|
71
|
+
server = property(
|
|
72
|
+
lambda self: object(), lambda self, v: None, lambda self: None
|
|
73
|
+
) # default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RSGIHTTPProtocol(object):
|
|
77
|
+
"""RSGI HTTP Protocol template, for type hinting"""
|
|
78
|
+
|
|
79
|
+
proto: str
|
|
80
|
+
http_version: str
|
|
81
|
+
rsgi_version: str
|
|
82
|
+
server: str
|
|
83
|
+
client: str
|
|
84
|
+
scheme: str
|
|
85
|
+
method: str
|
|
86
|
+
path: str
|
|
87
|
+
query_string: str
|
|
88
|
+
headers: List
|
|
89
|
+
body: bytes
|
|
90
|
+
|
|
91
|
+
def response_bytes(self, *args, **kwargs):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def response_empty(self, *args, **kwargs):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def response_file(self, *args, **kwargs):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def response_str(self, *args, **kwargs):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def response_stream(self, *args, **kwargs):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def __aiter__(self, *args, **kwargs):
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __anext__(self, *args, **kwargs):
|
|
110
|
+
if self.data:
|
|
111
|
+
return self.data.pop(0)
|
|
112
|
+
raise StopAsyncIteration
|
|
113
|
+
|
|
114
|
+
def __call__(self, *args, **kwargs):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def __init__(self, *args, **kwargs):
|
|
118
|
+
self.data = list(args)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def __new__(cls, *args, **kwargs):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RSGIWebsocketProtocol(object):
|
|
126
|
+
def accept(self, *args, **kwargs):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def close(self, *args, **kwargs):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def __init__(self, *args, **kwargs):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def __new__(cls, *args, **kwargs):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RSGIWebsocketScope(RSGIHTTPScope):
|
|
141
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BodyIter:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._body = []
|
|
8
|
+
|
|
9
|
+
def __len__(self):
|
|
10
|
+
return len(self._body)
|
|
11
|
+
|
|
12
|
+
def __iter__(self):
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
def __next__(self):
|
|
16
|
+
if not self._body:
|
|
17
|
+
raise StopIteration
|
|
18
|
+
return self._body.pop(0)
|
|
19
|
+
|
|
20
|
+
def __aiter__(self):
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
async def __anext__(self):
|
|
24
|
+
if not self._body:
|
|
25
|
+
raise StopAsyncIteration
|
|
26
|
+
return self._body.pop(0)
|
|
27
|
+
|
|
28
|
+
def append(self, data: Union[Dict[str, Union[bytes, str]], bytes]):
|
|
29
|
+
self._body.append(data)
|
|
30
|
+
|
|
31
|
+
def get_body(self):
|
|
32
|
+
return self._body
|
|
33
|
+
|
|
34
|
+
def clear_body(self):
|
|
35
|
+
self._body.clear()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Response:
|
|
40
|
+
status: Optional[int] = None
|
|
41
|
+
headers: Union[List[tuple], Tuple[tuple]] = field(default_factory=list)
|
|
42
|
+
body: BodyIter = field(default_factory=BodyIter)
|
|
43
|
+
path: Optional[str] = b""
|
|
44
|
+
stream: Optional[bool] = False
|
|
45
|
+
type: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
def get_body(self):
|
|
48
|
+
return self.body.get_body()
|
|
49
|
+
|
|
50
|
+
def clear_body(self):
|
|
51
|
+
self.body.clear_body()
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import CodeType
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, Mock, NonCallableMock, call
|
|
6
|
+
|
|
7
|
+
from rsgiadapter.asgi import ASGIToRSGIAdapter
|
|
8
|
+
from rsgiadapter.response import BodyIter, Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Stream(Mock):
|
|
12
|
+
|
|
13
|
+
send_bytes = AsyncMock()
|
|
14
|
+
|
|
15
|
+
send_str = AsyncMock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockAsyncIterator:
|
|
19
|
+
"""
|
|
20
|
+
Wraps an iterator in an asynchronous iterator.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, iterator):
|
|
24
|
+
self.iterator = iterator
|
|
25
|
+
code_mock = NonCallableMock(spec_set=CodeType)
|
|
26
|
+
code_mock.co_flags = inspect.CO_ITERABLE_COROUTINE
|
|
27
|
+
self.__dict__["__code__"] = code_mock
|
|
28
|
+
|
|
29
|
+
async def __anext__(self):
|
|
30
|
+
try:
|
|
31
|
+
return next(self.iterator)
|
|
32
|
+
except StopIteration:
|
|
33
|
+
pass
|
|
34
|
+
raise StopAsyncIteration
|
|
35
|
+
|
|
36
|
+
def __aiter__(self):
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestMakeASGIScope(unittest.TestCase):
|
|
41
|
+
|
|
42
|
+
def setUp(self):
|
|
43
|
+
self.test_scope = Mock()
|
|
44
|
+
self.test_scope.proto = "http"
|
|
45
|
+
self.test_scope.http_version = "1.1"
|
|
46
|
+
self.test_scope.server = "example.com:80"
|
|
47
|
+
self.test_scope.client = "127.0.0.1:1234"
|
|
48
|
+
self.test_scope.scheme = "https"
|
|
49
|
+
self.test_scope.method = "GET"
|
|
50
|
+
self.test_scope.path = "/test"
|
|
51
|
+
self.test_scope.query_string = "key=value"
|
|
52
|
+
self.test_scope.headers = {"Content-Type": "application/json"}
|
|
53
|
+
|
|
54
|
+
self.asgi_app = ASGIToRSGIAdapter(None)
|
|
55
|
+
|
|
56
|
+
def test_scope_not_none(self):
|
|
57
|
+
result = self.asgi_app.make_asgi_scope(self.test_scope)
|
|
58
|
+
self.assertIsNotNone(result)
|
|
59
|
+
|
|
60
|
+
def test_raise_value_error_if_scope_none(self):
|
|
61
|
+
with self.assertRaises(ValueError):
|
|
62
|
+
self.asgi_app.make_asgi_scope(None)
|
|
63
|
+
|
|
64
|
+
def test_correct_attribute_assignment(self):
|
|
65
|
+
result = self.asgi_app.make_asgi_scope(self.test_scope)
|
|
66
|
+
self.assertEqual(result["type"], "http")
|
|
67
|
+
self.assertEqual(result["http_version"], "1.1")
|
|
68
|
+
self.assertEqual(result["server"], ["example.com", "80"])
|
|
69
|
+
self.assertEqual(result["client"], ["127.0.0.1", "1234"])
|
|
70
|
+
self.assertEqual(result["scheme"], "https")
|
|
71
|
+
self.assertEqual(result["method"], "GET")
|
|
72
|
+
self.assertEqual(result["path"], "/test")
|
|
73
|
+
self.assertEqual(result["raw_path"], b"/test")
|
|
74
|
+
self.assertEqual(result["query_string"], b"key=value")
|
|
75
|
+
self.assertEqual(result["headers"], [(b"Content-Type", b"application/json")])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestYieldBody(unittest.IsolatedAsyncioTestCase):
|
|
79
|
+
async def test_yield_body(self):
|
|
80
|
+
# Test case: yielding messages from a protocol
|
|
81
|
+
mock_protocol = AsyncMock()
|
|
82
|
+
|
|
83
|
+
async def mock_protocol_async_iter():
|
|
84
|
+
yield 1
|
|
85
|
+
yield 2
|
|
86
|
+
yield 3
|
|
87
|
+
# 模拟迭代结束
|
|
88
|
+
raise StopAsyncIteration
|
|
89
|
+
|
|
90
|
+
mock_protocol = AsyncMock()
|
|
91
|
+
|
|
92
|
+
mock_protocol.__aiter__ = AsyncMock(return_value=mock_protocol_async_iter())
|
|
93
|
+
|
|
94
|
+
mock_protocol.__anext__.side_effect = mock_protocol_async_iter()
|
|
95
|
+
mock_protocol = MockAsyncIterator(iter([1, 2, 3]))
|
|
96
|
+
adapter = ASGIToRSGIAdapter(None)
|
|
97
|
+
result = []
|
|
98
|
+
async for msg in adapter.yield_body(mock_protocol):
|
|
99
|
+
result.append(msg)
|
|
100
|
+
|
|
101
|
+
self.assertEqual(result, [1, 2, 3])
|
|
102
|
+
|
|
103
|
+
# Test case: yielding an empty result
|
|
104
|
+
protocol = MockAsyncIterator(iter([]))
|
|
105
|
+
|
|
106
|
+
adapter = ASGIToRSGIAdapter(None)
|
|
107
|
+
result = []
|
|
108
|
+
async for msg in adapter.yield_body(protocol):
|
|
109
|
+
result.append(msg)
|
|
110
|
+
|
|
111
|
+
self.assertEqual(result, [])
|
|
112
|
+
|
|
113
|
+
# Test case: yielding a single message
|
|
114
|
+
protocol = MockAsyncIterator(iter([4]))
|
|
115
|
+
|
|
116
|
+
adapter = ASGIToRSGIAdapter(None)
|
|
117
|
+
result = []
|
|
118
|
+
async for msg in adapter.yield_body(protocol):
|
|
119
|
+
result.append(msg)
|
|
120
|
+
|
|
121
|
+
self.assertEqual(result, [4])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestPerformResponse(unittest.IsolatedAsyncioTestCase):
|
|
125
|
+
|
|
126
|
+
def setUp(self):
|
|
127
|
+
self.protocol = AsyncMock()
|
|
128
|
+
body = BodyIter()
|
|
129
|
+
body.append(b"hello")
|
|
130
|
+
body.append(b"world")
|
|
131
|
+
self.response = Response(
|
|
132
|
+
status=200,
|
|
133
|
+
headers=[("Content-Type", "text/plain")],
|
|
134
|
+
body=body,
|
|
135
|
+
path=None,
|
|
136
|
+
stream=None,
|
|
137
|
+
type=None,
|
|
138
|
+
)
|
|
139
|
+
self.mock_response_stream_return = Stream()
|
|
140
|
+
self.protocol.response_stream = Mock(
|
|
141
|
+
return_value=self.mock_response_stream_return
|
|
142
|
+
)
|
|
143
|
+
self.protocol.response_bytes = Mock()
|
|
144
|
+
self.protocol.response_file = Mock()
|
|
145
|
+
self.adapter = ASGIToRSGIAdapter(None, None, None)
|
|
146
|
+
|
|
147
|
+
async def test_response_file(self):
|
|
148
|
+
self.response.path = Path("test.txt")
|
|
149
|
+
await self.adapter.perform_response(self.protocol, self.response)
|
|
150
|
+
self.protocol.response_file.assert_called_once_with(
|
|
151
|
+
status=200,
|
|
152
|
+
headers=[("Content-Type", "text/plain")],
|
|
153
|
+
file=Path("test.txt"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def test_response_stream(self):
|
|
157
|
+
await self.adapter.perform_response(self.protocol, self.response)
|
|
158
|
+
self.protocol.response_stream.assert_called_once_with(
|
|
159
|
+
status=200,
|
|
160
|
+
headers=[("Content-Type", "text/plain")],
|
|
161
|
+
)
|
|
162
|
+
self.mock_response_stream_return.send_bytes.assert_has_calls(
|
|
163
|
+
[
|
|
164
|
+
call(b"hello"),
|
|
165
|
+
call(b"world"),
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def test_response_bytes(self):
|
|
170
|
+
self.response.body = BodyIter()
|
|
171
|
+
await self.adapter.perform_response(self.protocol, self.response)
|
|
172
|
+
self.protocol.response_bytes.assert_called_once_with(
|
|
173
|
+
status=200,
|
|
174
|
+
headers=[("Content-Type", "text/plain")],
|
|
175
|
+
body=b"",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
unittest.main()
|