Skip to content

Commit 53c953b

Browse files
committed
Fix generated client mixed request bodies
Assisted-By: devx/ab04ee6e-8d31-4580-9a88-93ad2a8b42e8
1 parent ba9e1ab commit 53c953b

7 files changed

Lines changed: 107 additions & 25 deletions

File tree

packages/tangle-api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tangle-api"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "Checked-in generated Tangle API models and operation proxies"
55
readme = "README.md"
66
authors = [

packages/tangle-api/src/tangle_api/generated/operations.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,13 @@ def component_libraries_list(self, name_substring: Any = None) -> ListComponentL
9191
response_model=self._response_model('ListComponentLibrariesResponse', ListComponentLibrariesResponse),
9292
)
9393

94-
def component_libraries_create(self, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse:
94+
def component_libraries_create(self, name: Any, hide_from_search: Any = None, body: Any = None) -> ComponentLibraryResponse:
9595
return self._request_json(
9696
'POST',
9797
'/api/component_libraries/',
9898
path_params=None,
9999
params={'hide_from_search': hide_from_search},
100-
json_data={'name': name},
100+
json_data={**(body or {}), **{'name': name}},
101101
response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse),
102102
)
103103

@@ -111,13 +111,13 @@ def component_libraries_get(self, id: Any, include_component_texts: Any = None)
111111
response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse),
112112
)
113113

114-
def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None) -> ComponentLibraryResponse:
114+
def component_libraries_update(self, id: Any, name: Any, hide_from_search: Any = None, body: Any = None) -> ComponentLibraryResponse:
115115
return self._request_json(
116116
'PUT',
117117
'/api/component_libraries/{id}',
118118
path_params={'id': id},
119119
params={'hide_from_search': hide_from_search},
120-
json_data={'name': name},
120+
json_data={**(body or {}), **{'name': name}},
121121
response_model=self._response_model('ComponentLibraryResponse', ComponentLibraryResponse),
122122
)
123123

@@ -291,13 +291,13 @@ def published_components_list(self, include_deprecated: Any = None, name_substri
291291
response_model=self._response_model('ListPublishedComponentsResponse', ListPublishedComponentsResponse),
292292
)
293293

294-
def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None) -> PublishedComponentResponse:
294+
def published_components_create(self, digest: Any = None, name: Any = None, tag: Any = None, text: Any = None, url: Any = None, body: Any = None) -> PublishedComponentResponse:
295295
return self._request_json(
296296
'POST',
297297
'/api/published_components/',
298298
path_params=None,
299299
params=None,
300-
json_data={key: value for key, value in {'digest': digest, 'name': name, 'tag': tag, 'text': text, 'url': url}.items() if value is not None},
300+
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}},
301301
response_model=self._response_model('PublishedComponentResponse', PublishedComponentResponse),
302302
)
303303

@@ -321,23 +321,23 @@ def secrets_list(self) -> ListSecretsResponse:
321321
response_model=self._response_model('ListSecretsResponse', ListSecretsResponse),
322322
)
323323

324-
def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse:
324+
def secrets_create(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None, body: Any = None) -> SecretInfoResponse:
325325
return self._request_json(
326326
'POST',
327327
'/api/secrets/',
328328
path_params=None,
329329
params={'secret_name': secret_name, 'description': description, 'expires_at': expires_at},
330-
json_data={'secret_value': secret_value},
330+
json_data={**(body or {}), **{'secret_value': secret_value}},
331331
response_model=self._response_model('SecretInfoResponse', SecretInfoResponse),
332332
)
333333

334-
def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None) -> SecretInfoResponse:
334+
def secrets_update(self, secret_name: Any, secret_value: Any, description: Any = None, expires_at: Any = None, body: Any = None) -> SecretInfoResponse:
335335
return self._request_json(
336336
'PUT',
337337
'/api/secrets/{secret_name}',
338338
path_params={'secret_name': secret_name},
339339
params={'description': description, 'expires_at': expires_at},
340-
json_data={'secret_value': secret_value},
340+
json_data={**(body or {}), **{'secret_value': secret_value}},
341341
response_model=self._response_model('SecretInfoResponse', SecretInfoResponse),
342342
)
343343

packages/tangle-cli/src/tangle_cli/openapi/codegen.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ def _param_signature(
510510
body_names.append(name)
511511
if parameter.required:
512512
required_body_names.add(name)
513-
include_body = has_request_body and not body_names
513+
include_body = has_request_body
514514
if include_body:
515515
body_annotation = "dict[str, Any] | None" if raw_body_override else "Any"
516516
signature_parts.append(f"body: {body_annotation} = None")
@@ -537,6 +537,12 @@ def _body_dict_literal(names: list[str], required_names: set[str]) -> str:
537537
return "{" + f"**{required_literal}, **{{{optional_expr}}}" + "}"
538538

539539

540+
def _merged_body_dict_literal(names: list[str], required_names: set[str]) -> str:
541+
"""Return request JSON with generic body fields overridden by named fields."""
542+
543+
return f"{{**(body or {{}}), **{_body_dict_literal(names, required_names)}}}"
544+
545+
540546
def _validate_operation_path(path: str) -> None:
541547
"""Reject OpenAPI operation paths that could override the configured origin."""
542548

@@ -625,7 +631,9 @@ def generate_operations(
625631
f" path_params={_dict_literal(path_names)},",
626632
f" params={_dict_literal(query_names)},",
627633
])
628-
if body_names:
634+
if body_names and include_body:
635+
lines.append(f" json_data={_merged_body_dict_literal(body_names, required_body_names)},")
636+
elif body_names:
629637
lines.append(f" json_data={_body_dict_literal(body_names, required_body_names)},")
630638
elif include_body:
631639
lines.append(" json_data=body,")

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tangle-cli"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
55
readme = "README.md"
66
authors = [
@@ -18,7 +18,7 @@ dependencies = [
1818
"pydantic>=2.0",
1919
"pyyaml>=6.0",
2020
"requests>=2.32.0",
21-
"tangle-api==0.1.0",
21+
"tangle-api==0.1.1",
2222
"tomli>=2.0; python_version < '3.11'",
2323
]
2424

tests/test_codegen.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,8 @@ def test_generate_operations_without_request_body_override_omits_unset_optional_
676676
assert "def search_create(self," in operations
677677
assert "query: Any = None" in operations
678678
assert "limit: Any = None" in operations
679-
assert "json_data={key: value for key, value in {'limit': limit, 'query': query}.items() if value is not None}" in operations
679+
assert "body: Any = None" in operations
680+
assert "json_data={**(body or {}), **{key: value for key, value in {'limit': limit, 'query': query}.items() if value is not None}}" in operations
680681
assert "body: dict[str, Any] | None" not in operations
681682

682683
monkeypatch.syspath_prepend(str(tmp_path))
@@ -693,9 +694,80 @@ def _request_json(self, *args, **kwargs):
693694
client = Client()
694695
client.search_create(query="widgets")
695696
client.search_create()
697+
client.search_create(query="widgets", body={"query": "old", "predicate": {"nested": True}})
696698

697699
assert client.calls[0][1]["json_data"] == {"query": "widgets"}
698700
assert client.calls[1][1]["json_data"] == {}
701+
assert client.calls[2][1]["json_data"] == {"query": "widgets", "predicate": {"nested": True}}
702+
703+
704+
def test_generate_operations_mixed_simple_and_complex_body_keeps_body_escape_hatch(monkeypatch, tmp_path) -> None:
705+
openapi = tmp_path / "openapi.json"
706+
out = tmp_path / "mixed_body_api"
707+
openapi.write_text(
708+
json.dumps({
709+
"openapi": "3.1.0",
710+
"paths": {
711+
"/api/schedules/pipelines": {
712+
"post": {
713+
"operationId": "create_pipeline_schedule",
714+
"requestBody": {
715+
"content": {
716+
"application/json": {
717+
"schema": {
718+
"type": "object",
719+
"required": ["name", "pipeline_task_spec"],
720+
"properties": {
721+
"name": {"type": "string"},
722+
"enabled": {"type": "boolean"},
723+
"pipeline_task_spec": {
724+
"type": "object",
725+
"additionalProperties": True,
726+
},
727+
},
728+
}
729+
}
730+
}
731+
},
732+
}
733+
}
734+
},
735+
"components": {"schemas": {}},
736+
}),
737+
encoding="utf-8",
738+
)
739+
740+
codegen.generate(openapi, out)
741+
742+
operations = (out / "operations.py").read_text(encoding="utf-8")
743+
assert "def schedules_pipelines(self, name: Any, enabled: Any = None, body: Any = None)" in operations
744+
assert "pipeline_task_spec" not in operations.split("def schedules_pipelines", 1)[1].split("return self._request_json", 1)[0]
745+
assert "json_data={**(body or {}), **{**{'name': name}, **{key: value for key, value in {'enabled': enabled}.items() if value is not None}}}" in operations
746+
747+
monkeypatch.syspath_prepend(str(tmp_path))
748+
generated_operations = importlib.import_module("mixed_body_api.operations")
749+
750+
class Client(generated_operations.GeneratedTangleApiOperations):
751+
def __init__(self) -> None:
752+
self.calls = []
753+
754+
def _request_json(self, *args, **kwargs):
755+
self.calls.append((args, kwargs))
756+
return {"ok": True}
757+
758+
client = Client()
759+
client.schedules_pipelines(
760+
"scheduled-name",
761+
body={
762+
"name": "body-name",
763+
"pipeline_task_spec": {"component": "train", "args": {"epochs": 3}},
764+
},
765+
)
766+
767+
assert client.calls[0][1]["json_data"] == {
768+
"name": "scheduled-name",
769+
"pipeline_task_spec": {"component": "train", "args": {"epochs": 3}},
770+
}
699771

700772

701773
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
733805
codegen.generate(openapi, out)
734806

735807
operations = (out / "operations.py").read_text(encoding="utf-8")
736-
assert "def secrets_create(self, secret_value: Any, description: Any = None)" in operations
737-
assert "json_data={**{'secret_value': secret_value}, **{key: value for key, value in {'description': description}.items() if value is not None}}" in operations
808+
assert "def secrets_create(self, secret_value: Any, description: Any = None, body: Any = None)" in operations
809+
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
738810

739811
monkeypatch.syspath_prepend(str(tmp_path))
740812
generated_operations = importlib.import_module("required_body_api.operations")
@@ -749,8 +821,10 @@ def _request_json(self, *args, **kwargs):
749821

750822
client = Client()
751823
client.secrets_create("secret")
824+
client.secrets_create("secret", body={"extra": {"nested": True}, "secret_value": "old"})
752825

753826
assert client.calls[0][1]["json_data"] == {"secret_value": "secret"}
827+
assert client.calls[1][1]["json_data"] == {"extra": {"nested": True}, "secret_value": "secret"}
754828

755829

756830
def test_codegen_main_accepts_request_body_schema_file(monkeypatch, tmp_path) -> None:

tests/test_packaging.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api
178178
requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")]
179179
assert not any(name.startswith("tangle_api/") for name in names)
180180
assert "tangle_cli/openapi/openapi.json" not in names
181-
assert "Version: 0.1.1" in metadata
182-
assert "Requires-Dist: tangle-api==0.1.0" in requires_dist
181+
assert "Version: 0.1.2" in metadata
182+
assert "Requires-Dist: tangle-api==0.1.1" in requires_dist
183183
assert not any("extra == 'native'" in line for line in requires_dist)
184184
assert "Provides-Extra: native" in metadata
185185
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
207207

208208

209209
def test_custom_tangle_api_local_version_can_satisfy_cli_pin() -> None:
210-
assert Version("0.1.0+yourorg") in SpecifierSet("==0.1.0")
210+
assert Version("0.1.1+yourorg") in SpecifierSet("==0.1.1")
211211

212212

213213
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:
396396
metadata = archive.read(metadata_name).decode()
397397

398398
requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")]
399-
assert "Version: 0.1.0" in metadata
399+
assert "Version: 0.1.1" in metadata
400400
assert "Requires-Dist: pydantic>=2.0" in requires_dist
401401
assert not any("tangle-cli" in line for line in requires_dist)
402402
env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(cli_wheel), str(api_wheel)])}

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)