From 6bf6d07a5a30fce9cb0e6bb450854493b54737e0 Mon Sep 17 00:00:00 2001 From: Dawei Date: Mon, 9 Mar 2026 14:31:18 -0700 Subject: [PATCH 1/3] Fix codegen with keyword --- src/replit_river/codegen/typing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/replit_river/codegen/typing.py b/src/replit_river/codegen/typing.py index 5968a66d..bcca5d46 100644 --- a/src/replit_river/codegen/typing.py +++ b/src/replit_river/codegen/typing.py @@ -1,3 +1,4 @@ +import keyword from dataclasses import dataclass from typing import NewType, assert_never, cast @@ -165,7 +166,11 @@ def work( def normalize_special_chars(value: str) -> str: for char in SPECIAL_CHARS: value = value.replace(char, "_") - return value.lstrip("_") + value = value.lstrip("_") + # Append underscore to Python keywords (e.g., "from" -> "from_") + if keyword.iskeyword(value): + value = value + "_" + return value def render_type_expr(value: TypeExpression) -> str: From ba09a586663458fd53098dc10b18638fa9bbb23d Mon Sep 17 00:00:00 2001 From: Dawei Date: Mon, 9 Mar 2026 14:34:06 -0700 Subject: [PATCH 2/3] add tests --- tests/v1/codegen/test_input_special_chars.py | 134 +++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/v1/codegen/test_input_special_chars.py b/tests/v1/codegen/test_input_special_chars.py index 86ffa617..e2d6b68b 100644 --- a/tests/v1/codegen/test_input_special_chars.py +++ b/tests/v1/codegen/test_input_special_chars.py @@ -164,3 +164,137 @@ def test_init_special_chars_typeddict() -> None: method_filter=None, protocol_version="v2.0", ) + + +def test_python_keyword_field_names_basemodel() -> None: + """Handles Python reserved keywords as field names for BaseModel.""" + + import ast + import json + from pathlib import Path + + keyword_schema = { + "services": { + "test_service": { + "procedures": { + "rpc_method": { + "input": { + "type": "object", + "properties": { + "from": {"type": "string"}, + "to": {"type": "string"}, + "class": {"type": "number"}, + "import": {"type": "boolean"}, + }, + "required": ["from", "to"], + }, + "output": { + "type": "object", + "properties": { + "from": {"type": "string"}, + "to": {"type": "string"}, + }, + "required": ["from", "to"], + }, + "errors": {"not": {}}, + "type": "rpc", + } + } + } + } + } + + files: dict[Path, StringIO] = {} + + def file_opener(path: Path) -> StringIO: + buf = StringIO() + files[path] = buf + return buf + + schema_to_river_client_codegen( + read_schema=lambda: StringIO(json.dumps(keyword_schema)), + target_path="test_keyword_bm", + client_name="KeywordBMClient", + typed_dict_inputs=False, + file_opener=file_opener, + method_filter=None, + protocol_version="v1.1", + ) + + # Verify all generated files are valid Python + for path, buf in files.items(): + buf.seek(0) + content = buf.read() + try: + ast.parse(content) + except SyntaxError as e: + raise AssertionError( + f"Generated file {path} has invalid syntax: {e}\n{content}" + ) + + +def test_python_keyword_field_names_typeddict() -> None: + """Handles Python reserved keywords as field names for TypedDict.""" + + import ast + import json + from pathlib import Path + + keyword_schema = { + "services": { + "test_service": { + "procedures": { + "rpc_method": { + "input": { + "type": "object", + "properties": { + "from": {"type": "string"}, + "to": {"type": "string"}, + "class": {"type": "number"}, + "import": {"type": "boolean"}, + }, + "required": ["from", "to"], + }, + "output": { + "type": "object", + "properties": { + "from": {"type": "string"}, + "to": {"type": "string"}, + }, + "required": ["from", "to"], + }, + "errors": {"not": {}}, + "type": "rpc", + } + } + } + } + } + + files: dict[Path, StringIO] = {} + + def file_opener(path: Path) -> StringIO: + buf = StringIO() + files[path] = buf + return buf + + schema_to_river_client_codegen( + read_schema=lambda: StringIO(json.dumps(keyword_schema)), + target_path="test_keyword_td", + client_name="KeywordTDClient", + typed_dict_inputs=True, + file_opener=file_opener, + method_filter=None, + protocol_version="v1.1", + ) + + # Verify all generated files are valid Python + for path, buf in files.items(): + buf.seek(0) + content = buf.read() + try: + ast.parse(content) + except SyntaxError as e: + raise AssertionError( + f"Generated file {path} has invalid syntax: {e}\n{content}" + ) From 9f4295951c279b5791643f7313387c4cb7cc3d46 Mon Sep 17 00:00:00 2001 From: Dawei Date: Mon, 9 Mar 2026 14:37:39 -0700 Subject: [PATCH 3/3] fix test --- tests/v1/codegen/test_input_special_chars.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/v1/codegen/test_input_special_chars.py b/tests/v1/codegen/test_input_special_chars.py index e2d6b68b..353b2a89 100644 --- a/tests/v1/codegen/test_input_special_chars.py +++ b/tests/v1/codegen/test_input_special_chars.py @@ -166,6 +166,11 @@ def test_init_special_chars_typeddict() -> None: ) +class UnclosableStringIO(StringIO): + def close(self) -> None: + pass + + def test_python_keyword_field_names_basemodel() -> None: """Handles Python reserved keywords as field names for BaseModel.""" @@ -204,10 +209,10 @@ def test_python_keyword_field_names_basemodel() -> None: } } - files: dict[Path, StringIO] = {} + files: dict[Path, UnclosableStringIO] = {} - def file_opener(path: Path) -> StringIO: - buf = StringIO() + def file_opener(path: Path) -> UnclosableStringIO: + buf = UnclosableStringIO() files[path] = buf return buf @@ -271,10 +276,10 @@ def test_python_keyword_field_names_typeddict() -> None: } } - files: dict[Path, StringIO] = {} + files: dict[Path, UnclosableStringIO] = {} - def file_opener(path: Path) -> StringIO: - buf = StringIO() + def file_opener(path: Path) -> UnclosableStringIO: + buf = UnclosableStringIO() files[path] = buf return buf