ephaptic 0.1.2__py3-none-any.whl → 0.2.0__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/cli/__init__.py +3 -0
- ephaptic/cli/__main__.py +106 -0
- ephaptic/client/client.py +18 -10
- ephaptic/ephaptic.py +34 -3
- {ephaptic-0.1.2.dist-info → ephaptic-0.2.0.dist-info}/METADATA +34 -9
- {ephaptic-0.1.2.dist-info → ephaptic-0.2.0.dist-info}/RECORD +10 -7
- ephaptic-0.2.0.dist-info/entry_points.txt +2 -0
- {ephaptic-0.1.2.dist-info → ephaptic-0.2.0.dist-info}/WHEEL +0 -0
- {ephaptic-0.1.2.dist-info → ephaptic-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {ephaptic-0.1.2.dist-info → ephaptic-0.2.0.dist-info}/top_level.txt +0 -0
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/client/client.py
CHANGED
|
@@ -3,7 +3,7 @@ import msgpack
|
|
|
3
3
|
import websockets
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
|
-
from typing import Callable, Any
|
|
6
|
+
from typing import Callable, Any, Optional
|
|
7
7
|
import inspect
|
|
8
8
|
|
|
9
9
|
class EphapticClient:
|
|
@@ -67,21 +67,29 @@ class EphapticClient:
|
|
|
67
67
|
except Exception as e:
|
|
68
68
|
logging.error(f"Connection error: {e}")
|
|
69
69
|
|
|
70
|
-
def on(self, event_name, func):
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
def on(self, event_name, func: Optional[Callable] = None):
|
|
71
|
+
def decorator(f):
|
|
72
|
+
if event_name not in self._event_handlers: self._event_handlers[event_name] = set()
|
|
73
|
+
self._event_handlers[event_name].add(func)
|
|
74
|
+
return f
|
|
75
|
+
|
|
76
|
+
return decorator(func) if func else decorator
|
|
73
77
|
|
|
74
|
-
def off(self, event_name, func):
|
|
78
|
+
def off(self, event_name, func: Callable):
|
|
75
79
|
if event_name not in self._event_handlers: return
|
|
76
80
|
s = self._event_handlers[event_name]
|
|
77
81
|
s.discard(func)
|
|
78
82
|
if not s: del self._event_handlers[event_name]
|
|
79
83
|
|
|
80
|
-
def once(self, event_name, func):
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
def once(self, event_name, func: Optional[Callable] = None):
|
|
85
|
+
def decorator(f):
|
|
86
|
+
async def wrapper(*args, **kwargs):
|
|
87
|
+
self.off(event_name, wrapper)
|
|
88
|
+
func(*args, **kwargs)
|
|
89
|
+
self.on(event_name, wrapper)
|
|
90
|
+
return f
|
|
91
|
+
|
|
92
|
+
return decorator(func) if func else decorator
|
|
85
93
|
|
|
86
94
|
def __getattr__(self, name):
|
|
87
95
|
async def remote_call(*args, **kwargs):
|
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
|
|
|
@@ -91,12 +93,11 @@ class Ephaptic:
|
|
|
91
93
|
|
|
92
94
|
@classmethod
|
|
93
95
|
def from_app(cls, app, path="/_ephaptic", redis_url=None):
|
|
94
|
-
# `app` could be Flask
|
|
96
|
+
# `app` could be ~Flask~, Quart, FastAPI, etc.
|
|
95
97
|
instance = cls()
|
|
96
98
|
|
|
97
99
|
if redis_url:
|
|
98
100
|
manager.init_redis(redis_url)
|
|
99
|
-
# TODO: framework-specific hooks for the background listener.
|
|
100
101
|
|
|
101
102
|
module = app.__class__.__module__.split(".")[0]
|
|
102
103
|
|
|
@@ -177,11 +178,41 @@ class Ephaptic:
|
|
|
177
178
|
|
|
178
179
|
if func_name in self._exposed_functions:
|
|
179
180
|
target_func = self._exposed_functions[func_name]
|
|
181
|
+
sig = inspect.signature(target_func)
|
|
182
|
+
try:
|
|
183
|
+
bound = sig.bind(*args, **kwargs)
|
|
184
|
+
bound.apply_defaults()
|
|
185
|
+
except TypeError as e:
|
|
186
|
+
await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
hints = typing.get_type_hints(target_func)
|
|
190
|
+
|
|
191
|
+
errors = []
|
|
192
|
+
|
|
193
|
+
for name, val in bound.arguments.items():
|
|
194
|
+
hint = hints.get(name)
|
|
195
|
+
|
|
196
|
+
if hint and inspect.isclass(hint) and issubclass(hint, pydantic.BaseModel):
|
|
197
|
+
try:
|
|
198
|
+
bound.arguments[name] = hint.model_validate(val)
|
|
199
|
+
except pydantic.ValidationError as e:
|
|
200
|
+
errors.extend(e.errors())
|
|
201
|
+
|
|
202
|
+
if errors:
|
|
203
|
+
await transport.send(msgpack.dumps({
|
|
204
|
+
"id": call_id,
|
|
205
|
+
"error": errors,
|
|
206
|
+
}))
|
|
207
|
+
continue
|
|
208
|
+
|
|
180
209
|
token_transport = _active_transport_ctx.set(transport)
|
|
181
210
|
token_user = _active_user_ctx.set(current_uid)
|
|
182
211
|
|
|
183
212
|
try:
|
|
184
|
-
result = await self._async(target_func)(
|
|
213
|
+
result = await self._async(target_func)(**bound.arguments)
|
|
214
|
+
if isinstance(result, pydantic.BaseModel):
|
|
215
|
+
result = result.model_dump()
|
|
185
216
|
await transport.send(msgpack.dumps({"id": call_id, "result": result}))
|
|
186
217
|
except Exception as e:
|
|
187
218
|
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.
|
|
3
|
+
Version: 0.2.0
|
|
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,13 @@ 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
|
|
|
132
135
|
Yep, it's really that simple.
|
|
133
136
|
|
|
134
|
-
But what if your code throws an error? No sweat, it just throws up on the frontend with the
|
|
137
|
+
But what if your code throws an error? No sweat, it just throws up on the frontend, with the error name.
|
|
135
138
|
|
|
136
139
|
And, want to say something to the frontend?
|
|
137
140
|
|
|
@@ -139,6 +142,13 @@ And, want to say something to the frontend?
|
|
|
139
142
|
await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
|
|
140
143
|
```
|
|
141
144
|
|
|
145
|
+
To create a schema of your RPC endpoints:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
$ ephaptic src.app:app -o schema.json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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
152
|
|
|
143
153
|
</details>
|
|
144
154
|
|
|
@@ -148,7 +158,7 @@ await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
|
|
|
148
158
|
#### To use with a framework / Vite:
|
|
149
159
|
|
|
150
160
|
```
|
|
151
|
-
npm install @ephaptic/client
|
|
161
|
+
$ npm install @ephaptic/client
|
|
152
162
|
```
|
|
153
163
|
|
|
154
164
|
Then:
|
|
@@ -175,13 +185,28 @@ You can even send auth objects to the server for identity loading.
|
|
|
175
185
|
const client = connect({ url: '...', auth: { token: window.localStorage.getItem('jwtToken') } })
|
|
176
186
|
```
|
|
177
187
|
|
|
188
|
+
And you can load types, too.
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
$ npm i --save-dev @ephaptic/type-gen
|
|
192
|
+
$ npx @ephaptic/type-gen ./schema.json -o schema.d.ts
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { connect } from "@ephaptic/client";
|
|
197
|
+
import { type EphapticService } from './schema';
|
|
198
|
+
|
|
199
|
+
const client = connect(...) as unknown as EphapticService;
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
|
|
178
203
|
#### Or, to use in your browser:
|
|
179
204
|
|
|
180
205
|
```html
|
|
181
206
|
<script type="module">
|
|
182
207
|
import { connect } from 'https://cdn.jsdelivr.net/npm/@ephaptic/client@latest/+esm';
|
|
183
208
|
|
|
184
|
-
const client = connect();
|
|
209
|
+
const client = connect(...);
|
|
185
210
|
</script>
|
|
186
211
|
```
|
|
187
212
|
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
ephaptic/__init__.py,sha256=1_7Rwb0WCZS2rbOOEVIctFGqF0Kt81JCWR7Mm3Ry4F4,92
|
|
2
|
-
ephaptic/ephaptic.py,sha256=
|
|
2
|
+
ephaptic/ephaptic.py,sha256=6k62iCSRZmpqt8ka8JgZePeZqojxY6gj6_u1yTPWTeE,8681
|
|
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
|
-
ephaptic/client/client.py,sha256=
|
|
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.
|
|
13
|
-
ephaptic-0.
|
|
14
|
-
ephaptic-0.
|
|
15
|
-
ephaptic-0.
|
|
16
|
-
ephaptic-0.
|
|
14
|
+
ephaptic-0.2.0.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
|
|
15
|
+
ephaptic-0.2.0.dist-info/METADATA,sha256=EiURw4x9UBZiNxhRchj1oXOys-_HECdx_b2w6Vze3bs,7309
|
|
16
|
+
ephaptic-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
ephaptic-0.2.0.dist-info/entry_points.txt,sha256=lis9vIDIrMVJM43r9ooFkb07KhrX4946duvYru3VfT4,46
|
|
18
|
+
ephaptic-0.2.0.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
|
|
19
|
+
ephaptic-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|