pyrpc-core 0.1.0a1__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.
- pyrpc_core-0.1.0a1/.gitignore +154 -0
- pyrpc_core-0.1.0a1/PKG-INFO +8 -0
- pyrpc_core-0.1.0a1/pyproject.toml +17 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/__init__.py +25 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/client/python_client.py +139 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/decorators.py +8 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/interpreter.py +70 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/introspection.py +73 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/models.py +29 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/procedure.py +91 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/core/registry.py +55 -0
- pyrpc_core-0.1.0a1/src/pyrpc_core/transport/asgi.py +98 -0
- pyrpc_core-0.1.0a1/tests/test_client.py +54 -0
- pyrpc_core-0.1.0a1/tests/test_features.py +92 -0
- pyrpc_core-0.1.0a1/tests/test_transport.py +96 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
!docs/lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Used for packaging Python scripts into standalone executables
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.stats
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
pytestdebug.log
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# Project-specific python versions
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if Pipfile.lock (and requirements.txt) are not
|
|
93
|
+
# preferred, then add them into the ignore list.
|
|
94
|
+
# Pipfile.lock
|
|
95
|
+
|
|
96
|
+
# poetry
|
|
97
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
98
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
99
|
+
# poetry.lock
|
|
100
|
+
|
|
101
|
+
# pdm
|
|
102
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
103
|
+
# pdm.lock
|
|
104
|
+
# .pdm-python
|
|
105
|
+
|
|
106
|
+
# PEP 582; used by e.g. github.com/fannheyward/coc-pyright
|
|
107
|
+
__pypackages__/
|
|
108
|
+
|
|
109
|
+
# Celery stuff
|
|
110
|
+
celerybeat-schedule
|
|
111
|
+
celerybeat.pid
|
|
112
|
+
|
|
113
|
+
# SageMath parsed files
|
|
114
|
+
*.sage.py
|
|
115
|
+
|
|
116
|
+
# Environments
|
|
117
|
+
.env
|
|
118
|
+
.venv
|
|
119
|
+
env/
|
|
120
|
+
venv/
|
|
121
|
+
ENV/
|
|
122
|
+
env.bak/
|
|
123
|
+
venv.bak/
|
|
124
|
+
|
|
125
|
+
# Spyder project settings
|
|
126
|
+
.spyderproject
|
|
127
|
+
.spyderformpoint
|
|
128
|
+
|
|
129
|
+
# Rope project settings
|
|
130
|
+
.ropeproject
|
|
131
|
+
|
|
132
|
+
# mkdocs documentation
|
|
133
|
+
/site
|
|
134
|
+
|
|
135
|
+
# mypy
|
|
136
|
+
.mypy_cache/
|
|
137
|
+
.dmypy.json
|
|
138
|
+
dmypy.json
|
|
139
|
+
|
|
140
|
+
# Pyre type checker
|
|
141
|
+
.pyre/
|
|
142
|
+
|
|
143
|
+
# pytype static type analyzer
|
|
144
|
+
.pytype/
|
|
145
|
+
|
|
146
|
+
# Cython debug symbols
|
|
147
|
+
cython_debug/
|
|
148
|
+
|
|
149
|
+
# OS X
|
|
150
|
+
.DS_Store
|
|
151
|
+
|
|
152
|
+
# Node modules
|
|
153
|
+
node_modules
|
|
154
|
+
dist
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyrpc-core"
|
|
3
|
+
version = "0.1.0-alpha.1"
|
|
4
|
+
description = "pyRPC Core - tRPC-style RPC protocol and runtime for Python"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pydantic>=2.0",
|
|
8
|
+
"httpx>=0.24.0",
|
|
9
|
+
"anyio>=4.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/pyrpc_core"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .client.python_client import RPCClient, RPCError
|
|
4
|
+
from .core.decorators import default_router, rpc, model
|
|
5
|
+
from .core.introspection import get_procedure_schema, get_registry_schema
|
|
6
|
+
from .core.interpreter import handle_request
|
|
7
|
+
from .core.models import RpcRequest, RpcResponse
|
|
8
|
+
from .core.registry import Router
|
|
9
|
+
from .transport.asgi import PyRPCAsgiApp, app as asgi_app
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Router",
|
|
13
|
+
"rpc",
|
|
14
|
+
"model",
|
|
15
|
+
"default_router",
|
|
16
|
+
"RpcRequest",
|
|
17
|
+
"RpcResponse",
|
|
18
|
+
"handle_request",
|
|
19
|
+
"PyRPCAsgiApp",
|
|
20
|
+
"asgi_app",
|
|
21
|
+
"RPCClient",
|
|
22
|
+
"RPCError",
|
|
23
|
+
"get_procedure_schema",
|
|
24
|
+
"get_registry_schema",
|
|
25
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RPCError(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Structured RPC error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code: int, message: str) -> None:
|
|
13
|
+
self.code = code
|
|
14
|
+
self.message = message
|
|
15
|
+
super().__init__(f"RPC {code}: {message}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RPCClient:
|
|
19
|
+
"""
|
|
20
|
+
A dynamic RPC client for pyRPC.
|
|
21
|
+
Allows calling remote procedures as if they were local methods.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str,
|
|
27
|
+
async_client: Optional[httpx.AsyncClient] = None,
|
|
28
|
+
sync_client: Optional[httpx.Client] = None
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Initialize the RPC client.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: The base URL of the pyRPC server.
|
|
35
|
+
async_client: Optional custom async httpx client.
|
|
36
|
+
sync_client: Optional custom sync httpx client.
|
|
37
|
+
"""
|
|
38
|
+
self.base_url = base_url.rstrip("/")
|
|
39
|
+
self._async_client = async_client or httpx.AsyncClient(base_url=self.base_url)
|
|
40
|
+
self._sync_client = sync_client or httpx.Client(base_url=self.base_url)
|
|
41
|
+
|
|
42
|
+
def __getattr__(self, name: str) -> Any:
|
|
43
|
+
"""
|
|
44
|
+
Returns a callable that executes the RPC.
|
|
45
|
+
Defaults to sync execution unless used in an async context or explicitly called.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
class CallableRPC:
|
|
49
|
+
def __init__(self, client: "RPCClient", method: str):
|
|
50
|
+
self.client = client
|
|
51
|
+
self.method = method
|
|
52
|
+
|
|
53
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
54
|
+
return self.client.call_sync(self.method, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
def __await__(self):
|
|
57
|
+
# This is a bit tricky for a dual-purpose object.
|
|
58
|
+
# Usually, we'd want `await client.add(1, 2)` to work.
|
|
59
|
+
# However, `client.add(1, 2)` is already called.
|
|
60
|
+
# So we return the result of call_async if it was awaited.
|
|
61
|
+
# But `client.add(1, 2)` returns a result, not a coroutine.
|
|
62
|
+
# To support both, we'd need the __getattr__ to return an object
|
|
63
|
+
# that is both a callable and awaitable.
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Simplified approach: expose explicit call_async and call_sync,
|
|
67
|
+
# and make __getattr__ return an object that decides based on usage?
|
|
68
|
+
# Actually, let's stick to the original robust suggestion of clear separation
|
|
69
|
+
# but keep the convenience of dynamic calls by returning a helper.
|
|
70
|
+
|
|
71
|
+
return RPCCallable(self, name)
|
|
72
|
+
|
|
73
|
+
async def call_async(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
74
|
+
payload = self._prepare_payload(method, *args, **kwargs)
|
|
75
|
+
response = await self._async_client.post("/rpc", json=payload)
|
|
76
|
+
return self._handle_response(response)
|
|
77
|
+
|
|
78
|
+
def call_sync(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
payload = self._prepare_payload(method, *args, **kwargs)
|
|
80
|
+
response = self._sync_client.post("/rpc", json=payload)
|
|
81
|
+
return self._handle_response(response)
|
|
82
|
+
|
|
83
|
+
def _prepare_payload(self, method: str, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
84
|
+
params: Union[List[Any], Dict[str, Any]]
|
|
85
|
+
if kwargs:
|
|
86
|
+
params = kwargs
|
|
87
|
+
else:
|
|
88
|
+
params = list(args)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"id": str(uuid.uuid4()),
|
|
92
|
+
"method": method,
|
|
93
|
+
"params": params,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
data = response.json()
|
|
99
|
+
if "error" in data and data["error"]:
|
|
100
|
+
error = data["error"]
|
|
101
|
+
raise RPCError(error["code"], error["message"])
|
|
102
|
+
return data.get("result")
|
|
103
|
+
|
|
104
|
+
async def aclose(self) -> None:
|
|
105
|
+
await self._async_client.aclose()
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
self._sync_client.close()
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self) -> "RPCClient":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
114
|
+
await self.aclose()
|
|
115
|
+
|
|
116
|
+
def __enter__(self) -> "RPCClient":
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RPCCallable:
|
|
124
|
+
"""
|
|
125
|
+
Helper for dynamic method calls.
|
|
126
|
+
Supports both sync call and async call (via .aio() or similar).
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, client: RPCClient, method: str):
|
|
130
|
+
self.client = client
|
|
131
|
+
self.method = method
|
|
132
|
+
|
|
133
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
134
|
+
"""Sync call by default."""
|
|
135
|
+
return self.client.call_sync(self.method, *args, **kwargs)
|
|
136
|
+
|
|
137
|
+
async def aio(self, *args: Any, **kwargs: Any) -> Any:
|
|
138
|
+
"""Explicit async call."""
|
|
139
|
+
return await self.client.call_async(self.method, *args, **kwargs)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .decorators import default_router
|
|
6
|
+
from .registry import Router
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from .models import RpcErrorModel, RpcRequest, RpcResponse
|
|
10
|
+
|
|
11
|
+
from .procedure import Procedure, ProcedureError, _format_validation_error
|
|
12
|
+
|
|
13
|
+
async def handle_request(
|
|
14
|
+
payload: Dict[str, Any],
|
|
15
|
+
router: Optional[Router] = None
|
|
16
|
+
) -> Dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
Handle an incoming RPC request using pre-compiled Procedures.
|
|
19
|
+
"""
|
|
20
|
+
if router is None:
|
|
21
|
+
router = default_router
|
|
22
|
+
|
|
23
|
+
request_id = payload.get("id")
|
|
24
|
+
try:
|
|
25
|
+
# 1. Parse Envelope
|
|
26
|
+
try:
|
|
27
|
+
request = RpcRequest.model_validate(payload)
|
|
28
|
+
except ValidationError as e:
|
|
29
|
+
return RpcResponse(
|
|
30
|
+
id=request_id,
|
|
31
|
+
error=RpcErrorModel(
|
|
32
|
+
code=-32600,
|
|
33
|
+
message="Invalid request",
|
|
34
|
+
data=_format_validation_error(e)
|
|
35
|
+
),
|
|
36
|
+
).model_dump()
|
|
37
|
+
|
|
38
|
+
# 2. Find Procedure
|
|
39
|
+
procedure = router.get(request.method)
|
|
40
|
+
if not procedure:
|
|
41
|
+
return RpcResponse(
|
|
42
|
+
id=request_id,
|
|
43
|
+
error=RpcErrorModel(code=-32601, message=f"Method not found: {request.method}"),
|
|
44
|
+
).model_dump()
|
|
45
|
+
|
|
46
|
+
# 3. Execute Procedure (Validation and Call happens inside)
|
|
47
|
+
try:
|
|
48
|
+
result = await procedure.execute(request.params if request.params is not None else {})
|
|
49
|
+
return RpcResponse(id=request_id, result=result).model_dump()
|
|
50
|
+
|
|
51
|
+
except ProcedureError as pe:
|
|
52
|
+
return RpcResponse(
|
|
53
|
+
id=request_id,
|
|
54
|
+
error=RpcErrorModel(
|
|
55
|
+
code=pe.code,
|
|
56
|
+
message=pe.message,
|
|
57
|
+
data=pe.data
|
|
58
|
+
),
|
|
59
|
+
).model_dump()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return RpcResponse(
|
|
62
|
+
id=request_id,
|
|
63
|
+
error=RpcErrorModel(code=-32603, message=str(e)),
|
|
64
|
+
).model_dump()
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return RpcResponse(
|
|
68
|
+
id=request_id,
|
|
69
|
+
error=RpcErrorModel(code=-32600, message=f"Invalid request: {str(e)}"),
|
|
70
|
+
).model_dump()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from .procedure import Procedure
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ParameterSchema(BaseModel):
|
|
8
|
+
name: str
|
|
9
|
+
type: str
|
|
10
|
+
schema_: Dict[str, Any]
|
|
11
|
+
required: bool
|
|
12
|
+
default: Optional[Any] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProcedureSchema(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
parameters: List[ParameterSchema]
|
|
18
|
+
return_type: str
|
|
19
|
+
return_schema: Dict[str, Any]
|
|
20
|
+
doc: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_procedure_schema(proc: Procedure) -> ProcedureSchema:
|
|
24
|
+
"""
|
|
25
|
+
Generate a schema from a compiled Procedure.
|
|
26
|
+
"""
|
|
27
|
+
parameters = []
|
|
28
|
+
|
|
29
|
+
for param_name, param in proc.sig.parameters.items():
|
|
30
|
+
# Get parameter type
|
|
31
|
+
param_type = param.annotation if param.annotation is not inspect.Parameter.empty else Any
|
|
32
|
+
|
|
33
|
+
# Use pre-built adapter if available
|
|
34
|
+
adapter = proc.arg_adapters.get(param_name)
|
|
35
|
+
if adapter:
|
|
36
|
+
param_json_schema = adapter.json_schema()
|
|
37
|
+
else:
|
|
38
|
+
param_json_schema = {"type": "any"}
|
|
39
|
+
|
|
40
|
+
parameters.append(
|
|
41
|
+
ParameterSchema(
|
|
42
|
+
name=param_name,
|
|
43
|
+
type=str(param_type),
|
|
44
|
+
schema_=param_json_schema,
|
|
45
|
+
required=param.default is inspect.Parameter.empty,
|
|
46
|
+
default=param.default if param.default is not inspect.Parameter.empty else None,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Get return type
|
|
51
|
+
return_type = proc.sig.return_annotation if proc.sig.return_annotation is not inspect.Signature.empty else Any
|
|
52
|
+
if proc.return_adapter:
|
|
53
|
+
return_json_schema = proc.return_adapter.json_schema()
|
|
54
|
+
else:
|
|
55
|
+
return_json_schema = {"type": "any"}
|
|
56
|
+
|
|
57
|
+
return ProcedureSchema(
|
|
58
|
+
name=proc.name,
|
|
59
|
+
parameters=parameters,
|
|
60
|
+
return_type=str(return_type),
|
|
61
|
+
return_schema=return_json_schema,
|
|
62
|
+
doc=inspect.getdoc(proc.fn),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_registry_schema(router: Any) -> Dict[str, ProcedureSchema]:
|
|
67
|
+
"""
|
|
68
|
+
Generate schemas for all procedures in a router.
|
|
69
|
+
"""
|
|
70
|
+
schemas = {}
|
|
71
|
+
for name, proc in router._procedures.items():
|
|
72
|
+
schemas[name] = get_procedure_schema(proc)
|
|
73
|
+
return schemas
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RpcRequest(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Represents an RPC request.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
id: Optional[Union[str, int]] = None
|
|
12
|
+
method: str
|
|
13
|
+
params: Optional[Union[List[Any], Dict[str, Any]]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RpcErrorModel(BaseModel):
|
|
17
|
+
code: int
|
|
18
|
+
message: str
|
|
19
|
+
data: Optional[Any] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RpcResponse(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
Represents an RPC response.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
id: Optional[Union[int, str]] = None
|
|
28
|
+
result: Optional[Any] = None
|
|
29
|
+
error: Optional[RpcErrorModel] = None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Any, Callable, Dict, Optional, List
|
|
4
|
+
from pydantic import TypeAdapter, ValidationError
|
|
5
|
+
|
|
6
|
+
def _format_validation_error(e: ValidationError) -> Dict[str, Any]:
|
|
7
|
+
errors = e.errors()
|
|
8
|
+
if not errors:
|
|
9
|
+
return {"field": "unknown", "message": "Validation failed"}
|
|
10
|
+
|
|
11
|
+
first_error = errors[0]
|
|
12
|
+
loc = ".".join(str(l) for l in first_error.get("loc", []))
|
|
13
|
+
return {
|
|
14
|
+
"field": loc,
|
|
15
|
+
"message": first_error.get("msg", "Validation failed"),
|
|
16
|
+
"type": first_error.get("type", "unknown")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class ProcedureError(Exception):
|
|
20
|
+
def __init__(self, code: int, message: str, data: Optional[Dict[str, Any]] = None):
|
|
21
|
+
self.code = code
|
|
22
|
+
self.message = message
|
|
23
|
+
self.data = data or {}
|
|
24
|
+
|
|
25
|
+
class Procedure:
|
|
26
|
+
"""
|
|
27
|
+
Represents a 'compiled' RPC procedure.
|
|
28
|
+
All expensive introspection and validator setup happens during initialization.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, fn: Callable[..., Any], name: Optional[str] = None):
|
|
32
|
+
self.fn = fn
|
|
33
|
+
self.name = name or fn.__name__
|
|
34
|
+
self.sig = inspect.signature(fn)
|
|
35
|
+
self.is_async = inspect.iscoroutinefunction(fn)
|
|
36
|
+
|
|
37
|
+
# Pre-build Pydantic TypeAdapters for all parameters
|
|
38
|
+
self.arg_adapters: Dict[str, TypeAdapter] = {}
|
|
39
|
+
for param_name, param in self.sig.parameters.items():
|
|
40
|
+
if param.annotation is not inspect.Parameter.empty and param.annotation is not Any:
|
|
41
|
+
self.arg_adapters[param_name] = TypeAdapter(param.annotation)
|
|
42
|
+
|
|
43
|
+
# Pre-build Return TypeAdapter
|
|
44
|
+
self.return_adapter: Optional[TypeAdapter] = None
|
|
45
|
+
if self.sig.return_annotation is not inspect.Signature.empty and self.sig.return_annotation is not Any:
|
|
46
|
+
self.return_adapter = TypeAdapter(self.sig.return_annotation)
|
|
47
|
+
|
|
48
|
+
async def execute(self, params: Any) -> Any:
|
|
49
|
+
"""
|
|
50
|
+
Execute the procedure with the given parameters.
|
|
51
|
+
This is the optimized 'hot path' for RPC requests.
|
|
52
|
+
"""
|
|
53
|
+
# 1. Bind arguments
|
|
54
|
+
try:
|
|
55
|
+
if isinstance(params, list):
|
|
56
|
+
bound_args = self.sig.bind(*params)
|
|
57
|
+
elif isinstance(params, dict):
|
|
58
|
+
bound_args = self.sig.bind(**params)
|
|
59
|
+
else:
|
|
60
|
+
raise TypeError("Params must be a list or dict")
|
|
61
|
+
except TypeError as e:
|
|
62
|
+
raise ProcedureError(code=-32602, message=f"Invalid params: {str(e)}")
|
|
63
|
+
|
|
64
|
+
# 2. Validate arguments using pre-built adapters
|
|
65
|
+
for name, value in bound_args.arguments.items():
|
|
66
|
+
adapter = self.arg_adapters.get(name)
|
|
67
|
+
if adapter:
|
|
68
|
+
try:
|
|
69
|
+
bound_args.arguments[name] = adapter.validate_python(value)
|
|
70
|
+
except ValidationError as ve:
|
|
71
|
+
# Enrich the error with the parameter name if missing
|
|
72
|
+
error_data = _format_validation_error(ve)
|
|
73
|
+
if not error_data.get("field") or error_data["field"] == "unknown":
|
|
74
|
+
error_data["field"] = name
|
|
75
|
+
raise ProcedureError(code=-32602, message="Validation failed", data=error_data)
|
|
76
|
+
|
|
77
|
+
# 3. Call the function (Sync or Async)
|
|
78
|
+
if self.is_async:
|
|
79
|
+
result = await self.fn(*bound_args.args, **bound_args.kwargs)
|
|
80
|
+
else:
|
|
81
|
+
result = self.fn(*bound_args.args, **bound_args.kwargs)
|
|
82
|
+
|
|
83
|
+
# 4. Validate return type
|
|
84
|
+
if self.return_adapter:
|
|
85
|
+
try:
|
|
86
|
+
result = self.return_adapter.validate_python(result)
|
|
87
|
+
except ValidationError as ve:
|
|
88
|
+
error_data = _format_validation_error(ve)
|
|
89
|
+
raise ProcedureError(code=-32603, message="Internal Error: Return type validation failed", data=error_data)
|
|
90
|
+
|
|
91
|
+
return result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
from .procedure import Procedure
|
|
4
|
+
|
|
5
|
+
class Router:
|
|
6
|
+
"""
|
|
7
|
+
A Router manages a set of RPC procedures.
|
|
8
|
+
It can be used as a decorator and merged with other routers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._procedures: Dict[str, Procedure] = {}
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
def rpc(self, name_or_fn: Any = None) -> Any:
|
|
16
|
+
"""
|
|
17
|
+
Decorator to register a function as an RPC procedure.
|
|
18
|
+
Usage:
|
|
19
|
+
@router.rpc
|
|
20
|
+
def my_func(): ...
|
|
21
|
+
|
|
22
|
+
@router.rpc(name="custom_name")
|
|
23
|
+
def my_func(): ...
|
|
24
|
+
"""
|
|
25
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
26
|
+
name = name_or_fn if isinstance(name_or_fn, str) else fn.__name__
|
|
27
|
+
proc = Procedure(fn, name=name)
|
|
28
|
+
self.register(name, proc)
|
|
29
|
+
return fn
|
|
30
|
+
|
|
31
|
+
if callable(name_or_fn):
|
|
32
|
+
return decorator(name_or_fn)
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
def register(self, name: str, proc: Procedure) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._procedures[name] = proc
|
|
38
|
+
|
|
39
|
+
def merge(self, other: "Router", prefix: str = "") -> None:
|
|
40
|
+
"""Merge another router into this one, optionally with a prefix."""
|
|
41
|
+
with other._lock:
|
|
42
|
+
with self._lock:
|
|
43
|
+
for name, proc in other._procedures.items():
|
|
44
|
+
new_name = f"{prefix}{name}" if prefix else name
|
|
45
|
+
# Create a new Procedure with the prefixed name if necessary
|
|
46
|
+
# though proc.name is mostly for introspection
|
|
47
|
+
self._procedures[new_name] = proc
|
|
48
|
+
|
|
49
|
+
def get(self, name: str) -> Optional[Procedure]:
|
|
50
|
+
with self._lock:
|
|
51
|
+
return self._procedures.get(name)
|
|
52
|
+
|
|
53
|
+
def list(self) -> List[str]:
|
|
54
|
+
with self._lock:
|
|
55
|
+
return list(self._procedures.keys())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Callable, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from ..core.interpreter import handle_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PyRPCAsgiApp:
|
|
8
|
+
"""
|
|
9
|
+
A minimal ASGI application for serving pyRPC requests.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, router: Optional[Any] = None) -> None:
|
|
13
|
+
self.router = router
|
|
14
|
+
|
|
15
|
+
async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
|
|
16
|
+
"""
|
|
17
|
+
The ASGI entry point.
|
|
18
|
+
"""
|
|
19
|
+
if scope["type"] != "http":
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
method = scope.get("method")
|
|
23
|
+
path = scope.get("path")
|
|
24
|
+
|
|
25
|
+
if method == "POST" and path == "/rpc":
|
|
26
|
+
await self.handle_rpc(receive, send)
|
|
27
|
+
elif method == "GET" and path == "/rpc":
|
|
28
|
+
await self.handle_introspection(send)
|
|
29
|
+
else:
|
|
30
|
+
await self.send_response(
|
|
31
|
+
send, 404, {"error": "Not Found", "message": f"Cannot {method} {path}"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def handle_introspection(self, send: Callable) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Handle a GET /rpc request for introspection.
|
|
37
|
+
"""
|
|
38
|
+
from ..core.introspection import get_registry_schema
|
|
39
|
+
# We need to convert the schema objects to dicts for JSON serialization
|
|
40
|
+
# Since get_registry_schema returns a dict of ProcedureSchema,
|
|
41
|
+
# and ProcedureSchema is likely a Pydantic model.
|
|
42
|
+
schemas = get_registry_schema(self.router)
|
|
43
|
+
|
|
44
|
+
# Convert Pydantic models to dicts
|
|
45
|
+
response_data = {
|
|
46
|
+
name: schema.model_dump() if hasattr(schema, "model_dump") else schema
|
|
47
|
+
for name, schema in schemas.items()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await self.send_response(send, 200, response_data)
|
|
51
|
+
|
|
52
|
+
async def handle_rpc(self, receive: Callable, send: Callable) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Handle an RPC request.
|
|
55
|
+
"""
|
|
56
|
+
body = b""
|
|
57
|
+
more_body = True
|
|
58
|
+
while more_body:
|
|
59
|
+
message = await receive()
|
|
60
|
+
body += message.get("body", b"")
|
|
61
|
+
more_body = message.get("more_body", False)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
if not body:
|
|
65
|
+
payload = {}
|
|
66
|
+
else:
|
|
67
|
+
payload = json.loads(body)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
await self.send_response(send, 400, {"error": "Invalid JSON"})
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
response_dict = await handle_request(payload, router=self.router)
|
|
73
|
+
await self.send_response(send, 200, response_dict)
|
|
74
|
+
|
|
75
|
+
async def send_response(self, send: Callable, status_code: int, content: Dict[str, Any]) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Helper to send a JSON response.
|
|
78
|
+
"""
|
|
79
|
+
response_body = json.dumps(content).encode("utf-8")
|
|
80
|
+
await send(
|
|
81
|
+
{
|
|
82
|
+
"type": "http.response.start",
|
|
83
|
+
"status": status_code,
|
|
84
|
+
"headers": [
|
|
85
|
+
(b"content-type", b"application/json"),
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
await send(
|
|
90
|
+
{
|
|
91
|
+
"type": "http.response.body",
|
|
92
|
+
"body": response_body,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Global instance for easy use
|
|
98
|
+
app = PyRPCAsgiApp()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import httpx
|
|
3
|
+
from pyrpc_core.client.python_client import RPCClient, RPCError
|
|
4
|
+
from pyrpc_core.transport.asgi import PyRPCAsgiApp
|
|
5
|
+
from pyrpc_core.core.registry import Router
|
|
6
|
+
|
|
7
|
+
@pytest.mark.asyncio
|
|
8
|
+
async def test_rpc_client_integration():
|
|
9
|
+
# 1. Setup Server
|
|
10
|
+
router = Router()
|
|
11
|
+
@router.rpc
|
|
12
|
+
def multiply(a: int, b: int) -> int:
|
|
13
|
+
return a * b
|
|
14
|
+
|
|
15
|
+
@router.rpc
|
|
16
|
+
def error_func():
|
|
17
|
+
raise ValueError("Something went wrong")
|
|
18
|
+
|
|
19
|
+
app = PyRPCAsgiApp(router=router)
|
|
20
|
+
|
|
21
|
+
# 2. Setup Client with ASGITransport
|
|
22
|
+
# This allows the client to talk to the app without a real server
|
|
23
|
+
transport = httpx.ASGITransport(app=app)
|
|
24
|
+
async_client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
|
25
|
+
sync_client = httpx.Client(transport=transport, base_url="http://testserver")
|
|
26
|
+
|
|
27
|
+
async with RPCClient("http://testserver", async_client=async_client) as client:
|
|
28
|
+
# Test Async Call
|
|
29
|
+
res = await client.multiply.aio(a=5, b=6)
|
|
30
|
+
assert res == 30
|
|
31
|
+
|
|
32
|
+
# Test Error Handling
|
|
33
|
+
with pytest.raises(RPCError) as excinfo:
|
|
34
|
+
await client.error_func.aio()
|
|
35
|
+
assert excinfo.value.code == -32603
|
|
36
|
+
assert "Something went wrong" in excinfo.value.message
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_rpc_client_validation_error():
|
|
40
|
+
router = Router()
|
|
41
|
+
@router.rpc
|
|
42
|
+
def square(n: int) -> int: return n * n
|
|
43
|
+
|
|
44
|
+
app = PyRPCAsgiApp(router=router)
|
|
45
|
+
transport = httpx.ASGITransport(app=app)
|
|
46
|
+
async_client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
|
47
|
+
|
|
48
|
+
async with RPCClient("http://testserver", async_client=async_client) as client:
|
|
49
|
+
# Send invalid type (string instead of int)
|
|
50
|
+
with pytest.raises(RPCError) as excinfo:
|
|
51
|
+
await client.square.aio(n="not-a-number")
|
|
52
|
+
|
|
53
|
+
assert excinfo.value.code == -32602 # Invalid Params
|
|
54
|
+
assert "Validation failed" in excinfo.value.message
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from pyrpc_core.core.registry import Router
|
|
6
|
+
from pyrpc_core.core.interpreter import handle_request
|
|
7
|
+
|
|
8
|
+
# --- Setup Procedures ---
|
|
9
|
+
|
|
10
|
+
router = Router()
|
|
11
|
+
|
|
12
|
+
@router.rpc
|
|
13
|
+
def add(a: int, b: int) -> int:
|
|
14
|
+
return a + b
|
|
15
|
+
|
|
16
|
+
@router.rpc
|
|
17
|
+
async def async_greet(name: str) -> str:
|
|
18
|
+
await asyncio.sleep(0.01)
|
|
19
|
+
return f"Hello {name}"
|
|
20
|
+
|
|
21
|
+
class User(BaseModel):
|
|
22
|
+
id: int
|
|
23
|
+
username: str
|
|
24
|
+
|
|
25
|
+
@router.rpc
|
|
26
|
+
def get_user_name(user: User) -> str:
|
|
27
|
+
return user.username
|
|
28
|
+
|
|
29
|
+
@router.rpc
|
|
30
|
+
def invalid_return() -> int:
|
|
31
|
+
return "not an int"
|
|
32
|
+
|
|
33
|
+
# --- Tests ---
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_primitive_validation_success():
|
|
37
|
+
payload = {"id": 1, "method": "add", "params": {"a": 10, "b": 20}}
|
|
38
|
+
response = await handle_request(payload, router=router)
|
|
39
|
+
assert response["result"] == 30
|
|
40
|
+
assert response["id"] == 1
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_primitive_validation_failure():
|
|
44
|
+
# Pass a string instead of an int
|
|
45
|
+
payload = {"id": 2, "method": "add", "params": {"a": "ten", "b": 20}}
|
|
46
|
+
response = await handle_request(payload, router=router)
|
|
47
|
+
assert response["error"]["code"] == -32602
|
|
48
|
+
assert "Validation failed" in response["error"]["message"]
|
|
49
|
+
assert response["error"]["data"]["field"] == "a"
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_async_procedure():
|
|
53
|
+
payload = {"id": 3, "method": "async_greet", "params": ["World"]}
|
|
54
|
+
response = await handle_request(payload, router=router)
|
|
55
|
+
assert response["result"] == "Hello World"
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_pydantic_model_validation():
|
|
59
|
+
payload = {
|
|
60
|
+
"id": 4,
|
|
61
|
+
"method": "get_user_name",
|
|
62
|
+
"params": {"user": {"id": 1, "username": "alice"}}
|
|
63
|
+
}
|
|
64
|
+
response = await handle_request(payload, router=router)
|
|
65
|
+
assert response["result"] == "alice"
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_return_type_validation_failure():
|
|
69
|
+
payload = {"id": 5, "method": "invalid_return", "params": {}}
|
|
70
|
+
response = await handle_request(payload, router=router)
|
|
71
|
+
assert response["error"]["code"] == -32603
|
|
72
|
+
assert "Return type validation failed" in response["error"]["message"]
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_router_merging():
|
|
76
|
+
sub_router = Router()
|
|
77
|
+
@sub_router.rpc
|
|
78
|
+
def sub_func(): return "sub"
|
|
79
|
+
|
|
80
|
+
main_router = Router()
|
|
81
|
+
main_router.merge(sub_router, prefix="test.")
|
|
82
|
+
|
|
83
|
+
# Check if sub_func is accessible via prefix
|
|
84
|
+
payload = {"id": 6, "method": "test.sub_func", "params": {}}
|
|
85
|
+
response = await handle_request(payload, router=main_router)
|
|
86
|
+
assert response["result"] == "sub"
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_method_not_found():
|
|
90
|
+
payload = {"id": 7, "method": "non_existent", "params": {}}
|
|
91
|
+
response = await handle_request(payload, router=router)
|
|
92
|
+
assert response["error"]["code"] == -32601
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pyrpc_core.core.registry import Router, Procedure
|
|
5
|
+
from pyrpc_core.core.introspection import get_procedure_schema, get_registry_schema
|
|
6
|
+
from pyrpc_core.transport.asgi import PyRPCAsgiApp
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
# --- Introspection Tests ---
|
|
10
|
+
|
|
11
|
+
class User(BaseModel):
|
|
12
|
+
id: int
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
def test_procedure_introspection():
|
|
16
|
+
def my_func(user: User, tags: List[str]) -> bool:
|
|
17
|
+
"""My docstring"""
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
proc = Procedure(my_func)
|
|
21
|
+
schema = get_procedure_schema(proc)
|
|
22
|
+
assert schema.name == "my_func"
|
|
23
|
+
assert schema.doc == "My docstring"
|
|
24
|
+
assert schema.return_type == "<class 'bool'>"
|
|
25
|
+
|
|
26
|
+
# Check parameters
|
|
27
|
+
params = {p.name: p for p in schema.parameters}
|
|
28
|
+
assert "user" in params
|
|
29
|
+
assert params["user"].schema_["properties"]["id"]["type"] == "integer"
|
|
30
|
+
assert "tags" in params
|
|
31
|
+
assert params["tags"].schema_["type"] == "array"
|
|
32
|
+
|
|
33
|
+
def test_registry_introspection():
|
|
34
|
+
router = Router()
|
|
35
|
+
@router.rpc
|
|
36
|
+
def add(a: int, b: int): pass
|
|
37
|
+
|
|
38
|
+
schemas = get_registry_schema(router)
|
|
39
|
+
assert "add" in schemas
|
|
40
|
+
assert len(schemas["add"].parameters) == 2
|
|
41
|
+
|
|
42
|
+
# --- ASGI Transport Tests ---
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_asgi_app_basic():
|
|
46
|
+
router = Router()
|
|
47
|
+
@router.rpc
|
|
48
|
+
def hello(): return "world"
|
|
49
|
+
|
|
50
|
+
app = PyRPCAsgiApp(router=router)
|
|
51
|
+
|
|
52
|
+
# Mock ASGI interaction
|
|
53
|
+
scope = {
|
|
54
|
+
"type": "http",
|
|
55
|
+
"method": "POST",
|
|
56
|
+
"path": "/rpc"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sent_messages = []
|
|
60
|
+
|
|
61
|
+
async def mock_receive():
|
|
62
|
+
return {
|
|
63
|
+
"type": "http.request",
|
|
64
|
+
"body": json.dumps({"id": 1, "method": "hello", "params": {}}).encode("utf-8"),
|
|
65
|
+
"more_body": False
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async def mock_send(message):
|
|
69
|
+
sent_messages.append(message)
|
|
70
|
+
|
|
71
|
+
await app(scope, mock_receive, mock_send)
|
|
72
|
+
|
|
73
|
+
# Verify response
|
|
74
|
+
# 1. Start message
|
|
75
|
+
assert sent_messages[0]["type"] == "http.response.start"
|
|
76
|
+
assert sent_messages[0]["status"] == 200
|
|
77
|
+
|
|
78
|
+
# 2. Body message
|
|
79
|
+
assert sent_messages[1]["type"] == "http.response.body"
|
|
80
|
+
res_payload = json.loads(sent_messages[1]["body"].decode("utf-8"))
|
|
81
|
+
assert res_payload["result"] == "world"
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_asgi_app_404():
|
|
85
|
+
app = PyRPCAsgiApp()
|
|
86
|
+
scope = {
|
|
87
|
+
"type": "http",
|
|
88
|
+
"method": "GET",
|
|
89
|
+
"path": "/wrong"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sent_messages = []
|
|
93
|
+
async def mock_send(message): sent_messages.append(message)
|
|
94
|
+
|
|
95
|
+
await app(scope, None, mock_send)
|
|
96
|
+
assert sent_messages[0]["status"] == 404
|