Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/replit_river/codegen/typing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import keyword
from dataclasses import dataclass
from typing import NewType, assert_never, cast

Expand Down Expand Up @@ -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:
Expand Down
139 changes: 139 additions & 0 deletions tests/v1/codegen/test_input_special_chars.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,142 @@ def test_init_special_chars_typeddict() -> None:
method_filter=None,
protocol_version="v2.0",
)


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."""

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, UnclosableStringIO] = {}

def file_opener(path: Path) -> UnclosableStringIO:
buf = UnclosableStringIO()
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, UnclosableStringIO] = {}

def file_opener(path: Path) -> UnclosableStringIO:
buf = UnclosableStringIO()
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}"
)
Loading