ephaptic 0.2.7__py3-none-any.whl → 0.3.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 -2
- ephaptic/cli/__main__.py +360 -26
- ephaptic/ctx.py +10 -0
- ephaptic/decorators.py +87 -0
- ephaptic/ephaptic.py +142 -78
- ephaptic/ext/fastapi/__init__.py +1 -0
- ephaptic/{adapters/fastapi_.py → ext/fastapi/adapter.py} +4 -1
- ephaptic/ext/fastapi/middleware.py +24 -0
- ephaptic/ext/fastapi/router.py +82 -0
- ephaptic/{adapters/quart_.py → ext/quart/adapter.py} +1 -1
- ephaptic/transports/__init__.py +8 -3
- ephaptic/transports/fastapi_ws.py +10 -1
- ephaptic/transports/websocket.py +1 -0
- ephaptic/utils.py +21 -0
- {ephaptic-0.2.7.dist-info → ephaptic-0.3.0.dist-info}/METADATA +19 -25
- ephaptic-0.3.0.dist-info/RECORD +25 -0
- {ephaptic-0.2.7.dist-info → ephaptic-0.3.0.dist-info}/WHEEL +1 -1
- ephaptic-0.2.7.dist-info/RECORD +0 -19
- /ephaptic/{adapters → ext}/__init__.py +0 -0
- {ephaptic-0.2.7.dist-info → ephaptic-0.3.0.dist-info}/entry_points.txt +0 -0
- {ephaptic-0.2.7.dist-info → ephaptic-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ephaptic-0.2.7.dist-info → ephaptic-0.3.0.dist-info}/top_level.txt +0 -0
ephaptic/__init__.py
CHANGED
ephaptic/cli/__main__.py
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
|
-
import sys, os,
|
|
1
|
+
import sys, os, subprocess as sp
|
|
2
|
+
import json, re
|
|
3
|
+
import inspect, importlib, typing
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
2
7
|
|
|
3
8
|
from pathlib import Path
|
|
4
9
|
from pydantic import TypeAdapter
|
|
5
|
-
from pydantic.json_schema import models_json_schema
|
|
6
10
|
|
|
7
11
|
from ephaptic import Ephaptic
|
|
12
|
+
from ephaptic.decorators import META_KEY
|
|
13
|
+
|
|
14
|
+
from typing import *
|
|
8
15
|
|
|
9
16
|
app = typer.Typer(help="Ephaptic CLI tool.")
|
|
10
17
|
|
|
18
|
+
IDENTIFIER_REGEX = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
|
19
|
+
|
|
20
|
+
LOG: List[str] = []
|
|
21
|
+
|
|
22
|
+
def log(*data):
|
|
23
|
+
global LOG
|
|
24
|
+
LOG.append(' '.join(data))
|
|
25
|
+
|
|
26
|
+
def clear_log():
|
|
27
|
+
global LOG
|
|
28
|
+
LOG = []
|
|
29
|
+
|
|
30
|
+
def key_name(key: str) -> str:
|
|
31
|
+
if IDENTIFIER_REGEX.match(key): return key
|
|
32
|
+
else: return json.dumps(key)
|
|
33
|
+
|
|
34
|
+
def validate(name: str) -> str:
|
|
35
|
+
if IDENTIFIER_REGEX.match(name):
|
|
36
|
+
safe = re.sub(r'[^a-zA-Z0-9_$]', '_', name)
|
|
37
|
+
log(typer.style("[warning] '{name}' is not a valid identifier. sanitizing to '{safe}'", fg=typer.colors.YELLOW))
|
|
38
|
+
return safe
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
def load_schema(input_path: str | Path):
|
|
42
|
+
return json.loads(Path(input_path).read_text())
|
|
43
|
+
# errors can be handled by typer
|
|
44
|
+
|
|
11
45
|
def load_ephaptic(import_name: str) -> Ephaptic:
|
|
12
46
|
try:
|
|
13
47
|
from dotenv import load_dotenv; load_dotenv()
|
|
@@ -16,30 +50,245 @@ def load_ephaptic(import_name: str) -> Ephaptic:
|
|
|
16
50
|
sys.path.insert(0, os.getcwd())
|
|
17
51
|
|
|
18
52
|
if ":" not in import_name:
|
|
19
|
-
typer.
|
|
53
|
+
log(typer.style(f"Warning: Import name did not specify client name. Defaulting to `client`.", fg=typer.colors.YELLOW))
|
|
20
54
|
import_name += ":client" # default: expect client to be named `client` inside the file
|
|
21
55
|
|
|
22
56
|
module_name, var_name = import_name.split(":", 1)
|
|
23
57
|
|
|
24
58
|
try:
|
|
25
|
-
typer.
|
|
59
|
+
log(typer.style(f"Attempting to import `{var_name}` from `{module_name}`..."))
|
|
26
60
|
module = importlib.import_module(module_name)
|
|
27
61
|
except ImportError as e:
|
|
28
|
-
typer.
|
|
62
|
+
typer.echo(typer.style(f"Error: Can't import '{module_name}'.\n{e}", fg=typer.colors.RED))
|
|
29
63
|
raise typer.Exit(1)
|
|
30
64
|
|
|
31
65
|
try:
|
|
32
66
|
instance = getattr(module, var_name)
|
|
33
67
|
except AttributeError:
|
|
34
|
-
typer.
|
|
68
|
+
typer.echo(typer.style(f"Error: Variable '{var_name}' not found in module '{module_name}'.", fg=typer.colors.RED))
|
|
35
69
|
raise typer.Exit(1)
|
|
36
70
|
|
|
37
71
|
if not isinstance(instance, Ephaptic):
|
|
38
|
-
typer.
|
|
72
|
+
typer.echo(typer.style(f"Error: '{var_name}' is not an Ephaptic client. It is type: {type(instance)}", fg=typer.colors.RED))
|
|
39
73
|
raise typer.Exit(1)
|
|
40
74
|
|
|
41
75
|
return instance
|
|
42
76
|
|
|
77
|
+
def TS_resolve_type(schema: Dict[str, Any]) -> str:
|
|
78
|
+
if not schema: return 'any'
|
|
79
|
+
if schema.get('$ref'): return validate(schema['$ref'].split('/').pop() or 'any')
|
|
80
|
+
if schema.get('enum'): return ' | '.join([json.dumps(val) for val in schema['enum']])
|
|
81
|
+
if schema.get('anyOf'): return ' | '.join({TS_resolve_type(s) for s in schema['anyOf']})
|
|
82
|
+
if schema.get('type') == 'array': return f"{TS_resolve_type(schema['items']) if schema.get('items') else 'any'}[]"
|
|
83
|
+
if schema.get('type') in ('integer', 'number'): return 'number'
|
|
84
|
+
if schema.get('type') == 'boolean': return 'boolean'
|
|
85
|
+
if schema.get('type') == 'string': return 'string'
|
|
86
|
+
if schema.get('type') == 'null': return 'null'
|
|
87
|
+
|
|
88
|
+
if schema['type'] == 'object':
|
|
89
|
+
if not schema.get('properties'): return 'Record<string, any>'
|
|
90
|
+
props = [
|
|
91
|
+
f"{key_name(key)}{'' if key in schema.get('required', []) else '?'}: {TS_resolve_type(prop_schema)}"
|
|
92
|
+
for key, prop_schema in schema['properties'].items()
|
|
93
|
+
]
|
|
94
|
+
return '{ ' + '; '.join(props) + ' }'
|
|
95
|
+
|
|
96
|
+
return 'any'
|
|
97
|
+
|
|
98
|
+
def TS_generate(data: dict):
|
|
99
|
+
lines: List[str] = []
|
|
100
|
+
|
|
101
|
+
lines.extend([
|
|
102
|
+
'/**',
|
|
103
|
+
' * Auto-generated by ephaptic',
|
|
104
|
+
' * Do not edit this file manually.',
|
|
105
|
+
' * */',
|
|
106
|
+
'',
|
|
107
|
+
'import { type EphapticClientBase } from "@ephaptic/client";',
|
|
108
|
+
'',
|
|
109
|
+
'export type EphapticQuery<TArgs extends any[], TReturn> = { queryKey: [string, ...TArgs]; queryFn: () => Promise<TReturn>; }'
|
|
110
|
+
'',
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
for name, schema in data.get('definitions', {}).items():
|
|
114
|
+
name = validate(name)
|
|
115
|
+
if schema['type'] == 'object':
|
|
116
|
+
lines.append(f'export interface {name} {{')
|
|
117
|
+
lines.extend([
|
|
118
|
+
f" {validate(prop_name)}{'' if prop_name in schema.get('required', []) else '?'}: {TS_resolve_type(prop_schema)};"
|
|
119
|
+
for prop_name, prop_schema in schema['properties'].items()
|
|
120
|
+
])
|
|
121
|
+
lines.append('}')
|
|
122
|
+
lines.append('')
|
|
123
|
+
else:
|
|
124
|
+
lines.append(f'export type {name} = {TS_resolve_type(schema)};')
|
|
125
|
+
lines.append('')
|
|
126
|
+
|
|
127
|
+
lines.append('export interface EphapticEvents {')
|
|
128
|
+
lines.extend([
|
|
129
|
+
f" {validate(event_name)}: {TS_resolve_type(event_schema)};"
|
|
130
|
+
for event_name, event_schema in data.get('events', {}).items()
|
|
131
|
+
])
|
|
132
|
+
lines.append('}')
|
|
133
|
+
lines.append('')
|
|
134
|
+
|
|
135
|
+
lines.append('export interface EphapticService extends EphapticClientBase {')
|
|
136
|
+
lines.append('')
|
|
137
|
+
|
|
138
|
+
for method_name, method_data in data.get('methods', {}).items():
|
|
139
|
+
args: List[str] = []
|
|
140
|
+
|
|
141
|
+
args.extend([
|
|
142
|
+
f"{validate(arg_name)}{'' if arg_name in method_data.get('required', []) else '?'}: {TS_resolve_type(arg_schema)}"
|
|
143
|
+
for arg_name, arg_schema in method_data.get('args', {}).items()
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
return_type = TS_resolve_type(method_data['return']) if method_data.get('return') else 'void'
|
|
147
|
+
|
|
148
|
+
lines.append(f" {validate(method_name)}({', '.join(args)}): Promise<{return_type}>;")
|
|
149
|
+
|
|
150
|
+
lines.append('')
|
|
151
|
+
lines.append(' queries: {')
|
|
152
|
+
|
|
153
|
+
for method_name, method_data in data.get('methods', {}).items():
|
|
154
|
+
args: List[str] = []
|
|
155
|
+
|
|
156
|
+
args.extend([
|
|
157
|
+
f"{validate(arg_name)}{'' if arg_name in method_data.get('required', {}) else '?'}: {TS_resolve_type(arg_schema)}"
|
|
158
|
+
for arg_name, arg_schema in method_data.get('args', {}).items()
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
arg_types = [TS_resolve_type(arg_schema) for arg_schema in method_data.get('args', {}).values()]
|
|
162
|
+
|
|
163
|
+
return_type = TS_resolve_type(method_data['return']) if method_data.get('return') else 'void'
|
|
164
|
+
|
|
165
|
+
lines.append(f" {validate(method_name)}({', '.join(args)}): EphapticQuery<[{', '.join(arg_types)}], {return_type}>;")
|
|
166
|
+
|
|
167
|
+
lines.append(' };')
|
|
168
|
+
|
|
169
|
+
lines.append('')
|
|
170
|
+
|
|
171
|
+
lines.append(' on<K extends keyof EphapticEvents>(event: K, callback: (data: EphapticEvents[K]) => void): void;')
|
|
172
|
+
lines.append(' off<K extends keyof EphapticEvents>(event: K, callback: (data: EphapticEvents[K]) => void): void;')
|
|
173
|
+
lines.append(' once<K extends keyof EphapticEvents>(event: K, callback: (data: EphapticEvents[K]) => void): void;')
|
|
174
|
+
|
|
175
|
+
lines.append('}');
|
|
176
|
+
lines.append('');
|
|
177
|
+
|
|
178
|
+
lines.extend([
|
|
179
|
+
'/**',
|
|
180
|
+
' * Usage:',
|
|
181
|
+
' * import { connect } from "@ephaptic/client";',
|
|
182
|
+
' * import { type EphapticService } from "./ephaptic";',
|
|
183
|
+
' * ',
|
|
184
|
+
' * const client = connect(...) as unknown as EphapticService;',
|
|
185
|
+
' */',
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
return lines
|
|
189
|
+
|
|
190
|
+
def KT_resolve_type(schema: Dict[str, Any]) -> str:
|
|
191
|
+
if not schema: return 'Any?'
|
|
192
|
+
if schema.get('$ref'): return validate(schema['$ref'].split('/').pop() or 'Any')
|
|
193
|
+
if schema.get('enum') and len(schema['enum']) > 0:
|
|
194
|
+
first = schema['enum'][0]
|
|
195
|
+
if isinstance(first, str): return 'String'
|
|
196
|
+
if isinstance(first, int): return 'Int'
|
|
197
|
+
if isinstance(first, float): return 'Double'
|
|
198
|
+
if isinstance(first, bool): return 'Boolean'
|
|
199
|
+
return 'Any?'
|
|
200
|
+
if schema.get('anyOf'):
|
|
201
|
+
nonNull = [t for t in schema['anyOf'] if t['type'] != 'null']
|
|
202
|
+
if len(nonNull) == 1:
|
|
203
|
+
type = KT_resolve_type(nonNull[0])
|
|
204
|
+
if not type.endswith('?'): type += '?'
|
|
205
|
+
return type
|
|
206
|
+
return 'Any?'
|
|
207
|
+
if schema.get('type') == 'array': return f"List<{KT_resolve_type(schema['items']) if schema.get('items') else 'Any?'}>"
|
|
208
|
+
if schema.get('type') == 'integer': return 'Long'
|
|
209
|
+
if schema.get('type') == 'boolean': return 'Boolean'
|
|
210
|
+
if schema.get('type') == 'string': return 'String'
|
|
211
|
+
if schema.get('type') == 'null': return 'Nothing?'
|
|
212
|
+
if schema.get('type') == 'object': return 'Map<String, Any?>'
|
|
213
|
+
return 'Any?'
|
|
214
|
+
|
|
215
|
+
def KT_generate(data: dict, package_name: str):
|
|
216
|
+
lines: List[str] = []
|
|
217
|
+
|
|
218
|
+
lines.extend([
|
|
219
|
+
'/**',
|
|
220
|
+
' * Auto-generated by ephaptic',
|
|
221
|
+
' * Do not edit this file manually.',
|
|
222
|
+
' * */',
|
|
223
|
+
'',
|
|
224
|
+
f'package {package_name}',
|
|
225
|
+
'',
|
|
226
|
+
'import com.squareup.moshi.JsonClass',
|
|
227
|
+
'import com.ephaptic.android.EphapticClient',
|
|
228
|
+
'import com.ephaptic.android.EphapticException',
|
|
229
|
+
'',
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
for name, schema in data.get('definitions', {}).items():
|
|
233
|
+
name = validate(name)
|
|
234
|
+
if schema['type'] == 'object':
|
|
235
|
+
lines.append('@JsonClass(generateAdapter = true)')
|
|
236
|
+
lines.append(f'data class {name}(')
|
|
237
|
+
|
|
238
|
+
for prop_name, prop_schema in schema['properties'].items():
|
|
239
|
+
kt_type = KT_resolve_type(prop_schema)
|
|
240
|
+
|
|
241
|
+
required = prop_name in schema.get('required', [])
|
|
242
|
+
explicit_null = prop_schema.get('type') == 'null'
|
|
243
|
+
union_null = any(t.get('type') == 'null' for t in prop_schema.get('anyOf', []))
|
|
244
|
+
|
|
245
|
+
nullable = not required or explicit_null or union_null
|
|
246
|
+
if nullable and not kt_type.endswith('?'): kt_type += '?'
|
|
247
|
+
|
|
248
|
+
lines.append(f" val {validate(prop_name)}: {kt_type}{' = null' if nullable else ''},")
|
|
249
|
+
|
|
250
|
+
lines.append(')')
|
|
251
|
+
lines.append('')
|
|
252
|
+
else:
|
|
253
|
+
lines.append(f'typealias {name} = {KT_resolve_type(schema)}')
|
|
254
|
+
lines.append('')
|
|
255
|
+
|
|
256
|
+
lines.append('sealed class EphapticEvent')
|
|
257
|
+
lines.extend([
|
|
258
|
+
f" data class {validate(event_name)}(val data: {KT_resolve_type(event_schema)}): EphapticEvent()"
|
|
259
|
+
for event_name, event_schema in data.get('events', {}).items()
|
|
260
|
+
])
|
|
261
|
+
lines.append('}')
|
|
262
|
+
lines.append('')
|
|
263
|
+
|
|
264
|
+
lines.append('class EphapticService(private val client: EphapticClient) {')
|
|
265
|
+
lines.append('')
|
|
266
|
+
|
|
267
|
+
for method_name, method_data in data.get('methods', {}).items():
|
|
268
|
+
args: List[str] = []
|
|
269
|
+
params: List[str] = []
|
|
270
|
+
|
|
271
|
+
for arg_name, arg_schema in method_data.get('args', {}).items():
|
|
272
|
+
is_req = arg_name in method_data.get('required', [])
|
|
273
|
+
kt_type = KT_resolve_type(arg_schema)
|
|
274
|
+
|
|
275
|
+
if not is_req and not kt_type.endswith('?'): kt_type += '?'
|
|
276
|
+
|
|
277
|
+
args.append(f"{validate(arg_name)}: {kt_type}")
|
|
278
|
+
params.append(validate(arg_name))
|
|
279
|
+
|
|
280
|
+
return_type = KT_resolve_type(method_data['return']) if method_data.get('return') else 'Any?'
|
|
281
|
+
|
|
282
|
+
lines.append(f" suspend fun {validate(method_name)}({', '.join(args)}): {return_type} {{")
|
|
283
|
+
lines.append(f' return client.request<{return_type}>("{method_name}", {", ".join(params)})')
|
|
284
|
+
lines.append(' }')
|
|
285
|
+
lines.append('')
|
|
286
|
+
|
|
287
|
+
lines.append('}')
|
|
288
|
+
lines.append('')
|
|
289
|
+
|
|
290
|
+
return lines
|
|
291
|
+
|
|
43
292
|
def create_schema(adapter: TypeAdapter, definitions: dict) -> dict:
|
|
44
293
|
schema = adapter.json_schema(ref_template='#/definitions/{model}')
|
|
45
294
|
|
|
@@ -58,12 +307,69 @@ def run_subprocess():
|
|
|
58
307
|
cmd += [arg for arg in sys.argv if arg not in {'--watch', '-w'}]
|
|
59
308
|
sp.run(cmd)
|
|
60
309
|
|
|
310
|
+
def calculate_language(lang: str, output: Path):
|
|
311
|
+
if lang is None:
|
|
312
|
+
if not output or str(output) == '-': raise ValueError("You must specify a language or an output path.")
|
|
313
|
+
lang = os.path.splitext(output)[-1]
|
|
314
|
+
|
|
315
|
+
map = {
|
|
316
|
+
'kotlin': 'kt',
|
|
317
|
+
'typescript': 'ts',
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if lang in map: lang = map[lang]
|
|
321
|
+
|
|
322
|
+
if output is None:
|
|
323
|
+
match lang:
|
|
324
|
+
case 'ts':
|
|
325
|
+
output = Path('ephaptic.d.ts')
|
|
326
|
+
case 'kt':
|
|
327
|
+
output = Path('Ephaptic.kt')
|
|
328
|
+
case _:
|
|
329
|
+
output = Path('schema.json')
|
|
330
|
+
|
|
331
|
+
return lang, output
|
|
332
|
+
|
|
333
|
+
class NothingToChange(Exception): ...
|
|
334
|
+
|
|
335
|
+
def generate_output(lang, schema_output, package_name, output: Path):
|
|
336
|
+
content = None
|
|
337
|
+
match lang:
|
|
338
|
+
case 'json':
|
|
339
|
+
content = json.dumps(schema_output, indent=2)
|
|
340
|
+
case 'ts':
|
|
341
|
+
content = '\n'.join(TS_generate(schema_output))
|
|
342
|
+
case 'kt':
|
|
343
|
+
content = '\n'.join(KT_generate(schema_output, package_name))
|
|
344
|
+
|
|
345
|
+
if str(output) == '-':
|
|
346
|
+
print(content)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
if output.exists():
|
|
350
|
+
if output.read_text() == content:
|
|
351
|
+
return
|
|
352
|
+
else:
|
|
353
|
+
output.write_text(content)
|
|
354
|
+
|
|
355
|
+
for line in LOG:
|
|
356
|
+
typer.echo(line)
|
|
357
|
+
|
|
358
|
+
clear_log()
|
|
359
|
+
|
|
360
|
+
typer.secho(f"Schema generated to `{output}`.", fg=typer.colors.GREEN, bold=True)
|
|
361
|
+
|
|
362
|
+
|
|
61
363
|
@app.command()
|
|
62
364
|
def generate(
|
|
63
|
-
client: str = typer.Argument('
|
|
64
|
-
output: Path = typer.Option(
|
|
365
|
+
client: str = typer.Argument('app:client', help="The import string for the Ephaptic client."),
|
|
366
|
+
output: Path = typer.Option(None, '--output', '-o', help="Output path for the generated file (default: schema.json / ephaptic.d.ts / Ephaptic.kt)."),
|
|
65
367
|
watch: bool = typer.Option(False, '--watch', '-w', help="Watch for changes in `.py` files and regenerate schema file automatically."),
|
|
368
|
+
lang: str = typer.Option(None, '--lang', '-l', help="Output language ('json', 'kotlin', 'kt', 'typescript', 'ts') (default: autodetected from output path)"),
|
|
369
|
+
package_name: str = typer.Option('com.example.app', '--package-name', '-p', help="Package name (required for Kotlin)")
|
|
66
370
|
):
|
|
371
|
+
lang, output = calculate_language(lang, output)
|
|
372
|
+
|
|
67
373
|
if watch:
|
|
68
374
|
import watchfiles
|
|
69
375
|
|
|
@@ -77,6 +383,8 @@ def generate(
|
|
|
77
383
|
typer.secho("Detected changes, regenerating...")
|
|
78
384
|
run_subprocess()
|
|
79
385
|
|
|
386
|
+
return
|
|
387
|
+
|
|
80
388
|
ephaptic = load_ephaptic(client)
|
|
81
389
|
|
|
82
390
|
schema_output = {
|
|
@@ -85,13 +393,15 @@ def generate(
|
|
|
85
393
|
"definitions": {},
|
|
86
394
|
}
|
|
87
395
|
|
|
88
|
-
typer.
|
|
396
|
+
log(typer.style("--- Functions ---"))
|
|
89
397
|
|
|
90
398
|
for name, func in ephaptic._exposed_functions.items():
|
|
91
|
-
typer.
|
|
399
|
+
log(typer.style(f" - {name}"))
|
|
400
|
+
|
|
401
|
+
meta = getattr(func, META_KEY, {})
|
|
92
402
|
|
|
93
|
-
hints = typing.get_type_hints(func)
|
|
94
|
-
sig = inspect.signature(func)
|
|
403
|
+
hints = meta.get('hints') or typing.get_type_hints(func)
|
|
404
|
+
sig = meta.get('sig') or inspect.signature(func)
|
|
95
405
|
|
|
96
406
|
method_schema = {
|
|
97
407
|
"args": {},
|
|
@@ -108,6 +418,8 @@ def generate(
|
|
|
108
418
|
schema_output["definitions"],
|
|
109
419
|
)
|
|
110
420
|
|
|
421
|
+
log(typer.style(f" - {param_name}: {hint} = {param.default}"))
|
|
422
|
+
|
|
111
423
|
if param.default == inspect.Parameter.empty:
|
|
112
424
|
method_schema["required"].append(param_name)
|
|
113
425
|
else:
|
|
@@ -115,8 +427,8 @@ def generate(
|
|
|
115
427
|
|
|
116
428
|
|
|
117
429
|
|
|
118
|
-
return_hint = hints.get("return", typing.Any)
|
|
119
|
-
if return_hint is not type(None):
|
|
430
|
+
return_hint = meta.get('response_model') or hints.get("return", typing.Any)
|
|
431
|
+
if return_hint and return_hint is not type(None):
|
|
120
432
|
adapter = TypeAdapter(return_hint)
|
|
121
433
|
method_schema["return"] = create_schema(
|
|
122
434
|
adapter,
|
|
@@ -125,10 +437,11 @@ def generate(
|
|
|
125
437
|
|
|
126
438
|
schema_output["methods"][name] = method_schema
|
|
127
439
|
|
|
128
|
-
typer.
|
|
440
|
+
log(typer.style("--- Events ---"))
|
|
441
|
+
|
|
129
442
|
|
|
130
443
|
for name, model in ephaptic._exposed_events.items():
|
|
131
|
-
typer.
|
|
444
|
+
log(typer.style(f" - {name}"))
|
|
132
445
|
adapter = TypeAdapter(model)
|
|
133
446
|
|
|
134
447
|
schema_output["events"][name] = create_schema(
|
|
@@ -136,17 +449,38 @@ def generate(
|
|
|
136
449
|
schema_output["definitions"],
|
|
137
450
|
)
|
|
138
451
|
|
|
139
|
-
|
|
452
|
+
generate_output(lang, schema_output, package_name, output)
|
|
140
453
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
454
|
+
@app.command()
|
|
455
|
+
def from_schema(
|
|
456
|
+
schema_path: Path = typer.Option('schema.json', help="Path to the schema file."),
|
|
457
|
+
output: Path = typer.Option(None, '--output', '-o', help="Output path for the generated file (default: ephaptic.d.ts / Ephaptic.kt)."),
|
|
458
|
+
watch: bool = typer.Option(False, '--watch', '-w', help="Watch for changes in `.py` files and regenerate schema file automatically."),
|
|
459
|
+
lang: str = typer.Option(None, '--lang', '-l', help="Output language ('kotlin', 'kt', 'typescript', 'ts') (default: autodetected from output path)"),
|
|
460
|
+
package_name: str = typer.Option('com.example.app', '--package-name', '-p', help="Package name (required for Kotlin)")
|
|
461
|
+
):
|
|
462
|
+
lang, output = calculate_language(lang, output)
|
|
145
463
|
|
|
146
|
-
with open(output, "w") as f:
|
|
147
|
-
f.write(new)
|
|
148
464
|
|
|
149
|
-
|
|
465
|
+
if watch:
|
|
466
|
+
import watchfiles
|
|
467
|
+
|
|
468
|
+
typer.secho(f"Watching for changes ({schema_path})...", fg=typer.colors.GREEN)
|
|
469
|
+
|
|
470
|
+
run_subprocess()
|
|
471
|
+
|
|
472
|
+
for changes in watchfiles.watch(schema_path):
|
|
473
|
+
if any(Path(f).name == schema_path.name for _, f in changes):
|
|
474
|
+
typer.secho("Detected changes, regenerating...")
|
|
475
|
+
run_subprocess()
|
|
476
|
+
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
schema_output = json.loads(schema_path.read_text())
|
|
480
|
+
|
|
481
|
+
generate_output(lang, schema_output, package_name, output)
|
|
482
|
+
|
|
483
|
+
click = typer.main.get_command(app)
|
|
150
484
|
|
|
151
485
|
if __name__ == "__main__":
|
|
152
|
-
app()
|
|
486
|
+
app()
|
ephaptic/ctx.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
|
|
3
|
+
_scope_ctx = ContextVar('ephaptic_scope', default='rpc')
|
|
4
|
+
_active_transport_ctx = ContextVar('active_transport', default=None)
|
|
5
|
+
_active_user_ctx = ContextVar('active_user', default=None)
|
|
6
|
+
|
|
7
|
+
def is_http() -> bool: return _scope_ctx.get() == 'http'
|
|
8
|
+
def is_rpc() -> bool: return _scope_ctx.get() == 'rpc'
|
|
9
|
+
|
|
10
|
+
def active_user(): return _active_user_ctx.get()
|
ephaptic/decorators.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import *
|
|
2
|
+
import inspect
|
|
3
|
+
import pydantic
|
|
4
|
+
from .utils import parse_limit
|
|
5
|
+
|
|
6
|
+
F = TypeVar('F', bound=Callable[..., Any])
|
|
7
|
+
M = TypeVar('M', bound=Type[pydantic.BaseModel])
|
|
8
|
+
|
|
9
|
+
META_KEY = '_ephaptic_metadata'
|
|
10
|
+
|
|
11
|
+
class Expose:
|
|
12
|
+
def __init__(self, registry: Dict[str, Callable]):
|
|
13
|
+
self.registry = registry
|
|
14
|
+
|
|
15
|
+
@overload
|
|
16
|
+
def __call__(self, func: F) -> F:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def __call__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
name: Optional[str] = None,
|
|
24
|
+
response_model: Optional[type] = None,
|
|
25
|
+
rate_limit: Optional[str] = None,
|
|
26
|
+
hints: Optional[dict[str, Any]] = None,
|
|
27
|
+
sig: Optional[inspect.Signature] = None,
|
|
28
|
+
):
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def __call__(self, func=None, **kwargs):
|
|
32
|
+
def inject(f: F) -> F:
|
|
33
|
+
self.registry[kwargs.get('name') or f.__name__] = f
|
|
34
|
+
|
|
35
|
+
if kwargs.get('rate_limit'): kwargs['rate_limit'] = parse_limit(kwargs['rate_limit'])
|
|
36
|
+
|
|
37
|
+
meta = getattr(f, META_KEY, {})
|
|
38
|
+
meta.update(kwargs)
|
|
39
|
+
setattr(f, META_KEY, meta)
|
|
40
|
+
|
|
41
|
+
return f
|
|
42
|
+
|
|
43
|
+
if func is not None and callable(func):
|
|
44
|
+
return inject(func)
|
|
45
|
+
|
|
46
|
+
return inject
|
|
47
|
+
|
|
48
|
+
class Event:
|
|
49
|
+
def __init__(self, registry: Dict[str, Type[pydantic.BaseModel]]):
|
|
50
|
+
self.registry = registry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def __call__(self, model: M) -> M:
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def __call__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
name: Optional[str] = None,
|
|
62
|
+
) -> Callable[[M], M]:
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def __call__(self, model=None, **kwargs):
|
|
66
|
+
def inject(m: M) -> M:
|
|
67
|
+
self.registry[kwargs.get('name') or m.__name__] = m
|
|
68
|
+
|
|
69
|
+
meta = getattr(m, META_KEY, {})
|
|
70
|
+
meta.update(kwargs)
|
|
71
|
+
setattr(m, META_KEY, meta)
|
|
72
|
+
|
|
73
|
+
return m
|
|
74
|
+
|
|
75
|
+
if model is not None and isinstance(model, type) and issubclass(model, pydantic.BaseModel):
|
|
76
|
+
return inject(model)
|
|
77
|
+
|
|
78
|
+
return inject
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IdentityLoader:
|
|
82
|
+
def __init__(self, setter: Callable[[Callable], None]):
|
|
83
|
+
self.setter = setter
|
|
84
|
+
|
|
85
|
+
def __call__(self, func: F) -> F:
|
|
86
|
+
self.setter(func)
|
|
87
|
+
return func
|
ephaptic/ephaptic.py
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
|
|
2
|
+
import warnings
|
|
3
3
|
import msgpack
|
|
4
4
|
import redis.asyncio as redis
|
|
5
5
|
import pydantic
|
|
6
|
+
import time
|
|
6
7
|
|
|
7
8
|
from contextvars import ContextVar
|
|
8
9
|
from .localproxy import LocalProxy
|
|
9
10
|
|
|
10
11
|
from .transports import Transport
|
|
11
12
|
|
|
13
|
+
from .decorators import META_KEY, Expose, Event, IdentityLoader
|
|
14
|
+
|
|
15
|
+
from .ctx import _scope_ctx, _active_transport_ctx, _active_user_ctx
|
|
16
|
+
|
|
12
17
|
import typing
|
|
13
18
|
from typing import Optional, Callable, Any, List, Set, Dict
|
|
14
19
|
import inspect
|
|
15
20
|
|
|
16
|
-
_active_transport_ctx = ContextVar('active_transport', default=None)
|
|
17
|
-
_active_user_ctx = ContextVar('active_user', default=None)
|
|
18
|
-
|
|
19
|
-
active_user = LocalProxy(_active_user_ctx.get)
|
|
20
|
-
|
|
21
21
|
CHANNEL_NAME = "ephaptic:broadcast"
|
|
22
22
|
|
|
23
|
+
F = typing.TypeVar('F', bound=Callable[..., Any])
|
|
24
|
+
|
|
23
25
|
class ConnectionManager:
|
|
24
26
|
def __init__(self):
|
|
25
27
|
self.active: Dict[str, Set[Transport]] = {} # Map[user_id, Set[Transport]]
|
|
@@ -74,6 +76,19 @@ manager = ConnectionManager()
|
|
|
74
76
|
_EXPOSED_FUNCTIONS = {}
|
|
75
77
|
_EXPOSED_EVENTS = {}
|
|
76
78
|
_IDENTITY_LOADER: Optional[Callable] = None
|
|
79
|
+
_HTTP_IDENTITY_LOADER: Optional[Callable] = None
|
|
80
|
+
|
|
81
|
+
_LOCAL_RATELIMIT_CACHE: Dict[str, List] = {} # [hits, expire_at]
|
|
82
|
+
# if redis isn't set up, assume that this is the only instance [no 'multiple nodes'] so ratelimits can be stored in memory.
|
|
83
|
+
# only used when Redis isn't set.
|
|
84
|
+
_LAST_CACHE_CLEANUP = time.time() # for manual cleaning up of the cache
|
|
85
|
+
|
|
86
|
+
class RatelimitExceededException(Exception):
|
|
87
|
+
retry_after: int
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, retry_after: int):
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.retry_after = retry_after
|
|
77
92
|
|
|
78
93
|
class EphapticTarget:
|
|
79
94
|
def __init__(self, user_ids: list[str]):
|
|
@@ -94,25 +109,29 @@ class EphapticTarget:
|
|
|
94
109
|
await manager.broadcast(self.user_ids, name, list(args), dict(kwargs))
|
|
95
110
|
return emitter
|
|
96
111
|
|
|
97
|
-
def
|
|
98
|
-
global _EXPOSED_FUNCTIONS
|
|
99
|
-
_EXPOSED_FUNCTIONS[func.__name__] = func
|
|
100
|
-
return func
|
|
101
|
-
|
|
102
|
-
def identity_loader(func: Callable):
|
|
112
|
+
def _set_identity_loader(f):
|
|
103
113
|
global _IDENTITY_LOADER
|
|
104
|
-
_IDENTITY_LOADER =
|
|
105
|
-
|
|
114
|
+
_IDENTITY_LOADER = f
|
|
115
|
+
|
|
116
|
+
def _set_http_identity_loader(f):
|
|
117
|
+
global _HTTP_IDENTITY_LOADER
|
|
118
|
+
_HTTP_IDENTITY_LOADER = f
|
|
106
119
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
expose = Expose(_EXPOSED_FUNCTIONS)
|
|
121
|
+
event = Event(_EXPOSED_EVENTS)
|
|
122
|
+
identity_loader = IdentityLoader(_set_identity_loader)
|
|
123
|
+
http_identity_loader = IdentityLoader(_set_http_identity_loader)
|
|
111
124
|
|
|
112
125
|
class Ephaptic:
|
|
113
126
|
_exposed_functions: Dict[str, Callable] = {}
|
|
114
127
|
_exposed_events: Dict[str, typing.Type[pydantic.BaseModel]]
|
|
115
128
|
_identity_loader: Optional[Callable] = None
|
|
129
|
+
_http_identity_loader: Optional[Callable] = None
|
|
130
|
+
|
|
131
|
+
expose: Expose
|
|
132
|
+
event: Event
|
|
133
|
+
identity_loader: IdentityLoader
|
|
134
|
+
http_identity_loader: IdentityLoader
|
|
116
135
|
|
|
117
136
|
def _async(self, func: Callable):
|
|
118
137
|
async def wrapper(*args, **kwargs) -> Any:
|
|
@@ -137,10 +156,10 @@ class Ephaptic:
|
|
|
137
156
|
|
|
138
157
|
match module:
|
|
139
158
|
case "quart":
|
|
140
|
-
from .
|
|
159
|
+
from .ext.quart.adapter import QuartAdapter
|
|
141
160
|
adapter = QuartAdapter(instance, app, path, manager)
|
|
142
161
|
case "fastapi":
|
|
143
|
-
from .
|
|
162
|
+
from .ext.fastapi.adapter import FastAPIAdapter
|
|
144
163
|
adapter = FastAPIAdapter(instance, app, path, manager)
|
|
145
164
|
case _:
|
|
146
165
|
raise TypeError(f"Unsupported app type: {module}")
|
|
@@ -148,21 +167,50 @@ class Ephaptic:
|
|
|
148
167
|
instance._exposed_functions = _EXPOSED_FUNCTIONS.copy()
|
|
149
168
|
instance._exposed_events = _EXPOSED_EVENTS.copy()
|
|
150
169
|
instance._identity_loader = _IDENTITY_LOADER
|
|
170
|
+
instance._http_identity_loader = _HTTP_IDENTITY_LOADER
|
|
151
171
|
|
|
152
|
-
|
|
172
|
+
instance.expose = Expose(instance._exposed_functions)
|
|
173
|
+
instance.event = Event(instance._exposed_events)
|
|
174
|
+
instance.identity_loader = IdentityLoader(lambda f: setattr(instance, '_identity_loader', f))
|
|
175
|
+
instance.http_identity_loader = IdentityLoader(lambda f: setattr(instance, '_http_identity_loader', f))
|
|
153
176
|
|
|
154
|
-
|
|
155
|
-
def expose(self, func: Callable):
|
|
156
|
-
self._exposed_functions[func.__name__] = func
|
|
157
|
-
return func
|
|
158
|
-
|
|
159
|
-
def event(self, model: typing.Type[pydantic.BaseModel]):
|
|
160
|
-
self._exposed_events[model.__name__] = model
|
|
161
|
-
return model
|
|
177
|
+
return instance
|
|
162
178
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
179
|
+
async def _check_ratelimit(self, func_name: str, limit: tuple[int, int], uid: str = None, ip: str = None):
|
|
180
|
+
max_reqs, window = limit
|
|
181
|
+
identifier = f'u:{uid}' if uid else f'ip:{ip}'
|
|
182
|
+
now = time.time()
|
|
183
|
+
current_window = int(now // window)
|
|
184
|
+
reset = (current_window + 1) * window
|
|
185
|
+
key = f'ephaptic:rl:{func_name}:{identifier}:{current_window}'
|
|
186
|
+
|
|
187
|
+
if manager.redis:
|
|
188
|
+
pipe = manager.redis.pipeline()
|
|
189
|
+
pipe.incr(key)
|
|
190
|
+
pipe.expire(key, window + 1)
|
|
191
|
+
results = await pipe.execute()
|
|
192
|
+
hits = results[0]
|
|
193
|
+
else:
|
|
194
|
+
global _LAST_CACHE_CLEANUP
|
|
195
|
+
if (now - _LAST_CACHE_CLEANUP) > 60:
|
|
196
|
+
for k in [
|
|
197
|
+
k for k, v in _LOCAL_RATELIMIT_CACHE.items()
|
|
198
|
+
if v[1] < now
|
|
199
|
+
]: del _LOCAL_RATELIMIT_CACHE[k]
|
|
200
|
+
_LAST_CACHE_CLEANUP = now
|
|
201
|
+
|
|
202
|
+
entry = _LOCAL_RATELIMIT_CACHE.get(key)
|
|
203
|
+
if not entry:
|
|
204
|
+
entry = [0, reset]
|
|
205
|
+
_LOCAL_RATELIMIT_CACHE[key] = entry
|
|
206
|
+
|
|
207
|
+
entry[0] += 1
|
|
208
|
+
hits = entry[0]
|
|
209
|
+
|
|
210
|
+
if hits > max_reqs:
|
|
211
|
+
retry_after = max(1, int(reset - now))
|
|
212
|
+
raise RatelimitExceededException(f'Rate Limit exceeded. Try again in {retry_after} seconds.', retry_after=retry_after)
|
|
213
|
+
|
|
166
214
|
|
|
167
215
|
def to(self, *args):
|
|
168
216
|
targets = []
|
|
@@ -170,25 +218,7 @@ class Ephaptic:
|
|
|
170
218
|
if isinstance(arg, list): targets.extend(arg)
|
|
171
219
|
else: targets.append(arg)
|
|
172
220
|
return EphapticTarget(targets)
|
|
173
|
-
|
|
174
|
-
def __getattr__(self, name: str):
|
|
175
|
-
@deprecated("Use `emit` and the new (typed) event system instead.")
|
|
176
|
-
async def emitter(*args, **kwargs):
|
|
177
|
-
transport: Transport = _active_transport_ctx.get()
|
|
178
|
-
if not transport:
|
|
179
|
-
raise RuntimeError(
|
|
180
|
-
f".{name}() called outside RPC context."
|
|
181
|
-
f"Use .to(...).{name}() to broadcast from background tasks, to specific user(s)."
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
await transport.send(msgpack.dumps({
|
|
185
|
-
"type": "event",
|
|
186
|
-
"name": name,
|
|
187
|
-
"payload": {"args": list(args), "kwargs": dict(kwargs)},
|
|
188
|
-
}))
|
|
189
|
-
|
|
190
|
-
return emitter
|
|
191
|
-
|
|
221
|
+
|
|
192
222
|
async def emit(self, event_instance: pydantic.BaseModel):
|
|
193
223
|
event_name = event_instance.__class__.__name__
|
|
194
224
|
payload = event_instance.model_dump(mode='json')
|
|
@@ -239,7 +269,31 @@ class Ephaptic:
|
|
|
239
269
|
|
|
240
270
|
if func_name in self._exposed_functions:
|
|
241
271
|
target_func = self._exposed_functions[func_name]
|
|
242
|
-
|
|
272
|
+
meta = getattr(target_func, META_KEY, {})
|
|
273
|
+
|
|
274
|
+
if meta.get('rate_limit'):
|
|
275
|
+
try:
|
|
276
|
+
await self._check_ratelimit(
|
|
277
|
+
func_name,
|
|
278
|
+
meta.get('rate_limit'),
|
|
279
|
+
uid=current_uid,
|
|
280
|
+
ip=transport.remote_addr,
|
|
281
|
+
)
|
|
282
|
+
except RatelimitExceededException as e:
|
|
283
|
+
await transport.send(msgpack.dumps({
|
|
284
|
+
"id": call_id,
|
|
285
|
+
"error": {
|
|
286
|
+
"code": "RATELIMIT",
|
|
287
|
+
"message": str(e),
|
|
288
|
+
"data": { "retry_after": e.retry_after },
|
|
289
|
+
},
|
|
290
|
+
}))
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
hints = meta.get('hints') or typing.get_type_hints(target_func)
|
|
295
|
+
sig = meta.get('sig') or inspect.signature(target_func)
|
|
296
|
+
|
|
243
297
|
try:
|
|
244
298
|
bound = sig.bind(*args, **kwargs)
|
|
245
299
|
bound.apply_defaults()
|
|
@@ -247,66 +301,76 @@ class Ephaptic:
|
|
|
247
301
|
await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
|
|
248
302
|
continue
|
|
249
303
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
304
|
+
fields = {}
|
|
305
|
+
for name, param in sig.parameters.items():
|
|
306
|
+
if name in hints:
|
|
307
|
+
fields[name] = (hints[name], param.default if param.default is not inspect.Parameter.empty else ...)
|
|
308
|
+
else:
|
|
309
|
+
fields[name] = (Any, param.default if param.default is not inspect.Parameter.empty else ...)
|
|
253
310
|
|
|
254
|
-
|
|
311
|
+
DynamicInputModel = pydantic.create_model(f'DynamicInputModel_{func_name}', **fields)
|
|
255
312
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
try:
|
|
261
|
-
bound.arguments[name] = hint.model_validate(val)
|
|
262
|
-
except pydantic.ValidationError as e:
|
|
263
|
-
errors.extend(e.errors())
|
|
264
|
-
|
|
265
|
-
if errors:
|
|
313
|
+
try:
|
|
314
|
+
validated_data = DynamicInputModel(**bound.arguments)
|
|
315
|
+
final_arguments = validated_data.model_dump()
|
|
316
|
+
except pydantic.ValidationError as e:
|
|
266
317
|
await transport.send(msgpack.dumps({
|
|
267
318
|
"id": call_id,
|
|
268
319
|
"error": {
|
|
269
320
|
"code": "VALIDATION_ERROR",
|
|
270
|
-
"message": "
|
|
271
|
-
"data": errors,
|
|
321
|
+
"message": "Input validation failed.",
|
|
322
|
+
"data": e.errors(),
|
|
272
323
|
},
|
|
273
324
|
}))
|
|
274
325
|
continue
|
|
275
|
-
|
|
326
|
+
|
|
276
327
|
token_transport = _active_transport_ctx.set(transport)
|
|
277
328
|
token_user = _active_user_ctx.set(current_uid)
|
|
329
|
+
token_scope = _scope_ctx.set('rpc')
|
|
278
330
|
|
|
279
331
|
try:
|
|
280
|
-
result = await self._async(target_func)(**
|
|
332
|
+
result = await self._async(target_func)(**final_arguments)
|
|
281
333
|
|
|
282
|
-
|
|
334
|
+
return_type = meta.get('response_model') or hints.get("return", typing.Any)
|
|
335
|
+
if return_type and return_type is not inspect.Signature.empty and return_type is not typing.Any:
|
|
283
336
|
try:
|
|
284
337
|
adapter = pydantic.TypeAdapter(return_type)
|
|
285
338
|
validated = adapter.validate_python(result, from_attributes=True)
|
|
286
339
|
result = adapter.dump_python(validated, mode='json')
|
|
287
|
-
except:
|
|
340
|
+
except Exception as e:
|
|
341
|
+
# Should we really treat this separately?
|
|
342
|
+
# For input it's understandable, but for server responses it feels like a server issue.
|
|
343
|
+
# Let's just return a RETURN_VALIDATION_ERROR and print the traceback.
|
|
344
|
+
import traceback
|
|
345
|
+
traceback.print_exc()
|
|
346
|
+
await transport.send(msgpack.dumps({
|
|
347
|
+
"id": call_id,
|
|
348
|
+
"error": {
|
|
349
|
+
"code": "RETURN_VALIDATION_ERROR",
|
|
350
|
+
"message": f"Server returned invalid type: {e}",
|
|
351
|
+
"data": None,
|
|
352
|
+
},
|
|
353
|
+
}))
|
|
354
|
+
continue
|
|
288
355
|
elif isinstance(result, pydantic.BaseModel):
|
|
289
356
|
result = result.model_dump(mode='json')
|
|
290
357
|
|
|
291
358
|
await transport.send(msgpack.dumps({"id": call_id, "result": result}))
|
|
292
|
-
# except pydantic.ValidationError as e:
|
|
293
|
-
# Should we really treat this separately?
|
|
294
|
-
# For input it's understandable, but for server responses it feels like a server issue.
|
|
295
|
-
# Ok, let's treat this like any other server error.
|
|
296
359
|
except Exception as e:
|
|
297
360
|
await transport.send(msgpack.dumps({"id": call_id, "error": str(e)}))
|
|
298
361
|
finally:
|
|
299
362
|
_active_transport_ctx.reset(token_transport)
|
|
300
363
|
_active_user_ctx.reset(token_user)
|
|
364
|
+
_scope_ctx.reset(token_scope)
|
|
301
365
|
else:
|
|
302
366
|
await transport.send(msgpack.dumps({
|
|
303
367
|
"id": call_id,
|
|
304
368
|
"error": f"Function '{func_name}' not found."
|
|
305
369
|
}))
|
|
306
|
-
except asyncio.CancelledError:
|
|
370
|
+
except (asyncio.CancelledError, Transport.ConnectionClosed):
|
|
307
371
|
...
|
|
308
372
|
except Exception:
|
|
309
373
|
import traceback
|
|
310
374
|
traceback.print_exc()
|
|
311
375
|
finally:
|
|
312
|
-
if current_uid: manager.remove(current_uid, transport)
|
|
376
|
+
if current_uid: manager.remove(current_uid, transport)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .router import Router
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
from fastapi import FastAPI, WebSocket
|
|
2
|
-
from
|
|
2
|
+
from ...transports.fastapi_ws import FastAPIWebSocketTransport
|
|
3
|
+
from .middleware import CtxMiddleware
|
|
3
4
|
|
|
4
5
|
class FastAPIAdapter:
|
|
5
6
|
def __init__(self, ephaptic, app: FastAPI, path, manager):
|
|
6
7
|
self.ephaptic = ephaptic
|
|
7
8
|
|
|
9
|
+
app.add_middleware(CtxMiddleware, ephaptic=ephaptic)
|
|
10
|
+
|
|
8
11
|
@app.websocket(path)
|
|
9
12
|
async def ephaptic_ws(websocket: WebSocket):
|
|
10
13
|
await websocket.accept()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2
|
+
from ...ctx import _scope_ctx
|
|
3
|
+
from ...ephaptic import Ephaptic, _active_user_ctx
|
|
4
|
+
|
|
5
|
+
class CtxMiddleware(BaseHTTPMiddleware):
|
|
6
|
+
def __init__(self, app, ephaptic: Ephaptic):
|
|
7
|
+
super().__init__(app)
|
|
8
|
+
self.ephaptic = ephaptic
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def dispatch(self, request, call_next):
|
|
12
|
+
token = _scope_ctx.set('http')
|
|
13
|
+
user_token = None
|
|
14
|
+
|
|
15
|
+
if self.ephaptic._http_identity_loader:
|
|
16
|
+
user = await self.ephaptic._async(self.ephaptic._http_identity_loader)(request)
|
|
17
|
+
if user:
|
|
18
|
+
user_token = _active_user_ctx.set(user)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return await call_next(request)
|
|
22
|
+
finally:
|
|
23
|
+
_scope_ctx.reset(token)
|
|
24
|
+
if user_token: _active_user_ctx.reset(user_token)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import *
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
6
|
+
from ...ephaptic import Ephaptic, RatelimitExceededException
|
|
7
|
+
from ...ctx import is_http, is_rpc, active_user
|
|
8
|
+
from ...utils import parse_limit
|
|
9
|
+
|
|
10
|
+
class Router(APIRouter):
|
|
11
|
+
ephaptic: Ephaptic
|
|
12
|
+
|
|
13
|
+
def __init__(self, ephaptic: Ephaptic, *args, **kwargs):
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
self.ephaptic = ephaptic
|
|
16
|
+
|
|
17
|
+
def _register(
|
|
18
|
+
self,
|
|
19
|
+
func: Callable,
|
|
20
|
+
methods: List[str],
|
|
21
|
+
path: str,
|
|
22
|
+
limit: Optional[str] = None,
|
|
23
|
+
auth: bool = False,
|
|
24
|
+
**kwargs,
|
|
25
|
+
):
|
|
26
|
+
limit_config = parse_limit(limit) if limit else None
|
|
27
|
+
|
|
28
|
+
async def http_rl_dep(req: Request):
|
|
29
|
+
if limit_config:
|
|
30
|
+
try:
|
|
31
|
+
await self.ephaptic._check_ratelimit(
|
|
32
|
+
func.__name__,
|
|
33
|
+
limit_config,
|
|
34
|
+
ip=req.client.host
|
|
35
|
+
)
|
|
36
|
+
except RatelimitExceededException as e:
|
|
37
|
+
raise HTTPException(status_code=429, detail=str(e), headers={'X-Retry-After': e.retry_after})
|
|
38
|
+
|
|
39
|
+
@wraps(func)
|
|
40
|
+
async def wrapper(*args, **kwargs):
|
|
41
|
+
if auth and active_user() is None:
|
|
42
|
+
raise Exception('Unauthorized') if is_rpc() else HTTPException(status_code=401, detail='Unauthorized')
|
|
43
|
+
|
|
44
|
+
return await self.ephaptic._async(func)(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
deps = kwargs.pop('dependencies', [])
|
|
47
|
+
if limit: deps.append(Depends(http_rl_dep))
|
|
48
|
+
|
|
49
|
+
self.add_api_route(
|
|
50
|
+
path,
|
|
51
|
+
wrapper,
|
|
52
|
+
methods=methods,
|
|
53
|
+
dependencies=deps,
|
|
54
|
+
**kwargs,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
self.ephaptic.expose(
|
|
58
|
+
name=func.__name__,
|
|
59
|
+
rate_limit=limit,
|
|
60
|
+
hints=get_type_hints(func),
|
|
61
|
+
sig=inspect.signature(func), # for bypassing the @wraps
|
|
62
|
+
)(wrapper)
|
|
63
|
+
|
|
64
|
+
def get (self, path, limit=None, requires_login=False):
|
|
65
|
+
def decorator(func): return self._register(func=func, methods=["GET"], path=path, limit=limit, auth=requires_login)
|
|
66
|
+
return decorator
|
|
67
|
+
|
|
68
|
+
def post (self, path, limit=None, requires_login=False):
|
|
69
|
+
def decorator(func): return self._register(func=func, methods=["POST"], path=path, limit=limit, auth=requires_login)
|
|
70
|
+
return decorator
|
|
71
|
+
|
|
72
|
+
def put (self, path, limit=None, requires_login=False):
|
|
73
|
+
def decorator(func): return self._register(func=func, methods=["PUT"], path=path, limit=limit, auth=requires_login)
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
def delete (self, path, limit=None, requires_login=False):
|
|
77
|
+
def decorator(func): return self._register(func=func, methods=["DELETE"], path=path, limit=limit, auth=requires_login)
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
def patch (self, path, limit=None, requires_login=False):
|
|
81
|
+
def decorator(func): return self._register(func=func, methods=["PATCH"], path=path, limit=limit, auth=requires_login)
|
|
82
|
+
return decorator
|
ephaptic/transports/__init__.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Optional
|
|
2
2
|
|
|
3
3
|
class Transport:
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
remote_addr: Optional[str] = None # usually, IP address (for most common transport types, like websocket, tcp/udp, etc.)
|
|
5
|
+
|
|
6
|
+
class ConnectionClosed(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
async def send(self, data: bytes): raise NotImplementedError()
|
|
10
|
+
async def receive(self) -> bytes: raise NotImplementedError()
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
from .websocket import WebSocketTransport
|
|
2
|
+
from . import Transport
|
|
3
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
2
4
|
|
|
3
5
|
class FastAPIWebSocketTransport(WebSocketTransport):
|
|
6
|
+
def __init__(self, ws: WebSocket):
|
|
7
|
+
super().__init__(ws)
|
|
8
|
+
self.remote_addr = ws.client.host if ws.client else 'unknown'
|
|
9
|
+
|
|
4
10
|
async def send(self, data: bytes):
|
|
5
11
|
await self.ws.send_bytes(data)
|
|
6
12
|
|
|
7
13
|
async def receive(self) -> bytes:
|
|
8
|
-
|
|
14
|
+
try:
|
|
15
|
+
return await self.ws.receive_bytes()
|
|
16
|
+
except WebSocketDisconnect:
|
|
17
|
+
raise Transport.ConnectionClosed from None
|
ephaptic/transports/websocket.py
CHANGED
ephaptic/utils.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
def parse_limit(limit: str) -> tuple[int, int]:
|
|
4
|
+
count, period = limit.replace(' per ', '/').split('/')
|
|
5
|
+
count = int(count)
|
|
6
|
+
|
|
7
|
+
match = re.fullmatch(r'(\d+)?\s*([smhd])', period.lower())
|
|
8
|
+
if not match:
|
|
9
|
+
raise ValueError(f"Invalid rate limit period: {period}")
|
|
10
|
+
|
|
11
|
+
multiplier = int(match.group(1) or 1)
|
|
12
|
+
unit = match.group(2)
|
|
13
|
+
|
|
14
|
+
s = {
|
|
15
|
+
's': 1,
|
|
16
|
+
'm': 60,
|
|
17
|
+
'h': 3600,
|
|
18
|
+
'd': 86400,
|
|
19
|
+
}[unit]
|
|
20
|
+
|
|
21
|
+
return count, multiplier * s
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ephaptic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The Python client/server package for ephaptic.
|
|
5
5
|
Author-email: uukelele <robustrobot11@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -37,8 +37,13 @@ Requires-Dist: pydantic>=2.0
|
|
|
37
37
|
Requires-Dist: typer[standard]>=0.20.0
|
|
38
38
|
Requires-Dist: python-dotenv
|
|
39
39
|
Requires-Dist: watchfiles
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
Requires-Dist: redis
|
|
41
|
+
Provides-Extra: test
|
|
42
|
+
Requires-Dist: pytest; extra == "test"
|
|
43
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
44
|
+
Requires-Dist: fastapi; extra == "test"
|
|
45
|
+
Requires-Dist: uvicorn; extra == "test"
|
|
46
|
+
Requires-Dist: httpx; extra == "test"
|
|
42
47
|
Dynamic: license-file
|
|
43
48
|
|
|
44
49
|
<div align="center">
|
|
@@ -51,15 +56,7 @@ Dynamic: license-file
|
|
|
51
56
|
<br>
|
|
52
57
|
<h1>ephaptic</h1>
|
|
53
58
|
<br>
|
|
54
|
-
<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"
|
|
55
|
-
<img alt="PyPI - Version"
|
|
56
|
-
src="https://img.shields.io/pypi/v/ephaptic?style=for-the-badge&labelColor=%23222222">
|
|
57
|
-
</a>
|
|
58
|
-
|
|
59
|
-
<a href="https://www.npmjs.com/package/@ephaptic/client">
|
|
60
|
-
<img alt="NPM - Version"
|
|
61
|
-
src="https://img.shields.io/npm/v/%40ephaptic%2Fclient?style=for-the-badge&labelColor=%23222222">
|
|
62
|
-
</a>
|
|
59
|
+
<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" /> <img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/ephaptic/ephaptic/tests.yml?style=for-the-badge&label=tests&labelColor=%23222222" /> <a href="https://pypi.org/project/ephaptic/"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/ephaptic?style=for-the-badge&labelColor=%23222222" /></a> <a href="https://www.npmjs.com/package/@ephaptic/client"><img alt="NPM - Version" src="https://img.shields.io/npm/v/%40ephaptic%2Fclient?style=for-the-badge&labelColor=%23222222" /></a>
|
|
63
60
|
|
|
64
61
|
|
|
65
62
|
</div>
|
|
@@ -92,18 +89,10 @@ What are you waiting for? **Let's go.**
|
|
|
92
89
|
<details>
|
|
93
90
|
<summary>Python</summary>
|
|
94
91
|
|
|
95
|
-
<h4>Client:</h4>
|
|
96
|
-
|
|
97
92
|
```
|
|
98
93
|
$ pip install ephaptic
|
|
99
94
|
```
|
|
100
95
|
|
|
101
|
-
<h4>Server:</h4>
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
$ pip install ephaptic[server]
|
|
105
|
-
```
|
|
106
|
-
|
|
107
96
|
```python
|
|
108
97
|
from fastapi import FastAPI # or `from quart import Quart`
|
|
109
98
|
from ephaptic import Ephaptic
|
|
@@ -142,13 +131,19 @@ But what if your code throws an error? No sweat, it just throws up on the fronte
|
|
|
142
131
|
And, want to say something to the frontend?
|
|
143
132
|
|
|
144
133
|
```python
|
|
145
|
-
|
|
134
|
+
class Notification(BaseModel):
|
|
135
|
+
message: str
|
|
136
|
+
priority: Literal["high", "low", "default"]
|
|
137
|
+
|
|
138
|
+
await ephaptic.to(user1, user2).emit(Notification(message="Hello, world!", priority="high"))
|
|
146
139
|
```
|
|
147
140
|
|
|
148
141
|
To create a schema of your RPC endpoints:
|
|
149
142
|
|
|
150
143
|
```
|
|
151
|
-
$ ephaptic src.app:app -o schema.json # --watch to run in background and auto-reload on file change.
|
|
144
|
+
$ ephaptic generate src.app:app -o schema.json # --watch to run in background and auto-reload on file change.
|
|
145
|
+
$ # Or:
|
|
146
|
+
$ ephaptic generate src.app:app -o ephaptic.d.ts # converts directly to typescript
|
|
152
147
|
```
|
|
153
148
|
|
|
154
149
|
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.
|
|
@@ -166,7 +161,7 @@ async def load_identity(auth): # You can use synchronous functions here too.
|
|
|
166
161
|
return user_id
|
|
167
162
|
```
|
|
168
163
|
|
|
169
|
-
From here, you can use <code>ephaptic.active_user</code> within any exposed function, and it will give you the current active user ID / whatever else your identity loading function returns. (This is also how <code>ephaptic.to</code> works.)
|
|
164
|
+
From here, you can use <code>ephaptic.active_user()</code> within any exposed function, and it will give you the current active user ID / whatever else your identity loading function returns. (This is also how <code>ephaptic.to</code> works.)
|
|
170
165
|
|
|
171
166
|
</details>
|
|
172
167
|
|
|
@@ -206,8 +201,7 @@ const client = connect({ url: '...', auth: { token: window.localStorage.getItem(
|
|
|
206
201
|
And you can load types, too.
|
|
207
202
|
|
|
208
203
|
```
|
|
209
|
-
$
|
|
210
|
-
$ npx @ephaptic/type-gen ./schema.json -o schema.d.ts # --watch to auto-reload upon changes
|
|
204
|
+
$ ephaptic from-schema ./schema.json -o schema.d.ts # --watch to auto-reload upon changes
|
|
211
205
|
```
|
|
212
206
|
|
|
213
207
|
```typescript
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
ephaptic/__init__.py,sha256=G0C6_5AiQKVK9478VoBFnS3SxxQEdJeA9u9cN8j0dnU,185
|
|
2
|
+
ephaptic/ctx.py,sha256=9nIAqQHO_hMxymex2DVmXElP2Tw3IX6OrqO5M21K0iQ,384
|
|
3
|
+
ephaptic/decorators.py,sha256=H7FrsMjXTZOFaR4ITzESDfo4hH-AHlYsOBfrfZ7kEQQ,2146
|
|
4
|
+
ephaptic/ephaptic.py,sha256=h85nkJTGHjwgAo1Qi5P3mahr1E5ieIGU9qmbQrdwZWA,15605
|
|
5
|
+
ephaptic/localproxy.py,sha256=fJaaskkiD6C2zaOod0F0HNWIbdKs_JMuHFwd0-sdLIM,19477
|
|
6
|
+
ephaptic/utils.py,sha256=D5Q09ec9Usbdel0-KlyDdXrC5OCuPs3KdUysUtSF--I,485
|
|
7
|
+
ephaptic/cli/__init__.py,sha256=p_mYumuQLr3HZa-6I4QKut6khZv3WQjEX-B-aa4cdEE,44
|
|
8
|
+
ephaptic/cli/__main__.py,sha256=p74lW4sgdeOliMDuZRMszvRf_7CRoisT5z46k3GHUn0,17260
|
|
9
|
+
ephaptic/client/__init__.py,sha256=NeaPIzTFeozP54wlDYHIg_adHP3Z3LWVujsRUlpn4_U,35
|
|
10
|
+
ephaptic/client/client.py,sha256=YYAlzA40xBvWsiDu0Gsd1EBJaqivLR-bSszepWdNODs,4181
|
|
11
|
+
ephaptic/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
ephaptic/ext/fastapi/__init__.py,sha256=oh4VtyCyqfAh2SkajR9Fd4tkRudOc43Wq7t_YosxYLM,26
|
|
13
|
+
ephaptic/ext/fastapi/adapter.py,sha256=r6yYy7OBVt2nc1EoaD2lQduBrDwphDHIt5XEqAYi7Yo,1108
|
|
14
|
+
ephaptic/ext/fastapi/middleware.py,sha256=rmicoPdBZW2CSACHy3DjKp6uPTkWyE7ylGAREEmJJK8,799
|
|
15
|
+
ephaptic/ext/fastapi/router.py,sha256=H3ZhPS9XxW3pMWoQVCezyVZEoeglpbgol0tD8npsLQ8,3066
|
|
16
|
+
ephaptic/ext/quart/adapter.py,sha256=j3QXuSGEbnRIvsGa_22x6tJOExdC2vyOW433ORC7McE,538
|
|
17
|
+
ephaptic/transports/__init__.py,sha256=F971utXKI_Cl6qoX3R3IkBM0-oojGhNzHgtmzN9Gk2g,357
|
|
18
|
+
ephaptic/transports/fastapi_ws.py,sha256=lM8ZdRtDZU1WAWi8xKg5Zem_oGF2DzI2b_aVHAJ-JNY,593
|
|
19
|
+
ephaptic/transports/websocket.py,sha256=atb9rztq2_ph9YHbv9gzFh596n59dWX97oU3ym8fG1M,321
|
|
20
|
+
ephaptic-0.3.0.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
|
|
21
|
+
ephaptic-0.3.0.dist-info/METADATA,sha256=k386hGPd4DAPkHO8npRg0gOLKxXx_2d50U-l3K9Il3k,8986
|
|
22
|
+
ephaptic-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
23
|
+
ephaptic-0.3.0.dist-info/entry_points.txt,sha256=lis9vIDIrMVJM43r9ooFkb07KhrX4946duvYru3VfT4,46
|
|
24
|
+
ephaptic-0.3.0.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
|
|
25
|
+
ephaptic-0.3.0.dist-info/RECORD,,
|
ephaptic-0.2.7.dist-info/RECORD
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
ephaptic/__init__.py,sha256=QIEfepOHMtM6Lrxg4AwZ28gEj0D5TY2UP8MGlrAb-DI,137
|
|
2
|
-
ephaptic/ephaptic.py,sha256=2Yd1gXrzXWjUiMm0yubt84JUC4PesUrRWnql_rbbfTU,11989
|
|
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/cli/__init__.py,sha256=p_mYumuQLr3HZa-6I4QKut6khZv3WQjEX-B-aa4cdEE,44
|
|
8
|
-
ephaptic/cli/__main__.py,sha256=MNzOfPK4DMwlnplJzT3NWBJdMnR0UFgcDl_8VvwO_QA,4866
|
|
9
|
-
ephaptic/client/__init__.py,sha256=NeaPIzTFeozP54wlDYHIg_adHP3Z3LWVujsRUlpn4_U,35
|
|
10
|
-
ephaptic/client/client.py,sha256=YYAlzA40xBvWsiDu0Gsd1EBJaqivLR-bSszepWdNODs,4181
|
|
11
|
-
ephaptic/transports/__init__.py,sha256=kSAlgvm8sV9nHHu61LTjjTpv4bweah90xvFrwQMDQtQ,169
|
|
12
|
-
ephaptic/transports/fastapi_ws.py,sha256=X0PMRcwM-KDpKA-zXShGTFhD1kHMSqrx3PBBKZtQ1W0,258
|
|
13
|
-
ephaptic/transports/websocket.py,sha256=jwgclSDSq0lQCvgwjwUXe9MzPk7NH0FdmsLhWxYBh-4,261
|
|
14
|
-
ephaptic-0.2.7.dist-info/licenses/LICENSE,sha256=kMpJjLKMj6zsAhf4uHApO4q0r31Ye1VyfBOl9cFW13M,1065
|
|
15
|
-
ephaptic-0.2.7.dist-info/METADATA,sha256=ej7C_HcS0ONyL_bs85XZjl8yu7egeNya-Q7DeTbqMS4,8533
|
|
16
|
-
ephaptic-0.2.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
-
ephaptic-0.2.7.dist-info/entry_points.txt,sha256=lis9vIDIrMVJM43r9ooFkb07KhrX4946duvYru3VfT4,46
|
|
18
|
-
ephaptic-0.2.7.dist-info/top_level.txt,sha256=nNhdhcz2o_IuwZ9I2uWQuLZrRmSW0dQVU3qwGrb35Io,9
|
|
19
|
-
ephaptic-0.2.7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|