From 53c953b1e56f6e6676dd8772379ad9af3475bf25 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 9 Jul 2026 16:29:11 -0700 Subject: [PATCH] Fix generated client mixed request bodies Assisted-By: devx/ab04ee6e-8d31-4580-9a88-93ad2a8b42e8 --- packages/tangle-api/pyproject.toml | 2 +- .../src/tangle_api/generated/operations.py | 20 ++--- .../src/tangle_cli/openapi/codegen.py | 12 ++- pyproject.toml | 4 +- tests/test_codegen.py | 80 ++++++++++++++++++- tests/test_packaging.py | 8 +- uv.lock | 6 +- 7 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/tangle-api/pyproject.toml b/packages/tangle-api/pyproject.toml index 29d54c6..e9bd9af 100644 --- a/packages/tangle-api/pyproject.toml +++ b/packages/tangle-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-api" -version = "0.1.0" +version = "0.1.1" description = "Checked-in generated Tangle API models and operation proxies" readme = "README.md" authors = [ diff --git a/packages/tangle-api/src/tangle_api/generated/operations.py b/packages/tangle-api/src/tangle_api/generated/operations.py index e0694c7..9baf19c 100644 --- a/packages/tangle-api/src/tangle_api/generated/operations.py +++ b/packages/tangle-api/src/tangle_api/generated/operations.py @@ -91,13 +91,13 @@ def component_libraries_list(self, name_substring: Any = None) -> ListComponentL response_model=self._response_model('ListComponentLibrariesResponse', ListComponentLibrariesResponse), ) - def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: + def component_libraries_create(self, name: Any, hide_from_search: Any = None, body: Any = None) -> ComponentLibraryResponse: return self._request_json( 'POST', '/api/component_libraries/', path_params=None, params={'hide_from_search': hide_from_search}, - json_data={'name': name}, + json_data={**(body or {}), **{'name': name}}, response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) @@ -111,13 +111,13 @@ def component_libraries_get(self, id: Any, include_component_texts: Any = None) response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) - def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse: + def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None, body: Any = None) -> ComponentLibraryResponse: return self._request_json( 'PUT', '/api/component_libraries/{id}', path_params={'id': id}, params={'hide_from_search': hide_from_search}, - json_data={'name': name}, + json_data={**(body or {}), **{'name': name}}, response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse), ) @@ -291,13 +291,13 @@ def published_components_list(self, include_deprecated: Any = None, name_substri response_model=self._response_model('ListPublishedComponentsResponse', ListPublishedComponentsResponse), ) - def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> PublishedComponentResponse: + def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None, body: Any = None) -> PublishedComponentResponse: return self._request_json( 'POST', '/api/published_components/', path_params=None, params=None, - json_data={key: value for key, value in {'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}.items() if value is not None}, + json_data={**(body or {}), **{key: value for key, value in {'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}.items() if value is not None}}, response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse), ) @@ -321,23 +321,23 @@ def secrets_list(self) -> ListSecretsResponse: response_model=self._response_model('ListSecretsResponse', ListSecretsResponse), ) - def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: + def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None, body: Any = None) -> SecretInfoResponse: return self._request_json( 'POST', '/api/secrets/', path_params=None, params={'secret_name': secret_name, 'description': description, 'expires_at': expires_at}, - json_data={'secret_value': secret_value}, + json_data={**(body or {}), **{'secret_value': secret_value}}, response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) - def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse: + def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None, body: Any = None) -> SecretInfoResponse: return self._request_json( 'PUT', '/api/secrets/{secret_name}', path_params={'secret_name': secret_name}, params={'description': description, 'expires_at': expires_at}, - json_data={'secret_value': secret_value}, + json_data={**(body or {}), **{'secret_value': secret_value}}, response_model=self._response_model('SecretInfoResponse', SecretInfoResponse), ) diff --git a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py index e95842c..24441e8 100644 --- a/packages/tangle-cli/src/tangle_cli/openapi/codegen.py +++ b/packages/tangle-cli/src/tangle_cli/openapi/codegen.py @@ -510,7 +510,7 @@ def _param_signature( body_names.append(name) if parameter.required: required_body_names.add(name) - include_body = has_request_body and not body_names + include_body = has_request_body if include_body: body_annotation = "dict[str, Any] | None" if raw_body_override else "Any" signature_parts.append(f"body: {body_annotation} = None") @@ -537,6 +537,12 @@ def _body_dict_literal(names: list[str], required_names: set[str]) -> str: return "{" + f"**{required_literal}, **{{{optional_expr}}}" + "}" +def _merged_body_dict_literal(names: list[str], required_names: set[str]) -> str: + """Return request JSON with generic body fields overridden by named fields.""" + + return f"{{**(body or {{}}), **{_body_dict_literal(names, required_names)}}}" + + def _validate_operation_path(path: str) -> None: """Reject OpenAPI operation paths that could override the configured origin.""" @@ -625,7 +631,9 @@ def generate_operations( f" path_params={_dict_literal(path_names)},", f" params={_dict_literal(query_names)},", ]) - if body_names: + if body_names and include_body: + lines.append(f" json_data={_merged_body_dict_literal(body_names, required_body_names)},") + elif body_names: lines.append(f" json_data={_body_dict_literal(body_names, required_body_names)},") elif include_body: lines.append(" json_data=body,") diff --git a/pyproject.toml b/pyproject.toml index b04e037..74bfa10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tangle-cli" -version = "0.1.1" +version = "0.1.2" description = "CLI for Tangle, the open-source ML pipeline orchestration platform" readme = "README.md" authors = [ @@ -18,7 +18,7 @@ dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", "requests>=2.32.0", - "tangle-api==0.1.0", + "tangle-api==0.1.1", "tomli>=2.0; python_version < '3.11'", ] diff --git a/tests/test_codegen.py b/tests/test_codegen.py index d7308fe..fa9a074 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -676,7 +676,8 @@ def test_generate_operations_without_request_body_override_omits_unset_optional_ assert "def search_create(self," in operations assert "query: Any = None" in operations assert "limit: Any = None" in operations - assert "json_data={key: value for key, value in {'limit': limit, 'query': query}.items() if value is not None}" in operations + assert "body: Any = None" in operations + assert "json_data={**(body or {}), **{key: value for key, value in {'limit': limit, 'query': query}.items() if value is not None}}" in operations assert "body: dict[str, Any] | None" not in operations monkeypatch.syspath_prepend(str(tmp_path)) @@ -693,9 +694,80 @@ def _request_json(self, *args, **kwargs): client = Client() client.search_create(query="widgets") client.search_create() + client.search_create(query="widgets", body={"query": "old", "predicate": {"nested": True}}) assert client.calls[0][1]["json_data"] == {"query": "widgets"} assert client.calls[1][1]["json_data"] == {} + assert client.calls[2][1]["json_data"] == {"query": "widgets", "predicate": {"nested": True}} + + +def test_generate_operations_mixed_simple_and_complex_body_keeps_body_escape_hatch(monkeypatch, tmp_path) -> None: + openapi = tmp_path / "openapi.json" + out = tmp_path / "mixed_body_api" + openapi.write_text( + json.dumps({ + "openapi": "3.1.0", + "paths": { + "/api/schedules/pipelines": { + "post": { + "operationId": "create_pipeline_schedule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "pipeline_task_spec"], + "properties": { + "name": {"type": "string"}, + "enabled": {"type": "boolean"}, + "pipeline_task_spec": { + "type": "object", + "additionalProperties": True, + }, + }, + } + } + } + }, + } + } + }, + "components": {"schemas": {}}, + }), + encoding="utf-8", + ) + + codegen.generate(openapi, out) + + operations = (out / "operations.py").read_text(encoding="utf-8") + assert "def schedules_pipelines(self, name: Any, enabled: Any = None, body: Any = None)" in operations + assert "pipeline_task_spec" not in operations.split("def schedules_pipelines", 1)[1].split("return self._request_json", 1)[0] + assert "json_data={**(body or {}), **{**{'name': name}, **{key: value for key, value in {'enabled': enabled}.items() if value is not None}}}" in operations + + monkeypatch.syspath_prepend(str(tmp_path)) + generated_operations = importlib.import_module("mixed_body_api.operations") + + class Client(generated_operations.GeneratedTangleApiOperations): + def __init__(self) -> None: + self.calls = [] + + def _request_json(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return {"ok": True} + + client = Client() + client.schedules_pipelines( + "scheduled-name", + body={ + "name": "body-name", + "pipeline_task_spec": {"component": "train", "args": {"epochs": 3}}, + }, + ) + + assert client.calls[0][1]["json_data"] == { + "name": "scheduled-name", + "pipeline_task_spec": {"component": "train", "args": {"epochs": 3}}, + } def test_generate_operations_preserves_required_body_kwargs(monkeypatch, tmp_path) -> None: @@ -733,8 +805,8 @@ def test_generate_operations_preserves_required_body_kwargs(monkeypatch, tmp_pat codegen.generate(openapi, out) operations = (out / "operations.py").read_text(encoding="utf-8") - assert "def secrets_create(self, secret_value: Any, description: Any = None)" in operations - assert "json_data={**{'secret_value': secret_value}, **{key: value for key, value in {'description': description}.items() if value is not None}}" in operations + assert "def secrets_create(self, secret_value: Any, description: Any = None, body: Any = None)" in operations + assert "json_data={**(body or {}), **{**{'secret_value': secret_value}, **{key: value for key, value in {'description': description}.items() if value is not None}}}" in operations monkeypatch.syspath_prepend(str(tmp_path)) generated_operations = importlib.import_module("required_body_api.operations") @@ -749,8 +821,10 @@ def _request_json(self, *args, **kwargs): client = Client() client.secrets_create("secret") + client.secrets_create("secret", body={"extra": {"nested": True}, "secret_value": "old"}) assert client.calls[0][1]["json_data"] == {"secret_value": "secret"} + assert client.calls[1][1]["json_data"] == {"extra": {"nested": True}, "secret_value": "secret"} def test_codegen_main_accepts_request_body_schema_file(monkeypatch, tmp_path) -> None: diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 6aa2593..e14705e 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -178,8 +178,8 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] assert not any(name.startswith("tangle_api/") for name in names) assert "tangle_cli/openapi/openapi.json" not in names - assert "Version: 0.1.1" in metadata - assert "Requires-Dist: tangle-api==0.1.0" in requires_dist + assert "Version: 0.1.2" in metadata + assert "Requires-Dist: tangle-api==0.1.1" in requires_dist assert not any("extra == 'native'" in line for line in requires_dist) assert "Provides-Extra: native" in metadata assert "tangle = tangle_cli.cli:main" in entry_points @@ -207,7 +207,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api def test_custom_tangle_api_local_version_can_satisfy_cli_pin() -> None: - assert Version("0.1.0+yourorg") in SpecifierSet("==0.1.0") + assert Version("0.1.1+yourorg") in SpecifierSet("==0.1.1") def test_tangle_cli_wheel_api_refresh_builds_in_expert_no_deps_fallback(tmp_path) -> None: @@ -396,7 +396,7 @@ def test_default_wheels_provide_static_client_binding(tmp_path) -> None: metadata = archive.read(metadata_name).decode() requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")] - assert "Version: 0.1.0" in metadata + assert "Version: 0.1.1" in metadata assert "Requires-Dist: pydantic>=2.0" in requires_dist assert not any("tangle-cli" in line for line in requires_dist) env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])} diff --git a/uv.lock b/uv.lock index e622122..d4a6517 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-01T02:35:01.441363Z" +exclude-newer = "2026-07-02T23:27:40.758633Z" exclude-newer-span = "P7D" [manifest] @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "tangle-api" -version = "0.1.0" +version = "0.1.1" source = { editable = "packages/tangle-api" } dependencies = [ { name = "pydantic" }, @@ -1786,7 +1786,7 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.0" }] [[package]] name = "tangle-cli" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "cloud-pipelines" },