pyrpc-codegen 0.2.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.
- pyrpc_codegen/__init__.py +4 -0
- pyrpc_codegen/main.py +178 -0
- pyrpc_codegen/templates/client.ts.j2 +22 -0
- pyrpc_codegen/ts_codegen.py +115 -0
- pyrpc_codegen-0.2.0.dist-info/METADATA +11 -0
- pyrpc_codegen-0.2.0.dist-info/RECORD +8 -0
- pyrpc_codegen-0.2.0.dist-info/WHEEL +4 -0
- pyrpc_codegen-0.2.0.dist-info/entry_points.txt +2 -0
pyrpc_codegen/main.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .ts_codegen import save_typescript_client, DEFAULT_OUTPUT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="pyrpc",
|
|
19
|
+
help="pyRPC CLI - type-safe Python-to-TypeScript RPC",
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _lazy_import_pyrpc_core():
|
|
26
|
+
"""Import pyrpc-core lazily so codegen-only usage avoids the dep."""
|
|
27
|
+
global default_router, get_registry_schema
|
|
28
|
+
from pyrpc_core import default_router, get_registry_schema
|
|
29
|
+
return default_router, get_registry_schema
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _import_module(module_path: str):
|
|
33
|
+
sys.path.append(os.getcwd())
|
|
34
|
+
try:
|
|
35
|
+
return importlib.import_module(module_path)
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
console.print(f"[bold red]Error:[/bold red] Could not import module '{module_path}': {e}")
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fetch_schema(url: str) -> dict:
|
|
42
|
+
import httpx
|
|
43
|
+
clean_url = url.rstrip("/")
|
|
44
|
+
if not clean_url.endswith("/rpc"):
|
|
45
|
+
clean_url += "/rpc"
|
|
46
|
+
response = httpx.get(clean_url)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_schema(path_or_url: str) -> dict:
|
|
52
|
+
if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
|
|
53
|
+
console.print(f"Fetching schema from [bold yellow]{path_or_url}[/bold yellow]...")
|
|
54
|
+
return _fetch_schema(path_or_url)
|
|
55
|
+
path = os.path.abspath(path_or_url)
|
|
56
|
+
console.print(f"Reading schema from [bold yellow]{path}[/bold yellow]...")
|
|
57
|
+
with open(path, "r") as f:
|
|
58
|
+
return json.load(f)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def version():
|
|
63
|
+
"""Show pyRPC version."""
|
|
64
|
+
console.print(f"pyRPC version: [bold cyan]{__version__}[/bold cyan]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def pull(
|
|
69
|
+
module: str = typer.Argument(..., help="Python module path (e.g. 'app.main')"),
|
|
70
|
+
output: str = typer.Option("pyrpc-schema.json", "--output", "-o", help="Output JSON schema file path"),
|
|
71
|
+
):
|
|
72
|
+
"""Extract RPC schema from a Python module and save as JSON."""
|
|
73
|
+
_lazy_import_pyrpc_core()
|
|
74
|
+
_import_module(module)
|
|
75
|
+
|
|
76
|
+
schemas = get_registry_schema(default_router)
|
|
77
|
+
|
|
78
|
+
if not schemas:
|
|
79
|
+
console.print("[yellow]No procedures found in registry for this module.[/yellow]")
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
serializable = {}
|
|
83
|
+
for name, schema in schemas.items():
|
|
84
|
+
serializable[name] = {
|
|
85
|
+
"name": schema.name,
|
|
86
|
+
"doc": schema.doc or "",
|
|
87
|
+
"parameters": [
|
|
88
|
+
{"name": p.name, "type": p.type, "required": p.required, "default": p.default}
|
|
89
|
+
for p in schema.parameters
|
|
90
|
+
],
|
|
91
|
+
"return_type": schema.return_type,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
output_path = os.path.abspath(output)
|
|
95
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
96
|
+
with open(output_path, "w") as f:
|
|
97
|
+
json.dump(serializable, f, indent=2)
|
|
98
|
+
|
|
99
|
+
console.print(f"[bold green]OK Schema extracted to {output_path}[/bold green]")
|
|
100
|
+
console.print(f" ({len(serializable)} procedure(s) written)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def serve(
|
|
105
|
+
module: str = typer.Argument(..., help="Module containing the pyRPC application (e.g. 'app.main')"),
|
|
106
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind socket to this host"),
|
|
107
|
+
port: int = typer.Option(8000, "--port", "-p", help="Bind socket to this port"),
|
|
108
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
|
109
|
+
):
|
|
110
|
+
"""Start the pyRPC ASGI server."""
|
|
111
|
+
_lazy_import_pyrpc_core()
|
|
112
|
+
import uvicorn
|
|
113
|
+
_import_module(module)
|
|
114
|
+
|
|
115
|
+
console.print(Panel(
|
|
116
|
+
f"Starting pyRPC server for [bold cyan]{module}[/bold cyan]\n"
|
|
117
|
+
f"Endpoint: [bold green]http://{host}:{port}/rpc[/bold green]",
|
|
118
|
+
title="pyRPC Serve",
|
|
119
|
+
border_style="blue"
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
uvicorn.run("pyrpc:asgi_app", host=host, port=port, reload=reload)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def codegen(
|
|
127
|
+
source: str = typer.Argument(..., help="Schema JSON file path or URL of a running pyRPC server (e.g. pyrpc-schema.json or http://localhost:8000)"),
|
|
128
|
+
output: str = typer.Option(DEFAULT_OUTPUT, "--output", "-o", help="Output file path for generated types"),
|
|
129
|
+
):
|
|
130
|
+
"""Generate TypeScript type definitions from a schema file or a running server."""
|
|
131
|
+
try:
|
|
132
|
+
schemas = _load_schema(source)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
console.print(f"[bold red]Error:[/bold red] Could not load schema from '{source}': {e}")
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
console.print(f"Generating TypeScript contracts [dim]({len(schemas)} procedures)[/dim]...")
|
|
138
|
+
save_typescript_client(schemas, output)
|
|
139
|
+
console.print(f"[bold green]OK Types written to {output}[/bold green]")
|
|
140
|
+
|
|
141
|
+
if os.path.exists(output):
|
|
142
|
+
console.print(f" Import: [bold]import type {{ Types }} from \"@pyrpc/types\"[/bold]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def inspect(
|
|
147
|
+
module: str = typer.Argument(..., help="Module to inspect")
|
|
148
|
+
):
|
|
149
|
+
"""List all registered RPC procedures in a module."""
|
|
150
|
+
_lazy_import_pyrpc_core()
|
|
151
|
+
_import_module(module)
|
|
152
|
+
|
|
153
|
+
schemas = get_registry_schema(default_router)
|
|
154
|
+
|
|
155
|
+
if not schemas:
|
|
156
|
+
console.print("[yellow]No procedures found in registry for this module.[/yellow]")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
table = Table(title=f"pyRPC Registry: {module}")
|
|
160
|
+
table.add_column("Method", style="cyan")
|
|
161
|
+
table.add_column("Params", style="green")
|
|
162
|
+
table.add_column("Returns", style="magenta")
|
|
163
|
+
table.add_column("Doc", style="white", no_wrap=False)
|
|
164
|
+
|
|
165
|
+
for name, schema in schemas.items():
|
|
166
|
+
params = ", ".join([f"{p.name}: {p.type}" for p in schema.parameters])
|
|
167
|
+
table.add_row(
|
|
168
|
+
name,
|
|
169
|
+
params or "None",
|
|
170
|
+
schema.return_type,
|
|
171
|
+
schema.doc or ""
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
console.print(table)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
app()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyrpc/types - Auto-generated by `pyrpc codegen`.
|
|
3
|
+
* @see https://pyrpc.io/docs/plugins/prpc-codegen
|
|
4
|
+
*
|
|
5
|
+
* Import `Types` and pass to `createClient<Types>()` for full type safety.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { createClient } from "@pyrpc/client"
|
|
9
|
+
* import type { Types } from "@pyrpc/types"
|
|
10
|
+
* const client = createClient<Types>({ baseUrl: "https://..." })
|
|
11
|
+
* const result = await client.someProcedure(42)
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Types {
|
|
16
|
+
{% for name, schema in schemas.items() %}
|
|
17
|
+
/**
|
|
18
|
+
* {{ schema.doc or "No documentation available." }}
|
|
19
|
+
*/
|
|
20
|
+
{{ name }}({% for param in schema.parameters %}{{ param.name }}: {{ param.type | pytype_to_ts }}{% if not loop.last %}, {% endif %}{% endfor %}): Promise<{{ schema.return_type | return_type_to_ts }}>;
|
|
21
|
+
{% endfor %}
|
|
22
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, FileSystemLoader
|
|
7
|
+
|
|
8
|
+
DEFAULT_OUTPUT = "node_modules/@pyrpc/types/src/index.ts"
|
|
9
|
+
|
|
10
|
+
_TYPE_MAP: Dict[str, str] = {
|
|
11
|
+
"int": "number",
|
|
12
|
+
"float": "number",
|
|
13
|
+
"str": "string",
|
|
14
|
+
"bool": "boolean",
|
|
15
|
+
"None": "null",
|
|
16
|
+
"NoneType": "null",
|
|
17
|
+
"Any": "any",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pytype_to_ts(type_str: str) -> str:
|
|
22
|
+
if not type_str:
|
|
23
|
+
return "any"
|
|
24
|
+
|
|
25
|
+
m = re.match(r"<class\s+'([^']+)'>", type_str)
|
|
26
|
+
if m:
|
|
27
|
+
name = m.group(1)
|
|
28
|
+
if name in _TYPE_MAP:
|
|
29
|
+
return _TYPE_MAP[name]
|
|
30
|
+
if name[0].isupper():
|
|
31
|
+
return name
|
|
32
|
+
return "any"
|
|
33
|
+
|
|
34
|
+
if type_str.startswith("typing."):
|
|
35
|
+
type_str = type_str[7:]
|
|
36
|
+
|
|
37
|
+
if type_str.startswith("Optional["):
|
|
38
|
+
inner = type_str[9:-1]
|
|
39
|
+
return f"{_pytype_to_ts(inner)} | null"
|
|
40
|
+
|
|
41
|
+
if type_str.startswith("Union["):
|
|
42
|
+
inner = type_str[6:-1]
|
|
43
|
+
parts = _split_type_args(inner)
|
|
44
|
+
ts_parts = [_pytype_to_ts(p.strip()) for p in parts]
|
|
45
|
+
non_null = [p for p in ts_parts if p != "null"]
|
|
46
|
+
if len(non_null) < len(ts_parts):
|
|
47
|
+
return f"{' | '.join(non_null)} | null"
|
|
48
|
+
return " | ".join(ts_parts)
|
|
49
|
+
|
|
50
|
+
if type_str.startswith("List[") or type_str.startswith("list["):
|
|
51
|
+
inner = type_str[5:-1]
|
|
52
|
+
return f"{_pytype_to_ts(inner.strip())}[]"
|
|
53
|
+
|
|
54
|
+
if type_str.startswith("Dict[") or type_str.startswith("dict["):
|
|
55
|
+
inner = type_str[5:-1]
|
|
56
|
+
parts = _split_type_args(inner)
|
|
57
|
+
if len(parts) >= 2:
|
|
58
|
+
return f"Record<{_pytype_to_ts(parts[0].strip())}, {_pytype_to_ts(parts[1].strip())}>"
|
|
59
|
+
return "Record<string, any>"
|
|
60
|
+
|
|
61
|
+
if type_str.startswith("Tuple[") or type_str.startswith("tuple["):
|
|
62
|
+
inner = type_str[6:-1]
|
|
63
|
+
parts = _split_type_args(inner)
|
|
64
|
+
ts_parts = [_pytype_to_ts(p.strip()) for p in parts]
|
|
65
|
+
return f"[{', '.join(ts_parts)}]"
|
|
66
|
+
|
|
67
|
+
if type_str.startswith("Set[") or type_str.startswith("set["):
|
|
68
|
+
inner = type_str[4:-1]
|
|
69
|
+
return f"Set<{_pytype_to_ts(inner.strip())}>"
|
|
70
|
+
|
|
71
|
+
return "any"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _split_type_args(s: str) -> list:
|
|
75
|
+
parts = []
|
|
76
|
+
depth = 0
|
|
77
|
+
current = ""
|
|
78
|
+
for c in s:
|
|
79
|
+
if c in "[(":
|
|
80
|
+
depth += 1
|
|
81
|
+
current += c
|
|
82
|
+
elif c in "])":
|
|
83
|
+
depth -= 1
|
|
84
|
+
current += c
|
|
85
|
+
elif c == "," and depth == 0:
|
|
86
|
+
parts.append(current)
|
|
87
|
+
current = ""
|
|
88
|
+
else:
|
|
89
|
+
current += c
|
|
90
|
+
if current:
|
|
91
|
+
parts.append(current)
|
|
92
|
+
return parts
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _return_type_to_ts(return_type: str) -> str:
|
|
96
|
+
return _pytype_to_ts(return_type)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def generate_typescript_client(schemas: Dict[str, Any]) -> str:
|
|
100
|
+
template_dir = Path(__file__).parent / "templates"
|
|
101
|
+
env = Environment(loader=FileSystemLoader(template_dir))
|
|
102
|
+
env.filters["pytype_to_ts"] = _pytype_to_ts
|
|
103
|
+
env.filters["return_type_to_ts"] = _return_type_to_ts
|
|
104
|
+
template = env.get_template("client.ts.j2")
|
|
105
|
+
|
|
106
|
+
return template.render(schemas=schemas)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_typescript_client(schemas: Dict[str, Any], output_path: str = DEFAULT_OUTPUT):
|
|
110
|
+
content = generate_typescript_client(schemas)
|
|
111
|
+
if not os.path.isabs(output_path):
|
|
112
|
+
output_path = os.path.join(os.getcwd(), output_path)
|
|
113
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
114
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
115
|
+
f.write(content)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrpc-codegen
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Codegen and CLI tools for pyRPC
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Requires-Dist: jinja2>=3.1.0
|
|
8
|
+
Requires-Dist: pyrpc-core
|
|
9
|
+
Requires-Dist: rich>=13.0.0
|
|
10
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
11
|
+
Requires-Dist: uvicorn>=0.23.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pyrpc_codegen/__init__.py,sha256=gljVt_n4DZHclCay30QEIDeAvH0Rt_NEwIFhnHrAyEc,176
|
|
2
|
+
pyrpc_codegen/main.py,sha256=V0V2RDHGs2yz_xLtU-KbVHxRFsEDNP8UvAC1LxcLk4U,5954
|
|
3
|
+
pyrpc_codegen/ts_codegen.py,sha256=xCBJ4j8WhFaXcAsBem3gyHrPqNdUUS3ap9cSDmQ9040,3513
|
|
4
|
+
pyrpc_codegen/templates/client.ts.j2,sha256=S4rMheMaWhP_dxD-7i4yHKWgVGNODC2YS8nOka3QC2E,797
|
|
5
|
+
pyrpc_codegen-0.2.0.dist-info/METADATA,sha256=npYBiBTtLhr9E0EnH05ZAg_AVT5VsVyn9iARxg5rwo8,298
|
|
6
|
+
pyrpc_codegen-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
pyrpc_codegen-0.2.0.dist-info/entry_points.txt,sha256=qreeaRwp5UqG0pH4x-0zX8Jsn066ZB3Q8Mp2VeBoUpw,49
|
|
8
|
+
pyrpc_codegen-0.2.0.dist-info/RECORD,,
|