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 +3 -1
- ephaptic/cli/__init__.py +3 -0
- ephaptic/cli/__main__.py +106 -0
- ephaptic/ephaptic.py +48 -3
- {ephaptic-0.1.3.dist-info → ephaptic-0.2.1.dist-info}/METADATA +37 -9
- {ephaptic-0.1.3.dist-info → ephaptic-0.2.1.dist-info}/RECORD +10 -7
- ephaptic-0.2.1.dist-info/entry_points.txt +2 -0
- {ephaptic-0.1.3.dist-info → ephaptic-0.2.1.dist-info}/WHEEL +0 -0
- {ephaptic-0.1.3.dist-info → ephaptic-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {ephaptic-0.1.3.dist-info → ephaptic-0.2.1.dist-info}/top_level.txt +0 -0
ephaptic/__init__.py
CHANGED
ephaptic/cli/__init__.py
ADDED
ephaptic/cli/__main__.py
ADDED
|
@@ -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
|
|
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)(
|
|
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
|
+
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.
|
|
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
|
-
|
|
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
|
|
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=
|
|
2
|
-
ephaptic/ephaptic.py,sha256=
|
|
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.
|
|
13
|
-
ephaptic-0.1.
|
|
14
|
-
ephaptic-0.1.
|
|
15
|
-
ephaptic-0.1.
|
|
16
|
-
ephaptic-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|