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
2 changes: 1 addition & 1 deletion packages/tangle-api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
20 changes: 10 additions & 10 deletions packages/tangle-api/src/tangle_api/generated/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand All @@ -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),
)

Expand Down Expand Up @@ -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),
)

Expand All @@ -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),
)

Expand Down
12 changes: 10 additions & 2 deletions packages/tangle-cli/src/tangle_cli/openapi/codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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."""

Expand Down Expand Up @@ -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,")
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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'",
]

Expand Down
80 changes: 77 additions & 3 deletions tests/test_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)])}
Expand Down
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading