Skip to content
Draft
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
1 change: 1 addition & 0 deletions changelog/+schema-containers.added.md
Original file line number Diff line number Diff line change
@@ -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`. `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.
9 changes: 9 additions & 0 deletions infrahub_sdk/ctl/schema_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"display_label",
"display_labels",
"uniqueness_constraints",
"containers",
"generate_profile",
"generate_template",
"used_by",
Expand Down Expand Up @@ -123,6 +124,7 @@
"branch",
"deprecation",
"state",
"container",
]
ATTRIBUTE_LAST = ["order_weight"]

Expand All @@ -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"]

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

Expand Down
10 changes: 10 additions & 0 deletions infrahub_sdk/schema/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions infrahub_sdk/schema/generated/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
Expand Down
47 changes: 47 additions & 0 deletions infrahub_sdk/schema/generated/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ 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",
)
inherited: bool = Field(
default=False,
description="Internal value to indicate if the container was inherited from a Generic node.",
)


class ComputedAttributeUserRead(BaseModel):
model_config = ConfigDict(extra="ignore", use_enum_values=True)
kind: Literal[ComputedAttributeKind.USER] = Field(
Expand Down Expand Up @@ -258,6 +293,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):
Expand Down Expand Up @@ -457,6 +496,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):
Expand Down Expand Up @@ -526,6 +569,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",
Expand Down
43 changes: 43 additions & 0 deletions infrahub_sdk/schema/generated/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions infrahub_sdk/schema/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AttributeSchemaBaseRead,
BaseNodeSchemaRead,
ComputedAttributeRead, # noqa: F401 (re-exported here to resolve the inherited forward reference)
ContainerSchemaRead,
GenericSchemaRead,
NodeSchemaRead,
ProfileSchemaRead,
Expand All @@ -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,
Expand All @@ -54,6 +56,8 @@
"BranchSchema",
"BranchSupportType",
"ComputedAttributeKind",
"ContainerSchemaRead",
"ContainerSchemaWrite",
"GenericSchema",
"GenericSchemaAPI",
"NodeSchema",
Expand Down
37 changes: 34 additions & 3 deletions tests/unit/ctl/test_schema_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -67,6 +81,9 @@ def test_node_key_order_is_canonical() -> None:
"namespace",
"description",
"label",
"uniqueness_constraints",
"containers",
"generate_profile",
"attributes",
"relationships",
]
Expand All @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/sdk/test_schema_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_schema_offline_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = {
Expand Down
Loading