From 6f9a737effe20dbbec786e42cdc50112a2a41724 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 12 Oct 2026 11:10:07 -0400 Subject: [PATCH 1/4] TPT-4731: Fix nested CLI arguments for schemas without an explicit type --- linodecli/baked/AGENTS.md | 1 + linodecli/baked/request.py | 9 ++++----- tests/fixtures/operation_with_one_ofs.yaml | 1 - tests/unit/test_request.py | 2 ++ 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/linodecli/baked/AGENTS.md b/linodecli/baked/AGENTS.md index f92d13c6b..1c52b0891 100644 --- a/linodecli/baked/AGENTS.md +++ b/linodecli/baked/AGENTS.md @@ -30,6 +30,7 @@ ## Request Parsing Gotchas - `_aggregate_schema_properties()` merges `oneOf`, `anyOf`, and `allOf`; a field is marked required only if every leaf schema that defines properties requires it. Schemas that only nest composition without their own `properties` do not increment the required-count denominator. +- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties, even if the schema omits `type: object`. A nested `oneOf`/`anyOf`/`allOf` without `type` must still become dotted CLI args (for example `--details.host`), not a leaf `--details` string. - `OpenAPIRequest.attr_routes` stores per-`oneOf` option args keyed by schema `title`; oneOf entries without titles raise `ValueError`. - `OpenAPIOperation.arg_routes` returns `self.request.attr_routes` (a dict) when a request exists, but `[]` when there is no request. Treat the empty case as a type inconsistency (list vs dict); callers that always call `.items()` will break on the no-request path. - Request and response attributes marked `linode-cli-skip` are omitted from generated args/output. diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index bbd255288..9448d99f2 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -164,12 +164,11 @@ def _parse_request_model( if v.extensions.get("linode-cli-skip"): continue + # Nested oneOf/anyOf/allOf schemas may omit type: object. + nested_props, _ = _aggregate_schema_properties(v) + # Handle nested objects which aren't read-only and have properties - if ( - v.type == "object" - and not v.readOnly - and len(_aggregate_schema_properties(v)[0]) > 0 - ): + if not v.readOnly and nested_props: # nested objects receive a prefix and are otherwise parsed normally pref = prefix + "." + k if prefix else k diff --git a/tests/fixtures/operation_with_one_ofs.yaml b/tests/fixtures/operation_with_one_ofs.yaml index 679e04ccb..79faccfdc 100644 --- a/tests/fixtures/operation_with_one_ofs.yaml +++ b/tests/fixtures/operation_with_one_ofs.yaml @@ -57,7 +57,6 @@ components: description: Some foofoo. barbar: description: Some barbar. - type: object anyOf: - type: object properties: diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index 81f55d07a..b63a71c64 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -17,6 +17,8 @@ def test_handle_one_ofs(self, post_operation_with_one_ofs): "barbar.baz": ("boolean", "Some baz.", False), } + assert len(arg_map) == len(expected) + for k, v in expected.items(): assert arg_map[k].datatype == v[0] assert arg_map[k].description == v[1] From f1794478292f3f515e7adceaaa670636491f4ef5 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 12 Oct 2026 11:27:16 -0400 Subject: [PATCH 2/4] TPT-4731: Fix typeless composed response schemas --- linodecli/baked/AGENTS.md | 1 + linodecli/baked/response.py | 4 +++- tests/unit/test_response.py | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/linodecli/baked/AGENTS.md b/linodecli/baked/AGENTS.md index 1c52b0891..0f432531b 100644 --- a/linodecli/baked/AGENTS.md +++ b/linodecli/baked/AGENTS.md @@ -49,6 +49,7 @@ - `--order-by` is restricted to filterable attrs and becomes `+order_by` in `X-Filter`; list filters serialize as `+and` entries. ## Response And Output Gotchas +- Nested response schemas with aggregated properties are expanded even without `type: object`; arbitrary-key dictionaries retain their existing leaf handling, and scalar compositions without properties are not expanded. - Bake-time pagination detection is structural: top-level response schema must have exactly `pages`, `page`, `results`, and `data`; only `data.items` becomes display attrs (`is_paginated`). - `OpenAPIResponse.fix_json()` handles `linode-cli-rows`, then `linode-cli-nested-list`, then unwraps via `"pages" in json` (not `self.is_paginated`), then wraps non-lists. Runtime unwrap is looser than bake-time pagination; do not tighten it to `is_paginated` without checking odd payloads that only include `pages`. - `linode-cli-rows` paths are extracted from the raw API JSON and concatenated; list values extend the result, scalar/object values are appended. diff --git a/linodecli/baked/response.py b/linodecli/baked/response.py index 901ece294..2b5cae2dd 100644 --- a/linodecli/baked/response.py +++ b/linodecli/baked/response.py @@ -208,7 +208,9 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0): k, v, prefix=prefix, nested_list_depth=nested_list_depth ) ) - elif v.type == "object": + elif v.type == "object" or ( + v.type is None and bool(_aggregate_schema_properties(v)[0]) + ): attrs += _parse_response_model(v, prefix=pref) elif v.type == "array" and ( v.items.type == "object" diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index f5e3632a3..4e740fee0 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -114,6 +114,8 @@ def test_handle_one_ofs(self, post_operation_with_one_ofs): "barbar.baz": ("boolean", "Some baz"), } + assert len(attr_map) == len(expected) + for k, v in expected.items(): assert attr_map[k].datatype == v[0] assert attr_map[k].description == v[1] From 74c749239752ee2741f5fdff6c4983e2a3da4f1c Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 12 Oct 2026 11:43:14 -0400 Subject: [PATCH 3/4] TPT-4731: Preserve JSON-formatted composed request fields --- linodecli/baked/AGENTS.md | 2 +- linodecli/baked/request.py | 3 ++- tests/unit/test_request.py | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/linodecli/baked/AGENTS.md b/linodecli/baked/AGENTS.md index 0f432531b..d380cea0f 100644 --- a/linodecli/baked/AGENTS.md +++ b/linodecli/baked/AGENTS.md @@ -30,7 +30,7 @@ ## Request Parsing Gotchas - `_aggregate_schema_properties()` merges `oneOf`, `anyOf`, and `allOf`; a field is marked required only if every leaf schema that defines properties requires it. Schemas that only nest composition without their own `properties` do not increment the required-count denominator. -- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties, even if the schema omits `type: object`. A nested `oneOf`/`anyOf`/`allOf` without `type` must still become dotted CLI args (for example `--details.host`), not a leaf `--details` string. +- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties, even if the schema omits `type: object`, unless the effective format is `json`. A nested `oneOf`/`anyOf`/`allOf` without `type` normally becomes dotted CLI args (for example `--details.host`); `x-linode-cli-format: json` or `format: json` instead preserves a raw-JSON parent argument, with the CLI extension taking precedence. - `OpenAPIRequest.attr_routes` stores per-`oneOf` option args keyed by schema `title`; oneOf entries without titles raise `ValueError`. - `OpenAPIOperation.arg_routes` returns `self.request.attr_routes` (a dict) when a request exists, but `[]` when there is no request. Treat the empty case as a type inconsistency (list vs dict); callers that always call `.items()` will break on the no-request path. - Request and response attributes marked `linode-cli-skip` are omitted from generated args/output. diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 9448d99f2..99a37096f 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -166,9 +166,10 @@ def _parse_request_model( # Nested oneOf/anyOf/allOf schemas may omit type: object. nested_props, _ = _aggregate_schema_properties(v) + effective_format = v.extensions.get("linode-cli-format") or v.format # Handle nested objects which aren't read-only and have properties - if not v.readOnly and nested_props: + if not v.readOnly and nested_props and effective_format != "json": # nested objects receive a prefix and are otherwise parsed normally pref = prefix + "." + k if prefix else k diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index b63a71c64..df0981b29 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -1,3 +1,11 @@ +import pytest +from openapi3 import OpenAPI +from yaml import safe_load + +from linodecli.baked.request import _parse_request_model +from tests.unit.conftest import open_fixture + + class TestRequest: """ Unit tests for baked requests. @@ -24,6 +32,25 @@ def test_handle_one_ofs(self, post_operation_with_one_ofs): assert arg_map[k].description == v[1] assert arg_map[k].required == v[2] + @pytest.mark.parametrize("format_marker", ["x-linode-cli-format", "format"]) + @pytest.mark.parametrize("schema_type", [None, "object"]) + def test_json_format_composed_objects(self, format_marker, schema_type): + with open_fixture("operation_with_one_ofs.yaml") as fixture: + document = safe_load(fixture) + + variant = document["components"]["schemas"]["Foo"]["oneOf"][1] + nested_schema = variant["properties"]["barbar"] + nested_schema[format_marker] = "json" + if schema_type is not None: + nested_schema["type"] = schema_type + + schema = OpenAPI(document).components.schemas["Foo"] + arg_map = {arg.path: arg for arg in _parse_request_model(schema)} + + assert set(arg_map) == {"foobar", "barfoo", "foofoo", "barbar"} + assert arg_map["barbar"].format == "json" + assert arg_map["barbar"].datatype == "object" + def test_skip_request_attributes(self, post_skip_test_operation): """ Test that request attributes with x-linode-cli-skip extension are excluded. From 25ab77f654775a96e0ee4c79dde1556b8931b791 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 12 Oct 2026 11:50:57 -0400 Subject: [PATCH 4/4] TPT-4731: Preserve explicit scalar and array request types --- linodecli/baked/AGENTS.md | 2 +- linodecli/baked/request.py | 7 ++++++- tests/unit/test_request.py | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/linodecli/baked/AGENTS.md b/linodecli/baked/AGENTS.md index d380cea0f..cdc386e42 100644 --- a/linodecli/baked/AGENTS.md +++ b/linodecli/baked/AGENTS.md @@ -30,7 +30,7 @@ ## Request Parsing Gotchas - `_aggregate_schema_properties()` merges `oneOf`, `anyOf`, and `allOf`; a field is marked required only if every leaf schema that defines properties requires it. Schemas that only nest composition without their own `properties` do not increment the required-count denominator. -- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties, even if the schema omits `type: object`, unless the effective format is `json`. A nested `oneOf`/`anyOf`/`allOf` without `type` normally becomes dotted CLI args (for example `--details.host`); `x-linode-cli-format: json` or `format: json` instead preserves a raw-JSON parent argument, with the CLI extension taking precedence. +- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties and the schema has `type: object` or no explicit type, unless the effective format is `json`. Explicit scalar and array types retain their dedicated parsing paths. A nested `oneOf`/`anyOf`/`allOf` without `type` normally becomes dotted CLI args (for example `--details.host`); `x-linode-cli-format: json` or `format: json` instead preserves a raw-JSON parent argument, with the CLI extension taking precedence. - `OpenAPIRequest.attr_routes` stores per-`oneOf` option args keyed by schema `title`; oneOf entries without titles raise `ValueError`. - `OpenAPIOperation.arg_routes` returns `self.request.attr_routes` (a dict) when a request exists, but `[]` when there is no request. Treat the empty case as a type inconsistency (list vs dict); callers that always call `.items()` will break on the no-request path. - Request and response attributes marked `linode-cli-skip` are omitted from generated args/output. diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 99a37096f..8648be043 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -169,7 +169,12 @@ def _parse_request_model( effective_format = v.extensions.get("linode-cli-format") or v.format # Handle nested objects which aren't read-only and have properties - if not v.readOnly and nested_props and effective_format != "json": + if ( + v.type in (None, "object") + and not v.readOnly + and nested_props + and effective_format != "json" + ): # nested objects receive a prefix and are otherwise parsed normally pref = prefix + "." + k if prefix else k diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index df0981b29..6b5260bb3 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -51,6 +51,28 @@ def test_json_format_composed_objects(self, format_marker, schema_type): assert arg_map["barbar"].format == "json" assert arg_map["barbar"].datatype == "object" + @pytest.mark.parametrize("schema_type", ["string", "integer", "array"]) + def test_explicit_non_object_composed_schema(self, schema_type): + with open_fixture("operation_with_one_ofs.yaml") as fixture: + document = safe_load(fixture) + + variant = document["components"]["schemas"]["Foo"]["oneOf"][1] + nested_schema = variant["properties"]["barbar"] + nested_schema["type"] = schema_type + for branch in nested_schema["anyOf"]: + branch.pop("type") + if schema_type == "array": + nested_schema["items"] = {"type": "string"} + + schema = OpenAPI(document).components.schemas["Foo"] + arg_map = {arg.path: arg for arg in _parse_request_model(schema)} + + assert set(arg_map) == {"foobar", "barfoo", "foofoo", "barbar"} + assert arg_map["barbar"].datatype == schema_type + assert arg_map["barbar"].format is None + if schema_type == "array": + assert arg_map["barbar"].item_type == "string" + def test_skip_request_attributes(self, post_skip_test_operation): """ Test that request attributes with x-linode-cli-skip extension are excluded.