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.
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrpc-core
3
+ Version: 0.1.0a1
4
+ Summary: pyRPC Core - tRPC-style RPC protocol and runtime for Python
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: anyio>=4.0
7
+ Requires-Dist: httpx>=0.24.0
8
+ Requires-Dist: pydantic>=2.0
@@ -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,8 @@
1
+ from pydantic.dataclasses import dataclass as model
2
+ from .registry import Router
3
+
4
+ # Global default router for easy use
5
+ default_router = Router()
6
+
7
+ # Alias the rpc decorator for the global router
8
+ rpc = default_router.rpc
@@ -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