Skip to content

Commit af775fc

Browse files
Fix codegen with from keyword (#177)
Why === For schema with `from` and `to` properties. from is a Python reserved keyword, and the river-python codegen was generating invalid Python like: ``` class Rewrites(TypedDict): from: NotRequired[str | None] # SyntaxError! ``` What changed ============ - src/replit_river/codegen/typing.py — Added import keyword and extended normalize_special_chars to append _ to Python keywords (e.g., from -> from_). The existing alias logic in client.py already handles setting Field(alias="from") for BaseModel when the field name is normalized, so no changes needed there. - tests/v1/codegen/test_input_special_chars.py — Added two new tests (test_python_keyword_field_names_basemodel and test_python_keyword_field_names_typeddict) that verify the codegen produces valid Python when schema fields use reserved keywords like from, class, and import. Test plan ========= Added new tests
1 parent c2138d8 commit af775fc

File tree

2 files changed

+145
-1
lines changed

2 files changed

+145
-1
lines changed

src/replit_river/codegen/typing.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import keyword
12
from dataclasses import dataclass
23
from typing import NewType, assert_never, cast
34

@@ -165,7 +166,11 @@ def work(
165166
def normalize_special_chars(value: str) -> str:
166167
for char in SPECIAL_CHARS:
167168
value = value.replace(char, "_")
168-
return value.lstrip("_")
169+
value = value.lstrip("_")
170+
# Append underscore to Python keywords (e.g., "from" -> "from_")
171+
if keyword.iskeyword(value):
172+
value = value + "_"
173+
return value
169174

170175

171176
def render_type_expr(value: TypeExpression) -> str:

tests/v1/codegen/test_input_special_chars.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,142 @@ def test_init_special_chars_typeddict() -> None:
164164
method_filter=None,
165165
protocol_version="v2.0",
166166
)
167+
168+
169+
class UnclosableStringIO(StringIO):
170+
def close(self) -> None:
171+
pass
172+
173+
174+
def test_python_keyword_field_names_basemodel() -> None:
175+
"""Handles Python reserved keywords as field names for BaseModel."""
176+
177+
import ast
178+
import json
179+
from pathlib import Path
180+
181+
keyword_schema = {
182+
"services": {
183+
"test_service": {
184+
"procedures": {
185+
"rpc_method": {
186+
"input": {
187+
"type": "object",
188+
"properties": {
189+
"from": {"type": "string"},
190+
"to": {"type": "string"},
191+
"class": {"type": "number"},
192+
"import": {"type": "boolean"},
193+
},
194+
"required": ["from", "to"],
195+
},
196+
"output": {
197+
"type": "object",
198+
"properties": {
199+
"from": {"type": "string"},
200+
"to": {"type": "string"},
201+
},
202+
"required": ["from", "to"],
203+
},
204+
"errors": {"not": {}},
205+
"type": "rpc",
206+
}
207+
}
208+
}
209+
}
210+
}
211+
212+
files: dict[Path, UnclosableStringIO] = {}
213+
214+
def file_opener(path: Path) -> UnclosableStringIO:
215+
buf = UnclosableStringIO()
216+
files[path] = buf
217+
return buf
218+
219+
schema_to_river_client_codegen(
220+
read_schema=lambda: StringIO(json.dumps(keyword_schema)),
221+
target_path="test_keyword_bm",
222+
client_name="KeywordBMClient",
223+
typed_dict_inputs=False,
224+
file_opener=file_opener,
225+
method_filter=None,
226+
protocol_version="v1.1",
227+
)
228+
229+
# Verify all generated files are valid Python
230+
for path, buf in files.items():
231+
buf.seek(0)
232+
content = buf.read()
233+
try:
234+
ast.parse(content)
235+
except SyntaxError as e:
236+
raise AssertionError(
237+
f"Generated file {path} has invalid syntax: {e}\n{content}"
238+
)
239+
240+
241+
def test_python_keyword_field_names_typeddict() -> None:
242+
"""Handles Python reserved keywords as field names for TypedDict."""
243+
244+
import ast
245+
import json
246+
from pathlib import Path
247+
248+
keyword_schema = {
249+
"services": {
250+
"test_service": {
251+
"procedures": {
252+
"rpc_method": {
253+
"input": {
254+
"type": "object",
255+
"properties": {
256+
"from": {"type": "string"},
257+
"to": {"type": "string"},
258+
"class": {"type": "number"},
259+
"import": {"type": "boolean"},
260+
},
261+
"required": ["from", "to"],
262+
},
263+
"output": {
264+
"type": "object",
265+
"properties": {
266+
"from": {"type": "string"},
267+
"to": {"type": "string"},
268+
},
269+
"required": ["from", "to"],
270+
},
271+
"errors": {"not": {}},
272+
"type": "rpc",
273+
}
274+
}
275+
}
276+
}
277+
}
278+
279+
files: dict[Path, UnclosableStringIO] = {}
280+
281+
def file_opener(path: Path) -> UnclosableStringIO:
282+
buf = UnclosableStringIO()
283+
files[path] = buf
284+
return buf
285+
286+
schema_to_river_client_codegen(
287+
read_schema=lambda: StringIO(json.dumps(keyword_schema)),
288+
target_path="test_keyword_td",
289+
client_name="KeywordTDClient",
290+
typed_dict_inputs=True,
291+
file_opener=file_opener,
292+
method_filter=None,
293+
protocol_version="v1.1",
294+
)
295+
296+
# Verify all generated files are valid Python
297+
for path, buf in files.items():
298+
buf.seek(0)
299+
content = buf.read()
300+
try:
301+
ast.parse(content)
302+
except SyntaxError as e:
303+
raise AssertionError(
304+
f"Generated file {path} has invalid syntax: {e}\n{content}"
305+
)

0 commit comments

Comments
 (0)