diff --git a/qh/jsclient.py b/qh/jsclient.py index 747e662..0cdaa33 100644 --- a/qh/jsclient.py +++ b/qh/jsclient.py @@ -8,6 +8,31 @@ import json +#: Origin names whose parameterised form becomes a TypeScript array. +_SEQUENCE_ORIGINS = frozenset( + { + "list", + "List", + "tuple", + "Tuple", + "set", + "Set", + "frozenset", + "FrozenSet", + "Sequence", + "Iterable", + "Collection", + "MutableSequence", + "Iterator", + } +) + +#: Origin names whose parameterised form becomes a TypeScript Record. +_MAPPING_ORIGINS = frozenset( + {"dict", "Dict", "Mapping", "MutableMapping", "OrderedDict"} +) + + def python_type_to_ts_type(python_type: str) -> str: """ Convert Python type annotation to TypeScript type. @@ -27,13 +52,42 @@ def python_type_to_ts_type(python_type: str) -> str: inner = python_type[9:-1] # Extract inner type return f"{python_type_to_ts_type(inner)} | null" - if python_type.startswith("list[") or python_type.startswith("List["): - inner = python_type.split("[")[1][:-1] - return f"{python_type_to_ts_type(inner)}[]" - - if python_type.startswith("dict[") or python_type.startswith("Dict["): - # Simplified - could be more sophisticated - return "Record" + # PEP 604 unions arrive from the signature extractor as "UnionType[a, b, ...]", + # and typing.Union as "Union[a, b, ...]". Both become a TypeScript union so an + # `str | None` parameter types as `string | null` rather than collapsing to `any`. + for prefix in ("UnionType[", "Union["): + if python_type.startswith(prefix): + members = _split_type_args(python_type[len(prefix) : -1]) + mapped = [python_type_to_ts_type(m) for m in members] + # Dedupe while preserving order: `str | Sequence | None` has no repeats, + # but `int | float` both map to `number` and one `number` is enough. + seen, unique = set(), [] + for m in mapped: + if m not in seen: + seen.add(m) + unique.append(m) + return " | ".join(unique) + + # Sequence-ish generics: `Sequence[str]` -> `string[]`. Matching on the bare + # origin name covers every spelling the signature extractor can produce + # (`list`, `List`, `tuple`, `Sequence`, `Iterable`, ...) without a case each. + if "[" in python_type: + origin, inner = python_type.split("[", 1) + inner = inner[:-1] + if origin in _SEQUENCE_ORIGINS: + members = _split_type_args(inner) + if not members: + return "any[]" + if origin in ("tuple", "Tuple") and len(members) > 1: + # A fixed-length tuple is a TS tuple type, not an array. + if members[-1] == "...": + return f"{python_type_to_ts_type(members[0])}[]" + return "[" + ", ".join(python_type_to_ts_type(m) for m in members) + "]" + return f"{python_type_to_ts_type(members[0])}[]" + if origin in _MAPPING_ORIGINS: + members = _split_type_args(inner) + value = python_type_to_ts_type(members[1]) if len(members) > 1 else "any" + return f"Record" # Basic types type_map = { @@ -42,13 +96,45 @@ def python_type_to_ts_type(python_type: str) -> str: "str": "string", "bool": "boolean", "list": "any[]", + "tuple": "any[]", + "set": "any[]", + "Sequence": "any[]", + "Iterable": "any[]", "dict": "Record", + "Mapping": "Record", "Any": "any", } return type_map.get(python_type, "any") +def _split_type_args(s: str) -> List[str]: + """Split "a, b[c, d], e" on its TOP-LEVEL commas only. + + A naive ``s.split(",")`` would cut ``dict[str, int]`` in half and produce two + nonsense members, so nested brackets are tracked. + + >>> _split_type_args("str, NoneType") + ['str', 'NoneType'] + >>> _split_type_args("str, dict[str, int], NoneType") + ['str', 'dict[str, int]', 'NoneType'] + """ + parts, depth, current = [], 0, [] + for ch in s: + if ch in "[(": + depth += 1 + elif ch in "])": + depth -= 1 + if ch == "," and depth == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(ch) + if "".join(current).strip(): + parts.append("".join(current).strip()) + return parts + + def generate_ts_interface(name: str, signature_info: Dict[str, Any]) -> str: """ Generate TypeScript interface for function parameters. @@ -173,15 +259,70 @@ def generate_js_function( return func -def generate_ts_function( +def _jsdoc(docstring: str, extra: Optional[List[str]] = None) -> str: + """Render a JSDoc block, safely. + + Two things the previous one-line version got wrong. A docstring spanning several + lines was emitted with only its first line prefixed by ``*``, so every later line + sat bare inside the comment; and a docstring containing ``*/`` -- a doctest or a + regex is enough -- closed the comment early and the rest of the docstring became + executable garbage. Neutralising ``*/`` is what makes this safe on arbitrary text. + + >>> print(_jsdoc("One line.")) + /** + * One line. + */ + >>> "*\u200b/" in _jsdoc("ends a comment: */ here") + True + """ + body = (docstring or "").replace("*/", "*\u200b/") # zero-width space defuses it + out = [" /**"] + for line in body.splitlines() or [""]: + out.append(f" * {line}".rstrip()) + for line in extra or []: + out.append(f" * {line}") + out.append(" */") + return "\n".join(out) + + +def _ts_params(params: List[Dict[str, Any]]) -> List[str]: + """Typed parameter list, required first, optional marked ``?``. + + TypeScript requires optional parameters to follow required ones, and a parameter + with a Python default must not be mandatory on the client -- the whole point of + the default is that the caller may omit it. Sorting is stable, so parameters keep + their declaration order within each group. + + >>> _ts_params([{"name": "a", "type": "str", "required": True}, + ... {"name": "b", "type": "int", "required": False}]) + ['a: string', 'b?: number'] + >>> _ts_params([{"name": "b", "type": "int", "required": False}, + ... {"name": "a", "type": "str", "required": True}]) + ['a: string', 'b?: number'] + """ + ordered = sorted(params, key=lambda p: not p.get("required", True)) + return [ + f"{p['name']}{'' if p.get('required', True) else '?'}: " + f"{python_type_to_ts_type(p['type'])}" + for p in ordered + ] + + +def generate_ts_method( name: str, path: str, method: str, signature_info: Optional[Dict[str, Any]] = None, use_axios: bool = False, ) -> str: - """ - Generate TypeScript function for calling an endpoint. + """Generate the TypeScript class method for one endpoint -- no interface. + + Split out from :func:`generate_ts_function` because the assembler needs the method + alone. It used to get it by splitting the combined string on its first blank line, + which silently broke on a zero-parameter function: that interface is + ``export interface FooParams {\n\n}``, whose blank line comes first, so the split + handed back a stray ``}`` that closed the client class early. Every method after + the first no-argument endpoint landed outside the class. Args: name: Function name @@ -191,82 +332,114 @@ def generate_ts_function( use_axios: Use axios instead of fetch Returns: - TypeScript function code with type annotations + TypeScript method source, indented for a class body """ - if not signature_info: - # Fallback to JavaScript version - return generate_js_function(name, path, method, signature_info, use_axios) - - # Generate interface - interface, interface_name, return_type = generate_ts_interface(name, signature_info) - - # Generate function with types - method_lower = method.lower() import re + method_lower = method.lower() path_params = re.findall(r"\{(\w+)\}", path) + params = signature_info.get("parameters", []) if signature_info else [] + return_type = python_type_to_ts_type( + (signature_info or {}).get("return_type", "any") + ) - params = signature_info.get("parameters", []) - - # Build function signature with types - func_params = [] - for param in params: - param_name = param["name"] - param_type = python_type_to_ts_type(param["type"]) - func_params.append(f"{param_name}: {param_type}") - - # Generate JSDoc - jsdoc = f" /**\n" - if signature_info.get("docstring"): - jsdoc += f" * {signature_info['docstring']}\n" - jsdoc += " */\n" - - func = jsdoc - func += f" async {name}({', '.join(func_params)}): Promise<{return_type}> {{\n" + func = _jsdoc((signature_info or {}).get("docstring", "")) + "\n" + func += ( + f" async {name}({', '.join(_ts_params(params))}): Promise<{return_type}> {{\n" + ) - # Build URL func += f" let url = `${{this.baseUrl}}{path}`;\n" for param in path_params: func += f" url = url.replace('{{{param}}}', String({param}));\n" - # Separate params - param_names = [p["name"] for p in params] - body_params = [p for p in param_names if p not in path_params] + body_params = [p["name"] for p in params if p["name"] not in path_params] + # An omitted optional parameter is `undefined`. JSON.stringify drops undefined + # keys, so the server sees the key as absent and applies its own Python default -- + # exactly the intent. A query string has no such rule, hence the explicit filter + # below; without it an omitted parameter is sent as the literal text "undefined". + obj = "{ " + ", ".join(body_params) + " }" if use_axios: - # Axios implementation if method_lower == "get" and body_params: - func += f" const params = {{ {', '.join(body_params)} }};\n" - func += f" const response = await this.axios.get<{return_type}>(url, {{ params }});\n" + func += f" const params = _defined({obj});\n" + func += ( + f" const response = await this.axios.get<{return_type}>" + "(url, { params });\n" + ) func += " return response.data;\n" elif method_lower in ["post", "put", "patch"] and body_params: - func += f" const data = {{ {', '.join(body_params)} }};\n" - func += f" const response = await this.axios.{method_lower}<{return_type}>(url, data);\n" + func += f" const data = {obj};\n" + func += ( + f" const response = await this.axios.{method_lower}" + f"<{return_type}>(url, data);\n" + ) func += " return response.data;\n" else: - func += f" const response = await this.axios.{method_lower}<{return_type}>(url);\n" + func += ( + f" const response = await this.axios.{method_lower}" + f"<{return_type}>(url);\n" + ) func += " return response.data;\n" else: - # Fetch implementation if method_lower == "get" and body_params: - func += f" const params = new URLSearchParams({{ {', '.join(body_params)} }});\n" - func += " url += '?' + params.toString();\n" + func += f" const params = new URLSearchParams(_defined({obj}));\n" + func += " const qs = params.toString();\n" + func += " if (qs) url += '?' + qs;\n" func += " const response = await fetch(url);\n" elif method_lower in ["post", "put", "patch"] and body_params: - func += f" const data = {{ {', '.join(body_params)} }};\n" + func += f" const data = {obj};\n" func += " const response = await fetch(url, {\n" func += f" method: '{method.upper()}',\n" - func += " headers: {{ 'Content-Type': 'application/json' }},\n" + func += " headers: { 'Content-Type': 'application/json' },\n" func += " body: JSON.stringify(data)\n" func += " });\n" else: - func += f" const response = await fetch(url, {{ method: '{method.upper()}' }});\n" - func += " if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n" + func += ( + " const response = await fetch(url, " + f"{{ method: '{method.upper()}' }});\n" + ) + func += ( + " if (!response.ok) throw new Error(" + "`HTTP error! status: ${response.status}`);\n" + ) func += f" return await response.json() as {return_type};\n" func += " }\n" + return func + + +def generate_ts_function( + name: str, + path: str, + method: str, + signature_info: Optional[Dict[str, Any]] = None, + use_axios: bool = False, +) -> str: + """ + Generate TypeScript function with type annotations, preceded by its interface. + + Args: + name: Function name + path: HTTP path + method: HTTP method + signature_info: Optional x-python-signature metadata + use_axios: Use axios instead of fetch + + Returns: + TypeScript function code + """ + if not signature_info: + # Fallback to JavaScript version + return generate_js_function(name, path, method, signature_info, use_axios) - return interface + "\n" + func + interface, _interface_name, _return_type = generate_ts_interface( + name, signature_info + ) + return ( + interface + + "\n" + + generate_ts_method(name, path, method, signature_info, use_axios) + ) def export_js_client( @@ -342,6 +515,44 @@ def export_js_client( return code +#: Emitted only into clients that have at least one GET endpoint with parameters. +#: A query string has no equivalent of JSON.stringify's "drop the undefined keys" +#: rule, so without this an omitted optional parameter travels as the six characters +#: "undefined" and the server parses that as a real value. +_DEFINED_HELPER_TS = """\ +/** Drop keys whose value is undefined, so omitted optional parameters are not sent. */ +function _defined(o: Record): Record { + return Object.fromEntries( + Object.entries(o).filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => [k, String(v)]) + ); +} + +""" + + +def _needs_defined_helper(paths: Dict[str, Any]) -> bool: + """Whether any GET endpoint in ``paths`` carries parameters. + + >>> _needs_defined_helper({}) + False + """ + for path, path_item in paths.items(): + if path in ["/openapi.json", "/docs", "/redoc"]: + continue + for method, operation in path_item.items(): + if method.lower() != "get": + continue + sig = operation.get("x-python-signature") or {} + if [ + p + for p in sig.get("parameters", []) + if "{" + p["name"] + "}" not in path + ]: + return True + return False + + def export_ts_client( openapi_spec: Dict[str, Any], *, @@ -397,6 +608,8 @@ def export_ts_client( code += "\n".join(interfaces) + "\n" # Generate class + if _needs_defined_helper(paths): + code += _DEFINED_HELPER_TS code += f"/**\n * Generated API client\n */\n" code += f"export class {class_name} {{\n" code += " private baseUrl: string;\n" @@ -429,13 +642,15 @@ def export_ts_client( else path.strip("/").replace("/", "_") ) - func_code = generate_ts_function( - func_name, path, method.upper(), signature_info, use_axios + # The method only -- its interface was emitted above. Asking for the + # combined form and splitting it back apart is what used to leak a + # stray closing brace into the class body. + code += ( + generate_ts_method( + func_name, path, method.upper(), signature_info, use_axios + ) + + "\n" ) - # Extract just the function part (skip interface) - if "\n\n" in func_code: - func_code = func_code.split("\n\n", 1)[1] - code += func_code + "\n" code += "}\n" diff --git a/qh/openapi.py b/qh/openapi.py index 4a2a9f5..4f3212b 100644 --- a/qh/openapi.py +++ b/qh/openapi.py @@ -465,22 +465,43 @@ def build_response_schema( # --------------------------------------------------------------------------- +def _is_union(origin: Any) -> bool: + """Whether ``origin`` is either spelling of a union. + + >>> from typing import Union, get_origin, Optional + >>> _is_union(get_origin(Union[int, str])), _is_union(get_origin(int | str)) + (True, True) + >>> _is_union(list) + False + """ + import types + import typing + + return origin is typing.Union or origin is getattr(types, "UnionType", ()) + + def get_python_type_name(type_hint: Any) -> str: """ - Get a string representation of a Python type. + Get a string representation of a Python type, keeping its arguments. - ``inspect.Parameter.empty`` and ``None`` map to ``"Any"``. Anything else - with a ``__name__`` uses that name alone, with no type arguments — on - Python 3.10+ this includes builtin generic aliases (``list[int]``) and - ``typing`` generics (``Optional[str]``, ``Dict[str, int]``), since they - all carry a ``__name__`` now. The bracketed-argument form only appears - for the rare origin type that lacks ``__name__``. + ``inspect.Parameter.empty`` and ``None`` map to ``"Any"``. A parameterised + generic keeps its arguments — ``list[int]`` is ``"list[int]"``, not + ``"list"`` — because this string is the only type information the client + generators ever see, and a client that types every ``Optional[str]`` as + ``any`` is not a typed client. + + This used to check ``__name__`` first. On Python 3.10+ ``typing`` generics + carry a ``__name__``, so ``Optional[str]`` and ``Union[int, str]`` returned + the bare words ``"Optional"`` and ``"Union"`` with every argument discarded, + while the PEP 604 spelling ``str | None`` — which has no ``__name__`` — kept + its arguments. Two spellings of one type produced different clients. The + ``__name__`` shortcut is now taken only when there is nothing to lose. Args: type_hint: A type or type annotation, or ``inspect.Parameter.empty``. Returns: - The type's bare name, e.g. ``"int"`` or ``"list"``. + The type's name, with arguments when it has them. Examples: >>> get_python_type_name(int) @@ -488,28 +509,43 @@ def get_python_type_name(type_hint: Any) -> str: >>> get_python_type_name(str) 'str' >>> get_python_type_name(list[int]) - 'list' - >>> from typing import Optional + 'list[int]' + >>> from typing import Optional, Union >>> get_python_type_name(Optional[str]) - 'Optional' + 'Union[str, NoneType]' + >>> get_python_type_name(Union[int, str]) + 'Union[int, str]' + + Both spellings of an optional now produce the same string, so the + generated client does not depend on which one the author typed: + + >>> get_python_type_name(str | None) + 'Union[str, NoneType]' + >>> get_python_type_name(Optional[str]) == get_python_type_name(str | None) + True """ if type_hint is inspect.Parameter.empty or type_hint is None: return "Any" - # Handle basic types - if hasattr(type_hint, "__name__"): - return type_hint.__name__ - - # Handle typing generics + # Parameterised generics first: their arguments are the part worth keeping. origin = get_origin(type_hint) args = get_args(type_hint) + if origin is not None and args: + args_str = ", ".join(get_python_type_name(arg) for arg in args) + # `Optional[str]`, `Union[str, None]` and `str | None` are one type wearing + # three names; normalise so the emitted client cannot depend on the spelling. + # (`Optional[str]` is `Union[str, None]`, so its own `__name__` would produce + # the nonsense `Optional[str, NoneType]`.) + if _is_union(origin): + return f"Union[{args_str}]" + return f"{getattr(origin, '__name__', str(origin))}[{args_str}]" + + # Plain classes, and unparameterised generics: the bare name is all there is. + if hasattr(type_hint, "__name__"): + return type_hint.__name__ if origin is not None: - origin_name = getattr(origin, "__name__", str(origin)) - if args: - args_str = ", ".join(get_python_type_name(arg) for arg in args) - return f"{origin_name}[{args_str}]" - return origin_name + return getattr(origin, "__name__", str(origin)) return str(type_hint) diff --git a/qh/tests/test_jsclient.py b/qh/tests/test_jsclient.py index 3e05b9b..650edaa 100644 --- a/qh/tests/test_jsclient.py +++ b/qh/tests/test_jsclient.py @@ -171,9 +171,12 @@ def greet(name: str, title: Optional[str] = None) -> str: ts_code = export_ts_client(spec) # Check optional parameter syntax (? indicates optional) - assert "title?:" in ts_code or "title: " in ts_code - # Function should have title parameter - assert "greet(name: string, title:" in ts_code + assert "title?: string | null" in ts_code + # ...on the method too, not only in the interface. This assertion used to + # read `greet(name: string, title:` -- i.e. it pinned the defect where a + # parameter with a Python default was emitted as a REQUIRED TypeScript + # argument, forcing every caller to pass it. + assert "greet(name: string, title?: string | null)" in ts_code def test_ts_client_complex_types(self): """Test TypeScript client with complex return types.""" @@ -181,8 +184,8 @@ def test_ts_client_complex_types(self): def analyze(numbers: list) -> dict: """Analyze a list of numbers.""" return { - 'count': len(numbers), - 'sum': sum(numbers), + "count": len(numbers), + "sum": sum(numbers), } app = mk_app([analyze]) @@ -198,7 +201,7 @@ def test_ts_client_with_conventions(self): """Test TypeScript client with convention-based routing.""" def get_user(user_id: str) -> dict: - return {'user_id': user_id, 'name': 'Test User'} + return {"user_id": user_id, "name": "Test User"} app = mk_app([get_user], use_conventions=True) spec = export_openapi(app, include_python_metadata=True) @@ -249,7 +252,7 @@ def test_generated_code_is_valid_syntax(self): """Test that generated code has valid syntax structure.""" def test_func(a: int, b: str, c: bool) -> dict: - return {'a': a, 'b': b, 'c': c} + return {"a": a, "b": b, "c": c} app = mk_app([test_func]) spec = export_openapi(app, include_python_metadata=True) @@ -269,5 +272,5 @@ def test_func(a: int, b: str, c: bool) -> dict: assert "return " in code -if __name__ == '__main__': - pytest.main([__file__, '-v']) +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/qh/tests/test_jsclient_compiles.py b/qh/tests/test_jsclient_compiles.py new file mode 100644 index 0000000..e37aa97 --- /dev/null +++ b/qh/tests/test_jsclient_compiles.py @@ -0,0 +1,165 @@ +"""The generated TypeScript client has to *compile*, not merely contain the right words. + +The tests in ``test_jsclient.py`` assert on substrings and on a balanced brace count. +Both passed while the emitter produced a file that ``tsc`` rejected outright: + +* ``headers: {{ 'Content-Type': 'application/json' }}`` -- a doubled brace left over + from a ``.format``-style string, which is a syntax error in TypeScript; +* a stray ``}`` before every method whose endpoint takes no parameters, closing the + client class early and leaving the remaining methods at module scope; +* every optional parameter emitted as required, so a caller had to pass all seven + arguments to a function with six defaults. + +A brace count cannot see any of those -- the stray ``}`` and the doubled braces even +balance each other out. So this module runs the real compiler, and falls back to +structural assertions that would each have caught one of the three when it cannot. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Optional, Sequence + +import pytest + +from qh import export_openapi, export_ts_client, mk_app + + +def sample_app(): + """An app with the three shapes that broke the emitter.""" + + def gauge( + source: str, + *, + fmt: str = "markdown", + detectors: str | Sequence[str] | None = None, + out: Optional[str] = None, + ) -> str: + """Many keyword-only defaults, and a PEP 604 union.""" + return source + + def listing() -> list: + """No parameters at all -- this is what leaked a closing brace.""" + return [] + + def add(x: int, y: int) -> int: + """All-required, the shape the old tests covered.""" + return x + y + + return mk_app([gauge, listing, add]) + + +@pytest.fixture(scope="module") +def ts_code() -> str: + spec = export_openapi(sample_app(), include_python_metadata=True) + return export_ts_client(spec, class_name="SampleClient") + + +def _tsc_available() -> bool: + return shutil.which("npx") is not None + + +@pytest.mark.skipif(not _tsc_available(), reason="npx (for tsc) not available") +def test_generated_client_compiles_under_tsc(ts_code: str, tmp_path: Path) -> None: + """The real gate: ``tsc --strict`` accepts the file.""" + src = tmp_path / "client.ts" + src.write_text(ts_code, encoding="utf-8") + try: + proc = subprocess.run( + [ + "npx", + "-y", + "-p", + "typescript@5", + "tsc", + "--noEmit", + "--strict", + "--target", + "es2020", + "--lib", + "es2020,dom", + str(src), + ], + capture_output=True, + text=True, + timeout=300, + ) + except (subprocess.TimeoutExpired, OSError) as e: # offline npx, no network + pytest.skip(f"could not run tsc: {e}") + if proc.returncode != 0 and "npm error" in (proc.stderr or ""): + pytest.skip("npx could not fetch typescript (offline?)") + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_no_doubled_braces(ts_code: str) -> None: + """``{{`` is a Python formatting artefact and never valid here.""" + assert "{{" not in ts_code + assert "}}" not in ts_code + + +def test_every_method_is_inside_the_class(ts_code: str) -> None: + """All three methods must appear after ``export class`` and before its close. + + The zero-parameter endpoint used to emit a ``}`` ahead of itself, so everything + from ``listing`` onwards landed outside the class. + """ + start = ts_code.index("export class SampleClient") + class_body = ts_code[start:] + depth, end = 0, None + for i, ch in enumerate(class_body): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + break + assert end is not None, "class never closes" + body = class_body[:end] + for name in ("async gauge(", "async listing(", "async add("): + assert name in body, f"{name} escaped the class body" + + +def test_optional_parameters_are_optional_and_last(ts_code: str) -> None: + """A parameter with a Python default must not be required on the client.""" + line = next(ln for ln in ts_code.splitlines() if "async gauge(" in ln) + assert "source: string" in line + assert "fmt?: string" in line + assert "out?: string | null" in line + # TypeScript rejects a required parameter after an optional one. + args = line[line.index("(") + 1 : line.rindex(")")].split(", ") + seen_optional = False + for arg in args: + optional = "?" in arg.split(":")[0] + assert not (seen_optional and not optional), ( + f"required arg after optional: {arg}" + ) + seen_optional = seen_optional or optional + + +def test_union_types_survive(ts_code: str) -> None: + """``str | Sequence[str] | None`` must reach TypeScript intact.""" + assert "detectors?: string | string[] | null" in ts_code + assert "out?: string | null" in ts_code + + +def test_docstring_cannot_close_the_jsdoc_comment() -> None: + """A docstring containing ``*/`` must not terminate the comment early.""" + + def tricky(x: str) -> str: + """Matches /\\*(.*?)\\*/ in the source.""" + return x + + spec = export_openapi(mk_app([tricky]), include_python_metadata=True) + code = export_ts_client(spec) + # Slice the method's own JSDoc, not the class header's. + start = code.rindex("/**", 0, code.index("async tricky")) + header = code[start : code.index("async tricky")] + assert header.count("*/") == 1, "the JSDoc block closes more than once" + + +def test_openapi_spec_is_json_serialisable() -> None: + json.dumps(export_openapi(sample_app(), include_python_metadata=True)) diff --git a/qh/tests/test_openapi_client.py b/qh/tests/test_openapi_client.py index c506526..880265f 100644 --- a/qh/tests/test_openapi_client.py +++ b/qh/tests/test_openapi_client.py @@ -27,10 +27,10 @@ def add(x: int, y: int) -> int: spec = export_openapi(app) # Check basic structure - assert 'openapi' in spec - assert 'paths' in spec - assert '/add' in spec['paths'] - assert 'post' in spec['paths']['/add'] + assert "openapi" in spec + assert "paths" in spec + assert "/add" in spec["paths"] + assert "post" in spec["paths"]["/add"] def test_python_signature_metadata(self): """Test x-python-signature extension is added.""" @@ -43,28 +43,28 @@ def add(x: int, y: int = 10) -> int: spec = export_openapi(app, include_python_metadata=True) # Check x-python-signature - operation = spec['paths']['/add']['post'] - assert 'x-python-signature' in operation + operation = spec["paths"]["/add"]["post"] + assert "x-python-signature" in operation - sig = operation['x-python-signature'] - assert sig['name'] == 'add' - assert sig['return_type'] == 'int' - assert sig['docstring'] == 'Add two numbers.' + sig = operation["x-python-signature"] + assert sig["name"] == "add" + assert sig["return_type"] == "int" + assert sig["docstring"] == "Add two numbers." # Check parameters - params = sig['parameters'] + params = sig["parameters"] assert len(params) == 2 # Check x parameter - x_param = next(p for p in params if p['name'] == 'x') - assert x_param['type'] == 'int' - assert x_param['required'] is True + x_param = next(p for p in params if p["name"] == "x") + assert x_param["type"] == "int" + assert x_param["required"] is True # Check y parameter with default - y_param = next(p for p in params if p['name'] == 'y') - assert y_param['type'] == 'int' - assert y_param['required'] is False - assert y_param['default'] == 10 + y_param = next(p for p in params if p["name"] == "y") + assert y_param["type"] == "int" + assert y_param["required"] is False + assert y_param["default"] == 10 def test_optional_parameters_in_signature(self): """Test that Optional parameters are handled correctly.""" @@ -78,12 +78,27 @@ def greet(name: str, title: Optional[str] = None) -> str: app = mk_app([greet]) spec = export_openapi(app, include_python_metadata=True) - sig = spec['paths']['/greet']['post']['x-python-signature'] - params = sig['parameters'] + sig = spec["paths"]["/greet"]["post"]["x-python-signature"] + params = sig["parameters"] - title_param = next(p for p in params if p['name'] == 'title') - assert 'Optional' in title_param['type'] - assert title_param['required'] is False + title_param = next(p for p in params if p["name"] == "title") + # `Optional[str]` is `Union[str, None]`; every union spelling normalises to + # `Union[...]` so that `Optional[str]` and `str | None` -- the same type -- + # cannot generate two different clients. This used to assert the bare word + # `Optional`, which was all the extractor kept: the `str` was discarded and + # the client typed the parameter `any`. + assert title_param["type"] == "Union[str, NoneType]" + assert title_param["required"] is False + + def greet604(name: str, title: str | None = None) -> str: + return name + + spec604 = export_openapi(mk_app([greet604]), include_python_metadata=True) + pep604 = spec604["paths"]["/greet604"]["post"]["x-python-signature"] + assert ( + next(p for p in pep604["parameters"] if p["name"] == "title")["type"] + == (title_param["type"]) + ) def test_examples_generation(self): """Test that examples are generated for requests.""" @@ -95,7 +110,7 @@ def add(x: int, y: int) -> int: spec = export_openapi(app, include_examples=True) # Check examples exist (may not be in requestBody if FastAPI doesn't create it) - operation = spec['paths']['/add']['post'] + operation = spec["paths"]["/add"]["post"] # Examples might be added if requestBody exists # For now, just verify the export doesn't crash assert operation is not None @@ -116,13 +131,13 @@ def multiply(x: int, y: int) -> int: spec = export_openapi(app, include_python_metadata=True) # Check all functions are present - assert '/add' in spec['paths'] - assert '/subtract' in spec['paths'] - assert '/multiply' in spec['paths'] + assert "/add" in spec["paths"] + assert "/subtract" in spec["paths"] + assert "/multiply" in spec["paths"] # Check all have signatures - for path in ['/add', '/subtract', '/multiply']: - assert 'x-python-signature' in spec['paths'][path]['post'] + for path in ["/add", "/subtract", "/multiply"]: + assert "x-python-signature" in spec["paths"][path]["post"] class TestClientGeneration: @@ -138,7 +153,7 @@ def add(x: int, y: int) -> int: client = mk_client_from_app(app) # Client should have add function - assert hasattr(client, 'add') + assert hasattr(client, "add") # Test calling the function result = client.add(x=3, y=5) @@ -173,8 +188,8 @@ def multiply(x: int, y: int) -> int: app = mk_app([add, multiply]) client = mk_client_from_app(app) - assert hasattr(client, 'add') - assert hasattr(client, 'multiply') + assert hasattr(client, "add") + assert hasattr(client, "multiply") assert client.add(x=3, y=5) == 8 assert client.multiply(x=3, y=5) == 15 @@ -183,17 +198,17 @@ def test_client_with_conventions(self): """Test client generation with convention-based routing.""" def get_user(user_id: str) -> dict: - return {'user_id': user_id, 'name': 'Test User'} + return {"user_id": user_id, "name": "Test User"} def list_users(limit: int = 10) -> list: - return [{'user_id': str(i), 'name': f'User {i}'} for i in range(limit)] + return [{"user_id": str(i), "name": f"User {i}"} for i in range(limit)] app = mk_app([get_user, list_users], use_conventions=True) client = mk_client_from_app(app) # Test get_user (path param) - result = client.get_user(user_id='123') - assert result['user_id'] == '123' + result = client.get_user(user_id="123") + assert result["user_id"] == "123" # Test list_users (query param) result = client.list_users(limit=5) @@ -213,7 +228,7 @@ def add(x: int, y: int) -> int: # Note: This test requires a running server or TestClient wrapper # For now, just verify client creation works - assert hasattr(client, 'add') + assert hasattr(client, "add") def test_client_error_handling(self): """Test that client properly handles errors.""" @@ -282,9 +297,9 @@ def test_round_trip_complex_types(self): def analyze(numbers: list) -> dict: """Analyze a list of numbers.""" return { - 'count': len(numbers), - 'sum': sum(numbers), - 'mean': sum(numbers) / len(numbers) if numbers else 0, + "count": len(numbers), + "sum": sum(numbers), + "mean": sum(numbers) / len(numbers) if numbers else 0, } original_result = analyze([1, 2, 3, 4, 5]) @@ -306,11 +321,11 @@ def __init__(self, x: float, y: float): self.y = y def to_dict(self): - return {'x': self.x, 'y': self.y} + return {"x": self.x, "y": self.y} @classmethod def from_dict(cls, data): - return cls(data['x'], data['y']) + return cls(data["x"], data["y"]) def create_point(x: float, y: float) -> Point: return Point(x, y) @@ -319,7 +334,7 @@ def create_point(x: float, y: float) -> Point: client = mk_client_from_app(app) result = client.create_point(x=3.0, y=4.0) - assert result == {'x': 3.0, 'y': 4.0} + assert result == {"x": 3.0, "y": 4.0} def test_signature_preservation(self): """Test that client functions preserve function metadata.""" @@ -332,7 +347,7 @@ def add(x: int, y: int = 10) -> int: client = mk_client_from_app(app) # Check function name - assert client.add.__name__ == 'add' + assert client.add.__name__ == "add" # Check docstring assert client.add.__doc__ is not None @@ -357,5 +372,5 @@ def multiply(x: int, y: int) -> int: assert client.multiply(x=10, y=3) == 30 -if __name__ == '__main__': - pytest.main([__file__, '-v']) +if __name__ == "__main__": + pytest.main([__file__, "-v"])