ephaptic 0.1.3__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
ephaptic/__init__.py CHANGED
@@ -1,6 +1,8 @@
1
1
  from .ephaptic import (
2
2
  Ephaptic,
3
- active_user
3
+ active_user,
4
+ expose,
5
+ identity_loader,
4
6
  )
5
7
 
6
8
  from .client import (
@@ -0,0 +1,3 @@
1
+ from .__main__ import app
2
+
3
+ __all__ = ["app"]
@@ -0,0 +1,106 @@
1
+ import sys, os, json, inspect, importlib, typing, typer
2
+
3
+ from pathlib import Path
4
+ from pydantic import TypeAdapter
5
+ from pydantic.json_schema import models_json_schema
6
+
7
+ from ephaptic import Ephaptic
8
+
9
+ app = typer.Typer(help="Ephaptic CLI tool.")
10
+
11
+ def load_ephaptic(import_name: str) -> Ephaptic:
12
+ try:
13
+ from dotenv import load_dotenv; load_dotenv()
14
+ except: ...
15
+
16
+ sys.path.insert(0, os.getcwd())
17
+
18
+ if ":" not in import_name:
19
+ typer.secho(f"Warning: Import name did not specify app name. Defaulting to `app`.", fg=typer.colors.YELLOW)
20
+ import_name += ":app" # default: expect app to be named `app` inside the file
21
+
22
+ module_name, var_name = import_name.split(":", 1)
23
+
24
+ try:
25
+ typer.secho(f"Attempting to import `{var_name}` from `{module_name}`...")
26
+ module = importlib.import_module(module_name)
27
+ except ImportError as e:
28
+ typer.secho(f"Error: Can't import '{module_name}'.\n{e}", fg=typer.colors.RED)
29
+ raise typer.Exit(1)
30
+
31
+ try:
32
+ instance = getattr(module, var_name)
33
+ except AttributeError:
34
+ typer.secho(f"Error: Variable '{var_name}' not found in module '{module_name}'.", fg=typer.colors.RED)
35
+ raise typer.Exit(1)
36
+
37
+ if not isinstance(instance, Ephaptic):
38
+ typer.secho(f"Error: '{var_name}' is not an Ephaptic instance. It is type: {type(instance)}", fg=typer.colors.RED)
39
+ raise typer.Exit(1)
40
+
41
+ return instance
42
+
43
+ def create_schema(adapter: TypeAdapter, definitions: dict) -> dict:
44
+ schema = adapter.json_schema(ref_template='#/definitions/{model}')
45
+
46
+ if '$defs' in schema:
47
+ definitions.update(schema.pop('$defs'))
48
+
49
+ if schema.get('type') == 'object' and 'title' in schema:
50
+ model = schema['title']
51
+ definitions[model] = schema
52
+ return { '$ref': f'#/definitions/{model}' }
53
+
54
+ return schema
55
+
56
+ @app.command()
57
+ def generate(
58
+ app: str = typer.Argument('app:app', help="The import string. (Default: `app:app`)"),
59
+ output: Path = typer.Option('schema.json', '--output', '-o', help="Output path for the JSON schema.")
60
+ ):
61
+ ephaptic = load_ephaptic(app)
62
+
63
+ typer.secho(f"Found {len(ephaptic._exposed_functions)} functions.", fg=typer.colors.GREEN)
64
+
65
+ schema_output = {
66
+ "methods": {},
67
+ "definitions": {},
68
+ }
69
+
70
+ for name, func in ephaptic._exposed_functions.items():
71
+ typer.secho(f" - {name}")
72
+
73
+ hints = typing.get_type_hints(func)
74
+ sig = inspect.signature(func)
75
+
76
+ method_schema = {
77
+ "args": {},
78
+ "return": None
79
+ }
80
+
81
+ for param_name in sig.parameters:
82
+ hint = hints.get(param_name, typing.Any)
83
+ adapter = TypeAdapter(hint)
84
+
85
+ method_schema["args"][param_name] = create_schema(
86
+ adapter,
87
+ schema_output["definitions"],
88
+ )
89
+
90
+ return_hint = hints.get("return", typing.Any)
91
+ if return_hint is not type(None):
92
+ adapter = TypeAdapter(return_hint)
93
+ method_schema["return"] = create_schema(
94
+ adapter,
95
+ schema_output["definitions"],
96
+ )
97
+
98
+ schema_output["methods"][name] = method_schema
99
+
100
+ with open(output, "w") as f:
101
+ json.dump(schema_output, f, indent=2)
102
+
103
+ typer.secho(f"Schema generated to `{output}`.", fg=typer.colors.GREEN, bold=True)
104
+
105
+ if __name__ == "__main__":
106
+ app()
ephaptic/ephaptic.py CHANGED
@@ -1,12 +1,14 @@
1
1
  import asyncio
2
2
  import msgpack
3
3
  import redis.asyncio as redis
4
+ import pydantic
4
5
 
5
6
  from contextvars import ContextVar
6
7
  from .localproxy import LocalProxy
7
8
 
8
9
  from .transports import Transport
9
10
 
11
+ import typing
10
12
  from typing import Optional, Callable, Any, List, Set, Dict
11
13
  import inspect
12
14
 
@@ -65,6 +67,9 @@ class ConnectionManager:
65
67
 
66
68
  manager = ConnectionManager()
67
69
 
70
+ _EXPOSED_FUNCTIONS = {}
71
+ _IDENTITY_LOADER: Optional[Callable] = None
72
+
68
73
  class EphapticTarget:
69
74
  def __init__(self, user_ids: list[str]):
70
75
  self.user_ids = user_ids
@@ -74,6 +79,14 @@ class EphapticTarget:
74
79
  await manager.broadcast(self.user_ids, name, list(args), dict(kwargs))
75
80
  return emitter
76
81
 
82
+ def expose(func: Callable):
83
+ _EXPOSED_FUNCTIONS[func.__name__] = func
84
+ return func
85
+
86
+ def identity_loader(func: Callable):
87
+ _IDENTITY_LOADER = func
88
+ return func
89
+
77
90
  class Ephaptic:
78
91
  _exposed_functions: Dict[str, Callable] = {}
79
92
  _identity_loader: Optional[Callable] = None
@@ -91,12 +104,11 @@ class Ephaptic:
91
104
 
92
105
  @classmethod
93
106
  def from_app(cls, app, path="/_ephaptic", redis_url=None):
94
- # `app` could be Flask, Quart, FastAPI, etc.
107
+ # `app` could be ~Flask~, Quart, FastAPI, etc.
95
108
  instance = cls()
96
109
 
97
110
  if redis_url:
98
111
  manager.init_redis(redis_url)
99
- # TODO: framework-specific hooks for the background listener.
100
112
 
101
113
  module = app.__class__.__module__.split(".")[0]
102
114
 
@@ -110,6 +122,9 @@ class Ephaptic:
110
122
  case _:
111
123
  raise TypeError(f"Unsupported app type: {module}")
112
124
 
125
+ cls._exposed_functions = _EXPOSED_FUNCTIONS.copy()
126
+ cls._identity_loader = _IDENTITY_LOADER
127
+
113
128
  return instance
114
129
 
115
130
 
@@ -177,11 +192,41 @@ class Ephaptic:
177
192
 
178
193
  if func_name in self._exposed_functions:
179
194
  target_func = self._exposed_functions[func_name]
195
+ sig = inspect.signature(target_func)
196
+ try:
197
+ bound = sig.bind(*args, **kwargs)
198
+ bound.apply_defaults()
199
+ except TypeError as e:
200
+ await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
201
+ continue
202
+
203
+ hints = typing.get_type_hints(target_func)
204
+
205
+ errors = []
206
+
207
+ for name, val in bound.arguments.items():
208
+ hint = hints.get(name)
209
+
210
+ if hint and inspect.isclass(hint) and issubclass(hint, pydantic.BaseModel):
211
+ try:
212
+ bound.arguments[name] = hint.model_validate(val)
213
+ except pydantic.ValidationError as e:
214
+ errors.extend(e.errors())
215
+
216
+ if errors:
217
+ await transport.send(msgpack.dumps({
218
+ "id": call_id,
219
+ "error": errors,
220
+ }))
221
+ continue
222
+
180
223
  token_transport = _active_transport_ctx.set(transport)
181
224
  token_user = _active_user_ctx.set(current_uid)
182
225
 
183
226
  try:
184
- result = await self._async(target_func)(*args, **kwargs)
227
+ result = await self._async(target_func)(**bound.arguments)
228
+ if isinstance(result, pydantic.BaseModel):
229
+ result = result.model_dump()
185
230
  await transport.send(msgpack.dumps({"id": call_id, "result": result}))
186
231
  except Exception as e:
187
232
  await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ephaptic
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: The Python client/server package for ephaptic.
5
5
  Author-email: uukelele <robustrobot11@gmail.com>
6
6
  License: MIT License
@@ -33,6 +33,9 @@ Description-Content-Type: text/markdown
33
33
  License-File: LICENSE
34
34
  Requires-Dist: msgpack>=1.0.0
35
35
  Requires-Dist: websockets>=12.0
36
+ Requires-Dist: pydantic>=2.0
37
+ Requires-Dist: typer[standard]>=0.20.0
38
+ Requires-Dist: python-dotenv
36
39
  Provides-Extra: server
37
40
  Requires-Dist: redis; extra == "server"
38
41
  Dynamic: license-file
@@ -58,8 +61,6 @@ Dynamic: license-file
58
61
  </a>
59
62
 
60
63
 
61
-
62
-
63
64
  </div>
64
65
 
65
66
  ## What is `ephaptic`?
@@ -73,7 +74,7 @@ Dynamic: license-file
73
74
 
74
75
  Nah, just kidding. It's an RPC framework.
75
76
 
76
- > **ephaptic** — Call your backend straight from your frontend. No JSON. No latency. No middleware.
77
+ > **ephaptic** — Call your backend straight from your frontend. No JSON. Low latency. Invisible middleware.
77
78
 
78
79
  ## Getting Started
79
80
 
@@ -83,7 +84,9 @@ Nah, just kidding. It's an RPC framework.
83
84
 
84
85
  - Oh, and the client can also listen to events broadcasted by the server. No, like literally. You just need to add an `eventListener`. Did I mention? Events can be sent to specific targets, specific users - not just anyone online.
85
86
 
86
- What are you waiting for? **Let's go.**
87
+ - Saved the best for last: it's type-safe. Don't believe me? Try it out for yourself. Simply type hint return values and parameters on the backend, and watch those very Python types transform into interfaces and types on the TypeScript frontend. Plus, you can use Pydantic - which means, for those of you who are FastAPI users, this is going to be great.
88
+
89
+ What are you waiting for? **Let's go.**
87
90
 
88
91
  <details>
89
92
  <summary>Python</summary>
@@ -125,13 +128,16 @@ Now, how do you expose your function to the frontend?
125
128
 
126
129
  ```python
127
130
  @ephaptic.expose
128
- async def add(num1, num2):
131
+ async def add(num1: int, num2: int) -> int:
129
132
  return num1 + num2
130
133
  ```
131
134
 
135
+ > [!TIP]
136
+ > If you're experiencing circular imports, feel free to instead import and use the `expose` function from the library instead of the instance. Please note that if you do this, you must define all exposed functions *before* creating the ephaptic instance - this is mainly for people importing RPC functions from another file. The same thing can be done with the global `identity_loader` decorator.
137
+
132
138
  Yep, it's really that simple.
133
139
 
134
- But what if your code throws an error? No sweat, it just throws up on the frontend with the same details.
140
+ But what if your code throws an error? No sweat, it just throws up on the frontend, with the error name.
135
141
 
136
142
  And, want to say something to the frontend?
137
143
 
@@ -139,6 +145,13 @@ And, want to say something to the frontend?
139
145
  await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
140
146
  ```
141
147
 
148
+ To create a schema of your RPC endpoints:
149
+
150
+ ```
151
+ $ ephaptic src.app:app -o schema.json
152
+ ```
153
+
154
+ Pydantic is entirely supported. It's validated for arguments, it's auto-serialized when you return a pydantic model, and your models receive type definitions in the schema.
142
155
 
143
156
  </details>
144
157
 
@@ -148,7 +161,7 @@ await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
148
161
  #### To use with a framework / Vite:
149
162
 
150
163
  ```
151
- npm install @ephaptic/client
164
+ $ npm install @ephaptic/client
152
165
  ```
153
166
 
154
167
  Then:
@@ -175,13 +188,28 @@ You can even send auth objects to the server for identity loading.
175
188
  const client = connect({ url: '...', auth: { token: window.localStorage.getItem('jwtToken') } })
176
189
  ```
177
190
 
191
+ And you can load types, too.
192
+
193
+ ```
194
+ $ npm i --save-dev @ephaptic/type-gen
195
+ $ npx @ephaptic/type-gen ./schema.json -o schema.d.ts
196
+ ```
197
+
198
+ ```typescript
199
+ import { connect } from "@ephaptic/client";
200
+ import { type EphapticService } from './schema';
201
+
202
+ const client = connect(...) as unknown as EphapticService;
203
+ ```
204
+
205
+
178
206
  #### Or, to use in your browser:
179
207
 
180
208
  ```html
181
209
  <script type="module">
182
210
  import { connect } from 'https://cdn.jsdelivr.net/npm/@ephaptic/client@latest/+esm';
183
211
 
184
- const client = connect();
212
+ const client = connect(...);
185
213
  </script>
186
214
  ```
187
215
 
@@ -1,16 +1,19 @@
1
- ephaptic/__init__.py,sha256=1_7Rwb0WCZS2rbOOEVIctFGqF0Kt81JCWR7Mm3Ry4F4,92
2
- ephaptic/ephaptic.py,sha256=m8gKQ_sjDVkap1EpbimuMvFa74eiRRpWkJd43yph8_c,7355
1
+ ephaptic/__init__.py,sha256=3GmVqhbye3snH7KfnyqIt8t_w6ULnWOvDw07eB13iDA,126
2
+ ephaptic/ephaptic.py,sha256=K9cOUk8guI6IesBACkdUxebI2UXpN-0yJfrhjHUC3iI,9034
3
3
  ephaptic/localproxy.py,sha256=fJaaskkiD6C2zaOod0F0HNWIbdKs_JMuHFwd0-sdLIM,19477
4
4
  ephaptic/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  ephaptic/adapters/fastapi_.py,sha256=yfSbJuA7Tgeh9EhZkfIve0Uj-cOZmTBljlBsCRKh2EE,1007
6
6
  ephaptic/adapters/quart_.py,sha256=MBo9g6h_zI63mL4aGdrvV5yEXsHaOd0Iv5J8SAPHBoA,537
7
+ ephaptic/cli/__init__.py,sha256=p_mYumuQLr3HZa-6I4QKut6khZv3WQjEX-B-aa4cdEE,44
8
+ ephaptic/cli/__main__.py,sha256=RNEL4c_MkEKV7kY9pswC1ihNToiaVb06t1aZ4dCfk9s,3389
7
9
  ephaptic/client/__init__.py,sha256=NeaPIzTFeozP54wlDYHIg_adHP3Z3LWVujsRUlpn4_U,35
8
10
  ephaptic/client/client.py,sha256=YYAlzA40xBvWsiDu0Gsd1EBJaqivLR-bSszepWdNODs,4181
9
11
  ephaptic/transports/__init__.py,sha256=kSAlgvm8sV9nHHu61LTjjTpv4bweah90xvFrwQMDQtQ,169
10
12
  ephaptic/transports/fastapi_ws.py,sha256=X0PMRcwM-KDpKA-zXShGTFhD1kHMSqrx3PBBKZtQ1W0,258
11
13
  ephaptic/transports/websocket.py,sha256=jwgclSDSq0lQCvgwjwUXe9MzPk7NH0FdmsLhWxYBh-4,261
12
- ephaptic-0.1.3.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
13
- ephaptic-0.1.3.dist-info/METADATA,sha256=Qs74Bl4B09bcz1d3UMlCoEgFSd3aDnU4X7uthC25qLg,6263
14
- ephaptic-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- ephaptic-0.1.3.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
16
- ephaptic-0.1.3.dist-info/RECORD,,
14
+ ephaptic-0.2.1.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
15
+ ephaptic-0.2.1.dist-info/METADATA,sha256=9-6m-C21Y01fry6UHDGoQ-2LtUqYpI_rOxJZv_fzaGM,7716
16
+ ephaptic-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ ephaptic-0.2.1.dist-info/entry_points.txt,sha256=lis9vIDIrMVJM43r9ooFkb07KhrX4946duvYru3VfT4,46
18
+ ephaptic-0.2.1.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
19
+ ephaptic-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ephaptic = ephaptic.cli:app