diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 324961f..a2b9d00 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,14 +1,14 @@ name: Test EasyRpc Core Functionality on: ['pull_request'] jobs: - test_easyrpc_core_38: + test_easyrpc_core: # Containers must run in Linux based operating systems runs-on: ubuntu-latest # Docker Hub image that `container-job` executes in #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.8] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -25,115 +25,13 @@ jobs: - name: Test EasyRpc Core Functionality run: | pytest tests/test_core.py - test_easyrpc_cluster_1_38: - needs: test_easyrpc_core_38 - # Containers must run in Linux based operating systems - runs-on: ubuntu-latest - # Docker Hub image that `container-job` executes in - #container: joshjamison/python38:latest + test_easyrpc_cluster_1: + needs: test_easyrpc_core strategy: matrix: - python-version: [3.8] - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - # Downloads a copy of the code in your repository before running CI tests - - name: Check out repository code - uses: actions/checkout@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pytest requests pytest-asyncio - - name: Test EasyRpc Cluster Functionality - 1 - run: | - pytest tests/test_clustering_1.py - test_easyrpc_cluster_2_38: - needs: test_easyrpc_cluster_1_38 + python-version: [3.11.14, 3.12.12, 3.13.12] # Containers must run in Linux based operating systems runs-on: ubuntu-latest - # Docker Hub image that `container-job` executes in - #container: joshjamison/python38:latest - strategy: - matrix: - python-version: [3.8] - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - # Downloads a copy of the code in your repository before running CI tests - - name: Check out repository code - uses: actions/checkout@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pytest requests pytest-asyncio - - name: Test EasyRpc Cluster Functionality - 2 - run: | - pytest tests/test_clustering_2.py - test_easyrpc_cluster_3_38: - needs: test_easyrpc_cluster_2_38 - # Containers must run in Linux based operating systems - runs-on: ubuntu-latest - # Docker Hub image that `container-job` executes in - #container: joshjamison/python38:latest - strategy: - matrix: - python-version: [3.8] - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - # Downloads a copy of the code in your repository before running CI tests - - name: Check out repository code - uses: actions/checkout@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pytest requests pytest-asyncio - - name: Test EasyRpc Cluster Functionality - 3 - run: | - pytest tests/test_clustering_3.py - test_easyrpc_core_39: - needs: test_easyrpc_cluster_3_38 - # Containers must run in Linux based operating systems - runs-on: ubuntu-latest - # Docker Hub image that `container-job` executes in - #container: joshjamison/python38:latest - strategy: - matrix: - python-version: [3.9] - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - # Downloads a copy of the code in your repository before running CI tests - - name: Check out repository code - uses: actions/checkout@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pytest requests pytest-asyncio - - name: Test EasyRpc Core Functionality - run: | - pytest tests/test_core.py - test_easyrpc_cluster_1_39: - needs: test_easyrpc_core_39 - # Containers must run in Linux based operating systems - runs-on: ubuntu-latest - # Docker Hub image that `container-job` executes in - #container: joshjamison/python38:latest - strategy: - matrix: - python-version: [3.9] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -150,15 +48,15 @@ jobs: - name: Test EasyRpc Cluster Functionality - 1 run: | pytest tests/test_clustering_1.py - test_easyrpc_cluster_2_39: - needs: test_easyrpc_cluster_1_39 + test_easyrpc_cluster_2: + needs: test_easyrpc_cluster_1 # Containers must run in Linux based operating systems runs-on: ubuntu-latest # Docker Hub image that `container-job` executes in #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.9] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -175,15 +73,15 @@ jobs: - name: Test EasyRpc Cluster Functionality - 2 run: | pytest tests/test_clustering_2.py - test_easyrpc_cluster_3_39: - needs: test_easyrpc_cluster_2_39 + test_easyrpc_cluster_3: + needs: test_easyrpc_cluster_2 # Containers must run in Linux based operating systems runs-on: ubuntu-latest # Docker Hub image that `container-job` executes in #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.9] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 diff --git a/.github/workflows/package.yaml b/.github/workflows/package.yaml index 6e3903b..902a46e 100644 --- a/.github/workflows/package.yaml +++ b/.github/workflows/package.yaml @@ -7,6 +7,9 @@ jobs: package: name: Package easyrpc for PyPI runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.11.14, 3.12.12, 3.13.12] steps: # Downloads a copy of the code in your repository before running CI tests - name: Check out repository code @@ -14,7 +17,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: ${{ matrix.python-version }} - name: Install Packaging dependencies run: | pip install wheel twine diff --git a/.github/workflows/test_flows.yaml b/.github/workflows/test_flows.yaml index 7e9233a..104179d 100644 --- a/.github/workflows/test_flows.yaml +++ b/.github/workflows/test_flows.yaml @@ -11,7 +11,7 @@ jobs: #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.8] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -36,7 +36,7 @@ jobs: #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.8] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -61,7 +61,7 @@ jobs: #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.8] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -86,7 +86,7 @@ jobs: #container: joshjamison/python38:latest strategy: matrix: - python-version: [3.8] + python-version: [3.11.14, 3.12.12, 3.13.12] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 diff --git a/README.md b/README.md index c265f54..864b7c5 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,9 @@ Easily share functions between hosts, processes, containers without the complexi ## Quick Start ```bash -$ virtualenv -p python3.7 easy-rpc-env +$ uv init --python ">=3.11" -$ source easy-rpc-env/bin/activate - -(easy-rpc-env)$ pip install easyrpc +$ uv add easyrpc ``` ## Basic Usage: diff --git a/docs/quick_start.md b/docs/quick_start.md index 121f8ba..1ce4b95 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -1,8 +1,6 @@ ## Installation ```bash -$ virtualenv -p python3.7 easy-rpc-env +$ uv init --python >=3.11 -$ source easy-rpc-env/bin/activate - -(easy-rpc-env)$ pip install easyrpc +$ uv add easyrpc ``` \ No newline at end of file diff --git a/easyrpc/auth.py b/easyrpc/auth.py index 9c3563e..d01c27c 100644 --- a/easyrpc/auth.py +++ b/easyrpc/auth.py @@ -1,4 +1,4 @@ -import os, uuid, time, json, base64, jwt, string, random +import jwt def encode(secret, log=None, **kw): try: diff --git a/easyrpc/generator.py b/easyrpc/generator.py index 91ea04a..a59ff65 100644 --- a/easyrpc/generator.py +++ b/easyrpc/generator.py @@ -1,5 +1,5 @@ from typing import Optional -from easyrpc.register import Generator, AsyncGenerator +from easyrpc.register import AsyncGenerator class RpcGenerator: def __init__(self, generator): diff --git a/easyrpc/origin.py b/easyrpc/origin.py index ba726cf..8da0907 100644 --- a/easyrpc/origin.py +++ b/easyrpc/origin.py @@ -1,6 +1,5 @@ from easyrpc.register import ( - get_origin_register, - get_signature_as_dict + get_origin_register ) diff --git a/easyrpc/proxy.py b/easyrpc/proxy.py index ea0eaca..997bcf7 100644 --- a/easyrpc/proxy.py +++ b/easyrpc/proxy.py @@ -2,7 +2,6 @@ import pickle import logging import asyncio -from traceback import format_exc from concurrent.futures._base import CancelledError from aiohttp import ClientSession, WSMessage, WSMsgType diff --git a/easyrpc/register.py b/easyrpc/register.py index a272e8f..5a0400f 100644 --- a/easyrpc/register.py +++ b/easyrpc/register.py @@ -1,17 +1,8 @@ from inspect import ( - signature, - Signature, - FullArgSpec, - getfullargspec, - Parameter, - _empty, - _ParameterKind, iscoroutinefunction ) -import pickle -from copy import deepcopy -from collections import OrderedDict -from makefun import create_function + +from easyrpc.sigtools import serialize_function_signature, create_proxy_from_spec from typing import Callable async def coro(): @@ -29,8 +20,6 @@ async def async_gen(): async_generator_asend = type(ag.asend(None)) - - def create_proxy_from_config(config: dict, proxy: Callable): """ input: @@ -40,76 +29,8 @@ def create_proxy_from_config(config: dict, proxy: Callable): origin function and hides away the websocket rpc logic calling function on origin """ - async def __proxy__(*args, **kwargs): - result = proxy(*args, **kwargs) - if isinstance(result, Coroutine): - return await result - return result - - __proxy__.__name__ = f"{config['name']}" - __proxy__.__doc__ = config.get('doc', '') - nf = create_function( - create_signature_from_dict( - config['sig'] - ), - __proxy__ - ) - - return nf -def create_signature_from_dict(func_sig: dict): - """ - contstruct a function signature from dict - config, created via get_signature_as_dict - """ - sig_dict = deepcopy(func_sig) - - params_od = OrderedDict() - for k in list(sig_dict.keys()): - for pk in list(sig_dict[k].keys()): - sig_dict[k][pk]['kind'] = _ParameterKind.__dict__[sig_dict[k][pk]['kind']] - - name, kind = sig_dict[k][pk]['name'], sig_dict[k][pk]['kind'] - default_or_annotations = {} - for config in ('default', 'annotation'): - if config == 'annotation' and config in sig_dict[k][pk]: - annotation = sig_dict[k][pk][config] - default_or_annotations[config] = pickle.loads(annotation) - """ - for allowed_type in {int, float, str, dict, list}: - if str(allowed_type) == annotation: - default_or_annotations[config] = allowed_type - """ - continue - if config in sig_dict[k][pk]: - default_or_annotations[config] = sig_dict[k][pk][config] - - if len(default_or_annotations) > 0: - params_od[pk] = Parameter(name, _ParameterKind(kind), **default_or_annotations) - else: - params_od[pk] = Parameter(name, _ParameterKind(kind)) - - list_of_params = [v for k,v in params_od.items()] - return Signature(list_of_params) - -def get_signature_as_dict(f): - """ - dictify a function signature so it can be - applied to a proxy function - """ - sig = signature(f) - pars = sig.parameters - - pars_dict = {} - for par, par_item in pars.items(): - pars_dict[par] = {} - pars_dict[par]['name'] = par_item._name - pars_dict[par]['kind'] = par_item._kind.name - if not par_item._default is _empty: - pars_dict[par]['default'] = par_item._default - if not par_item._annotation is _empty: - #pars_dict[par]['annotation'] = str(par_item._annotation) - pars_dict[par]['annotation'] = pickle.dumps(par_item._annotation) - return {f.__name__: pars_dict} + return create_proxy_from_spec(config, proxy=proxy) + def get_origin_register(obj: object): """ @@ -124,7 +45,7 @@ def register(f, namespace): if not f.__name__ in obj.namespaces[namespace]: obj.namespaces[namespace][f.__name__] = {} obj.namespaces[namespace][f.__name__]['config'] = { - 'sig': get_signature_as_dict(f), + 'sig': serialize_function_signature(f), 'name': f.__name__, 'doc': f.__doc__, 'is_async': iscoroutinefunction(f) diff --git a/easyrpc/server.py b/easyrpc/server.py index 6c46d20..30ea214 100644 --- a/easyrpc/server.py +++ b/easyrpc/server.py @@ -2,7 +2,6 @@ import uuid, json, pickle import logging from concurrent.futures._base import CancelledError -from typing import Iterable from fastapi import FastAPI from fastapi.websockets import WebSocket, WebSocketDisconnect diff --git a/easyrpc/sigtools.py b/easyrpc/sigtools.py new file mode 100644 index 0000000..fe29851 --- /dev/null +++ b/easyrpc/sigtools.py @@ -0,0 +1,424 @@ +from __future__ import annotations +import enum +import importlib +import inspect +import json +import types +import typing +from typing import Any, Coroutine, get_args, get_origin + + +# ----------------------------- +# JSON value codec (for defaults + Annotated metadata) +# ----------------------------- + +def encode_json_value(v: Any) -> dict: + """JSON-safe codec that preserves tuple/list/dict distinctions.""" + if isinstance(v, enum.Enum): + return { + "k": "enum_member", + "enum_type": { + "module": v.__class__.__module__, + "qualname": v.__class__.__qualname__, + }, + "name": v.name, + } + if v is None or isinstance(v, (bool, int, float, str)): + return {"k": "prim", "v": v} + if isinstance(v, list): + return {"k": "list", "v": [encode_json_value(x) for x in v]} + if isinstance(v, tuple): + return {"k": "tuple", "v": [encode_json_value(x) for x in v]} + if isinstance(v, dict): + # Restrict to string keys for JSON objects + if not all(isinstance(k, str) for k in v): + raise TypeError("Only dict defaults/metadata with string keys are supported") + return {"k": "dict", "v": {k: encode_json_value(x) for k, x in v.items()}} + raise TypeError(f"Value is not supported by this JSON codec: {v!r}") + + +def decode_json_value(spec: dict) -> Any: + k = spec["k"] + if k == "enum_member": + cls = _import_qualname(spec["enum_type"]["module"], spec["enum_type"]["qualname"]) + return cls[spec["name"]] + if k == "prim": + return spec["v"] + if k == "list": + return [decode_json_value(x) for x in spec["v"]] + if k == "tuple": + return tuple(decode_json_value(x) for x in spec["v"]) + if k == "dict": + return {k: decode_json_value(x) for k, x in spec["v"].items()} + raise ValueError(f"Unknown JSON value kind: {k}") + + +# ----------------------------- +# Defaults codec +# ----------------------------- + +def encode_default(v: Any) -> dict: + if v is inspect._empty: + return {"kind": "empty"} + return {"kind": "value", "value": encode_json_value(v)} + + +def decode_default(spec: dict) -> Any: + if spec["kind"] == "empty": + return inspect._empty + if spec["kind"] == "value": + return decode_json_value(spec["value"]) + raise ValueError(f"Unknown default kind: {spec['kind']}") + + +# ----------------------------- +# Type annotation codec (recursive) +# ----------------------------- + +def encode_type(tp: Any) -> dict: + if tp is inspect._empty: + return {"kind": "empty"} + if tp is Any: + return {"kind": "any"} + if tp is Ellipsis: + return {"kind": "ellipsis"} + if tp is None or tp is type(None): + return {"kind": "none"} + + origin = get_origin(tp) + args = get_args(tp) + + if origin is typing.Literal: + return { + "kind": "literal", + "values": [encode_json_value(v) for v in args], + } + + # Annotated[T, ...] + if origin is typing.Annotated: + base, *meta = args + return { + "kind": "annotated", + "base": encode_type(base), + "metadata": [encode_json_value(m) for m in meta], # metadata must be JSON-able + } + + # Union / Optional / PEP 604 unions (A | B) + if origin is typing.Union or origin is types.UnionType: + return {"kind": "union", "args": [encode_type(a) for a in args]} + + # Generic aliases e.g. list[int], dict[str, int], tuple[int, ...], Literal["x"] + if origin is not None: + return { + "kind": "generic", + "origin": encode_type(origin), + "args": [encode_type(a) for a in args], + } + + # Plain runtime classes/types + if isinstance(tp, type): + return { + "kind": "type", + "module": tp.__module__, + "qualname": tp.__qualname__, + } + + # Best effort for some typing/runtime objects that expose module+qualname + mod = getattr(tp, "__module__", None) + qn = getattr(tp, "__qualname__", None) + if mod and qn: + return {"kind": "type", "module": mod, "qualname": qn} + + raise TypeError(f"Unsupported annotation for JSON encoding: {tp!r}") + + +def _import_qualname(module_name: str, qualname: str) -> Any: + mod = importlib.import_module(module_name) + obj = mod + for part in qualname.split("."): + obj = getattr(obj, part) + return obj + + +def decode_type(spec: dict) -> Any: + kind = spec["kind"] + + if kind == "empty": + return inspect._empty + if kind == "any": + return Any + if kind == "ellipsis": + return Ellipsis + if kind == "none": + return type(None) + + if kind == "literal": + vals = [decode_json_value(v) for v in spec["values"]] + return typing.Literal[tuple(vals)] # type: ignore[index] + + if kind == "type": + mod = spec["module"] + qn = spec["qualname"] + # builtins.NoneType may not be importable as attribute on builtins + if mod == "builtins" and qn == "NoneType": + return type(None) + return _import_qualname(mod, qn) + + if kind == "annotated": + base = decode_type(spec["base"]) + metadata = [decode_json_value(m) for m in spec["metadata"]] + return typing.Annotated[base, *metadata] + + if kind == "union": + items = [decode_type(a) for a in spec["args"]] + if not items: + raise ValueError("Union with no args is invalid") + return typing.Union[tuple(items)] # type: ignore[index] + + if kind == "generic": + origin = decode_type(spec["origin"]) + args = tuple(decode_type(a) for a in spec["args"]) + return origin[args] + + raise ValueError(f"Unknown annotation kind: {kind}") + + +# ----------------------------- +# Signature <-> JSON spec +# ----------------------------- + +def _encode_parameter(p: inspect.Parameter) -> dict: + return { + "name": p.name, + "kind": p.kind.name, # POSITIONAL_ONLY, VAR_POSITIONAL, etc. + "default": encode_default(p.default), + "annotation": encode_type(p.annotation), + } + + +def _decode_parameter(d: dict) -> inspect.Parameter: + return inspect.Parameter( + name=d["name"], + kind=getattr(inspect.Parameter, d["kind"]), + default=decode_default(d["default"]), + annotation=decode_type(d["annotation"]), + ) + + +def serialize_function_signature(fn: Any) -> dict: + """ + Returns a JSON-serializable dict describing the function signature and annotations. + """ + sig = inspect.signature(fn) + + # include_extras=True preserves Annotated metadata + try: + hints = typing.get_type_hints(fn, include_extras=True) + except Exception: + # Fallback if hints can't be resolved (e.g., unresolved forward refs) + hints = getattr(fn, "__annotations__", {}) or {} + + params = [] + for p in sig.parameters.values(): + ann = hints.get(p.name, p.annotation) + params.append(_encode_parameter(p.replace(annotation=ann))) + + ret_ann = hints.get("return", sig.return_annotation) + + return { + "version": 1, + "name": getattr(fn, "__name__", "anonymous"), + "parameters": params, + "return_annotation": encode_type(ret_ann), + "doc": getattr(fn, "__doc__", "") + } + + +def deserialize_signature(spec: dict) -> inspect.Signature: + if spec.get("version") != 1: + raise ValueError(f"Unsupported spec version: {spec.get('version')}") + params = [_decode_parameter(p) for p in spec["parameters"]] + ret = decode_type(spec["return_annotation"]) + return inspect.Signature(params, return_annotation=ret) + + +# ----------------------------- +# Runtime type validation (common cases) +# ----------------------------- + +def _check_type(value: Any, tp: Any) -> bool: + if tp is inspect._empty or tp is Any: + return True + if tp is None or tp is type(None): + return value is None + + origin = get_origin(tp) + args = get_args(tp) + + if origin is typing.Annotated: + # Validate against underlying type, ignore metadata for enforcement. + return _check_type(value, args[0]) + + if origin is typing.Union or origin is types.UnionType: + return any(_check_type(value, a) for a in args) + + if origin is typing.Literal: + return value in args + + if origin in (list, set, frozenset): + if not isinstance(value, origin): + return False + (elem_t,) = args or (Any,) + return all(_check_type(x, elem_t) for x in value) + + if origin is dict: + if not isinstance(value, dict): + return False + key_t, val_t = args if args else (Any, Any) + return all(_check_type(k, key_t) and _check_type(v, val_t) for k, v in value.items()) + + if origin is tuple: + if not isinstance(value, tuple): + return False + if not args: + return True + # tuple[T, ...] + if len(args) == 2 and args[1] is Ellipsis: + return all(_check_type(x, args[0]) for x in value) + # tuple[T1, T2, ...] + return len(value) == len(args) and all(_check_type(x, t) for x, t in zip(value, args)) + + # Fallback for many parameterized generics: shallow isinstance(origin) + if origin is not None: + try: + return isinstance(value, origin) + except TypeError: + return True # unsupported runtime-check generic + + if isinstance(tp, type): + return isinstance(value, tp) + + # Unknown/unsupported runtime-checkable annotation -> allow + return True + +def _coerce_value(value: Any, tp: Any) -> Any: + origin = get_origin(tp) + args = get_args(tp) + + if tp is inspect._empty or tp is Any: + return value + if tp is None or tp is type(None): + if value is None: + return None + raise TypeError(f"Expected None, got {value!r}") + + if origin is typing.Annotated: + return _coerce_value(value, args[0]) + + if origin is typing.Union or origin is types.UnionType: + last_err = None + for a in args: + try: + return _coerce_value(value, a) + except Exception as e: + last_err = e + raise TypeError(f"Value {value!r} does not match any Union option") from last_err + + if isinstance(tp, type) and issubclass(tp, enum.Enum): + if isinstance(value, tp): + return value + try: + return tp(value) # converts "a" -> CustomEnum.a + except Exception as e: + raise TypeError(f"Invalid enum value {value!r} for {tp.__name__}") from e + + # fallback: no coercion, just return original + if _check_type(value, tp): + return value + raise TypeError(f"Value {value!r} does not match {tp!r}") + + +# ----------------------------- +# Rebuild a stub function with matching call-shape + validation +# ----------------------------- + +def create_proxy_from_spec(spec: dict, proxy: typing.Callable[..., Any] = None) -> typing.Callable[..., Any]: + sig = deserialize_signature(spec['sig']) + + annotations = { + p.name: p.annotation + for p in sig.parameters.values() + if p.annotation is not inspect._empty + } + if sig.return_annotation is not inspect._empty: + annotations["return"] = sig.return_annotation + + # proxy.__name__ = spec.get("name", "stub") + # proxy.__annotations__ = annotations + # proxy.__signature__ = sig + # proxy.__doc__ = spec.get("doc", "") + + def stub(*args, **kwargs): + # Enforce same call signature rules (missing args, bad kwargs, etc.) + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + + for name, value in list(bound.arguments.items()): + p = sig.parameters[name] + ann = p.annotation + if ann is inspect._empty: + continue + + if p.kind is inspect.Parameter.VAR_POSITIONAL: + bound.arguments[name] = tuple(_coerce_value(x, ann) for x in value) + elif p.kind is inspect.Parameter.VAR_KEYWORD: + bound.arguments[name] = {k: _coerce_value(v, ann) for k, v in value.items()} + else: + bound.arguments[name] = _coerce_value(value, ann) + + result = proxy(*args, **kwargs) + return result + # if isinstance(result, Coroutine): + # return await result + # return result + + stub.__name__ = spec.get("name", "stub") + stub.__annotations__ = annotations + # Helps inspect.signature(stub) report the reconstructed signature + stub.__signature__ = sig + stub.__doc__ = spec.get("doc", "") + return stub + + +# ----------------------------- +# Example +# ----------------------------- +if __name__ == "__main__": + from typing import Annotated, Optional, Union + + def original( + a: int, + /, + b: Optional[Union[int, str]], + *args: float, + c: Annotated[list[int] | None, "meta"], + d: tuple[int, ...] = (1, 2), + **kw: int, + ) -> Annotated[bool, "ret"]: + return True + + # Store in a JSON field + spec = serialize_function_signature(original) + blob = json.dumps(spec) + + # Later... + restored_spec = json.loads(blob) + stub = create_proxy_from_spec(restored_spec) + + print(inspect.signature(stub)) # same displayed signature + stub(1, "x", 1.0, 2.0, c=[1, 2], extra=3) # OK + + try: + stub(1, "x", c=["bad"]) # list[int] validation fails + except TypeError as e: + print("Validation error:", e) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0e8b59c..74072e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -makefun==1.9.5 fastapi uvicorn aiohttp diff --git a/setup.py b/setup.py index ec58ec1..1262811 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,6 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], - python_requires='>=3.7, <4', - install_requires=['makefun==1.9.5', 'PyJWT==2.0.0', 'fastapi', 'uvicorn', 'websockets', 'aiohttp'], + python_requires='>=3.11.14, <4', + install_requires=['PyJWT==2.0.0', 'fastapi', 'uvicorn', 'websockets', 'aiohttp'], ) \ No newline at end of file diff --git a/tests/core.py b/tests/core.py index ffebcb3..c61cda7 100644 --- a/tests/core.py +++ b/tests/core.py @@ -1,60 +1,74 @@ -from enum import IntFlag +from typing import Literal, Union, Optional +from enum import Enum +from click import Tuple from fastapi import FastAPI from easyrpc.server import EasyRpcServer server = FastAPI() -@server.on_event('startup') -async def setup(): - math_server = EasyRpcServer( - server, - '/ws/core', - server_secret='abcd1234' - ) - - @math_server.origin(namespace='basic_math') - async def add(a: int, b: int): - return a + b - - @math_server.origin(namespace='basic_math') - async def subtract(a, b): - return a-b - - @math_server.origin(namespace='basic_math') - async def divide(a, b): - return a / b - - @math_server.origin(namespace='basic_math') - async def compare(a, b): - return a == b - - @math_server.origin(namespace='core') - async def get_dict(a, b, c): - return {a: a, b: b, c: c} - - @math_server.origin(namespace='core') - async def get_list(a, b, c): - return [a, b, c] - - @math_server.origin(namespace='core') - async def complex(obj): - return obj - - # generator - class Data: - a: int = 1 - b: float = 2.0 - c: bool = False - d: list = [1,2,3] - - @math_server.origin(namespace='core') - async def generator(): - data = Data() - yield data.a - yield data.b - yield data.c - yield data.d - @math_server.origin(namespace='core') - async def generate_objects(*args): - for object in args: - yield object +# @server.on_event('startup') +# async def setup(): +math_server = EasyRpcServer( + server, + '/ws/core', + server_secret='abcd1234' +) + +@math_server.origin(namespace='basic_math') +async def add(a: int, b: int): + return a + b + +@math_server.origin(namespace='basic_math') +async def subtract(a, b): + return a-b + +@math_server.origin(namespace='basic_math') +async def divide(a, b): + return a / b + +@math_server.origin(namespace='basic_math') +async def compare(a, b): + return a == b + +@math_server.origin(namespace='core') +async def get_dict(a, b, c): + return {a: a, b: b, c: c} + +@math_server.origin(namespace='core') +async def get_list(a, b, c): + return [a, b, c] + +@math_server.origin(namespace='core') +async def complex(obj): + return obj + +@math_server.origin(namespace='core') +async def annotations(a: int, b: Literal['a', 'b', 'c'], c: list, d: Optional[str] = None) -> Tuple: + return a, b, c, d + +class CustomEnum(str, Enum): + a = 'a' + b = 'b' + c = 'c' +@math_server.origin(namespace='core') +async def enum_test(e: CustomEnum) -> str: + return e + +# generator +class Data: + a: int = 1 + b: float = 2.0 + c: bool = False + d: list = [1,2,3] + +@math_server.origin(namespace='core') +async def generator(): + data = Data() + yield data.a + yield data.b + yield data.c + yield data.d +@math_server.origin(namespace='core') +async def generate_objects(*args): + for object in args: + yield object diff --git a/tests/test_core.py b/tests/test_core.py index 84e1fd6..b3c49ef 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -96,4 +96,30 @@ async def test_core_functionality(manager): for obj in objects: assert isinstance(obj, SomethingComplex), f'expected object is of type SomethingComplex' assert obj.test == 'test', f"expected 'test'" - assert obj.also == {'a': 1}, f"expected {{'a': 1}}" \ No newline at end of file + assert obj.also == {'a': 1}, f"expected {{'a': 1}}" + + # core & annotations + result = await core['annotations'](1, 'a', [1,2,3]) + + assert result[0] == 1, f"expected 1" + assert result[1] == 'a', f"expected 'a'" + assert result[2] == [1,2,3], f"expected {[1,2,3]}" + assert result[3] == None, f"expected None" + + # bad input + with pytest.raises(TypeError): + await core['annotations']('a', 'd', (1,2,3)) + + with pytest.raises(TypeError): + await core['annotations'](1, 'd', (1,2,3)) + + with pytest.raises(TypeError): + await core['annotations'](1, 'c', (1,2,3)) + + # enum test + assert await core['enum_test']('a') == 'a', f"expected 'a'" + assert await core['enum_test']('b') == 'b', f"expected 'b'" + assert await core['enum_test']('c') == 'c', f"expected 'c'" + + with pytest.raises(TypeError): + await core['enum_test']('f') \ No newline at end of file