diff --git a/polyapi/generate.py b/polyapi/generate.py index 883f0ca..78bfff2 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -399,7 +399,21 @@ def clear() -> None: def render_spec(spec: SpecificationDto) -> Tuple[str, str]: function_type = spec["type"] - function_description = spec["description"] + raw_description = spec.get("description", "") + def _flatten_description(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + flat: List[str] = [] + for item in value: + flat.extend(_flatten_description(item)) + return flat + return [str(value)] + + if isinstance(raw_description, str): + function_description = raw_description + else: + function_description = "\n".join(_flatten_description(raw_description)) function_name = spec["name"] function_context = spec["context"] function_id = spec["id"] diff --git a/polyapi/poly_tables.py b/polyapi/poly_tables.py index 4a391fa..c9fa561 100644 --- a/polyapi/poly_tables.py +++ b/polyapi/poly_tables.py @@ -531,10 +531,27 @@ def _render_table(table: TableSpecDto) -> str: table_where_class = _render_table_where_class( table["name"], columns, required_columns ) - if table.get("description", ""): + raw_description = table.get("description", "") + + def _flatten_description(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + flat: List[str] = [] + for item in value: + flat.extend(_flatten_description(item)) + return flat + return [str(value)] + + if isinstance(raw_description, str): + normalized_description = raw_description + else: + normalized_description = "\n".join(_flatten_description(raw_description)) + + if normalized_description: table_description = '\n """' table_description += "\n ".join( - table["description"].replace('"', "'").split("\n") + normalized_description.replace('"', "'").split("\n") ) table_description += '\n """' else: diff --git a/polyapi/schema.py b/polyapi/schema.py index 29ecbe3..590fa04 100644 --- a/polyapi/schema.py +++ b/polyapi/schema.py @@ -50,6 +50,13 @@ def wrapped_generate_schema_types(type_spec: dict, root, fallback_type): # if we have no root, just add "My" root = "My" + root + if isinstance(root, list): + root = "_".join([str(x) for x in root if x is not None]) or fallback_type + elif root is None: + root = fallback_type + elif not isinstance(root, str): + root = str(root) + root = clean_title(root) try: @@ -131,10 +138,19 @@ def clean_title(title: str) -> str: """ used by library generation, sometimes functions can be added with spaces in the title or other nonsense. fix them! """ + if isinstance(title, list): + title = "_".join([str(x) for x in title if x is not None]) + elif title is None: + title = "" + elif not isinstance(title, str): + title = str(title) + title = title.replace(" ", "") # certain reserved words cant be titles, let's replace them if title == "List": title = "List_" + if not title: + title = "Dict" return title diff --git a/polyapi/server.py b/polyapi/server.py index 9ee8ae0..40afee9 100644 --- a/polyapi/server.py +++ b/polyapi/server.py @@ -62,7 +62,7 @@ def render_server_function( return_type_def=return_type_def, ) func_str = SERVER_FUNCTION_TEMPLATE.format( - return_type_name=add_type_import_path(function_name, return_type_name), + return_type_name=_normalize_return_type_for_annotation(function_name, return_type_name), function_type="server", function_name=function_name, function_id=function_id, @@ -74,9 +74,15 @@ def render_server_function( return func_str, func_type_defs +def _normalize_return_type_for_annotation(function_name: str, return_type_name: str) -> str: + if return_type_name == "ReturnType": + return "ReturnType" + return add_type_import_path(function_name, return_type_name) + + def _get_server_return_action(return_type_name: str) -> str: if return_type_name == "str": return_action = "resp.text" else: return_action = "resp.json()" - return return_action \ No newline at end of file + return return_action diff --git a/polyapi/utils.py b/polyapi/utils.py index 1a6d168..1cf91db 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -71,11 +71,57 @@ def print_red(s: str): print(Fore.RED + s + Style.RESET_ALL) +def normalize_cross_language_type(type_name: str) -> str: + """Normalize TS-flavored type strings into safe Python typing hints. + + This prevents TypeScript utility types (e.g. ReturnType<...>) from leaking + into Python codegen where they would be treated as importable symbols. + """ + value = (type_name or "").strip() + if not value: + return "Any" + + primitive_map = { + "string": "str", + "number": "float", + "integer": "int", + "boolean": "bool", + "null": "None", + "void": "None", + "any": "Any", + "object": "Dict", + } + + if value.startswith("Promise<") and value.endswith(">"): + return normalize_cross_language_type(value[len("Promise<"):-1]) + + if value.startswith("Awaited<") and value.endswith(">"): + return normalize_cross_language_type(value[len("Awaited<"):-1]) + + if "|" in value: + parts = [p.strip() for p in value.split("|") if p.strip()] + normalized = [normalize_cross_language_type(part) for part in parts] + return " | ".join(normalized) if normalized else "Any" + + if value == "ReturnType" or value.startswith("ReturnType<") or "typeof" in value: + return "Any" + + return primitive_map.get(value, value) + + def add_type_import_path(function_name: str, arg: str) -> str: """if not basic type, coerce to camelCase and add the import path""" # from now, we start qualifying non-basic types :)) # e.g. Callable[[EmailAddress, Dict, Dict, Dict], None] # becomes Callable[[Set_profile_email.EmailAddress, Dict, Dict, Dict], None] + arg = normalize_cross_language_type(arg) + + if "|" in arg: + return " | ".join( + add_type_import_path(function_name, token.strip()) + for token in arg.split("|") + if token.strip() + ) if arg.startswith("Callable"): inner = arg[len("Callable["):-1] # strip outer Callable[...] @@ -90,7 +136,7 @@ def add_type_import_path(function_name: str, arg: str) -> str: return "Callable[" + ",".join(qualified) + "]" # return arg - if arg in BASIC_PYTHON_TYPES: + if arg == "Any" or arg in BASIC_PYTHON_TYPES: return arg if arg.startswith("List["): @@ -121,14 +167,23 @@ def get_type_and_def( return "Any", "" if type_spec["kind"] == "plain": - value = type_spec.get("value", "") + value = normalize_cross_language_type(type_spec.get("value", "")) + + if "|" in value or value in BASIC_PYTHON_TYPES: + return value, "" + if value.endswith("[]"): - primitive = map_primitive_types(value[:-2]) + primitive = normalize_cross_language_type(value[:-2]) + if primitive not in BASIC_PYTHON_TYPES: + primitive = map_primitive_types(primitive) return f"List[{primitive}]", "" else: return map_primitive_types(value), "" elif type_spec["kind"] == "primitive": - return map_primitive_types(type_spec.get("type", "any")), "" + primitive = normalize_cross_language_type(type_spec.get("type", "any")) + if primitive in BASIC_PYTHON_TYPES: + return primitive, "" + return map_primitive_types(primitive), "" elif type_spec["kind"] == "array": if type_spec.get("items"): items = type_spec["items"] diff --git a/tests/test_server.py b/tests/test_server.py index c8126be..e5e4492 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -66,6 +66,34 @@ } +RETURN_TYPE_NAMED_RETURN_TYPE = { + "id": "ret-1234", + "type": "serverFunction", + "context": "mixed", + "name": "fooFunc", + "description": "Return type name collision regression test.", + "requirements": [], + "function": { + "arguments": [], + "returnType": { + "kind": "object", + "schema": { + "title": "ReturnType", + "type": "object", + "properties": { + "value": {"type": "string"}, + }, + "required": ["value"], + }, + }, + "synchronous": True, + }, + "code": "", + "language": "javascript", + "visibilityMetadata": {"visibility": "ENVIRONMENT"}, +} + + class T(unittest.TestCase): def test_render_function_twilio_server(self): # same test but try it as a serverFunction rather than an apiFunction @@ -116,4 +144,18 @@ def test_render_function_list_recommendations(self): # stay_date: Required[str] # """ Required property """''' -# self.assertIn(expected_return_type, func_str) \ No newline at end of file +# self.assertIn(expected_return_type, func_str) + + def test_render_function_return_type_name_collision_does_not_reference_module_attr(self): + return_type = RETURN_TYPE_NAMED_RETURN_TYPE["function"]["returnType"] + func_str, func_type_defs = render_server_function( + RETURN_TYPE_NAMED_RETURN_TYPE["type"], + RETURN_TYPE_NAMED_RETURN_TYPE["name"], + RETURN_TYPE_NAMED_RETURN_TYPE["id"], + RETURN_TYPE_NAMED_RETURN_TYPE["description"], + RETURN_TYPE_NAMED_RETURN_TYPE["function"]["arguments"], + return_type, + ) + self.assertIn("-> dict", func_str) + self.assertNotIn(".returnType", func_str) + self.assertNotIn(".ReturnType", func_str) diff --git a/tests/test_utils.py b/tests/test_utils.py index 55c446b..35bea17 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ import unittest -from polyapi.utils import get_type_and_def, rewrite_reserved +from polyapi.utils import add_type_import_path, get_type_and_def, rewrite_reserved OPENAPI_FUNCTION = { "kind": "function", @@ -84,3 +84,17 @@ def test_get_type_and_def(self): def test_rewrite_reserved(self): rv = rewrite_reserved("from") self.assertEqual(rv, "_from") + + def test_plain_return_type_utility_normalizes_to_any(self): + arg_type, arg_def = get_type_and_def({"kind": "plain", "value": "ReturnType"}) + self.assertEqual(arg_type, "Any") + self.assertEqual(arg_def, "") + + def test_plain_promise_union_normalizes_to_python_union(self): + arg_type, arg_def = get_type_and_def({"kind": "plain", "value": "Promise"}) + self.assertEqual(arg_type, "str | None") + self.assertEqual(arg_def, "") + + def test_add_type_import_path_never_qualifies_return_type_utility(self): + arg_type = add_type_import_path("fooFunc", "ReturnType") + self.assertEqual(arg_type, "Any")