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 ADDED
@@ -0,0 +1,7 @@
1
+ from .ephaptic import (
2
+ Ephaptic,
3
+ )
4
+
5
+ from .client import (
6
+ connect
7
+ )
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,3 @@
1
+ from .client import (
2
+ connect
3
+ )
@@ -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,5 @@
1
+ from typing import Coroutine
2
+
3
+ class Transport:
4
+ async def send(data: bytes): raise NotImplementedError()
5
+ async def receive() -> bytes: raise NotImplementedError()
@@ -0,0 +1,8 @@
1
+ from .websocket import WebSocketTransport
2
+
3
+ class FastAPIWebSocketTransport(WebSocketTransport):
4
+ async def send(self, data: bytes):
5
+ await self.ws.send_bytes(data)
6
+
7
+ async def receive(self) -> bytes:
8
+ return await self.ws.receive_bytes()
@@ -0,0 +1,11 @@
1
+ from . import Transport
2
+
3
+ class WebSocketTransport(Transport):
4
+ def __init__(self, ws):
5
+ self.ws = ws
6
+
7
+ async def send(self, data: bytes):
8
+ await self.ws.send(data)
9
+
10
+ async def receive(self) -> bytes:
11
+ return await self.ws.receive()
@@ -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
+ &copy; 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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