ephaptic 0.2.7__tar.gz → 0.3.0__tar.gz

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.
Files changed (33) hide show
  1. {ephaptic-0.2.7 → ephaptic-0.3.0}/PKG-INFO +19 -25
  2. {ephaptic-0.2.7 → ephaptic-0.3.0}/README.md +11 -22
  3. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/__init__.py +7 -2
  4. ephaptic-0.3.0/ephaptic/cli/__main__.py +486 -0
  5. ephaptic-0.3.0/ephaptic/ctx.py +10 -0
  6. ephaptic-0.3.0/ephaptic/decorators.py +87 -0
  7. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/ephaptic.py +142 -78
  8. ephaptic-0.3.0/ephaptic/ext/fastapi/__init__.py +1 -0
  9. ephaptic-0.2.7/ephaptic/adapters/fastapi_.py → ephaptic-0.3.0/ephaptic/ext/fastapi/adapter.py +4 -1
  10. ephaptic-0.3.0/ephaptic/ext/fastapi/middleware.py +24 -0
  11. ephaptic-0.3.0/ephaptic/ext/fastapi/router.py +82 -0
  12. ephaptic-0.2.7/ephaptic/adapters/quart_.py → ephaptic-0.3.0/ephaptic/ext/quart/adapter.py +1 -1
  13. ephaptic-0.3.0/ephaptic/transports/__init__.py +10 -0
  14. ephaptic-0.3.0/ephaptic/transports/fastapi_ws.py +17 -0
  15. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/transports/websocket.py +1 -0
  16. ephaptic-0.3.0/ephaptic/utils.py +21 -0
  17. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/PKG-INFO +19 -25
  18. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/SOURCES.txt +9 -3
  19. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/requires.txt +7 -2
  20. {ephaptic-0.2.7 → ephaptic-0.3.0}/pyproject.toml +9 -2
  21. ephaptic-0.2.7/ephaptic/cli/__main__.py +0 -152
  22. ephaptic-0.2.7/ephaptic/transports/__init__.py +0 -5
  23. ephaptic-0.2.7/ephaptic/transports/fastapi_ws.py +0 -8
  24. {ephaptic-0.2.7 → ephaptic-0.3.0}/LICENSE +0 -0
  25. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/cli/__init__.py +0 -0
  26. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/client/__init__.py +0 -0
  27. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/client/client.py +0 -0
  28. {ephaptic-0.2.7/ephaptic/adapters → ephaptic-0.3.0/ephaptic/ext}/__init__.py +0 -0
  29. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic/localproxy.py +0 -0
  30. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/dependency_links.txt +0 -0
  31. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/entry_points.txt +0 -0
  32. {ephaptic-0.2.7 → ephaptic-0.3.0}/ephaptic.egg-info/top_level.txt +0 -0
  33. {ephaptic-0.2.7 → ephaptic-0.3.0}/setup.cfg +0 -0
@@ -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
@@ -8,15 +8,7 @@
8
8
  <br>
9
9
  <h1>ephaptic</h1>
10
10
  <br>
11
- <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/">
12
- <img alt="PyPI - Version"
13
- src="https://img.shields.io/pypi/v/ephaptic?style=for-the-badge&labelColor=%23222222">
14
- </a>
15
-
16
- <a href="https://www.npmjs.com/package/@ephaptic/client">
17
- <img alt="NPM - Version"
18
- src="https://img.shields.io/npm/v/%40ephaptic%2Fclient?style=for-the-badge&labelColor=%23222222">
19
- </a>
11
+ <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>
20
12
 
21
13
 
22
14
  </div>
@@ -49,18 +41,10 @@ What are you waiting for? **Let's go.**
49
41
  <details>
50
42
  <summary>Python</summary>
51
43
 
52
- <h4>Client:</h4>
53
-
54
44
  ```
55
45
  $ pip install ephaptic
56
46
  ```
57
47
 
58
- <h4>Server:</h4>
59
-
60
- ```
61
- $ pip install ephaptic[server]
62
- ```
63
-
64
48
  ```python
65
49
  from fastapi import FastAPI # or `from quart import Quart`
66
50
  from ephaptic import Ephaptic
@@ -99,13 +83,19 @@ But what if your code throws an error? No sweat, it just throws up on the fronte
99
83
  And, want to say something to the frontend?
100
84
 
101
85
  ```python
102
- await ephaptic.to(user1, user2).notification("Hello, world!", priority="high")
86
+ class Notification(BaseModel):
87
+ message: str
88
+ priority: Literal["high", "low", "default"]
89
+
90
+ await ephaptic.to(user1, user2).emit(Notification(message="Hello, world!", priority="high"))
103
91
  ```
104
92
 
105
93
  To create a schema of your RPC endpoints:
106
94
 
107
95
  ```
108
- $ ephaptic src.app:app -o schema.json # --watch to run in background and auto-reload on file change.
96
+ $ ephaptic generate src.app:app -o schema.json # --watch to run in background and auto-reload on file change.
97
+ $ # Or:
98
+ $ ephaptic generate src.app:app -o ephaptic.d.ts # converts directly to typescript
109
99
  ```
110
100
 
111
101
  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.
@@ -123,7 +113,7 @@ async def load_identity(auth): # You can use synchronous functions here too.
123
113
  return user_id
124
114
  ```
125
115
 
126
- 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.)
116
+ 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.)
127
117
 
128
118
  </details>
129
119
 
@@ -163,8 +153,7 @@ const client = connect({ url: '...', auth: { token: window.localStorage.getItem(
163
153
  And you can load types, too.
164
154
 
165
155
  ```
166
- $ npm i --save-dev @ephaptic/type-gen
167
- $ npx @ephaptic/type-gen ./schema.json -o schema.d.ts # --watch to auto-reload upon changes
156
+ $ ephaptic from-schema ./schema.json -o schema.d.ts # --watch to auto-reload upon changes
168
157
  ```
169
158
 
170
159
  ```typescript
@@ -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
  )
@@ -0,0 +1,486 @@
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
7
+
8
+ from pathlib import Path
9
+ from pydantic import TypeAdapter
10
+
11
+ from ephaptic import Ephaptic
12
+ from ephaptic.decorators import META_KEY
13
+
14
+ from typing import *
15
+
16
+ app = typer.Typer(help="Ephaptic CLI tool.")
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
+
45
+ def load_ephaptic(import_name: str) -> Ephaptic:
46
+ try:
47
+ from dotenv import load_dotenv; load_dotenv()
48
+ except: ...
49
+
50
+ sys.path.insert(0, os.getcwd())
51
+
52
+ if ":" not in import_name:
53
+ log(typer.style(f"Warning: Import name did not specify client name. Defaulting to `client`.", fg=typer.colors.YELLOW))
54
+ import_name += ":client" # default: expect client to be named `client` inside the file
55
+
56
+ module_name, var_name = import_name.split(":", 1)
57
+
58
+ try:
59
+ log(typer.style(f"Attempting to import `{var_name}` from `{module_name}`..."))
60
+ module = importlib.import_module(module_name)
61
+ except ImportError as e:
62
+ typer.echo(typer.style(f"Error: Can't import '{module_name}'.\n{e}", fg=typer.colors.RED))
63
+ raise typer.Exit(1)
64
+
65
+ try:
66
+ instance = getattr(module, var_name)
67
+ except AttributeError:
68
+ typer.echo(typer.style(f"Error: Variable '{var_name}' not found in module '{module_name}'.", fg=typer.colors.RED))
69
+ raise typer.Exit(1)
70
+
71
+ if not isinstance(instance, Ephaptic):
72
+ typer.echo(typer.style(f"Error: '{var_name}' is not an Ephaptic client. It is type: {type(instance)}", fg=typer.colors.RED))
73
+ raise typer.Exit(1)
74
+
75
+ return instance
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
+
292
+ def create_schema(adapter: TypeAdapter, definitions: dict) -> dict:
293
+ schema = adapter.json_schema(ref_template='#/definitions/{model}')
294
+
295
+ if '$defs' in schema:
296
+ definitions.update(schema.pop('$defs'))
297
+
298
+ if schema.get('type') == 'object' and 'title' in schema:
299
+ model = schema['title']
300
+ definitions[model] = schema
301
+ return { '$ref': f'#/definitions/{model}' }
302
+
303
+ return schema
304
+
305
+ def run_subprocess():
306
+ cmd = [sys.executable]
307
+ cmd += [arg for arg in sys.argv if arg not in {'--watch', '-w'}]
308
+ sp.run(cmd)
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
+
363
+ @app.command()
364
+ def generate(
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)."),
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)")
370
+ ):
371
+ lang, output = calculate_language(lang, output)
372
+
373
+ if watch:
374
+ import watchfiles
375
+
376
+ cwd = os.getcwd()
377
+ typer.secho(f"Watching for changes ({cwd})...", fg=typer.colors.GREEN)
378
+
379
+ run_subprocess()
380
+
381
+ for changes in watchfiles.watch(cwd):
382
+ if any(f.endswith('.py') for _, f in changes):
383
+ typer.secho("Detected changes, regenerating...")
384
+ run_subprocess()
385
+
386
+ return
387
+
388
+ ephaptic = load_ephaptic(client)
389
+
390
+ schema_output = {
391
+ "methods": {},
392
+ "events": {},
393
+ "definitions": {},
394
+ }
395
+
396
+ log(typer.style("--- Functions ---"))
397
+
398
+ for name, func in ephaptic._exposed_functions.items():
399
+ log(typer.style(f" - {name}"))
400
+
401
+ meta = getattr(func, META_KEY, {})
402
+
403
+ hints = meta.get('hints') or typing.get_type_hints(func)
404
+ sig = meta.get('sig') or inspect.signature(func)
405
+
406
+ method_schema = {
407
+ "args": {},
408
+ "return": None,
409
+ "required": [],
410
+ }
411
+
412
+ for param_name, param in sig.parameters.items():
413
+ hint = hints.get(param_name, typing.Any)
414
+ adapter = TypeAdapter(hint)
415
+
416
+ method_schema["args"][param_name] = create_schema(
417
+ adapter,
418
+ schema_output["definitions"],
419
+ )
420
+
421
+ log(typer.style(f" - {param_name}: {hint} = {param.default}"))
422
+
423
+ if param.default == inspect.Parameter.empty:
424
+ method_schema["required"].append(param_name)
425
+ else:
426
+ method_schema["args"][param_name]["default"] = str(param.default)
427
+
428
+
429
+
430
+ return_hint = meta.get('response_model') or hints.get("return", typing.Any)
431
+ if return_hint and return_hint is not type(None):
432
+ adapter = TypeAdapter(return_hint)
433
+ method_schema["return"] = create_schema(
434
+ adapter,
435
+ schema_output["definitions"],
436
+ )
437
+
438
+ schema_output["methods"][name] = method_schema
439
+
440
+ log(typer.style("--- Events ---"))
441
+
442
+
443
+ for name, model in ephaptic._exposed_events.items():
444
+ log(typer.style(f" - {name}"))
445
+ adapter = TypeAdapter(model)
446
+
447
+ schema_output["events"][name] = create_schema(
448
+ adapter,
449
+ schema_output["definitions"],
450
+ )
451
+
452
+ generate_output(lang, schema_output, package_name, output)
453
+
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)
463
+
464
+
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)
484
+
485
+ if __name__ == "__main__":
486
+ app()