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 CHANGED
@@ -1,11 +1,16 @@
1
1
  from .ephaptic import (
2
2
  Ephaptic,
3
- active_user,
4
3
  expose,
5
4
  identity_loader,
6
5
  event,
7
6
  )
8
7
 
9
8
  from .client import (
10
- connect
9
+ connect,
10
+ )
11
+
12
+ from .ctx import (
13
+ is_http,
14
+ is_rpc,
15
+ active_user,
11
16
  )
ephaptic/cli/__main__.py CHANGED
@@ -1,13 +1,47 @@
1
- import sys, os, json, inspect, importlib, typing, typer, subprocess as sp
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.secho(f"Warning: Import name did not specify client name. Defaulting to `client`.", fg=typer.colors.YELLOW)
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.secho(f"Attempting to import `{var_name}` from `{module_name}`...")
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.secho(f"Error: Can't import '{module_name}'.\n{e}", fg=typer.colors.RED)
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.secho(f"Error: Variable '{var_name}' not found in module '{module_name}'.", fg=typer.colors.RED)
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.secho(f"Error: '{var_name}' is not an Ephaptic client. It is type: {type(instance)}", fg=typer.colors.RED)
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('client:client', help="The import string for the Ephaptic client."),
64
- output: Path = typer.Option('schema.json', '--output', '-o', help="Output path for the JSON schema."),
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.secho(f"Found {len(ephaptic._exposed_functions)} functions.", fg=typer.colors.GREEN)
396
+ log(typer.style("--- Functions ---"))
89
397
 
90
398
  for name, func in ephaptic._exposed_functions.items():
91
- typer.secho(f" - {name}")
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.secho(f"Found {len(ephaptic._exposed_events)} events.", fg=typer.colors.GREEN)
440
+ log(typer.style("--- Events ---"))
441
+
129
442
 
130
443
  for name, model in ephaptic._exposed_events.items():
131
- typer.secho(f" - {name}")
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
- new = json.dumps(schema_output, indent=2)
452
+ generate_output(lang, schema_output, package_name, output)
140
453
 
141
- if output.exists():
142
- old = output.read_text()
143
- if old == new:
144
- return
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
- typer.secho(f"Schema generated to `{output}`.", fg=typer.colors.GREEN, bold=True)
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
- from warnings import deprecated
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 expose(func: Callable):
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 = func
105
- return func
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
- def event(model: typing.Type[pydantic.BaseModel]):
108
- global _EXPOSED_EVENTS
109
- _EXPOSED_EVENTS[model.__name__] = model
110
- return model
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 .adapters.quart_ import QuartAdapter
159
+ from .ext.quart.adapter import QuartAdapter
141
160
  adapter = QuartAdapter(instance, app, path, manager)
142
161
  case "fastapi":
143
- from .adapters.fastapi_ import FastAPIAdapter
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
- return instance
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 identity_loader(self, func: Callable):
164
- self._identity_loader = func
165
- return func
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
- sig = inspect.signature(target_func)
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
- hints = typing.get_type_hints(target_func)
251
-
252
- return_type = hints.get("return", typing.Any)
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
- errors = []
311
+ DynamicInputModel = pydantic.create_model(f'DynamicInputModel_{func_name}', **fields)
255
312
 
256
- for name, val in bound.arguments.items():
257
- hint = hints.get(name)
258
-
259
- if hint and inspect.isclass(hint) and issubclass(hint, pydantic.BaseModel):
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": "Validation failed.",
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)(**bound.arguments)
332
+ result = await self._async(target_func)(**final_arguments)
281
333
 
282
- if return_type is not inspect.Signature.empty:
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 ..transports.fastapi_ws import FastAPIWebSocketTransport
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
@@ -1,5 +1,5 @@
1
1
  from quart import websocket, Quart
2
- from ..transports.websocket import WebSocketTransport
2
+ from ...transports.websocket import WebSocketTransport
3
3
 
4
4
  class QuartAdapter:
5
5
  def __init__(self, ephaptic, app: Quart, path, manager):
@@ -1,5 +1,10 @@
1
- from typing import Coroutine
1
+ from typing import Optional
2
2
 
3
3
  class Transport:
4
- async def send(data: bytes): raise NotImplementedError()
5
- async def receive() -> bytes: raise NotImplementedError()
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
- return await self.ws.receive_bytes()
14
+ try:
15
+ return await self.ws.receive_bytes()
16
+ except WebSocketDisconnect:
17
+ raise Transport.ConnectionClosed from None
@@ -3,6 +3,7 @@ from . import Transport
3
3
  class WebSocketTransport(Transport):
4
4
  def __init__(self, ws):
5
5
  self.ws = ws
6
+ self.remote_addr = getattr(ws, 'remote_addr', None)
6
7
 
7
8
  async def send(self, data: bytes):
8
9
  await self.ws.send(data)
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.2.7
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
- Provides-Extra: server
41
- Requires-Dist: redis; extra == "server"
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"></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"> <a href="https://pypi.org/project/ephaptic/">
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
- await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
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
- $ npm i --save-dev @ephaptic/type-gen
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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