ephaptic 0.1.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/__init__.py +7 -0
- ephaptic/adapters/__init__.py +0 -0
- ephaptic/adapters/fastapi_.py +30 -0
- ephaptic/adapters/quart_.py +16 -0
- ephaptic/client/__init__.py +3 -0
- ephaptic/client/client.py +114 -0
- ephaptic/ephaptic.py +200 -0
- ephaptic/localproxy.py +557 -0
- ephaptic/transports/__init__.py +5 -0
- ephaptic/transports/fastapi_ws.py +8 -0
- ephaptic/transports/websocket.py +11 -0
- ephaptic-0.1.0.dist-info/METADATA +188 -0
- ephaptic-0.1.0.dist-info/RECORD +16 -0
- ephaptic-0.1.0.dist-info/WHEEL +5 -0
- ephaptic-0.1.0.dist-info/licenses/LICENSE +21 -0
- ephaptic-0.1.0.dist-info/top_level.txt +1 -0
ephaptic/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from fastapi import FastAPI, WebSocket
|
|
2
|
+
from ..transports.fastapi_ws import FastAPIWebSocketTransport
|
|
3
|
+
|
|
4
|
+
class FastAPIAdapter:
|
|
5
|
+
def __init__(self, ephaptic, app: FastAPI, path, manager):
|
|
6
|
+
self.ephaptic = ephaptic
|
|
7
|
+
|
|
8
|
+
@app.websocket(path)
|
|
9
|
+
async def ephaptic_ws(websocket: WebSocket):
|
|
10
|
+
await websocket.accept()
|
|
11
|
+
transport = FastAPIWebSocketTransport(websocket)
|
|
12
|
+
await self.ephaptic.handle_transport(transport)
|
|
13
|
+
|
|
14
|
+
if manager.redis:
|
|
15
|
+
lifespan = app.router.lifespan_context
|
|
16
|
+
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def ephaptic_lifespan_wrapper(app):
|
|
22
|
+
asyncio.create_task(manager.start_redis())
|
|
23
|
+
|
|
24
|
+
if lifespan:
|
|
25
|
+
async with lifespan(app) as state:
|
|
26
|
+
yield state
|
|
27
|
+
else:
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
app.router.lifespan_context = ephaptic_lifespan_wrapper
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from quart import websocket, Quart
|
|
2
|
+
from ..transports.websocket import WebSocketTransport
|
|
3
|
+
|
|
4
|
+
class QuartAdapter:
|
|
5
|
+
def __init__(self, ephaptic, app: Quart, path, manager):
|
|
6
|
+
self.ephaptic = ephaptic
|
|
7
|
+
|
|
8
|
+
@app.websocket(path)
|
|
9
|
+
async def ephaptic_ws():
|
|
10
|
+
transport = WebSocketTransport(websocket)
|
|
11
|
+
await self.ephaptic.handle_transport(transport)
|
|
12
|
+
|
|
13
|
+
if manager.redis:
|
|
14
|
+
@app.before_serving
|
|
15
|
+
async def start_redis():
|
|
16
|
+
app.add_background_task(manager.start_redis)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import msgpack
|
|
3
|
+
import websockets
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Any
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
9
|
+
class EphapticClient:
|
|
10
|
+
def __init__(self, url: str, auth = None):
|
|
11
|
+
self.url = url
|
|
12
|
+
self.auth = auth
|
|
13
|
+
self.ws = None
|
|
14
|
+
self._call_id = 0
|
|
15
|
+
self._pending_calls = {} # id -> asyncio.Future (asyncio.Future is a Python equivalent of a Promise)
|
|
16
|
+
self._event_handlers = {} # name: str -> Set(Callable)
|
|
17
|
+
self._listen_task = None
|
|
18
|
+
|
|
19
|
+
def _async(self, func: Callable):
|
|
20
|
+
async def wrapper(*args, **kwargs) -> Any:
|
|
21
|
+
if inspect.iscoroutinefunction(func):
|
|
22
|
+
return await func(*args, **kwargs)
|
|
23
|
+
else:
|
|
24
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
async def connect(self):
|
|
28
|
+
if self.ws: return
|
|
29
|
+
|
|
30
|
+
self.ws = await websockets.connect(self.url)
|
|
31
|
+
|
|
32
|
+
payload = {"type": "init"}
|
|
33
|
+
if self.auth: payload["auth"] = self.auth
|
|
34
|
+
|
|
35
|
+
await self.ws.send(msgpack.dumps(payload))
|
|
36
|
+
|
|
37
|
+
self._listen_task = asyncio.create_task(self._listener())
|
|
38
|
+
|
|
39
|
+
async def _listener(self):
|
|
40
|
+
try:
|
|
41
|
+
async for message in self.ws:
|
|
42
|
+
data = msgpack.loads(message)
|
|
43
|
+
|
|
44
|
+
if data.get('id') is not None:
|
|
45
|
+
call_id = data['id']
|
|
46
|
+
if call_id in self._pending_calls:
|
|
47
|
+
future = self._pending_calls.pop(call_id)
|
|
48
|
+
if 'error' in data:
|
|
49
|
+
future.set_exception(Exception(data['error']))
|
|
50
|
+
else:
|
|
51
|
+
future.set_result(data.get('result'))
|
|
52
|
+
|
|
53
|
+
elif data.get('type') == 'event':
|
|
54
|
+
name = data['name']
|
|
55
|
+
payload = data.get('payload', {})
|
|
56
|
+
args = payload.get('args', [])
|
|
57
|
+
kwargs = payload.get('kwargs', {})
|
|
58
|
+
|
|
59
|
+
if name in self._event_handlers:
|
|
60
|
+
for handler in self._event_handlers[name]:
|
|
61
|
+
try:
|
|
62
|
+
asyncio.create_task(self._async(handler)(*args, **kwargs))
|
|
63
|
+
# We don't await it, we want to execute all handlers in parallel.
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logging.error(f"Error in event handler {name}: {e}")
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logging.error(f"Connection error: {e}")
|
|
69
|
+
|
|
70
|
+
def on(self, event_name, func):
|
|
71
|
+
if event_name not in self._event_handlers: self._event_handlers[event_name] = set()
|
|
72
|
+
self._event_handlers[event_name].add(func)
|
|
73
|
+
|
|
74
|
+
def off(self, event_name, func):
|
|
75
|
+
if event_name not in self._event_handlers: return
|
|
76
|
+
s = self._event_handlers[event_name]
|
|
77
|
+
s.discard(func)
|
|
78
|
+
if not s: del self._event_handlers[event_name]
|
|
79
|
+
|
|
80
|
+
def once(self, event_name, func):
|
|
81
|
+
async def wrapper(*args, **kwargs):
|
|
82
|
+
self.off(event_name, wrapper)
|
|
83
|
+
func(*args, **kwargs)
|
|
84
|
+
self.on(event_name, wrapper)
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name):
|
|
87
|
+
async def remote_call(*args, **kwargs):
|
|
88
|
+
if not self.ws: await self.connect()
|
|
89
|
+
|
|
90
|
+
self._call_id += 1
|
|
91
|
+
call_id = self._call_id
|
|
92
|
+
|
|
93
|
+
future = asyncio.Future()
|
|
94
|
+
self._pending_calls[call_id] = future
|
|
95
|
+
|
|
96
|
+
payload = {
|
|
97
|
+
"type": "rpc",
|
|
98
|
+
"id": call_id,
|
|
99
|
+
"name": name,
|
|
100
|
+
"args": args,
|
|
101
|
+
"kwargs": kwargs,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await self.ws.send(msgpack.dumps(payload))
|
|
105
|
+
return await future
|
|
106
|
+
|
|
107
|
+
return remote_call
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def connect(url: str = "ws://localhost:8000/_ephaptic", auth = None):
|
|
112
|
+
client = EphapticClient(url, auth)
|
|
113
|
+
await client.connect()
|
|
114
|
+
return client
|
ephaptic/ephaptic.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import msgpack
|
|
3
|
+
import redis.asyncio as redis
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from .localproxy import LocalProxy
|
|
7
|
+
|
|
8
|
+
from .transports import Transport
|
|
9
|
+
|
|
10
|
+
from typing import Optional, Callable, Any, List, Set, Dict
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
_active_transport_ctx = ContextVar('active_transport', default=None)
|
|
14
|
+
_active_user_ctx = ContextVar('active_user', default=None)
|
|
15
|
+
|
|
16
|
+
active_user = LocalProxy(_active_user_ctx.get)
|
|
17
|
+
|
|
18
|
+
CHANNEL_NAME = "ephaptic:broadcast"
|
|
19
|
+
|
|
20
|
+
class ConnectionManager:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.active: Dict[str, Set[Transport]] = {} # Map[user_id, Set[Transport]]
|
|
23
|
+
self.redis: Optional[redis.Redis] = None
|
|
24
|
+
|
|
25
|
+
def init_redis(self, url: str):
|
|
26
|
+
self.redis = redis.from_url(url)
|
|
27
|
+
|
|
28
|
+
def add(self, user_id: str, transport: Transport):
|
|
29
|
+
if user_id not in self.active: self.active[user_id] = set()
|
|
30
|
+
self.active[user_id].add(transport)
|
|
31
|
+
|
|
32
|
+
def remove(self, user_id: str, transport: Transport):
|
|
33
|
+
if user_id in self.active:
|
|
34
|
+
self.active[user_id].discard(transport)
|
|
35
|
+
if not self.active[user_id]: del self.active[user_id]
|
|
36
|
+
|
|
37
|
+
async def broadcast(self, user_ids: List[str], event_name: str, args: list, kwargs: dict):
|
|
38
|
+
payload = msgpack.dumps({
|
|
39
|
+
"target_users": user_ids,
|
|
40
|
+
"type": "event",
|
|
41
|
+
"name": event_name,
|
|
42
|
+
"payload": {"args": args, "kwargs": kwargs}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if self.redis: await self.redis.publish(CHANNEL_NAME, payload)
|
|
46
|
+
else: await self._send(user_ids, payload)
|
|
47
|
+
|
|
48
|
+
async def _send(self, user_ids: list[str], payload: bytes):
|
|
49
|
+
for user_id in user_ids:
|
|
50
|
+
if user_id in self.active:
|
|
51
|
+
for transport in list(self.active[user_id]):
|
|
52
|
+
try:
|
|
53
|
+
await transport.send(payload)
|
|
54
|
+
except: pass
|
|
55
|
+
|
|
56
|
+
async def start_redis(self):
|
|
57
|
+
if not self.redis: return
|
|
58
|
+
pubsub = self.redis.pubsub()
|
|
59
|
+
await pubsub.subscribe(CHANNEL_NAME)
|
|
60
|
+
async for message in pubsub.listen():
|
|
61
|
+
if message['type'] == 'message':
|
|
62
|
+
data = msgpack.loads(message['data'])
|
|
63
|
+
targets = data.get('target_users', [])
|
|
64
|
+
await self._send(targets, message['data'])
|
|
65
|
+
|
|
66
|
+
manager = ConnectionManager()
|
|
67
|
+
|
|
68
|
+
class EphapticTarget:
|
|
69
|
+
def __init__(self, user_ids: list[str]):
|
|
70
|
+
self.user_ids = user_ids
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, name: str):
|
|
73
|
+
async def emitter(*args, **kwargs):
|
|
74
|
+
await manager.broadcast(self.user_ids, name, list(args), dict(kwargs))
|
|
75
|
+
return emitter
|
|
76
|
+
|
|
77
|
+
class Ephaptic:
|
|
78
|
+
_exposed_functions: Dict[str, Callable] = {}
|
|
79
|
+
_identity_loader: Optional[Callable] = None
|
|
80
|
+
|
|
81
|
+
def _async(self, func: Callable):
|
|
82
|
+
async def wrapper(*args, **kwargs) -> Any:
|
|
83
|
+
if inspect.iscoroutinefunction(func):
|
|
84
|
+
return await func(*args, **kwargs)
|
|
85
|
+
else:
|
|
86
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
87
|
+
return wrapper
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_app(cls, app, path="/_ephaptic", redis_url=None):
|
|
94
|
+
# `app` could be Flask, Quart, FastAPI, etc.
|
|
95
|
+
instance = cls()
|
|
96
|
+
|
|
97
|
+
if redis_url:
|
|
98
|
+
manager.init_redis(redis_url)
|
|
99
|
+
# TODO: framework-specific hooks for the background listener.
|
|
100
|
+
|
|
101
|
+
module = app.__class__.__module__.split(".")[0]
|
|
102
|
+
|
|
103
|
+
match module:
|
|
104
|
+
case "quart":
|
|
105
|
+
from .adapters.quart_ import QuartAdapter
|
|
106
|
+
adapter = QuartAdapter(instance, app, path, manager)
|
|
107
|
+
case "fastapi":
|
|
108
|
+
from .adapters.fastapi_ import FastAPIAdapter
|
|
109
|
+
adapter = FastAPIAdapter(instance, app, path, manager)
|
|
110
|
+
case _:
|
|
111
|
+
raise TypeError(f"Unsupported app type: {module}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def expose(self, func: Callable):
|
|
115
|
+
self._exposed_functions[func.__name__] = func
|
|
116
|
+
return func
|
|
117
|
+
|
|
118
|
+
def identity_loader(self, func: Callable):
|
|
119
|
+
self._identity_loader = func
|
|
120
|
+
return func
|
|
121
|
+
|
|
122
|
+
def to(self, *args):
|
|
123
|
+
targets = []
|
|
124
|
+
for arg in args:
|
|
125
|
+
if isinstance(arg, list): targets.extend(arg)
|
|
126
|
+
else: targets.append(arg)
|
|
127
|
+
return EphapticTarget(targets)
|
|
128
|
+
|
|
129
|
+
def __getattr__(self, name: str):
|
|
130
|
+
async def emitter(*args, **kwargs):
|
|
131
|
+
transport: Transport = _active_transport_ctx.get()
|
|
132
|
+
if not transport:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f".{name}() called outside RPC context."
|
|
135
|
+
f"Use .to(...).{name}() to broadcast from background tasks, to specific user(s)."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
await transport.send(msgpack.dumps({
|
|
139
|
+
"type": "event",
|
|
140
|
+
"name": name,
|
|
141
|
+
"payload": {"args": list(args), "kwargs": dict(kwargs)},
|
|
142
|
+
}))
|
|
143
|
+
|
|
144
|
+
return emitter
|
|
145
|
+
|
|
146
|
+
async def handle_transport(self, transport: Transport):
|
|
147
|
+
current_uid = None
|
|
148
|
+
try:
|
|
149
|
+
raw = await transport.receive()
|
|
150
|
+
init = msgpack.loads(raw)
|
|
151
|
+
|
|
152
|
+
if init.get('type') == 'init':
|
|
153
|
+
try:
|
|
154
|
+
if self._identity_loader:
|
|
155
|
+
current_uid = await self._async(self._identity_loader)(init.get('auth'))
|
|
156
|
+
|
|
157
|
+
if current_uid:
|
|
158
|
+
_active_user_ctx.set(current_uid)
|
|
159
|
+
manager.add(current_uid, transport)
|
|
160
|
+
else:
|
|
161
|
+
pass
|
|
162
|
+
except Exception:
|
|
163
|
+
import traceback
|
|
164
|
+
traceback.print_exc()
|
|
165
|
+
|
|
166
|
+
while True:
|
|
167
|
+
raw = await transport.receive()
|
|
168
|
+
data = msgpack.loads(raw)
|
|
169
|
+
|
|
170
|
+
if data.get('type') == 'rpc':
|
|
171
|
+
call_id = data.get('id')
|
|
172
|
+
func_name = data.get('name')
|
|
173
|
+
args = data.get('args', [])
|
|
174
|
+
kwargs = data.get('kwargs', {}) # Note: Only Python client (currently) sends these, JS client does not.
|
|
175
|
+
|
|
176
|
+
if func_name in self._exposed_functions:
|
|
177
|
+
target_func = self._exposed_functions[func_name]
|
|
178
|
+
token_transport = _active_transport_ctx.set(transport)
|
|
179
|
+
token_user = _active_user_ctx.set(current_uid)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = await self._async(target_func)(*args, **kwargs)
|
|
183
|
+
await transport.send(msgpack.dumps({"id": call_id, "result": result}))
|
|
184
|
+
except Exception as e:
|
|
185
|
+
await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
|
|
186
|
+
finally:
|
|
187
|
+
_active_transport_ctx.reset(token_transport)
|
|
188
|
+
_active_user_ctx.reset(token_user)
|
|
189
|
+
else:
|
|
190
|
+
await transport.send(msgpack.dumps({
|
|
191
|
+
"id": call_id,
|
|
192
|
+
"error": f"Function '{func_name}' not found."
|
|
193
|
+
}))
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
...
|
|
196
|
+
except Exception:
|
|
197
|
+
import traceback
|
|
198
|
+
traceback.print_exc()
|
|
199
|
+
finally:
|
|
200
|
+
if current_uid: manager.remove(current_uid, transport)
|
ephaptic/localproxy.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import math
|
|
3
|
+
import operator
|
|
4
|
+
import typing as t
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from functools import partial
|
|
7
|
+
from operator import attrgetter
|
|
8
|
+
|
|
9
|
+
T = t.TypeVar("T")
|
|
10
|
+
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Local:
|
|
14
|
+
"""Create a namespace of context-local data. This wraps a
|
|
15
|
+
:class:`ContextVar` containing a :class:`dict` value.
|
|
16
|
+
|
|
17
|
+
This may incur a performance penalty compared to using individual
|
|
18
|
+
context vars, as it has to copy data to avoid mutating the dict
|
|
19
|
+
between nested contexts.
|
|
20
|
+
|
|
21
|
+
:param context_var: The :class:`~contextvars.ContextVar` to use as
|
|
22
|
+
storage for this local. If not given, one will be created.
|
|
23
|
+
Context vars not created at the global scope may interfere with
|
|
24
|
+
garbage collection.
|
|
25
|
+
|
|
26
|
+
.. versionchanged:: 2.0
|
|
27
|
+
Uses ``ContextVar`` instead of a custom storage implementation.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("__storage",)
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, context_var: t.Optional[ContextVar[t.Dict[str, t.Any]]] = None
|
|
34
|
+
) -> None:
|
|
35
|
+
if context_var is None:
|
|
36
|
+
# A ContextVar not created at global scope interferes with
|
|
37
|
+
# Python's garbage collection. However, a local only makes
|
|
38
|
+
# sense defined at the global scope as well, in which case
|
|
39
|
+
# the GC issue doesn't seem relevant.
|
|
40
|
+
context_var = ContextVar(f"contextlocal.Local<{id(self)}>.storage")
|
|
41
|
+
|
|
42
|
+
object.__setattr__(self, "_Local__storage", context_var)
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> t.Iterator[t.Tuple[str, t.Any]]:
|
|
45
|
+
return iter(self.__storage.get({}).items())
|
|
46
|
+
|
|
47
|
+
def __call__(
|
|
48
|
+
self, name: str, *, unbound_message: t.Optional[str] = None
|
|
49
|
+
) -> "LocalProxy":
|
|
50
|
+
"""Create a :class:`LocalProxy` that access an attribute on this
|
|
51
|
+
local namespace.
|
|
52
|
+
|
|
53
|
+
:param name: Proxy this attribute.
|
|
54
|
+
:param unbound_message: The error message that the proxy will
|
|
55
|
+
show if the attribute isn't set.
|
|
56
|
+
"""
|
|
57
|
+
return LocalProxy(self, name, unbound_message=unbound_message)
|
|
58
|
+
|
|
59
|
+
def __release_local__(self) -> None:
|
|
60
|
+
self.__storage.set({})
|
|
61
|
+
|
|
62
|
+
def __getattr__(self, name: str) -> t.Any:
|
|
63
|
+
values = self.__storage.get({})
|
|
64
|
+
|
|
65
|
+
if name in values:
|
|
66
|
+
return values[name]
|
|
67
|
+
|
|
68
|
+
raise AttributeError(name)
|
|
69
|
+
|
|
70
|
+
def __setattr__(self, name: str, value: t.Any) -> None:
|
|
71
|
+
values = self.__storage.get({}).copy()
|
|
72
|
+
values[name] = value
|
|
73
|
+
self.__storage.set(values)
|
|
74
|
+
|
|
75
|
+
def __delattr__(self, name: str) -> None:
|
|
76
|
+
values = self.__storage.get({})
|
|
77
|
+
|
|
78
|
+
if name in values:
|
|
79
|
+
values = values.copy()
|
|
80
|
+
del values[name]
|
|
81
|
+
self.__storage.set(values)
|
|
82
|
+
else:
|
|
83
|
+
raise AttributeError(name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LocalStack(t.Generic[T]):
|
|
87
|
+
"""Create a stack of context-local data. This wraps a
|
|
88
|
+
:class:`ContextVar` containing a :class:`list` value.
|
|
89
|
+
|
|
90
|
+
This may incur a performance penalty compared to using individual
|
|
91
|
+
context vars, as it has to copy data to avoid mutating the list
|
|
92
|
+
between nested contexts.
|
|
93
|
+
|
|
94
|
+
:param context_var: The :class:`~contextvars.ContextVar` to use as
|
|
95
|
+
storage for this local. If not given, one will be created.
|
|
96
|
+
Context vars not created at the global scope may interfere with
|
|
97
|
+
garbage collection.
|
|
98
|
+
|
|
99
|
+
.. versionchanged:: 2.0
|
|
100
|
+
Uses ``ContextVar`` instead of a custom storage implementation.
|
|
101
|
+
|
|
102
|
+
.. versionadded:: 0.6.1
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
__slots__ = ("_storage",)
|
|
106
|
+
|
|
107
|
+
def __init__(self, context_var: t.Optional[ContextVar[t.List[T]]] = None) -> None:
|
|
108
|
+
if context_var is None:
|
|
109
|
+
# A ContextVar not created at global scope interferes with
|
|
110
|
+
# Python's garbage collection. However, a local only makes
|
|
111
|
+
# sense defined at the global scope as well, in which case
|
|
112
|
+
# the GC issue doesn't seem relevant.
|
|
113
|
+
context_var = ContextVar(f"contextlocal.LocalStack<{id(self)}>.storage")
|
|
114
|
+
|
|
115
|
+
self._storage = context_var
|
|
116
|
+
|
|
117
|
+
def __release_local__(self) -> None:
|
|
118
|
+
self._storage.set([])
|
|
119
|
+
|
|
120
|
+
def push(self, obj: T) -> t.List[T]:
|
|
121
|
+
"""Add a new item to the top of the stack."""
|
|
122
|
+
stack = self._storage.get([]).copy()
|
|
123
|
+
stack.append(obj)
|
|
124
|
+
self._storage.set(stack)
|
|
125
|
+
return stack
|
|
126
|
+
|
|
127
|
+
def pop(self) -> t.Optional[T]:
|
|
128
|
+
"""Remove the top item from the stack and return it. If the
|
|
129
|
+
stack is empty, return ``None``.
|
|
130
|
+
"""
|
|
131
|
+
stack = self._storage.get([])
|
|
132
|
+
|
|
133
|
+
if len(stack) == 0:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
rv = stack[-1]
|
|
137
|
+
self._storage.set(stack[:-1])
|
|
138
|
+
return rv
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def top(self) -> t.Optional[T]:
|
|
142
|
+
"""The topmost item on the stack. If the stack is empty,
|
|
143
|
+
`None` is returned.
|
|
144
|
+
"""
|
|
145
|
+
stack = self._storage.get([])
|
|
146
|
+
|
|
147
|
+
if len(stack) == 0:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
return stack[-1]
|
|
151
|
+
|
|
152
|
+
def __call__(
|
|
153
|
+
self, name: t.Optional[str] = None, *, unbound_message: t.Optional[str] = None
|
|
154
|
+
) -> "LocalProxy":
|
|
155
|
+
"""Create a :class:`LocalProxy` that accesses the top of this
|
|
156
|
+
local stack.
|
|
157
|
+
|
|
158
|
+
:param name: If given, the proxy access this attribute of the
|
|
159
|
+
top item, rather than the item itself.
|
|
160
|
+
:param unbound_message: The error message that the proxy will
|
|
161
|
+
show if the stack is empty.
|
|
162
|
+
"""
|
|
163
|
+
return LocalProxy(self, name, unbound_message=unbound_message)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _ProxyLookup:
|
|
167
|
+
"""Descriptor that handles proxied attribute lookup for
|
|
168
|
+
:class:`LocalProxy`.
|
|
169
|
+
|
|
170
|
+
:param f: The built-in function this attribute is accessed through.
|
|
171
|
+
Instead of looking up the special method, the function call
|
|
172
|
+
is redone on the object.
|
|
173
|
+
:param fallback: Return this function if the proxy is unbound
|
|
174
|
+
instead of raising a :exc:`RuntimeError`.
|
|
175
|
+
:param is_attr: This proxied name is an attribute, not a function.
|
|
176
|
+
Call the fallback immediately to get the value.
|
|
177
|
+
:param class_value: Value to return when accessed from the
|
|
178
|
+
``LocalProxy`` class directly. Used for ``__doc__`` so building
|
|
179
|
+
docs still works.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("bind_f", "fallback", "is_attr", "class_value", "name")
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
f: t.Optional[t.Callable] = None,
|
|
187
|
+
fallback: t.Optional[t.Callable] = None,
|
|
188
|
+
class_value: t.Optional[t.Any] = None,
|
|
189
|
+
is_attr: bool = False,
|
|
190
|
+
) -> None:
|
|
191
|
+
bind_f: t.Optional[t.Callable[["LocalProxy", t.Any], t.Callable]]
|
|
192
|
+
|
|
193
|
+
if hasattr(f, "__get__"):
|
|
194
|
+
# A Python function, can be turned into a bound method.
|
|
195
|
+
|
|
196
|
+
def bind_f(instance: "LocalProxy", obj: t.Any) -> t.Callable:
|
|
197
|
+
return f.__get__(obj, type(obj)) # type: ignore
|
|
198
|
+
|
|
199
|
+
elif f is not None:
|
|
200
|
+
# A C function, use partial to bind the first argument.
|
|
201
|
+
|
|
202
|
+
def bind_f(instance: "LocalProxy", obj: t.Any) -> t.Callable:
|
|
203
|
+
return partial(f, obj)
|
|
204
|
+
|
|
205
|
+
else:
|
|
206
|
+
# Use getattr, which will produce a bound method.
|
|
207
|
+
bind_f = None
|
|
208
|
+
|
|
209
|
+
self.bind_f = bind_f
|
|
210
|
+
self.fallback = fallback
|
|
211
|
+
self.class_value = class_value
|
|
212
|
+
self.is_attr = is_attr
|
|
213
|
+
|
|
214
|
+
def __set_name__(self, owner: "LocalProxy", name: str) -> None:
|
|
215
|
+
self.name = name
|
|
216
|
+
|
|
217
|
+
def __get__(self, instance: "LocalProxy", owner: t.Optional[type] = None) -> t.Any:
|
|
218
|
+
if instance is None:
|
|
219
|
+
if self.class_value is not None:
|
|
220
|
+
return self.class_value
|
|
221
|
+
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
obj = instance._get_current_object()
|
|
226
|
+
except RuntimeError:
|
|
227
|
+
if self.fallback is None:
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
fallback = self.fallback.__get__(instance, owner)
|
|
231
|
+
|
|
232
|
+
if self.is_attr:
|
|
233
|
+
# __class__ and __doc__ are attributes, not methods.
|
|
234
|
+
# Call the fallback to get the value.
|
|
235
|
+
return fallback()
|
|
236
|
+
|
|
237
|
+
return fallback
|
|
238
|
+
|
|
239
|
+
if self.bind_f is not None:
|
|
240
|
+
return self.bind_f(instance, obj)
|
|
241
|
+
|
|
242
|
+
return getattr(obj, self.name)
|
|
243
|
+
|
|
244
|
+
def __repr__(self) -> str:
|
|
245
|
+
return f"proxy {self.name}"
|
|
246
|
+
|
|
247
|
+
def __call__(self, instance: "LocalProxy", *args: t.Any, **kwargs: t.Any) -> t.Any:
|
|
248
|
+
"""Support calling unbound methods from the class. For example,
|
|
249
|
+
this happens with ``copy.copy``, which does
|
|
250
|
+
``type(x).__copy__(x)``. ``type(x)`` can't be proxied, so it
|
|
251
|
+
returns the proxy type and descriptor.
|
|
252
|
+
"""
|
|
253
|
+
return self.__get__(instance, type(instance))(*args, **kwargs)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _ProxyIOp(_ProxyLookup):
|
|
257
|
+
"""Look up an augmented assignment method on a proxied object. The
|
|
258
|
+
method is wrapped to return the proxy instead of the object.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
__slots__ = ()
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self, f: t.Optional[t.Callable] = None, fallback: t.Optional[t.Callable] = None
|
|
265
|
+
) -> None:
|
|
266
|
+
super().__init__(f, fallback)
|
|
267
|
+
|
|
268
|
+
def bind_f(instance: "LocalProxy", obj: t.Any) -> t.Callable:
|
|
269
|
+
def i_op(self: t.Any, other: t.Any) -> "LocalProxy":
|
|
270
|
+
f(self, other) # type: ignore
|
|
271
|
+
return instance
|
|
272
|
+
|
|
273
|
+
return i_op.__get__(obj, type(obj)) # type: ignore
|
|
274
|
+
|
|
275
|
+
self.bind_f = bind_f
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _l_to_r_op(op: F) -> F:
|
|
279
|
+
"""Swap the argument order to turn an l-op into an r-op."""
|
|
280
|
+
|
|
281
|
+
def r_op(obj: t.Any, other: t.Any) -> t.Any:
|
|
282
|
+
return op(other, obj)
|
|
283
|
+
|
|
284
|
+
return t.cast(F, r_op)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _identity(o: T) -> T:
|
|
288
|
+
return o
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class LocalProxy(t.Generic[T]):
|
|
292
|
+
"""A proxy to the object bound to a context-local object. All
|
|
293
|
+
operations on the proxy are forwarded to the bound object. If no
|
|
294
|
+
object is bound, a ``RuntimeError`` is raised.
|
|
295
|
+
|
|
296
|
+
:param local: The context-local object that provides the proxied
|
|
297
|
+
object.
|
|
298
|
+
:param name: Proxy this attribute from the proxied object.
|
|
299
|
+
:param unbound_message: The error message to show if the
|
|
300
|
+
context-local object is unbound.
|
|
301
|
+
|
|
302
|
+
Proxy a :class:`~contextvars.ContextVar` to make it easier to
|
|
303
|
+
access. Pass a name to proxy that attribute.
|
|
304
|
+
|
|
305
|
+
.. code-block:: python
|
|
306
|
+
|
|
307
|
+
_request_var = ContextVar("request")
|
|
308
|
+
request = LocalProxy(_request_var)
|
|
309
|
+
session = LocalProxy(_request_var, "session")
|
|
310
|
+
|
|
311
|
+
Proxy an attribute on a :class:`Local` namespace by calling the
|
|
312
|
+
local with the attribute name:
|
|
313
|
+
|
|
314
|
+
.. code-block:: python
|
|
315
|
+
|
|
316
|
+
data = Local()
|
|
317
|
+
user = data("user")
|
|
318
|
+
|
|
319
|
+
Proxy the top item on a :class:`LocalStack` by calling the local.
|
|
320
|
+
Pass a name to proxy that attribute.
|
|
321
|
+
|
|
322
|
+
.. code-block::
|
|
323
|
+
|
|
324
|
+
app_stack = LocalStack()
|
|
325
|
+
current_app = app_stack()
|
|
326
|
+
g = app_stack("g")
|
|
327
|
+
|
|
328
|
+
Pass a function to proxy the return value from that function. This
|
|
329
|
+
was previously used to access attributes of local objects before
|
|
330
|
+
that was supported directly.
|
|
331
|
+
|
|
332
|
+
.. code-block:: python
|
|
333
|
+
|
|
334
|
+
session = LocalProxy(lambda: request.session)
|
|
335
|
+
|
|
336
|
+
``__repr__`` and ``__class__`` are proxied, so ``repr(x)`` and
|
|
337
|
+
``isinstance(x, cls)`` will look like the proxied object. Use
|
|
338
|
+
``issubclass(type(x), LocalProxy)`` to check if an object is a
|
|
339
|
+
proxy.
|
|
340
|
+
|
|
341
|
+
.. code-block:: python
|
|
342
|
+
|
|
343
|
+
repr(user) # <User admin>
|
|
344
|
+
isinstance(user, User) # True
|
|
345
|
+
issubclass(type(user), LocalProxy) # True
|
|
346
|
+
|
|
347
|
+
.. versionchanged:: 2.2.2
|
|
348
|
+
``__wrapped__`` is set when wrapping an object, not only when
|
|
349
|
+
wrapping a function, to prevent doctest from failing.
|
|
350
|
+
|
|
351
|
+
.. versionchanged:: 2.2
|
|
352
|
+
Can proxy a ``ContextVar`` or ``LocalStack`` directly.
|
|
353
|
+
|
|
354
|
+
.. versionchanged:: 2.2
|
|
355
|
+
The ``name`` parameter can be used with any proxied object, not
|
|
356
|
+
only ``Local``.
|
|
357
|
+
|
|
358
|
+
.. versionchanged:: 2.2
|
|
359
|
+
Added the ``unbound_message`` parameter.
|
|
360
|
+
|
|
361
|
+
.. versionchanged:: 2.0
|
|
362
|
+
Updated proxied attributes and methods to reflect the current
|
|
363
|
+
data model.
|
|
364
|
+
|
|
365
|
+
.. versionchanged:: 0.6.1
|
|
366
|
+
The class can be instantiated with a callable.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
__slots__ = ("__wrapped", "_get_current_object")
|
|
370
|
+
|
|
371
|
+
_get_current_object: t.Callable[[], T]
|
|
372
|
+
"""Return the current object this proxy is bound to. If the proxy is
|
|
373
|
+
unbound, this raises a ``RuntimeError``.
|
|
374
|
+
|
|
375
|
+
This should be used if you need to pass the object to something that
|
|
376
|
+
doesn't understand the proxy. It can also be useful for performance
|
|
377
|
+
if you are accessing the object multiple times in a function, rather
|
|
378
|
+
than going through the proxy multiple times.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(
|
|
382
|
+
self,
|
|
383
|
+
local: t.Union[ContextVar[T], Local, LocalStack[T], t.Callable[[], T]],
|
|
384
|
+
name: t.Optional[str] = None,
|
|
385
|
+
*,
|
|
386
|
+
unbound_message: t.Optional[str] = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
if name is None:
|
|
389
|
+
get_name = _identity
|
|
390
|
+
else:
|
|
391
|
+
get_name = attrgetter(name) # type: ignore[assignment]
|
|
392
|
+
|
|
393
|
+
if unbound_message is None:
|
|
394
|
+
unbound_message = "object is not bound"
|
|
395
|
+
|
|
396
|
+
if isinstance(local, Local):
|
|
397
|
+
if name is None:
|
|
398
|
+
raise TypeError("'name' is required when proxying a 'Local' object.")
|
|
399
|
+
|
|
400
|
+
def _get_current_object() -> T:
|
|
401
|
+
try:
|
|
402
|
+
return get_name(local) # type: ignore[return-value]
|
|
403
|
+
except AttributeError:
|
|
404
|
+
raise RuntimeError(unbound_message) from None
|
|
405
|
+
|
|
406
|
+
elif isinstance(local, LocalStack):
|
|
407
|
+
|
|
408
|
+
def _get_current_object() -> T:
|
|
409
|
+
obj = local.top # type: ignore[union-attr]
|
|
410
|
+
|
|
411
|
+
if obj is None:
|
|
412
|
+
raise RuntimeError(unbound_message)
|
|
413
|
+
|
|
414
|
+
return get_name(obj)
|
|
415
|
+
|
|
416
|
+
elif isinstance(local, ContextVar):
|
|
417
|
+
|
|
418
|
+
def _get_current_object() -> T:
|
|
419
|
+
try:
|
|
420
|
+
obj = local.get() # type: ignore[union-attr]
|
|
421
|
+
except LookupError:
|
|
422
|
+
raise RuntimeError(unbound_message) from None
|
|
423
|
+
|
|
424
|
+
return get_name(obj)
|
|
425
|
+
|
|
426
|
+
elif callable(local):
|
|
427
|
+
|
|
428
|
+
def _get_current_object() -> T:
|
|
429
|
+
return get_name(local()) # type: ignore
|
|
430
|
+
|
|
431
|
+
else:
|
|
432
|
+
raise TypeError(f"Don't know how to proxy '{type(local)}'.")
|
|
433
|
+
|
|
434
|
+
object.__setattr__(self, "_LocalProxy__wrapped", local)
|
|
435
|
+
object.__setattr__(self, "_get_current_object", _get_current_object)
|
|
436
|
+
|
|
437
|
+
__doc__ = _ProxyLookup( # type: ignore
|
|
438
|
+
class_value=__doc__, fallback=lambda self: type(self).__doc__, is_attr=True
|
|
439
|
+
)
|
|
440
|
+
__wrapped__ = _ProxyLookup(
|
|
441
|
+
fallback=lambda self: self._LocalProxy__wrapped, is_attr=True
|
|
442
|
+
)
|
|
443
|
+
# __del__ should only delete the proxy
|
|
444
|
+
__repr__ = _ProxyLookup( # type: ignore
|
|
445
|
+
repr, fallback=lambda self: f"<{type(self).__name__} unbound>"
|
|
446
|
+
)
|
|
447
|
+
__str__ = _ProxyLookup(str) # type: ignore
|
|
448
|
+
__bytes__ = _ProxyLookup(bytes)
|
|
449
|
+
__format__ = _ProxyLookup() # type: ignore
|
|
450
|
+
__lt__ = _ProxyLookup(operator.lt)
|
|
451
|
+
__le__ = _ProxyLookup(operator.le)
|
|
452
|
+
__eq__ = _ProxyLookup(operator.eq) # type: ignore
|
|
453
|
+
__ne__ = _ProxyLookup(operator.ne) # type: ignore
|
|
454
|
+
__gt__ = _ProxyLookup(operator.gt)
|
|
455
|
+
__ge__ = _ProxyLookup(operator.ge)
|
|
456
|
+
__hash__ = _ProxyLookup(hash) # type: ignore
|
|
457
|
+
__bool__ = _ProxyLookup(bool, fallback=lambda self: False)
|
|
458
|
+
__getattr__ = _ProxyLookup(getattr)
|
|
459
|
+
# __getattribute__ triggered through __getattr__
|
|
460
|
+
__setattr__ = _ProxyLookup(setattr) # type: ignore
|
|
461
|
+
__delattr__ = _ProxyLookup(delattr) # type: ignore
|
|
462
|
+
__dir__ = _ProxyLookup(dir, fallback=lambda self: []) # type: ignore
|
|
463
|
+
# __get__ (proxying descriptor not supported)
|
|
464
|
+
# __set__ (descriptor)
|
|
465
|
+
# __delete__ (descriptor)
|
|
466
|
+
# __set_name__ (descriptor)
|
|
467
|
+
# __objclass__ (descriptor)
|
|
468
|
+
# __slots__ used by proxy itself
|
|
469
|
+
# __dict__ (__getattr__)
|
|
470
|
+
# __weakref__ (__getattr__)
|
|
471
|
+
# __init_subclass__ (proxying metaclass not supported)
|
|
472
|
+
# __prepare__ (metaclass)
|
|
473
|
+
__class__ = _ProxyLookup(
|
|
474
|
+
fallback=lambda self: type(self), is_attr=True
|
|
475
|
+
) # type: ignore
|
|
476
|
+
__instancecheck__ = _ProxyLookup(lambda self, other: isinstance(other, self))
|
|
477
|
+
__subclasscheck__ = _ProxyLookup(lambda self, other: issubclass(other, self))
|
|
478
|
+
# __class_getitem__ triggered through __getitem__
|
|
479
|
+
__call__ = _ProxyLookup(lambda self, *args, **kwargs: self(*args, **kwargs))
|
|
480
|
+
__len__ = _ProxyLookup(len)
|
|
481
|
+
__length_hint__ = _ProxyLookup(operator.length_hint)
|
|
482
|
+
__getitem__ = _ProxyLookup(operator.getitem)
|
|
483
|
+
__setitem__ = _ProxyLookup(operator.setitem)
|
|
484
|
+
__delitem__ = _ProxyLookup(operator.delitem)
|
|
485
|
+
# __missing__ triggered through __getitem__
|
|
486
|
+
__iter__ = _ProxyLookup(iter)
|
|
487
|
+
__next__ = _ProxyLookup(next)
|
|
488
|
+
__reversed__ = _ProxyLookup(reversed)
|
|
489
|
+
__contains__ = _ProxyLookup(operator.contains)
|
|
490
|
+
__add__ = _ProxyLookup(operator.add)
|
|
491
|
+
__sub__ = _ProxyLookup(operator.sub)
|
|
492
|
+
__mul__ = _ProxyLookup(operator.mul)
|
|
493
|
+
__matmul__ = _ProxyLookup(operator.matmul)
|
|
494
|
+
__truediv__ = _ProxyLookup(operator.truediv)
|
|
495
|
+
__floordiv__ = _ProxyLookup(operator.floordiv)
|
|
496
|
+
__mod__ = _ProxyLookup(operator.mod)
|
|
497
|
+
__divmod__ = _ProxyLookup(divmod)
|
|
498
|
+
__pow__ = _ProxyLookup(pow)
|
|
499
|
+
__lshift__ = _ProxyLookup(operator.lshift)
|
|
500
|
+
__rshift__ = _ProxyLookup(operator.rshift)
|
|
501
|
+
__and__ = _ProxyLookup(operator.and_)
|
|
502
|
+
__xor__ = _ProxyLookup(operator.xor)
|
|
503
|
+
__or__ = _ProxyLookup(operator.or_)
|
|
504
|
+
__radd__ = _ProxyLookup(_l_to_r_op(operator.add))
|
|
505
|
+
__rsub__ = _ProxyLookup(_l_to_r_op(operator.sub))
|
|
506
|
+
__rmul__ = _ProxyLookup(_l_to_r_op(operator.mul))
|
|
507
|
+
__rmatmul__ = _ProxyLookup(_l_to_r_op(operator.matmul))
|
|
508
|
+
__rtruediv__ = _ProxyLookup(_l_to_r_op(operator.truediv))
|
|
509
|
+
__rfloordiv__ = _ProxyLookup(_l_to_r_op(operator.floordiv))
|
|
510
|
+
__rmod__ = _ProxyLookup(_l_to_r_op(operator.mod))
|
|
511
|
+
__rdivmod__ = _ProxyLookup(_l_to_r_op(divmod))
|
|
512
|
+
__rpow__ = _ProxyLookup(_l_to_r_op(pow))
|
|
513
|
+
__rlshift__ = _ProxyLookup(_l_to_r_op(operator.lshift))
|
|
514
|
+
__rrshift__ = _ProxyLookup(_l_to_r_op(operator.rshift))
|
|
515
|
+
__rand__ = _ProxyLookup(_l_to_r_op(operator.and_))
|
|
516
|
+
__rxor__ = _ProxyLookup(_l_to_r_op(operator.xor))
|
|
517
|
+
__ror__ = _ProxyLookup(_l_to_r_op(operator.or_))
|
|
518
|
+
__iadd__ = _ProxyIOp(operator.iadd)
|
|
519
|
+
__isub__ = _ProxyIOp(operator.isub)
|
|
520
|
+
__imul__ = _ProxyIOp(operator.imul)
|
|
521
|
+
__imatmul__ = _ProxyIOp(operator.imatmul)
|
|
522
|
+
__itruediv__ = _ProxyIOp(operator.itruediv)
|
|
523
|
+
__ifloordiv__ = _ProxyIOp(operator.ifloordiv)
|
|
524
|
+
__imod__ = _ProxyIOp(operator.imod)
|
|
525
|
+
__ipow__ = _ProxyIOp(operator.ipow)
|
|
526
|
+
__ilshift__ = _ProxyIOp(operator.ilshift)
|
|
527
|
+
__irshift__ = _ProxyIOp(operator.irshift)
|
|
528
|
+
__iand__ = _ProxyIOp(operator.iand)
|
|
529
|
+
__ixor__ = _ProxyIOp(operator.ixor)
|
|
530
|
+
__ior__ = _ProxyIOp(operator.ior)
|
|
531
|
+
__neg__ = _ProxyLookup(operator.neg)
|
|
532
|
+
__pos__ = _ProxyLookup(operator.pos)
|
|
533
|
+
__abs__ = _ProxyLookup(abs)
|
|
534
|
+
__invert__ = _ProxyLookup(operator.invert)
|
|
535
|
+
__complex__ = _ProxyLookup(complex)
|
|
536
|
+
__int__ = _ProxyLookup(int)
|
|
537
|
+
__float__ = _ProxyLookup(float)
|
|
538
|
+
__index__ = _ProxyLookup(operator.index)
|
|
539
|
+
__round__ = _ProxyLookup(round)
|
|
540
|
+
__trunc__ = _ProxyLookup(math.trunc)
|
|
541
|
+
__floor__ = _ProxyLookup(math.floor)
|
|
542
|
+
__ceil__ = _ProxyLookup(math.ceil)
|
|
543
|
+
__enter__ = _ProxyLookup()
|
|
544
|
+
__exit__ = _ProxyLookup()
|
|
545
|
+
__await__ = _ProxyLookup()
|
|
546
|
+
__aiter__ = _ProxyLookup()
|
|
547
|
+
__anext__ = _ProxyLookup()
|
|
548
|
+
__aenter__ = _ProxyLookup()
|
|
549
|
+
__aexit__ = _ProxyLookup()
|
|
550
|
+
__copy__ = _ProxyLookup(copy.copy)
|
|
551
|
+
__deepcopy__ = _ProxyLookup(copy.deepcopy)
|
|
552
|
+
# __getnewargs_ex__ (pickle through proxy not supported)
|
|
553
|
+
# __getnewargs__ (pickle)
|
|
554
|
+
# __getstate__ (pickle)
|
|
555
|
+
# __setstate__ (pickle)
|
|
556
|
+
# __reduce__ (pickle)
|
|
557
|
+
# __reduce_ex__ (pickle)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ephaptic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Python client/server package for ephaptic.
|
|
5
|
+
Author-email: uukelele <robustrobot11@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 ephaptic
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/ephaptic/ephaptic
|
|
29
|
+
Project-URL: Repository, https://github.com/ephaptic/ephaptic
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/ephaptic/ephaptic/issues
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Requires-Dist: msgpack>=1.0.0
|
|
35
|
+
Requires-Dist: websockets>=12.0
|
|
36
|
+
Provides-Extra: server
|
|
37
|
+
Requires-Dist: redis; extra == "server"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
<div align="center">
|
|
41
|
+
<a href="https://github.com/ephaptic/ephaptic">
|
|
42
|
+
<picture>
|
|
43
|
+
<img src="https://raw.githubusercontent.com/ephaptic/ephaptic/refs/heads/main/.github/assets/logo.png" alt="ephaptic logo" height="200">
|
|
44
|
+
<!-- <img src="https://avatars.githubusercontent.com/u/248199226?s=256" alt="ephaptic logo" height="200> -->
|
|
45
|
+
</picture>
|
|
46
|
+
</a>
|
|
47
|
+
<br>
|
|
48
|
+
<h1>ephaptic</h1>
|
|
49
|
+
<br>
|
|
50
|
+
<a href="https://github.com/ephaptic/ephaptic/blob/main/LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/ephaptic/ephaptic?style=for-the-badge&labelColor=%23222222"></a> <img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/ephaptic/ephaptic/publish-js.yml?style=for-the-badge&label=NPM%20Build%20Status&labelColor=%23222222"> <img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/ephaptic/ephaptic/publish-python.yml?style=for-the-badge&label=PyPI%20Build%20Status&labelColor=%23222222">
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
## What is `ephaptic`?
|
|
56
|
+
|
|
57
|
+
<br>
|
|
58
|
+
|
|
59
|
+
<blockquote>
|
|
60
|
+
<b>ephaptic (adj.)</b><br>
|
|
61
|
+
electrical conduction of a nerve impulse across an ephapse without the mediation of a neurotransmitter.
|
|
62
|
+
</blockquote>
|
|
63
|
+
|
|
64
|
+
Nah, just kidding. It's an RPC framework.
|
|
65
|
+
|
|
66
|
+
> **ephaptic** — Call your backend straight from your frontend. No JSON. No latency. No middleware.
|
|
67
|
+
|
|
68
|
+
## Getting Started
|
|
69
|
+
|
|
70
|
+
- Ephaptic is designed to be invisible. Write a function on the server, call it on the client. No extra boilerplate.
|
|
71
|
+
|
|
72
|
+
- Plus, it's horizontally scalable with Redis (optional), and features extremely low latency thanks to [msgpack](https://github.com/msgpack).
|
|
73
|
+
|
|
74
|
+
- 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.
|
|
75
|
+
|
|
76
|
+
What are you waiting for? **Let's go.**
|
|
77
|
+
|
|
78
|
+
<details>
|
|
79
|
+
<summary>Python</summary>
|
|
80
|
+
|
|
81
|
+
#### Client:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
pip install ephaptic
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### Server:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
pip install ephaptic[server]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from fastapi import FastAPI # or `from quart import Quart`
|
|
95
|
+
from ephaptic import Ephaptic
|
|
96
|
+
|
|
97
|
+
app = FastAPI() # or `app = Quart(__name__)`
|
|
98
|
+
|
|
99
|
+
ephaptic = Ephaptic.from_app(app) # Finds which framework you're using, and creates an ephaptic server.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
You can also specify a custom path:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
ephaptic = Ephaptic.from_app(app, path="/websocket")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
And you can even use Redis for horizontal scaling!
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
ephaptic = Ephaptic.from_app(app, redis_url="redis://my-redis-container:6379/0")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Now, how do you expose your function to the frontend?
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
@ephaptic.expose
|
|
118
|
+
async def add(num1, num2):
|
|
119
|
+
return num1 + num2
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Yep, it's really that simple.
|
|
123
|
+
|
|
124
|
+
But what if your code throws an error? No sweat, it just throws up on the frontend with the same details.
|
|
125
|
+
|
|
126
|
+
And, want to say something to the frontend?
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
</details>
|
|
134
|
+
|
|
135
|
+
<details>
|
|
136
|
+
<summary>JavaScript/TypeScript — Browser (Svelt, React, Angular, Vite, etc.)</summary>
|
|
137
|
+
|
|
138
|
+
#### To use with a framework / Vite:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
npm install @ephaptic/client
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import { connect } from "@ephaptic/client";
|
|
148
|
+
|
|
149
|
+
const client = connect(); // Defaults to `/_ephaptic`.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Or, you can use it with a custom URL:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
const client = connect({ url: '/ws' });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
const client = connect({ url: 'wss://my-backend.deployment/ephaptic' });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
You can even send auth objects to the server for identity loading.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const client = connect({ url: '...', auth: { token: window.localStorage.getItem('jwtToken') } })
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### Or, to use in your browser:
|
|
169
|
+
|
|
170
|
+
```html
|
|
171
|
+
<script type="module">
|
|
172
|
+
import { connect } from 'https://cdn.jsdelivr.net/npm/@ephaptic/client@latest/+esm';
|
|
173
|
+
|
|
174
|
+
const client = connect();
|
|
175
|
+
</script>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
<!-- TODO: Add extended documentation -->
|
|
179
|
+
|
|
180
|
+
</details>
|
|
181
|
+
|
|
182
|
+
## [License](https://github.com/ephaptic/ephaptic/blob/main/LICENSE)
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
<p align="center">
|
|
187
|
+
© ephaptic 2025
|
|
188
|
+
</p>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ephaptic/__init__.py,sha256=rz-zKp0bx6p7K9t3OGgTzYBlA5hZ1q1Gx8l00DzYJ8A,76
|
|
2
|
+
ephaptic/ephaptic.py,sha256=x3DAbAsV3mlikxjX4RT6hx-I9AzmlOIPy8ZwF31ySTs,7318
|
|
3
|
+
ephaptic/localproxy.py,sha256=fJaaskkiD6C2zaOod0F0HNWIbdKs_JMuHFwd0-sdLIM,19477
|
|
4
|
+
ephaptic/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
ephaptic/adapters/fastapi_.py,sha256=yfSbJuA7Tgeh9EhZkfIve0Uj-cOZmTBljlBsCRKh2EE,1007
|
|
6
|
+
ephaptic/adapters/quart_.py,sha256=MBo9g6h_zI63mL4aGdrvV5yEXsHaOd0Iv5J8SAPHBoA,537
|
|
7
|
+
ephaptic/client/__init__.py,sha256=NeaPIzTFeozP54wlDYHIg_adHP3Z3LWVujsRUlpn4_U,35
|
|
8
|
+
ephaptic/client/client.py,sha256=nM54QgdD55w2J5wEWRjOogJQEE5_XmACrWKdikpNvZM,3863
|
|
9
|
+
ephaptic/transports/__init__.py,sha256=kSAlgvm8sV9nHHu61LTjjTpv4bweah90xvFrwQMDQtQ,169
|
|
10
|
+
ephaptic/transports/fastapi_ws.py,sha256=X0PMRcwM-KDpKA-zXShGTFhD1kHMSqrx3PBBKZtQ1W0,258
|
|
11
|
+
ephaptic/transports/websocket.py,sha256=jwgclSDSq0lQCvgwjwUXe9MzPk7NH0FdmsLhWxYBh-4,261
|
|
12
|
+
ephaptic-0.1.0.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
|
|
13
|
+
ephaptic-0.1.0.dist-info/METADATA,sha256=Fca5koF2PY6a6LwdIG7FLpJMqMvlj1hXR4hmYQ2c6CI,5892
|
|
14
|
+
ephaptic-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
ephaptic-0.1.0.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
|
|
16
|
+
ephaptic-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ephaptic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ephaptic
|