From 820d6c8aa86521e0d856d0adabb20cbc92d6dea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Wed, 4 Nov 2026 18:55:20 +0100 Subject: [PATCH 1/2] feat(schema): add containers to the schema models and formatter Co-Authored-By: Claude Opus 5.5 --- changelog/+schema-containers.added.md | 1 + infrahub_sdk/ctl/schema_format.py | 9 ++++ infrahub_sdk/schema/generated/contract.py | 1 + infrahub_sdk/schema/generated/read.py | 43 ++++++++++++++++++++ infrahub_sdk/schema/generated/write.py | 43 ++++++++++++++++++++ infrahub_sdk/schema/main.py | 4 ++ tests/unit/ctl/test_schema_format.py | 37 +++++++++++++++-- tests/unit/test_schema_offline_validation.py | 17 ++++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 changelog/+schema-containers.added.md diff --git a/changelog/+schema-containers.added.md b/changelog/+schema-containers.added.md new file mode 100644 index 000000000..617020edc --- /dev/null +++ b/changelog/+schema-containers.added.md @@ -0,0 +1 @@ +Add the `containers` property of a node or generic schema and the `container` property of an attribute or relationship to the schema models. `validate_schema` accepts both properties, `infrahubctl schema format` places `containers` after `uniqueness_constraints`, `container` before `order_weight`, and orders the keys of each container item, and `infrahub_sdk.schema.main` exports `ContainerSchemaRead` and `ContainerSchemaWrite`. diff --git a/infrahub_sdk/ctl/schema_format.py b/infrahub_sdk/ctl/schema_format.py index 4decb8754..3ffa8b0c6 100644 --- a/infrahub_sdk/ctl/schema_format.py +++ b/infrahub_sdk/ctl/schema_format.py @@ -94,6 +94,7 @@ "display_label", "display_labels", "uniqueness_constraints", + "containers", "generate_profile", "generate_template", "used_by", @@ -123,6 +124,7 @@ "branch", "deprecation", "state", + "container", ] ATTRIBUTE_LAST = ["order_weight"] @@ -147,11 +149,14 @@ "deprecation", "state", "description", + "container", ] RELATIONSHIP_LAST = ["order_weight"] CHOICE_ORDER = ["name", "label", "description", "color"] +CONTAINER_ORDER = ["name", "label", "description", "order_weight", "collapsed", "state"] + EXTENSION_NODE_ORDER = ["kind", "inherit_from"] EXTENSION_NODE_LAST = ["attributes", "relationships"] @@ -281,6 +286,10 @@ def _format_entity(entity: Any, leading: list[str], trailing: list[str], options if options.strip_defaults: _strip_default_keys(entity, ENTITY_DEFAULTS) reorder_mapping(entity, leading, trailing) + containers = entity.get("containers") + if isinstance(containers, list): + for container in containers: + reorder_mapping(container, CONTAINER_ORDER, []) _format_item_list(entity.get("attributes"), ATTRIBUTE_DEFAULTS, ATTRIBUTE_ORDER, options) _format_item_list(entity.get("relationships"), RELATIONSHIP_DEFAULTS, RELATIONSHIP_ORDER, options) diff --git a/infrahub_sdk/schema/generated/contract.py b/infrahub_sdk/schema/generated/contract.py index efa23b612..12a32f914 100644 --- a/infrahub_sdk/schema/generated/contract.py +++ b/infrahub_sdk/schema/generated/contract.py @@ -16,6 +16,7 @@ "ComputedAttributeJinja2Write": frozenset({"id", "state", "transform"}), "ComputedAttributeTransformPythonWrite": frozenset({"id", "jinja2_template", "state"}), "ComputedAttributeUserWrite": frozenset({"id", "jinja2_template", "state", "transform"}), + "ContainerSchemaWrite": frozenset({"id", "inherited"}), "DropdownChoiceWrite": frozenset({"id", "state"}), "GenericAttributeWrite": frozenset({"inherited"}), "GenericSchemaWrite": frozenset({"hash", "kind", "used_by"}), diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index f6af52ae8..6dcc47e2b 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -126,6 +126,37 @@ class DropdownChoiceRead(BaseModel): ) +class ContainerSchemaRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + name: str = Field( + ..., + description="Name of the section, must be unique within the node.", + pattern=r"^[a-z0-9\_]+$", + min_length=2, + max_length=32, + ) + label: str = Field( + ..., + description="Title of the section.", + ) + description: str | None = Field( + default=None, + description="Description shown under the title of the section.", + ) + order_weight: int | None = Field( + default=None, + description="Position of the section among the sections of the node, lowest first.", + ) + collapsed: bool = Field( + default=False, + description="Whether the section starts collapsed on the detail page.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the container after loading the schema", + ) + + class ComputedAttributeUserRead(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ComputedAttributeKind.USER] = Field( @@ -258,6 +289,10 @@ class AttributeSchemaBaseRead(BaseModel): default=SchemaAttributeDisplay.DEFAULT, description="Controls where the attribute is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", ) + container: str | None = Field( + default=None, + description="Section that shows this attribute: a name from the node's 'containers' or 'advanced'. Unset, it shows in the main section.", + ) class TextAttributeRead(AttributeSchemaBaseRead): @@ -457,6 +492,10 @@ class RelationshipSchemaRead(BaseModel): default=SchemaAttributeDisplay.DEFAULT, description="Controls where the relationship is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", ) + container: str | None = Field( + default=None, + description="Section that shows this relationship: a name from the node's 'containers' or 'advanced'. Unset, it shows in the main section.", + ) class BaseNodeSchemaRead(BaseModel): @@ -526,6 +565,10 @@ class BaseNodeSchemaRead(BaseModel): default=None, description="List of entries to order results by. Supports attributes, relationship attributes, and node_metadata with __asc/__desc.", ) + containers: list[ContainerSchemaRead] = Field( + default_factory=list, + description="Named sections that group the attributes and relationships of this node in the UI; a field joins one through 'container'.", + ) uniqueness_constraints: list[list[str]] | None = Field( default=None, description="List of multi-element uniqueness constraints that can combine relationships and attributes", diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py index 31fccda96..ac5d442d9 100644 --- a/infrahub_sdk/schema/generated/write.py +++ b/infrahub_sdk/schema/generated/write.py @@ -122,6 +122,37 @@ class DropdownChoiceWrite(BaseModel): ) +class ContainerSchemaWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + name: str = Field( + ..., + description="Name of the section, must be unique within the node.", + pattern=r"^[a-z0-9\_]+$", + min_length=2, + max_length=32, + ) + label: str = Field( + ..., + description="Title of the section.", + ) + description: str | None = Field( + default=None, + description="Description shown under the title of the section.", + ) + order_weight: int | None = Field( + default=None, + description="Position of the section among the sections of the node, lowest first.", + ) + collapsed: bool = Field( + default=False, + description="Whether the section starts collapsed on the detail page.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the container after loading the schema", + ) + + class ComputedAttributeUserWrite(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ComputedAttributeKind.USER] = Field( @@ -250,6 +281,10 @@ class AttributeSchemaBaseWrite(BaseModel): default=SchemaAttributeDisplay.DEFAULT, description="Controls where the attribute is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", ) + container: str | None = Field( + default=None, + description="Section that shows this attribute: a name from the node's 'containers' or 'advanced'. Unset, it shows in the main section.", + ) class TextAttributeWrite(AttributeSchemaBaseWrite): @@ -441,6 +476,10 @@ class RelationshipSchemaWrite(BaseModel): default=SchemaAttributeDisplay.DEFAULT, description="Controls where the relationship is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", ) + container: str | None = Field( + default=None, + description="Section that shows this relationship: a name from the node's 'containers' or 'advanced'. Unset, it shows in the main section.", + ) class BaseNodeSchemaWrite(BaseModel): @@ -510,6 +549,10 @@ class BaseNodeSchemaWrite(BaseModel): default=None, description="List of entries to order results by. Supports attributes, relationship attributes, and node_metadata with __asc/__desc.", ) + containers: list[ContainerSchemaWrite] = Field( + default_factory=list, + description="Named sections that group the attributes and relationships of this node in the UI; a field joins one through 'container'.", + ) uniqueness_constraints: list[list[str]] | None = Field( default=None, description="List of multi-element uniqueness constraints that can combine relationships and attributes", diff --git a/infrahub_sdk/schema/main.py b/infrahub_sdk/schema/main.py index 04352d965..215dd44d3 100644 --- a/infrahub_sdk/schema/main.py +++ b/infrahub_sdk/schema/main.py @@ -22,6 +22,7 @@ AttributeSchemaBaseRead, BaseNodeSchemaRead, ComputedAttributeRead, # noqa: F401 (re-exported here to resolve the inherited forward reference) + ContainerSchemaRead, GenericSchemaRead, NodeSchemaRead, ProfileSchemaRead, @@ -31,6 +32,7 @@ from .generated.write import ( AttributeSchemaBaseWrite, ComputedAttributeWrite, # noqa: F401 (re-exported here to resolve the inherited forward reference) + ContainerSchemaWrite, GenericSchemaWrite, NodeSchemaWrite, RelationshipSchemaWrite, @@ -54,6 +56,8 @@ "BranchSchema", "BranchSupportType", "ComputedAttributeKind", + "ContainerSchemaRead", + "ContainerSchemaWrite", "GenericSchema", "GenericSchemaAPI", "NodeSchema", diff --git a/tests/unit/ctl/test_schema_format.py b/tests/unit/ctl/test_schema_format.py index b862f3fdd..7b3e3c8e1 100644 --- a/tests/unit/ctl/test_schema_format.py +++ b/tests/unit/ctl/test_schema_format.py @@ -21,11 +21,14 @@ version: "1.0" nodes: - - relationships: + - generate_profile: false + relationships: - peer: BuiltinTag + container: inventory name: tags attributes: - order_weight: 1500 + container: inventory optional: true name: status kind: Dropdown @@ -34,6 +37,17 @@ name: active label: Active namespace: Dcim + containers: + - state: present + collapsed: true + order_weight: 2000 + description: Rack and serial details. + label: Inventory + name: inventory + - label: Monitoring + name: monitoring + uniqueness_constraints: + - [name__value] name: Device label: Device description: A device. @@ -67,6 +81,9 @@ def test_node_key_order_is_canonical() -> None: "namespace", "description", "label", + "uniqueness_constraints", + "containers", + "generate_profile", "attributes", "relationships", ] @@ -76,11 +93,25 @@ def test_attribute_relationship_and_choice_inner_order() -> None: text = format_schema_text(NODE_DOC) attr_keys = _keys_of(text, ["nodes", 0, "attributes", 0]) - assert attr_keys == ["name", "kind", "choices", "optional", "order_weight"] + assert attr_keys == ["name", "kind", "choices", "optional", "container", "order_weight"] assert attr_keys[-1] == "order_weight" assert _keys_of(text, ["nodes", 0, "attributes", 0, "choices", 0]) == ["name", "label", "color"] - assert _keys_of(text, ["nodes", 0, "relationships", 0]) == ["name", "peer"] + assert _keys_of(text, ["nodes", 0, "relationships", 0]) == ["name", "peer", "container"] + + +def test_container_item_key_order_is_canonical() -> None: + text = format_schema_text(NODE_DOC) + + assert _keys_of(text, ["nodes", 0, "containers", 0]) == [ + "name", + "label", + "description", + "order_weight", + "collapsed", + "state", + ] + assert _keys_of(text, ["nodes", 0, "containers", 1]) == ["name", "label"] def test_restricted_namespace_nodes_are_untouched() -> None: diff --git a/tests/unit/test_schema_offline_validation.py b/tests/unit/test_schema_offline_validation.py index a673b8297..bd2d0486a 100644 --- a/tests/unit/test_schema_offline_validation.py +++ b/tests/unit/test_schema_offline_validation.py @@ -134,6 +134,23 @@ def test_valid_payload_passes() -> None: result.raise_for_status() +def test_valid_payload_with_containers_passes() -> None: + schema = _valid_schema() + node = schema["nodes"][0] + node["containers"] = [ + {"name": "inventory", "label": "Inventory", "description": "Serial details.", "order_weight": 1000}, + {"name": "monitoring", "label": "Monitoring", "collapsed": True}, + ] + node["attributes"][0]["container"] = "inventory" + node["relationships"][0]["container"] = "monitoring" + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + assert result.errors == [] + assert result.warnings == [] + + def test_valid_payload_with_extensions_block_passes() -> None: schema = _valid_schema() schema["extensions"] = { From f6791e948e0f5f690c9644b9b85f07fb42c8f156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Thu, 5 Nov 2026 16:13:44 +0100 Subject: [PATCH 2/2] feat(schema): expose inherited on containers and skip them in the schema export ContainerSchemaRead carries the read-only `inherited` flag, and `infrahubctl schema export` writes only the containers a node or generic declares itself, so an exported node no longer redeclares the containers of its generics. Co-Authored-By: Claude Opus 5.5 --- changelog/+schema-containers.added.md | 2 +- infrahub_sdk/schema/export.py | 10 ++++++++ infrahub_sdk/schema/generated/read.py | 4 ++++ tests/unit/sdk/test_schema_export.py | 33 +++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/changelog/+schema-containers.added.md b/changelog/+schema-containers.added.md index 617020edc..0ce722d82 100644 --- a/changelog/+schema-containers.added.md +++ b/changelog/+schema-containers.added.md @@ -1 +1 @@ -Add the `containers` property of a node or generic schema and the `container` property of an attribute or relationship to the schema models. `validate_schema` accepts both properties, `infrahubctl schema format` places `containers` after `uniqueness_constraints`, `container` before `order_weight`, and orders the keys of each container item, and `infrahub_sdk.schema.main` exports `ContainerSchemaRead` and `ContainerSchemaWrite`. +Add the `containers` property of a node or generic schema and the `container` property of an attribute or relationship to the schema models. `validate_schema` accepts both properties, `infrahubctl schema format` places `containers` after `uniqueness_constraints`, `container` before `order_weight`, and orders the keys of each container item, and `infrahub_sdk.schema.main` exports `ContainerSchemaRead` and `ContainerSchemaWrite`. `ContainerSchemaRead` carries the read-only `inherited` flag, and `infrahubctl schema export` writes only the containers a node or generic declares itself, without the ones it inherits from a generic. diff --git a/infrahub_sdk/schema/export.py b/infrahub_sdk/schema/export.py index 378f0386d..1d0d7efdb 100644 --- a/infrahub_sdk/schema/export.py +++ b/infrahub_sdk/schema/export.py @@ -75,6 +75,16 @@ def schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, data.pop("attributes", None) data.pop("relationships", None) + containers = [ + container.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True) + for container in schema.containers + if not container.inherited + ] + if containers: + data["containers"] = containers + else: + data.pop("containers", None) + # Generics with Hierarchy relationships were defined with `hierarchical: true`. # Restore that flag and drop the auto-generated rels so the schema round-trips cleanly. if isinstance(schema, GenericSchemaAPI) and any( diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index 6dcc47e2b..5bccdd7a4 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -155,6 +155,10 @@ class ContainerSchemaRead(BaseModel): default=SchemaState.PRESENT, description="Expected state of the container after loading the schema", ) + inherited: bool = Field( + default=False, + description="Internal value to indicate if the container was inherited from a Generic node.", + ) class ComputedAttributeUserRead(BaseModel): diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py index e579168c6..9526b6d66 100644 --- a/tests/unit/sdk/test_schema_export.py +++ b/tests/unit/sdk/test_schema_export.py @@ -211,6 +211,39 @@ def test_export_preserves_non_default_ordered_flag() -> None: assert "ordered" not in attrs["tags_ordered"] +def test_export_skips_containers_inherited_from_a_generic() -> None: + node = NodeSchemaAPI( + **{ + **_BASE_NODE, + "namespace": "Infra", + "name": "LagInterfaceL2", + "inherit_from": ["InfraLagInterface"], + "containers": [ + {"name": "lacp", "label": "LACP", "order_weight": 100, "inherited": True}, + {"name": "spare", "label": "Spare"}, + ], + } + ) + + exported = schema_to_export_dict(node) + + assert exported["containers"] == [{"name": "spare", "label": "Spare", "collapsed": False}] + + +def test_export_omits_containers_when_all_are_inherited() -> None: + node = NodeSchemaAPI( + **{ + **_BASE_NODE, + "namespace": "Infra", + "name": "LagInterfaceL2", + "inherit_from": ["InfraLagInterface"], + "containers": [{"name": "lacp", "label": "LACP", "inherited": True}], + } + ) + + assert "containers" not in schema_to_export_dict(node) + + # --------------------------------------------------------------------------- # Integration tests for export() method on client.schema # ---------------------------------------------------------------------------