pyrpc-codegen 0.7.4__tar.gz → 0.7.6__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrpc-codegen
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Codegen and CLI tools for pyRPC
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: jinja2>=3.1.0
7
- Requires-Dist: jsonschema-ts>=0.1.0
7
+ Requires-Dist: jsonschema-ts>=0.2.0
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "pyrpc-codegen"
3
- version = "0.7.4"
3
+ version = "0.7.6"
4
4
  description = "Codegen and CLI tools for pyRPC"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
7
7
  "jinja2>=3.1.0",
8
- "jsonschema-ts>=0.1.0",
8
+ "jsonschema-ts>=0.2.0",
9
9
  ]
10
10
 
11
11
  [build-system]
@@ -1,11 +1,12 @@
1
1
  import os
2
2
  import re
3
+ import unicodedata
3
4
  from pathlib import Path
4
5
  from typing import Any, Dict
5
6
 
6
7
  from jinja2 import Environment, FileSystemLoader
7
8
  from jsonschema_ts import Options as JsonschemaTsOptions
8
- from jsonschema_ts import assemble, collect_defs, convert_all
9
+ from jsonschema_ts import assemble, collect_defs, convert_all, ensure_inline_models
9
10
 
10
11
  DEFAULT_OUTPUT = "node_modules/@pyrpc/types/src/index.ts"
11
12
 
@@ -20,6 +21,27 @@ _TYPE_MAP: Dict[str, str] = {
20
21
  }
21
22
 
22
23
 
24
+ def _to_safe_name(name: str) -> str:
25
+ s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
26
+ s = re.sub(r"[^a-zA-Z0-9]", " ", s)
27
+ s = _to_pascal_case(s)
28
+ return s or "GeneratedType"
29
+
30
+
31
+ def _to_pascal_case(s: str) -> str:
32
+ parts = s.split()
33
+ result: list[str] = []
34
+ for part in parts:
35
+ sub_parts = re.findall(
36
+ r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\b)|[A-Z]+|\d+",
37
+ part,
38
+ )
39
+ if not sub_parts:
40
+ sub_parts = [part]
41
+ result.extend(sub_parts)
42
+ return "".join(seg.capitalize() for seg in result if seg)
43
+
44
+
23
45
  def _pytype_to_ts(type_str: str) -> str:
24
46
  if not type_str:
25
47
  return "any"
@@ -31,7 +53,7 @@ def _pytype_to_ts(type_str: str) -> str:
31
53
  name = name.rsplit('.', 1)[1]
32
54
  if name in _TYPE_MAP:
33
55
  return _TYPE_MAP[name]
34
- return name
56
+ return _to_safe_name(name)
35
57
 
36
58
  if type_str.startswith("typing."):
37
59
  type_str = type_str[7:]
@@ -106,13 +128,21 @@ def _collect_schema_defs(schemas: Dict[str, Any]) -> dict:
106
128
  else:
107
129
  params = schema.parameters
108
130
  for param in params:
109
- js = param.get("schema") if isinstance(param, dict) else param.schema_
131
+ if isinstance(param, dict):
132
+ js = param.get("schema") or param.get("schema_")
133
+ else:
134
+ js = param.schema_
110
135
  if js:
111
136
  schema_sources.append(js)
112
- rs = schema.get("return_schema") if isinstance(schema, dict) else schema.return_schema
137
+ if isinstance(schema, dict):
138
+ rs = schema.get("return_schema")
139
+ else:
140
+ rs = schema.return_schema
113
141
  if rs:
114
142
  schema_sources.append(rs)
115
- return collect_defs(*schema_sources)
143
+
144
+ processed = ensure_inline_models(*schema_sources)
145
+ return collect_defs(*processed)
116
146
 
117
147
 
118
148
  def generate_typescript_client(schemas: Dict[str, Any]) -> str:
@@ -0,0 +1,255 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import pytest
6
+ from pydantic import BaseModel
7
+ from pydantic.dataclasses import dataclass
8
+ from pyrpc_core import rpc, default_router, get_registry_schema
9
+ from pyrpc_codegen import generate_typescript_client, save_typescript_client
10
+ from pyrpc_codegen.ts_codegen import _to_safe_name, _to_pascal_case, _collect_schema_defs
11
+
12
+ def _npx_works() -> bool:
13
+ """Check if npx works via subprocess.run without shell=True (how jsonschema_ts calls it)."""
14
+ path = shutil.which("npx")
15
+ if not path:
16
+ return False
17
+ try:
18
+ import subprocess
19
+ result = subprocess.run(
20
+ ["npx", "--version"],
21
+ capture_output=True, text=True, timeout=5, shell=False,
22
+ )
23
+ return result.returncode == 0
24
+ except (OSError, subprocess.SubprocessError):
25
+ return False
26
+
27
+ npx_available = _npx_works()
28
+
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def clear_registry():
32
+ default_router._procedures.clear()
33
+
34
+
35
+ def test_generate_typescript_client():
36
+ @rpc
37
+ def add(a: int, b: int) -> int:
38
+ """Add two numbers."""
39
+ return a + b
40
+
41
+ schemas = get_registry_schema(default_router)
42
+ content = generate_typescript_client(schemas)
43
+
44
+ assert "export interface Types" in content
45
+ assert "add(a: number, b: number): Promise<number>;" in content
46
+
47
+
48
+ def test_generate_typescript_client_empty():
49
+ schemas = get_registry_schema(default_router)
50
+ content = generate_typescript_client(schemas)
51
+ assert "export interface Types {" in content
52
+
53
+
54
+ def test_save_typescript_client_from_file():
55
+ schemas = {
56
+ "greet": {
57
+ "name": "greet",
58
+ "doc": "Say hello",
59
+ "parameters": [
60
+ {"name": "name", "type": "<class 'str'>", "required": True, "default": None}
61
+ ],
62
+ "return_type": "<class 'str'>",
63
+ }
64
+ }
65
+
66
+ with tempfile.TemporaryDirectory() as tmpdir:
67
+ schema_file = os.path.join(tmpdir, "schema.json")
68
+ with open(schema_file, "w") as f:
69
+ json.dump(schemas, f)
70
+
71
+ from pyrpc_core.cli import _load_schema
72
+ loaded = _load_schema(schema_file)
73
+ assert loaded == schemas
74
+
75
+ output_file = os.path.join(tmpdir, "types.ts")
76
+ save_typescript_client(schemas, output_file)
77
+
78
+ with open(output_file) as f:
79
+ content = f.read()
80
+
81
+ assert "export interface Types" in content
82
+ assert "greet(name: string): Promise<string>;" in content
83
+
84
+
85
+ def test_save_typescript_client_serialized_schema():
86
+ @rpc
87
+ def add(a: int) -> int:
88
+ return a
89
+
90
+ schemas = get_registry_schema(default_router)
91
+
92
+ serializable = {}
93
+ for name, schema in schemas.items():
94
+ serializable[name] = {
95
+ "name": schema.name,
96
+ "doc": schema.doc or "",
97
+ "parameters": [
98
+ {"name": p.name, "type": p.type, "required": p.required, "default": p.default}
99
+ for p in schema.parameters
100
+ ],
101
+ "return_type": schema.return_type,
102
+ }
103
+
104
+ with tempfile.TemporaryDirectory() as tmpdir:
105
+ output_file = os.path.join(tmpdir, "types.ts")
106
+ save_typescript_client(serializable, output_file)
107
+
108
+ with open(output_file) as f:
109
+ content = f.read()
110
+
111
+ assert "export interface Types" in content
112
+ assert "add(a: number): Promise<number>;" in content
113
+
114
+
115
+ def test_to_pascal_case():
116
+ assert _to_pascal_case("User") == "User"
117
+ assert _to_pascal_case("user") == "User"
118
+ assert _to_pascal_case("MyClass") == "MyClass"
119
+ assert _to_pascal_case("my_model") == "MyModel"
120
+ assert _to_pascal_case("") == ""
121
+
122
+
123
+ def test_to_safe_name():
124
+ assert _to_safe_name("User") == "User"
125
+ assert _to_safe_name("my_model") == "MyModel"
126
+ assert _to_safe_name("MyClass") == "MyClass"
127
+ assert _to_safe_name("") == "GeneratedType"
128
+
129
+
130
+ def test_collect_defs_base_model():
131
+ class UserModel(BaseModel):
132
+ name: str
133
+ age: int
134
+
135
+ @rpc
136
+ def get_user(u: UserModel) -> UserModel:
137
+ return u
138
+
139
+ schemas = get_registry_schema(default_router)
140
+ defs = _collect_schema_defs(schemas)
141
+ assert "UserModel" in defs
142
+
143
+
144
+ def test_collect_defs_at_model():
145
+ @dataclass
146
+ class Item:
147
+ name: str
148
+ price: float
149
+
150
+ @rpc
151
+ def buy(item: Item) -> Item:
152
+ return item
153
+
154
+ schemas = get_registry_schema(default_router)
155
+ defs = _collect_schema_defs(schemas)
156
+ assert "Item" in defs
157
+
158
+
159
+ def test_collect_defs_nested_base_model():
160
+ class Address(BaseModel):
161
+ city: str
162
+ zip: str
163
+
164
+ class UserNested(BaseModel):
165
+ name: str
166
+ address: Address
167
+
168
+ @rpc
169
+ def get_user(u: UserNested) -> UserNested:
170
+ return u
171
+
172
+ schemas = get_registry_schema(default_router)
173
+ defs = _collect_schema_defs(schemas)
174
+ assert "UserNested" in defs
175
+ assert "Address" in defs
176
+
177
+
178
+ def test_collect_defs_nested_at_model():
179
+ @dataclass
180
+ class Address:
181
+ city: str
182
+ zip: str
183
+
184
+ @dataclass
185
+ class Person:
186
+ name: str
187
+ address: Address
188
+
189
+ @rpc
190
+ def get_person(p: Person) -> Person:
191
+ return p
192
+
193
+ schemas = get_registry_schema(default_router)
194
+ defs = _collect_schema_defs(schemas)
195
+ assert "Person" in defs
196
+ assert "Address" in defs
197
+
198
+
199
+ def test_collect_defs_nested_mixed():
200
+ class Address(BaseModel):
201
+ city: str
202
+ zip: str
203
+
204
+ @dataclass
205
+ class Person:
206
+ name: str
207
+ address: Address
208
+
209
+ class Organization(BaseModel):
210
+ name: str
211
+ owner: Person
212
+
213
+ @rpc
214
+ def get_org(o: Organization) -> Organization:
215
+ return o
216
+
217
+ schemas = get_registry_schema(default_router)
218
+ defs = _collect_schema_defs(schemas)
219
+ assert "Organization" in defs
220
+ assert "Person" in defs
221
+ assert "Address" in defs
222
+
223
+
224
+ @pytest.mark.skipif(not npx_available, reason="requires npx (json-schema-to-typescript)")
225
+ def test_generate_typescript_client_with_base_model():
226
+ class UserModel(BaseModel):
227
+ name: str
228
+ age: int
229
+ email: str
230
+
231
+ @rpc
232
+ def create_user(user: UserModel) -> UserModel:
233
+ return user
234
+
235
+ schemas = get_registry_schema(default_router)
236
+ content = generate_typescript_client(schemas)
237
+ assert "export interface Types" in content
238
+ assert "UserModel" in content
239
+
240
+
241
+ @pytest.mark.skipif(not npx_available, reason="requires npx (json-schema-to-typescript)")
242
+ def test_generate_typescript_client_with_at_model():
243
+ @dataclass
244
+ class Item:
245
+ name: str
246
+ price: float
247
+
248
+ @rpc
249
+ def buy_item(item: Item) -> Item:
250
+ return item
251
+
252
+ schemas = get_registry_schema(default_router)
253
+ content = generate_typescript_client(schemas)
254
+ assert "export interface Types" in content
255
+ assert "Item" in content
@@ -1,91 +0,0 @@
1
- import json
2
- import os
3
- import tempfile
4
- import pytest
5
- from pyrpc_core import rpc, default_router, get_registry_schema
6
- from pyrpc_codegen import generate_typescript_client, save_typescript_client
7
-
8
-
9
- @pytest.fixture(autouse=True)
10
- def clear_registry():
11
- default_router._procedures.clear()
12
-
13
-
14
- def test_generate_typescript_client():
15
- @rpc
16
- def add(a: int, b: int) -> int:
17
- """Add two numbers."""
18
- return a + b
19
-
20
- schemas = get_registry_schema(default_router)
21
- content = generate_typescript_client(schemas)
22
-
23
- assert "export interface Types" in content
24
- assert "add(a: number, b: number): Promise<number>;" in content
25
-
26
-
27
- def test_generate_typescript_client_empty():
28
- schemas = get_registry_schema(default_router)
29
- content = generate_typescript_client(schemas)
30
- assert "export interface Types {" in content
31
-
32
-
33
- def test_save_typescript_client_from_file():
34
- schemas = {
35
- "greet": {
36
- "name": "greet",
37
- "doc": "Say hello",
38
- "parameters": [
39
- {"name": "name", "type": "<class 'str'>", "required": True, "default": None}
40
- ],
41
- "return_type": "<class 'str'>",
42
- }
43
- }
44
-
45
- with tempfile.TemporaryDirectory() as tmpdir:
46
- schema_file = os.path.join(tmpdir, "schema.json")
47
- with open(schema_file, "w") as f:
48
- json.dump(schemas, f)
49
-
50
- from pyrpc_core.cli import _load_schema
51
- loaded = _load_schema(schema_file)
52
- assert loaded == schemas
53
-
54
- output_file = os.path.join(tmpdir, "types.ts")
55
- save_typescript_client(schemas, output_file)
56
-
57
- with open(output_file) as f:
58
- content = f.read()
59
-
60
- assert "export interface Types" in content
61
- assert "greet(name: string): Promise<string>;" in content
62
-
63
-
64
- def test_save_typescript_client_serialized_schema():
65
- @rpc
66
- def add(a: int) -> int:
67
- return a
68
-
69
- schemas = get_registry_schema(default_router)
70
-
71
- serializable = {}
72
- for name, schema in schemas.items():
73
- serializable[name] = {
74
- "name": schema.name,
75
- "doc": schema.doc or "",
76
- "parameters": [
77
- {"name": p.name, "type": p.type, "required": p.required, "default": p.default}
78
- for p in schema.parameters
79
- ],
80
- "return_type": schema.return_type,
81
- }
82
-
83
- with tempfile.TemporaryDirectory() as tmpdir:
84
- output_file = os.path.join(tmpdir, "types.ts")
85
- save_typescript_client(serializable, output_file)
86
-
87
- with open(output_file) as f:
88
- content = f.read()
89
-
90
- assert "export interface Types" in content
91
- assert "add(a: number): Promise<number>;" in content
File without changes
File without changes