From 0587e7f5f4591add9046ce1eca3a1b196b20ae63 Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Wed, 18 Feb 2026 15:27:04 +0100 Subject: [PATCH 01/16] Improve schema export output readability - Strip relationship fields that match schema loading defaults (direction: bidirectional, on_delete: no-action, cardinality: many, optional: true, min_count: 0, max_count: 0) - Exclude branch from attribute/relationship dumps (inherited from node) - Ensure scalar fields appear before attributes/relationships lists Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/ctl/schema.py | 37 ++++++++++---- tests/unit/ctl/test_schema_export.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 45cd7561..7da6bd39 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -221,28 +221,47 @@ def _default_export_directory() -> str: _SCHEMA_EXPORT_EXCLUDE: set[str] = {"hash", "hierarchy", "used_by", "id", "state"} -_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "read_only", "allow_override", "hierarchical", "id", "state"} +# branch is inherited from the node and need not be repeated on each field +_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "read_only", "allow_override", "hierarchical", "id", "state", "branch"} + +# Relationship field values that match schema loading defaults — omitted for cleaner output +_REL_EXPORT_DEFAULTS: dict[str, Any] = { + "direction": "bidirectional", + "on_delete": "no-action", + "cardinality": "many", + "optional": True, + "min_count": 0, + "max_count": 0, +} def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, Any]: """Convert an API schema object to an export-ready dict (omits API-internal fields).""" data = schema.model_dump(exclude=_SCHEMA_EXPORT_EXCLUDE, exclude_none=True) - data["attributes"] = [ + # Pop attrs/rels so they can be re-inserted last for better readability + data.pop("attributes", None) + data.pop("relationships", None) + + attributes = [ dict(attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True)) for attr in schema.attributes if not attr.inherited ] - if not data["attributes"]: - data.pop("attributes") - - data["relationships"] = [ - dict(rel.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True)) + if attributes: + data["attributes"] = attributes + + relationships = [ + { + k: v + for k, v in rel.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() + if k not in _REL_EXPORT_DEFAULTS or v != _REL_EXPORT_DEFAULTS[k] + } for rel in schema.relationships if not rel.inherited ] - if not data["relationships"]: - data.pop("relationships") + if relationships: + data["relationships"] = relationships return data diff --git a/tests/unit/ctl/test_schema_export.py b/tests/unit/ctl/test_schema_export.py index f32b2413..ffdf95d9 100644 --- a/tests/unit/ctl/test_schema_export.py +++ b/tests/unit/ctl/test_schema_export.py @@ -66,6 +66,34 @@ } +def _make_rel(name: str, peer: str, **kwargs: object) -> dict: + """Build a minimal RelationshipSchemaAPI-compatible dict.""" + rel: dict = { + "id": None, + "state": "present", + "name": name, + "peer": peer, + "kind": "Generic", + "label": None, + "description": None, + "identifier": None, + "min_count": 0, + "max_count": 0, + "direction": "bidirectional", + "on_delete": "no-action", + "cardinality": "many", + "branch": "aware", + "optional": True, + "order_weight": None, + "inherited": False, + "read_only": False, + "hierarchical": None, + "allow_override": "any", + } + rel.update(kwargs) + return rel + + def _make_node(namespace: str, name: str, **kwargs: object) -> dict: node = {**_BASE_NODE, "namespace": namespace, "name": name} node.update(kwargs) @@ -286,3 +314,51 @@ def test_schema_export_includes_generics(httpx_mock: HTTPXMock, tmp_path: Path) data = yaml.safe_load(infra_file.read_text()) assert any(g["name"] == "GenericInterface" for g in data["generics"]) assert any(n["name"] == "Device" for n in data["nodes"]) + + +def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Relationships strip defaults; attrs/rels appear after scalar fields.""" + node = _make_node( + "Infra", + "Device", + relationships=[ + # default-value rel — all strippable fields + _make_rel("tags", "BuiltinTag"), + # non-default rel — cardinality one, optional false, min/max_count 1 + _make_rel("site", "LocationSite", cardinality="one", optional=False, min_count=1, max_count=1), + ], + ) + response = _schema_response(nodes=[node]) + httpx_mock.add_response( + method="GET", + url="http://mock/api/schema?branch=main", + json=response, + ) + + output_dir = tmp_path / "export" + result = runner.invoke(app=app, args=["export", "--directory", str(output_dir)]) + assert result.exit_code == 0, result.stdout + + data = yaml.safe_load((output_dir / "infra.yml").read_text()) + node_data = data["nodes"][0] + + # --- field ordering: relationships must be last --- + keys = list(node_data.keys()) + assert keys.index("name") < keys.index("relationships") + + tags_rel = next(r for r in node_data["relationships"] if r["name"] == "tags") + site_rel = next(r for r in node_data["relationships"] if r["name"] == "site") + + # default values stripped from 'tags' rel + for stripped_key in ("direction", "on_delete", "cardinality", "optional", "min_count", "max_count", "branch"): + assert stripped_key not in tags_rel, f"'{stripped_key}' should have been stripped" + + # non-default values kept in 'site' rel + assert site_rel["cardinality"] == "one" + assert site_rel["optional"] is False + assert site_rel["min_count"] == 1 + assert site_rel["max_count"] == 1 + # default direction/on_delete still stripped even on non-default rel + assert "direction" not in site_rel + assert "on_delete" not in site_rel + assert "branch" not in site_rel From 8ab1c571c4fb347d630a4b9a961138a187f5c62b Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Wed, 18 Feb 2026 15:31:15 +0100 Subject: [PATCH 02/16] Preserve read_only on computed attributes in schema export read_only was unconditionally excluded from attribute dumps; move it to _ATTR_EXPORT_DEFAULTS so it is stripped only when False (the default) and kept when True (computed/read-only attributes). Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/ctl/schema.py | 13 ++++++-- tests/unit/ctl/test_schema_export.py | 45 ++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 7da6bd39..da0de390 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -222,7 +222,12 @@ def _default_export_directory() -> str: _SCHEMA_EXPORT_EXCLUDE: set[str] = {"hash", "hierarchy", "used_by", "id", "state"} # branch is inherited from the node and need not be repeated on each field -_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "read_only", "allow_override", "hierarchical", "id", "state", "branch"} +_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "allow_override", "hierarchical", "id", "state", "branch"} + +# Attribute field values that match schema loading defaults — omitted for cleaner output +_ATTR_EXPORT_DEFAULTS: dict[str, Any] = { + "read_only": False, +} # Relationship field values that match schema loading defaults — omitted for cleaner output _REL_EXPORT_DEFAULTS: dict[str, Any] = { @@ -244,7 +249,11 @@ def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str data.pop("relationships", None) attributes = [ - dict(attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True)) + { + k: v + for k, v in attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() + if k not in _ATTR_EXPORT_DEFAULTS or v != _ATTR_EXPORT_DEFAULTS[k] + } for attr in schema.attributes if not attr.inherited ] diff --git a/tests/unit/ctl/test_schema_export.py b/tests/unit/ctl/test_schema_export.py index ffdf95d9..6830989d 100644 --- a/tests/unit/ctl/test_schema_export.py +++ b/tests/unit/ctl/test_schema_export.py @@ -66,6 +66,33 @@ } +def _make_attr(name: str, kind: str = "Text", **kwargs: object) -> dict: + """Build a minimal AttributeSchemaAPI-compatible dict.""" + attr: dict = { + "id": None, + "state": "present", + "name": name, + "kind": kind, + "label": None, + "description": None, + "default_value": None, + "unique": False, + "branch": "aware", + "optional": False, + "choices": None, + "enum": None, + "max_length": None, + "min_length": None, + "regex": None, + "order_weight": None, + "inherited": False, + "read_only": False, + "allow_override": "any", + } + attr.update(kwargs) + return attr + + def _make_rel(name: str, peer: str, **kwargs: object) -> dict: """Build a minimal RelationshipSchemaAPI-compatible dict.""" rel: dict = { @@ -317,10 +344,14 @@ def test_schema_export_includes_generics(httpx_mock: HTTPXMock, tmp_path: Path) def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> None: - """Relationships strip defaults; attrs/rels appear after scalar fields.""" + """Relationships strip defaults; computed attrs keep read_only; attrs/rels appear after scalar fields.""" node = _make_node( "Infra", "Device", + attributes=[ + _make_attr("name", "Text"), # read_only=False → stripped + _make_attr("computed", "Text", read_only=True), # read_only=True → kept + ], relationships=[ # default-value rel — all strippable fields _make_rel("tags", "BuiltinTag"), @@ -342,10 +373,20 @@ def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> data = yaml.safe_load((output_dir / "infra.yml").read_text()) node_data = data["nodes"][0] - # --- field ordering: relationships must be last --- + # --- field ordering: attributes and relationships must come after scalar fields --- keys = list(node_data.keys()) + assert keys.index("name") < keys.index("attributes") assert keys.index("name") < keys.index("relationships") + # --- attributes: read_only stripped when False, kept when True --- + name_attr = next(a for a in node_data["attributes"] if a["name"] == "name") + computed_attr = next(a for a in node_data["attributes"] if a["name"] == "computed") + assert "read_only" not in name_attr + assert computed_attr["read_only"] is True + # branch always stripped from attributes + assert "branch" not in name_attr + assert "branch" not in computed_attr + tags_rel = next(r for r in node_data["relationships"] if r["name"] == "tags") site_rel = next(r for r in node_data["relationships"] if r["name"] == "site") From fbf268a09cb42767c4ea93181785863517a2baec Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Wed, 18 Feb 2026 15:43:45 +0100 Subject: [PATCH 03/16] Filter auto-generated relationships and restore hierarchical flag on export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop relationships with kind Group, Profile, or Hierarchy from export output — these are always auto-generated by Infrahub and must not be re-loaded manually (doing so caused validator_not_available errors for hierarchical generics) - For GenericSchemaAPI objects that had Hierarchy relationships, restore the `hierarchical: true` flag so the schema round-trips cleanly - Add read_only: false to _REL_EXPORT_DEFAULTS to stop it leaking into relationship output Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/ctl/schema.py | 13 ++++- tests/unit/ctl/test_schema_export.py | 72 +++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index da0de390..799164b0 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -237,8 +237,12 @@ def _default_export_directory() -> str: "optional": True, "min_count": 0, "max_count": 0, + "read_only": False, } +# Relationship kinds that Infrahub generates automatically — never user-defined +_AUTO_GENERATED_REL_KINDS: frozenset[str] = frozenset({"Group", "Profile", "Hierarchy"}) + def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, Any]: """Convert an API schema object to an export-ready dict (omits API-internal fields).""" @@ -248,6 +252,13 @@ def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str data.pop("attributes", None) data.pop("relationships", 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( + rel.kind == "Hierarchy" for rel in schema.relationships if not rel.inherited + ): + data["hierarchical"] = True + attributes = [ { k: v @@ -267,7 +278,7 @@ def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str if k not in _REL_EXPORT_DEFAULTS or v != _REL_EXPORT_DEFAULTS[k] } for rel in schema.relationships - if not rel.inherited + if not rel.inherited and rel.kind not in _AUTO_GENERATED_REL_KINDS ] if relationships: data["relationships"] = relationships diff --git a/tests/unit/ctl/test_schema_export.py b/tests/unit/ctl/test_schema_export.py index 6830989d..5ff2cb9c 100644 --- a/tests/unit/ctl/test_schema_export.py +++ b/tests/unit/ctl/test_schema_export.py @@ -391,7 +391,16 @@ def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> site_rel = next(r for r in node_data["relationships"] if r["name"] == "site") # default values stripped from 'tags' rel - for stripped_key in ("direction", "on_delete", "cardinality", "optional", "min_count", "max_count", "branch"): + for stripped_key in ( + "direction", + "on_delete", + "cardinality", + "optional", + "min_count", + "max_count", + "branch", + "read_only", + ): assert stripped_key not in tags_rel, f"'{stripped_key}' should have been stripped" # non-default values kept in 'site' rel @@ -399,7 +408,66 @@ def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> assert site_rel["optional"] is False assert site_rel["min_count"] == 1 assert site_rel["max_count"] == 1 - # default direction/on_delete still stripped even on non-default rel + # default direction/on_delete/read_only still stripped even on non-default rel assert "direction" not in site_rel assert "on_delete" not in site_rel assert "branch" not in site_rel + assert "read_only" not in site_rel + + +def test_schema_export_auto_generated_relationships_removed(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Group, Profile and Hierarchy relationships are auto-generated and must not be exported.""" + node = _make_node( + "Infra", + "Device", + relationships=[ + _make_rel("member_of_groups", "CoreGroup", kind="Group"), + _make_rel("subscriber_of_groups", "CoreGroup", kind="Group"), + _make_rel("profiles", "CoreProfile", kind="Profile"), + _make_rel("tags", "BuiltinTag"), # user-defined — kept + ], + ) + response = _schema_response(nodes=[node]) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + output_dir = tmp_path / "export" + result = runner.invoke(app=app, args=["export", "--directory", str(output_dir)]) + assert result.exit_code == 0, result.stdout + + data = yaml.safe_load((output_dir / "infra.yml").read_text()) + rel_names = [r["name"] for r in data["nodes"][0]["relationships"]] + assert rel_names == ["tags"] + + +def test_schema_export_hierarchical_generic(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Hierarchy rels are replaced with hierarchical: true on the generic.""" + generic = _make_generic( + "Location", + "Generic", + relationships=[ + _make_rel( + "parent", + "LocationGeneric", + kind="Hierarchy", + direction="outbound", + cardinality="one", + max_count=1, + optional=False, + ), + _make_rel("children", "LocationGeneric", kind="Hierarchy", direction="inbound"), + _make_rel("member_of_groups", "CoreGroup", kind="Group"), + ], + ) + response = _schema_response(generics=[generic]) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + output_dir = tmp_path / "export" + result = runner.invoke(app=app, args=["export", "--directory", str(output_dir)]) + assert result.exit_code == 0, result.stdout + + data = yaml.safe_load((output_dir / "location.yml").read_text()) + generic_data = data["generics"][0] + + assert generic_data.get("hierarchical") is True + # no Hierarchy or Group relationships in the output + assert "relationships" not in generic_data From bbff1e410c1dbe7d5f8186a638959405bb908dd5 Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Wed, 18 Feb 2026 15:47:58 +0100 Subject: [PATCH 04/16] Export generics before nodes; strip auto-generated uniqueness_constraints - generics key now appears before nodes in the exported YAML payload - uniqueness_constraints entries that are auto-generated from unique: true attributes (single-field ["__value"] entries) are removed on export; user-defined multi-field constraints are preserved Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/ctl/schema.py | 16 ++++++++++++-- tests/unit/ctl/test_schema_export.py | 32 +++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 799164b0..120bf1bc 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -259,6 +259,18 @@ def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str ): data["hierarchical"] = True + # Strip uniqueness_constraints that are auto-generated from `unique: true` attributes + # (single-field entries of the form ["__value"]). User-defined multi-field + # constraints are preserved. + unique_attr_suffixes = {f"{attr.name}__value" for attr in schema.attributes if attr.unique} + user_constraints = [ + c + for c in (data.pop("uniqueness_constraints", None) or []) + if not (len(c) == 1 and c[0] in unique_attr_suffixes) + ] + if user_constraints: + data["uniqueness_constraints"] = user_constraints + attributes = [ { k: v @@ -325,10 +337,10 @@ async def export( for ns, data in sorted(user_schemas.items()): payload: dict[str, Any] = {"version": "1.0"} - if data["nodes"]: - payload["nodes"] = data["nodes"] if data["generics"]: payload["generics"] = data["generics"] + if data["nodes"]: + payload["nodes"] = data["nodes"] output_file = directory / f"{ns.lower()}.yml" output_file.write_text( diff --git a/tests/unit/ctl/test_schema_export.py b/tests/unit/ctl/test_schema_export.py index 5ff2cb9c..afbcd427 100644 --- a/tests/unit/ctl/test_schema_export.py +++ b/tests/unit/ctl/test_schema_export.py @@ -320,7 +320,7 @@ def test_schema_export_custom_directory(httpx_mock: HTTPXMock, tmp_path: Path) - def test_schema_export_includes_generics(httpx_mock: HTTPXMock, tmp_path: Path) -> None: - """Generic schemas are exported under the 'generics' key.""" + """Generic schemas appear before nodes, both under the correct keys.""" response = _schema_response( generics=[_make_generic("Infra", "GenericInterface")], nodes=[_make_node("Infra", "Device")], @@ -342,6 +342,10 @@ def test_schema_export_includes_generics(httpx_mock: HTTPXMock, tmp_path: Path) assert any(g["name"] == "GenericInterface" for g in data["generics"]) assert any(n["name"] == "Device" for n in data["nodes"]) + # generics must come before nodes in the file + keys = list(data.keys()) + assert keys.index("generics") < keys.index("nodes") + def test_schema_export_output_quality(httpx_mock: HTTPXMock, tmp_path: Path) -> None: """Relationships strip defaults; computed attrs keep read_only; attrs/rels appear after scalar fields.""" @@ -471,3 +475,29 @@ def test_schema_export_hierarchical_generic(httpx_mock: HTTPXMock, tmp_path: Pat assert generic_data.get("hierarchical") is True # no Hierarchy or Group relationships in the output assert "relationships" not in generic_data + + +def test_schema_export_uniqueness_constraints(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Auto-generated single-field uniqueness constraints are stripped; user-defined ones are kept.""" + node = _make_node( + "Infra", + "Device", + attributes=[_make_attr("name", unique=True)], + uniqueness_constraints=[ + ["name__value"], # auto-generated from unique: true → stripped + ["namespace__value", "name__value"], # user-defined multi-field → kept + ], + ) + response = _schema_response(nodes=[node]) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + output_dir = tmp_path / "export" + result = runner.invoke(app=app, args=["export", "--directory", str(output_dir)]) + assert result.exit_code == 0, result.stdout + + data = yaml.safe_load((output_dir / "infra.yml").read_text()) + node_data = data["nodes"][0] + + constraints = node_data.get("uniqueness_constraints", []) + assert ["name__value"] not in constraints + assert ["namespace__value", "name__value"] in constraints From c2e5c923a67fb772296c2bb114cd1a695b3b559a Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Wed, 18 Feb 2026 16:57:36 +0100 Subject: [PATCH 05/16] add changelog --- changelog/151.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/151.added.md diff --git a/changelog/151.added.md b/changelog/151.added.md new file mode 100644 index 00000000..c770976f --- /dev/null +++ b/changelog/151.added.md @@ -0,0 +1 @@ +Add `infrahubctl schema export` command to export schemas from Infrahub. From 91fe6271bb5067904bb415ec085daab1eaf977f4 Mon Sep 17 00:00:00 2001 From: Bearchitek Date: Thu, 19 Feb 2026 09:34:13 +0100 Subject: [PATCH 06/16] Refactor schema export logic into dedicated module and regenerate docs Move export helpers from ctl/schema.py to infrahub_sdk/schema/export.py so the conversion logic is reusable outside the CLI. Regenerate infrahubctl docs to include the new export subcommand. Co-Authored-By: Claude Opus 4.6 --- docs/docs/infrahubctl/infrahubctl-schema.mdx | 20 +++++ infrahub_sdk/ctl/schema.py | 88 ++------------------ infrahub_sdk/schema/__init__.py | 2 + infrahub_sdk/schema/export.py | 83 ++++++++++++++++++ 4 files changed, 113 insertions(+), 80 deletions(-) create mode 100644 infrahub_sdk/schema/export.py diff --git a/docs/docs/infrahubctl/infrahubctl-schema.mdx b/docs/docs/infrahubctl/infrahubctl-schema.mdx index 7d569cc4..bee685b1 100644 --- a/docs/docs/infrahubctl/infrahubctl-schema.mdx +++ b/docs/docs/infrahubctl/infrahubctl-schema.mdx @@ -17,6 +17,7 @@ $ infrahubctl schema [OPTIONS] COMMAND [ARGS]... **Commands**: * `check`: Check if schema files are valid and what... +* `export`: Export the schema from Infrahub as YAML... * `load`: Load one or multiple schema files into... ## `infrahubctl schema check` @@ -40,6 +41,25 @@ $ infrahubctl schema check [OPTIONS] SCHEMAS... * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. +## `infrahubctl schema export` + +Export the schema from Infrahub as YAML files, one per namespace. + +**Usage**: + +```console +$ infrahubctl schema export [OPTIONS] +``` + +**Options**: + +* `--directory PATH`: Directory path to store schema files [default: (dynamic)] +* `--branch TEXT`: Branch from which to export the schema +* `--namespace TEXT`: Namespace(s) to export (default: all user-defined) +* `--debug / --no-debug`: [default: no-debug] +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + ## `infrahubctl schema load` Load one or multiple schema files into Infrahub. diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 120bf1bc..57b87447 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -16,7 +16,13 @@ from ..ctl.client import initialize_client from ..ctl.utils import catch_exception, init_logging from ..queries import SCHEMA_HASH_SYNC_STATUS -from ..schema import GenericSchemaAPI, NodeSchemaAPI, ProfileSchemaAPI, SchemaWarning, TemplateSchemaAPI +from ..schema import ( + GenericSchemaAPI, + ProfileSchemaAPI, + SchemaWarning, + TemplateSchemaAPI, + schema_to_export_dict, +) from ..yaml import SchemaFile from .parameters import CONFIG_PARAM from .utils import load_yamlfile_from_disk_and_exit @@ -220,84 +226,6 @@ def _default_export_directory() -> str: return f"infrahub-schema-export-{timestamp}" -_SCHEMA_EXPORT_EXCLUDE: set[str] = {"hash", "hierarchy", "used_by", "id", "state"} -# branch is inherited from the node and need not be repeated on each field -_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "allow_override", "hierarchical", "id", "state", "branch"} - -# Attribute field values that match schema loading defaults — omitted for cleaner output -_ATTR_EXPORT_DEFAULTS: dict[str, Any] = { - "read_only": False, -} - -# Relationship field values that match schema loading defaults — omitted for cleaner output -_REL_EXPORT_DEFAULTS: dict[str, Any] = { - "direction": "bidirectional", - "on_delete": "no-action", - "cardinality": "many", - "optional": True, - "min_count": 0, - "max_count": 0, - "read_only": False, -} - -# Relationship kinds that Infrahub generates automatically — never user-defined -_AUTO_GENERATED_REL_KINDS: frozenset[str] = frozenset({"Group", "Profile", "Hierarchy"}) - - -def _schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, Any]: - """Convert an API schema object to an export-ready dict (omits API-internal fields).""" - data = schema.model_dump(exclude=_SCHEMA_EXPORT_EXCLUDE, exclude_none=True) - - # Pop attrs/rels so they can be re-inserted last for better readability - data.pop("attributes", None) - data.pop("relationships", 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( - rel.kind == "Hierarchy" for rel in schema.relationships if not rel.inherited - ): - data["hierarchical"] = True - - # Strip uniqueness_constraints that are auto-generated from `unique: true` attributes - # (single-field entries of the form ["__value"]). User-defined multi-field - # constraints are preserved. - unique_attr_suffixes = {f"{attr.name}__value" for attr in schema.attributes if attr.unique} - user_constraints = [ - c - for c in (data.pop("uniqueness_constraints", None) or []) - if not (len(c) == 1 and c[0] in unique_attr_suffixes) - ] - if user_constraints: - data["uniqueness_constraints"] = user_constraints - - attributes = [ - { - k: v - for k, v in attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() - if k not in _ATTR_EXPORT_DEFAULTS or v != _ATTR_EXPORT_DEFAULTS[k] - } - for attr in schema.attributes - if not attr.inherited - ] - if attributes: - data["attributes"] = attributes - - relationships = [ - { - k: v - for k, v in rel.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() - if k not in _REL_EXPORT_DEFAULTS or v != _REL_EXPORT_DEFAULTS[k] - } - for rel in schema.relationships - if not rel.inherited and rel.kind not in _AUTO_GENERATED_REL_KINDS - ] - if relationships: - data["relationships"] = relationships - - return data - - @app.command() @catch_exception(console=console) async def export( @@ -323,7 +251,7 @@ async def export( continue ns = schema.namespace user_schemas.setdefault(ns, {"nodes": [], "generics": []}) - schema_dict = _schema_to_export_dict(schema) + schema_dict = schema_to_export_dict(schema) if isinstance(schema, GenericSchemaAPI): user_schemas[ns]["generics"].append(schema_dict) else: diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 3e61ad2a..2f51410b 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -22,6 +22,7 @@ ) from ..graphql import Mutation from ..queries import SCHEMA_HASH_SYNC_STATUS +from .export import schema_to_export_dict from .main import ( AttributeSchema, AttributeSchemaAPI, @@ -64,6 +65,7 @@ "SchemaRoot", "SchemaRootAPI", "TemplateSchemaAPI", + "schema_to_export_dict", ] diff --git a/infrahub_sdk/schema/export.py b/infrahub_sdk/schema/export.py new file mode 100644 index 00000000..2105a6d3 --- /dev/null +++ b/infrahub_sdk/schema/export.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from .main import GenericSchemaAPI, NodeSchemaAPI + +_SCHEMA_EXPORT_EXCLUDE: set[str] = {"hash", "hierarchy", "used_by", "id", "state"} +# branch is inherited from the node and need not be repeated on each field +_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "allow_override", "hierarchical", "id", "state", "branch"} + +# Attribute field values that match schema loading defaults — omitted for cleaner output +_ATTR_EXPORT_DEFAULTS: dict[str, Any] = { + "read_only": False, + "optional": False, +} + +# Relationship field values that match schema loading defaults — omitted for cleaner output +_REL_EXPORT_DEFAULTS: dict[str, Any] = { + "direction": "bidirectional", + "on_delete": "no-action", + "cardinality": "many", + "optional": True, + "min_count": 0, + "max_count": 0, + "read_only": False, +} + +# Relationship kinds that Infrahub generates automatically — never user-defined +_AUTO_GENERATED_REL_KINDS: frozenset[str] = frozenset({"Group", "Profile", "Hierarchy"}) + + +def schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, Any]: + """Convert an API schema object to an export-ready dict (omits API-internal fields).""" + data = schema.model_dump(exclude=_SCHEMA_EXPORT_EXCLUDE, exclude_none=True) + + # Pop attrs/rels so they can be re-inserted last for better readability + data.pop("attributes", None) + data.pop("relationships", 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( + rel.kind == "Hierarchy" for rel in schema.relationships if not rel.inherited + ): + data["hierarchical"] = True + + # Strip uniqueness_constraints that are auto-generated from `unique: true` attributes + # (single-field entries of the form ["__value"]). User-defined multi-field + # constraints are preserved. + unique_attr_suffixes = {f"{attr.name}__value" for attr in schema.attributes if attr.unique} + user_constraints = [ + c + for c in (data.pop("uniqueness_constraints", None) or []) + if not (len(c) == 1 and c[0] in unique_attr_suffixes) + ] + if user_constraints: + data["uniqueness_constraints"] = user_constraints + + attributes = [ + { + k: v + for k, v in attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() + if k not in _ATTR_EXPORT_DEFAULTS or v != _ATTR_EXPORT_DEFAULTS[k] + } + for attr in schema.attributes + if not attr.inherited + ] + if attributes: + data["attributes"] = attributes + + relationships = [ + { + k: v + for k, v in rel.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items() + if k not in _REL_EXPORT_DEFAULTS or v != _REL_EXPORT_DEFAULTS[k] + } + for rel in schema.relationships + if not rel.inherited and rel.kind not in _AUTO_GENERATED_REL_KINDS + ] + if relationships: + data["relationships"] = relationships + + return data From e63783e9447fd086bb0af5343e65b795b7066fd6 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Thu, 19 Feb 2026 17:01:37 +0100 Subject: [PATCH 07/16] rework exporter function --- infrahub_sdk/ctl/schema.py | 30 +--- infrahub_sdk/schema/__init__.py | 75 ++++++++ tests/unit/sdk/test_schema_export.py | 244 +++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 25 deletions(-) create mode 100644 tests/unit/sdk/test_schema_export.py diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 57b87447..a301da8d 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -12,17 +12,10 @@ from rich.console import Console from ..async_typer import AsyncTyper -from ..constants import RESTRICTED_NAMESPACES from ..ctl.client import initialize_client from ..ctl.utils import catch_exception, init_logging from ..queries import SCHEMA_HASH_SYNC_STATUS -from ..schema import ( - GenericSchemaAPI, - ProfileSchemaAPI, - SchemaWarning, - TemplateSchemaAPI, - schema_to_export_dict, -) +from ..schema import SchemaWarning from ..yaml import SchemaFile from .parameters import CONFIG_PARAM from .utils import load_yamlfile_from_disk_and_exit @@ -239,23 +232,10 @@ async def export( init_logging(debug=debug) client = initialize_client() - schema_nodes = await client.schema.fetch(branch=branch or client.default_branch) - - user_schemas: dict[str, dict[str, list[dict[str, Any]]]] = {} - for schema in schema_nodes.values(): - if isinstance(schema, (ProfileSchemaAPI, TemplateSchemaAPI)): - continue - if schema.namespace in RESTRICTED_NAMESPACES: - continue - if namespace and schema.namespace not in namespace: - continue - ns = schema.namespace - user_schemas.setdefault(ns, {"nodes": [], "generics": []}) - schema_dict = schema_to_export_dict(schema) - if isinstance(schema, GenericSchemaAPI): - user_schemas[ns]["generics"].append(schema_dict) - else: - user_schemas[ns]["nodes"].append(schema_dict) + user_schemas = await client.schema.export( + branch=branch, + namespaces=namespace or None, + ) if not user_schemas: console.print("[yellow]No user-defined schema found to export.") diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 2f51410b..c568c070 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -13,6 +13,7 @@ import httpx from pydantic import BaseModel, Field +from ..constants import RESTRICTED_NAMESPACES from ..exceptions import ( BranchNotFoundError, InvalidResponseError, @@ -120,6 +121,36 @@ def __init__(self, client: InfrahubClient | InfrahubClientSync) -> None: self.client = client self.cache = {} + @staticmethod + def _build_export_schemas( + schema_nodes: MutableMapping[str, MainSchemaTypesAPI], + namespaces: list[str] | None = None, + ) -> dict[str, dict[str, list[dict[str, Any]]]]: + """Organize fetched schemas into a per-namespace export structure. + + Filters out system types (Profile/Template), restricted namespaces, + and optionally limits to specific namespaces. + + Returns: + Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + """ + user_schemas: dict[str, dict[str, list[dict[str, Any]]]] = {} + for schema in schema_nodes.values(): + if isinstance(schema, (ProfileSchemaAPI, TemplateSchemaAPI)): + continue + if schema.namespace in RESTRICTED_NAMESPACES: + continue + if namespaces and schema.namespace not in namespaces: + continue + ns = schema.namespace + user_schemas.setdefault(ns, {"nodes": [], "generics": []}) + schema_dict = schema_to_export_dict(schema) + if isinstance(schema, GenericSchemaAPI): + user_schemas[ns]["generics"].append(schema_dict) + else: + user_schemas[ns]["nodes"].append(schema_dict) + return user_schemas + def validate(self, data: dict[str, Any]) -> None: SchemaRoot(**data) @@ -499,6 +530,28 @@ async def fetch( return branch_schema.nodes + async def export( + self, + branch: str | None = None, + namespaces: list[str] | None = None, + ) -> dict[str, dict[str, list[dict[str, Any]]]]: + """Export user-defined schemas organized by namespace. + + Fetches all schemas from the server, filters out system types and + restricted namespaces, and returns a dict keyed by namespace with + ``"nodes"`` and ``"generics"`` lists of export-ready dicts. + + Args: + branch: Branch to export from. Defaults to default_branch. + namespaces: Optional list of namespaces to include. If empty/None, all user-defined namespaces are exported. + + Returns: + Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + """ + branch = branch or self.client.default_branch + schema_nodes = await self.fetch(branch=branch) + return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) + async def get_graphql_schema(self, branch: str | None = None) -> str: """Get the GraphQL schema as a string. @@ -741,6 +794,28 @@ def fetch( return branch_schema.nodes + def export( + self, + branch: str | None = None, + namespaces: list[str] | None = None, + ) -> dict[str, dict[str, list[dict[str, Any]]]]: + """Export user-defined schemas organized by namespace. + + Fetches all schemas from the server, filters out system types and + restricted namespaces, and returns a dict keyed by namespace with + ``"nodes"`` and ``"generics"`` lists of export-ready dicts. + + Args: + branch: Branch to export from. Defaults to default_branch. + namespaces: Optional list of namespaces to include. If empty/None, all user-defined namespaces are exported. + + Returns: + Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + """ + branch = branch or self.client.default_branch + schema_nodes = self.fetch(branch=branch) + return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) + def get_graphql_schema(self, branch: str | None = None) -> str: """Get the GraphQL schema as a string. diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py new file mode 100644 index 00000000..ca66a09f --- /dev/null +++ b/tests/unit/sdk/test_schema_export.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock +from infrahub_sdk.schema import ( + GenericSchemaAPI, + InfrahubSchemaBase, + NodeSchemaAPI, + ProfileSchemaAPI, + TemplateSchemaAPI, +) + +# --------------------------------------------------------------------------- +# Minimal schema API response builders (reused from ctl tests) +# --------------------------------------------------------------------------- + +_BASE_NODE: dict[str, Any] = { + "id": None, + "state": "present", + "hash": None, + "hierarchy": None, + "label": None, + "description": None, + "include_in_menu": None, + "menu_placement": None, + "display_label": None, + "display_labels": None, + "human_friendly_id": None, + "icon": None, + "uniqueness_constraints": None, + "documentation": None, + "order_by": None, + "inherit_from": [], + "branch": "aware", + "default_filter": None, + "generate_profile": None, + "generate_template": None, + "parent": None, + "children": None, + "attributes": [], + "relationships": [], +} + +_BASE_GENERIC: dict[str, Any] = { + "id": None, + "state": "present", + "hash": None, + "used_by": [], + "label": None, + "description": None, + "include_in_menu": None, + "menu_placement": None, + "display_label": None, + "display_labels": None, + "human_friendly_id": None, + "icon": None, + "uniqueness_constraints": None, + "documentation": None, + "order_by": None, + "attributes": [], + "relationships": [], +} + + +def _make_node_schema(namespace: str, name: str) -> NodeSchemaAPI: + return NodeSchemaAPI(**{**_BASE_NODE, "namespace": namespace, "name": name}) + + +def _make_generic_schema(namespace: str, name: str) -> GenericSchemaAPI: + return GenericSchemaAPI(**{**_BASE_GENERIC, "namespace": namespace, "name": name}) + + +def _make_profile_schema(namespace: str, name: str) -> ProfileSchemaAPI: + return ProfileSchemaAPI( + **{ + **_BASE_NODE, + "namespace": namespace, + "name": name, + } + ) + + +def _make_template_schema(namespace: str, name: str) -> TemplateSchemaAPI: + return TemplateSchemaAPI( + **{ + **_BASE_NODE, + "namespace": namespace, + "name": name, + } + ) + + +# --------------------------------------------------------------------------- +# _build_export_schemas tests +# --------------------------------------------------------------------------- + + +class TestBuildExportSchemas: + def test_separates_nodes_and_generics(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + "InfraInterface": _make_generic_schema("Infra", "Interface"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + assert "Infra" in result + assert len(result["Infra"]["nodes"]) == 1 + assert len(result["Infra"]["generics"]) == 1 + assert result["Infra"]["nodes"][0]["name"] == "Device" + assert result["Infra"]["generics"][0]["name"] == "Interface" + + def test_groups_by_namespace(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + "DcimRack": _make_node_schema("Dcim", "Rack"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + assert set(result.keys()) == {"Infra", "Dcim"} + + def test_filters_profiles_and_templates(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + "ProfileInfraDevice": _make_profile_schema("Profile", "InfraDevice"), + "TemplateInfraDevice": _make_template_schema("Template", "InfraDevice"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + assert "Infra" in result + assert "Profile" not in result + assert "Template" not in result + + def test_filters_restricted_namespaces(self) -> None: + schema_nodes = { + "CoreRepository": _make_node_schema("Core", "Repository"), + "BuiltinTag": _make_node_schema("Builtin", "Tag"), + "InfraDevice": _make_node_schema("Infra", "Device"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + assert "Core" not in result + assert "Builtin" not in result + assert "Infra" in result + + def test_namespace_filter(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + "DcimRack": _make_node_schema("Dcim", "Rack"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes, namespaces=["Infra"]) + assert "Infra" in result + assert "Dcim" not in result + + def test_empty_when_no_user_schemas(self) -> None: + schema_nodes = { + "CoreRepository": _make_node_schema("Core", "Repository"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + assert result == {} + + +# --------------------------------------------------------------------------- +# Integration tests for export() method on client.schema +# --------------------------------------------------------------------------- + + +def _schema_response( + nodes: list[dict] | None = None, + generics: list[dict] | None = None, + profiles: list[dict] | None = None, + templates: list[dict] | None = None, +) -> dict: + return { + "main": "aabbccdd", + "nodes": nodes or [], + "generics": generics or [], + "profiles": profiles or [], + "templates": templates or [], + } + + +def _make_node_dict(namespace: str, name: str) -> dict[str, Any]: + return {**_BASE_NODE, "namespace": namespace, "name": name} + + +def _make_generic_dict(namespace: str, name: str) -> dict[str, Any]: + return {**_BASE_GENERIC, "namespace": namespace, "name": name} + + +@pytest.mark.parametrize("client_type", ["async", "sync"]) +async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, client_type: str) -> None: + response = _schema_response( + nodes=[_make_node_dict("Infra", "Device"), _make_node_dict("Dcim", "Rack")], + generics=[_make_generic_dict("Infra", "GenericInterface")], + ) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + if client_type == "async": + client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) + result = await client.schema.export(branch="main") + else: + client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) + result = client.schema.export(branch="main") + + assert "Infra" in result + assert "Dcim" in result + assert len(result["Infra"]["nodes"]) == 1 + assert len(result["Infra"]["generics"]) == 1 + assert len(result["Dcim"]["nodes"]) == 1 + + +@pytest.mark.parametrize("client_type", ["async", "sync"]) +async def test_export_with_namespace_filter(httpx_mock: HTTPXMock, client_type: str) -> None: + response = _schema_response( + nodes=[_make_node_dict("Infra", "Device"), _make_node_dict("Dcim", "Rack")], + ) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + if client_type == "async": + client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) + result = await client.schema.export(branch="main", namespaces=["Infra"]) + else: + client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) + result = client.schema.export(branch="main", namespaces=["Infra"]) + + assert "Infra" in result + assert "Dcim" not in result + + +@pytest.mark.parametrize("client_type", ["async", "sync"]) +async def test_export_empty_when_only_restricted(httpx_mock: HTTPXMock, client_type: str) -> None: + response = _schema_response(nodes=[_make_node_dict("Core", "Repository")]) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + + if client_type == "async": + client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) + result = await client.schema.export(branch="main") + else: + client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) + result = client.schema.export(branch="main") + + assert result == {} From f371427628db7f99d24a5a6052f8c1bb1e16df66 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Fri, 20 Feb 2026 11:51:19 +0100 Subject: [PATCH 08/16] Fix _default_export_directory return type to match Path annotation Co-Authored-By: Claude Opus 4.6 --- infrahub_sdk/ctl/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index a301da8d..7957caed 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -214,9 +214,9 @@ def _display_schema_warnings(console: Console, warnings: list[SchemaWarning]) -> ) -def _default_export_directory() -> str: +def _default_export_directory() -> Path: timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") - return f"infrahub-schema-export-{timestamp}" + return Path(f"infrahub-schema-export-{timestamp}") @app.command() From 85dc2803bc77b43f75cc52260122a7211405c775 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Mon, 23 Feb 2026 10:25:32 +0100 Subject: [PATCH 09/16] following coderabbit and merge from stable --- infrahub_sdk/schema/__init__.py | 42 ++++++++++++++++++++-------- tests/unit/sdk/test_schema_export.py | 16 +++++++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 8e7671e3..5e8a49d8 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -127,12 +127,22 @@ def _build_export_schemas( ) -> dict[str, dict[str, list[dict[str, Any]]]]: """Organize fetched schemas into a per-namespace export structure. - Filters out system types (Profile/Template), restricted namespaces, - and optionally limits to specific namespaces. + Filters out system types (Profile/Template) and restricted namespaces + (see :data:`RESTRICTED_NAMESPACES`), and optionally limits to specific + namespaces. If the caller requests restricted namespaces they are + silently excluded and a :func:`warnings.warn` is emitted. Returns: Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. """ + if namespaces: + restricted = set(namespaces) & set(RESTRICTED_NAMESPACES) + if restricted: + warnings.warn( + f"Restricted namespace(s) {sorted(restricted)} requested but will be excluded from export", + stacklevel=2, + ) + user_schemas: dict[str, dict[str, list[dict[str, Any]]]] = {} for schema in schema_nodes.values(): if isinstance(schema, (ProfileSchemaAPI, TemplateSchemaAPI)): @@ -536,19 +546,23 @@ async def export( ) -> dict[str, dict[str, list[dict[str, Any]]]]: """Export user-defined schemas organized by namespace. - Fetches all schemas from the server, filters out system types and - restricted namespaces, and returns a dict keyed by namespace with - ``"nodes"`` and ``"generics"`` lists of export-ready dicts. + Fetches schemas from the server, filters out system types and + restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns + a dict keyed by namespace with ``"nodes"`` and ``"generics"`` lists of + export-ready dicts. Restricted namespaces such as ``Core`` and + ``Builtin`` are always excluded even if explicitly listed in + *namespaces*; a warning is emitted when this happens. Args: branch: Branch to export from. Defaults to default_branch. - namespaces: Optional list of namespaces to include. If empty/None, all user-defined namespaces are exported. + namespaces: Optional list of namespaces to include. If empty/None, + all user-defined namespaces are exported. Returns: Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. """ branch = branch or self.client.default_branch - schema_nodes = await self.fetch(branch=branch) + schema_nodes = await self.fetch(branch=branch, namespaces=namespaces) return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) async def get_graphql_schema(self, branch: str | None = None) -> str: @@ -800,19 +814,23 @@ def export( ) -> dict[str, dict[str, list[dict[str, Any]]]]: """Export user-defined schemas organized by namespace. - Fetches all schemas from the server, filters out system types and - restricted namespaces, and returns a dict keyed by namespace with - ``"nodes"`` and ``"generics"`` lists of export-ready dicts. + Fetches schemas from the server, filters out system types and + restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns + a dict keyed by namespace with ``"nodes"`` and ``"generics"`` lists of + export-ready dicts. Restricted namespaces such as ``Core`` and + ``Builtin`` are always excluded even if explicitly listed in + *namespaces*; a warning is emitted when this happens. Args: branch: Branch to export from. Defaults to default_branch. - namespaces: Optional list of namespaces to include. If empty/None, all user-defined namespaces are exported. + namespaces: Optional list of namespaces to include. If empty/None, + all user-defined namespaces are exported. Returns: Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. """ branch = branch or self.client.default_branch - schema_nodes = self.fetch(branch=branch) + schema_nodes = self.fetch(branch=branch, namespaces=namespaces) return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) def get_graphql_schema(self, branch: str | None = None) -> str: diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py index ca66a09f..5ba0f07f 100644 --- a/tests/unit/sdk/test_schema_export.py +++ b/tests/unit/sdk/test_schema_export.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any import pytest @@ -160,6 +161,17 @@ def test_empty_when_no_user_schemas(self) -> None: result = InfrahubSchemaBase._build_export_schemas(schema_nodes) assert result == {} + def test_warns_on_restricted_namespaces(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + } + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = InfrahubSchemaBase._build_export_schemas(schema_nodes, namespaces=["Infra", "Core"]) + assert len(w) == 1 + assert "Core" in str(w[0].message) + assert "Infra" in result + # --------------------------------------------------------------------------- # Integration tests for export() method on client.schema @@ -214,9 +226,9 @@ async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, client_type: s @pytest.mark.parametrize("client_type", ["async", "sync"]) async def test_export_with_namespace_filter(httpx_mock: HTTPXMock, client_type: str) -> None: response = _schema_response( - nodes=[_make_node_dict("Infra", "Device"), _make_node_dict("Dcim", "Rack")], + nodes=[_make_node_dict("Infra", "Device")], ) - httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) + httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main&namespaces=Infra", json=response) if client_type == "async": client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) From 97a7e6b527593b65fffdcf0efde0ea856c5cb208 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Mon, 23 Feb 2026 14:40:35 +0100 Subject: [PATCH 10/16] Address PR review feedback from ogenstad and coderabbit - Fix import ordering in test_schema_export.py (infrahub_sdk.schema above TYPE_CHECKING) - Use clients fixture + client_types pattern matching existing test conventions - Fix warnings.warn stacklevel from 2 to 3 for correct caller attribution - Pass populate_cache=False in both async/sync export to prevent cache poisoning Co-Authored-By: Claude Opus 4.6 --- infrahub_sdk/schema/__init__.py | 6 ++-- tests/unit/sdk/test_schema_export.py | 47 +++++++++++++--------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 5e8a49d8..d0a98da1 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -140,7 +140,7 @@ def _build_export_schemas( if restricted: warnings.warn( f"Restricted namespace(s) {sorted(restricted)} requested but will be excluded from export", - stacklevel=2, + stacklevel=3, ) user_schemas: dict[str, dict[str, list[dict[str, Any]]]] = {} @@ -562,7 +562,7 @@ async def export( Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. """ branch = branch or self.client.default_branch - schema_nodes = await self.fetch(branch=branch, namespaces=namespaces) + schema_nodes = await self.fetch(branch=branch, namespaces=namespaces, populate_cache=False) return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) async def get_graphql_schema(self, branch: str | None = None) -> str: @@ -830,7 +830,7 @@ def export( Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. """ branch = branch or self.client.default_branch - schema_nodes = self.fetch(branch=branch, namespaces=namespaces) + schema_nodes = self.fetch(branch=branch, namespaces=namespaces, populate_cache=False) return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces) def get_graphql_schema(self, branch: str | None = None) -> str: diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py index 5ba0f07f..a7dd6317 100644 --- a/tests/unit/sdk/test_schema_export.py +++ b/tests/unit/sdk/test_schema_export.py @@ -5,10 +5,6 @@ import pytest -from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync - -if TYPE_CHECKING: - from pytest_httpx import HTTPXMock from infrahub_sdk.schema import ( GenericSchemaAPI, InfrahubSchemaBase, @@ -17,6 +13,13 @@ TemplateSchemaAPI, ) +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from tests.unit.sdk.conftest import BothClients + +client_types = ["standard", "sync"] + # --------------------------------------------------------------------------- # Minimal schema API response builders (reused from ctl tests) # --------------------------------------------------------------------------- @@ -201,20 +204,18 @@ def _make_generic_dict(namespace: str, name: str) -> dict[str, Any]: return {**_BASE_GENERIC, "namespace": namespace, "name": name} -@pytest.mark.parametrize("client_type", ["async", "sync"]) -async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, client_type: str) -> None: +@pytest.mark.parametrize("client_type", client_types) +async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, clients: BothClients, client_type: str) -> None: response = _schema_response( nodes=[_make_node_dict("Infra", "Device"), _make_node_dict("Dcim", "Rack")], generics=[_make_generic_dict("Infra", "GenericInterface")], ) httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) - if client_type == "async": - client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) - result = await client.schema.export(branch="main") + if client_type == "standard": + result = await clients.standard.schema.export(branch="main") else: - client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) - result = client.schema.export(branch="main") + result = clients.sync.schema.export(branch="main") assert "Infra" in result assert "Dcim" in result @@ -223,34 +224,30 @@ async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, client_type: s assert len(result["Dcim"]["nodes"]) == 1 -@pytest.mark.parametrize("client_type", ["async", "sync"]) -async def test_export_with_namespace_filter(httpx_mock: HTTPXMock, client_type: str) -> None: +@pytest.mark.parametrize("client_type", client_types) +async def test_export_with_namespace_filter(httpx_mock: HTTPXMock, clients: BothClients, client_type: str) -> None: response = _schema_response( nodes=[_make_node_dict("Infra", "Device")], ) httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main&namespaces=Infra", json=response) - if client_type == "async": - client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) - result = await client.schema.export(branch="main", namespaces=["Infra"]) + if client_type == "standard": + result = await clients.standard.schema.export(branch="main", namespaces=["Infra"]) else: - client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) - result = client.schema.export(branch="main", namespaces=["Infra"]) + result = clients.sync.schema.export(branch="main", namespaces=["Infra"]) assert "Infra" in result assert "Dcim" not in result -@pytest.mark.parametrize("client_type", ["async", "sync"]) -async def test_export_empty_when_only_restricted(httpx_mock: HTTPXMock, client_type: str) -> None: +@pytest.mark.parametrize("client_type", client_types) +async def test_export_empty_when_only_restricted(httpx_mock: HTTPXMock, clients: BothClients, client_type: str) -> None: response = _schema_response(nodes=[_make_node_dict("Core", "Repository")]) httpx_mock.add_response(method="GET", url="http://mock/api/schema?branch=main", json=response) - if client_type == "async": - client = InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) - result = await client.schema.export(branch="main") + if client_type == "standard": + result = await clients.standard.schema.export(branch="main") else: - client = InfrahubClientSync(config=Config(address="http://mock", insert_tracker=True)) - result = client.schema.export(branch="main") + result = clients.sync.schema.export(branch="main") assert result == {} From fd79f90663ff507de4585ab2d013deaf64eaf0ae Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Mon, 23 Feb 2026 18:21:01 +0100 Subject: [PATCH 11/16] Replace nested dict return type with SchemaExport/NamespaceExport Pydantic models Addresses PR review feedback: export() now returns a proper SchemaExport object instead of dict[str, dict[str, list[dict[str, Any]]]], making the API easier to understand and use. Includes .to_dict() for serialization. Co-Authored-By: Claude Opus 4.6 --- infrahub_sdk/ctl/schema.py | 12 +++--- infrahub_sdk/schema/__init__.py | 43 ++++++++++--------- infrahub_sdk/schema/export.py | 20 +++++++++ tests/unit/sdk/test_schema_export.py | 64 +++++++++++++++++----------- 4 files changed, 89 insertions(+), 50 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 7957caed..2179da7d 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -237,18 +237,18 @@ async def export( namespaces=namespace or None, ) - if not user_schemas: + if not user_schemas.namespaces: console.print("[yellow]No user-defined schema found to export.") return directory.mkdir(parents=True, exist_ok=True) - for ns, data in sorted(user_schemas.items()): + for ns, data in sorted(user_schemas.namespaces.items()): payload: dict[str, Any] = {"version": "1.0"} - if data["generics"]: - payload["generics"] = data["generics"] - if data["nodes"]: - payload["nodes"] = data["nodes"] + if data.generics: + payload["generics"] = data.generics + if data.nodes: + payload["nodes"] = data.nodes output_file = directory / f"{ns.lower()}.yml" output_file.write_text( diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index d0a98da1..557e76f3 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -22,7 +22,7 @@ ) from ..graphql import Mutation from ..queries import SCHEMA_HASH_SYNC_STATUS -from .export import RESTRICTED_NAMESPACES, schema_to_export_dict +from .export import RESTRICTED_NAMESPACES, NamespaceExport, SchemaExport, schema_to_export_dict from .main import ( AttributeSchema, AttributeSchemaAPI, @@ -55,6 +55,7 @@ "BranchSupportType", "GenericSchema", "GenericSchemaAPI", + "NamespaceExport", "NodeSchema", "NodeSchemaAPI", "ProfileSchemaAPI", @@ -62,6 +63,7 @@ "RelationshipKind", "RelationshipSchema", "RelationshipSchemaAPI", + "SchemaExport", "SchemaRoot", "SchemaRootAPI", "TemplateSchemaAPI", @@ -124,7 +126,7 @@ def __init__(self, client: InfrahubClient | InfrahubClientSync) -> None: def _build_export_schemas( schema_nodes: MutableMapping[str, MainSchemaTypesAPI], namespaces: list[str] | None = None, - ) -> dict[str, dict[str, list[dict[str, Any]]]]: + ) -> SchemaExport: """Organize fetched schemas into a per-namespace export structure. Filters out system types (Profile/Template) and restricted namespaces @@ -133,7 +135,7 @@ def _build_export_schemas( silently excluded and a :func:`warnings.warn` is emitted. Returns: - Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + A :class:`SchemaExport` containing user-defined schemas by namespace. """ if namespaces: restricted = set(namespaces) & set(RESTRICTED_NAMESPACES) @@ -143,7 +145,7 @@ def _build_export_schemas( stacklevel=3, ) - user_schemas: dict[str, dict[str, list[dict[str, Any]]]] = {} + ns_map: dict[str, NamespaceExport] = {} for schema in schema_nodes.values(): if isinstance(schema, (ProfileSchemaAPI, TemplateSchemaAPI)): continue @@ -152,13 +154,14 @@ def _build_export_schemas( if namespaces and schema.namespace not in namespaces: continue ns = schema.namespace - user_schemas.setdefault(ns, {"nodes": [], "generics": []}) + if ns not in ns_map: + ns_map[ns] = NamespaceExport() schema_dict = schema_to_export_dict(schema) if isinstance(schema, GenericSchemaAPI): - user_schemas[ns]["generics"].append(schema_dict) + ns_map[ns].generics.append(schema_dict) else: - user_schemas[ns]["nodes"].append(schema_dict) - return user_schemas + ns_map[ns].nodes.append(schema_dict) + return SchemaExport(namespaces=ns_map) def validate(self, data: dict[str, Any]) -> None: SchemaRoot(**data) @@ -543,15 +546,15 @@ async def export( self, branch: str | None = None, namespaces: list[str] | None = None, - ) -> dict[str, dict[str, list[dict[str, Any]]]]: + ) -> SchemaExport: """Export user-defined schemas organized by namespace. Fetches schemas from the server, filters out system types and restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns - a dict keyed by namespace with ``"nodes"`` and ``"generics"`` lists of - export-ready dicts. Restricted namespaces such as ``Core`` and - ``Builtin`` are always excluded even if explicitly listed in - *namespaces*; a warning is emitted when this happens. + a :class:`SchemaExport` object with per-namespace data. Restricted + namespaces such as ``Core`` and ``Builtin`` are always excluded even if + explicitly listed in *namespaces*; a warning is emitted when this + happens. Args: branch: Branch to export from. Defaults to default_branch. @@ -559,7 +562,7 @@ async def export( all user-defined namespaces are exported. Returns: - Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + A :class:`SchemaExport` containing user-defined schemas by namespace. """ branch = branch or self.client.default_branch schema_nodes = await self.fetch(branch=branch, namespaces=namespaces, populate_cache=False) @@ -811,15 +814,15 @@ def export( self, branch: str | None = None, namespaces: list[str] | None = None, - ) -> dict[str, dict[str, list[dict[str, Any]]]]: + ) -> SchemaExport: """Export user-defined schemas organized by namespace. Fetches schemas from the server, filters out system types and restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns - a dict keyed by namespace with ``"nodes"`` and ``"generics"`` lists of - export-ready dicts. Restricted namespaces such as ``Core`` and - ``Builtin`` are always excluded even if explicitly listed in - *namespaces*; a warning is emitted when this happens. + a :class:`SchemaExport` object with per-namespace data. Restricted + namespaces such as ``Core`` and ``Builtin`` are always excluded even if + explicitly listed in *namespaces*; a warning is emitted when this + happens. Args: branch: Branch to export from. Defaults to default_branch. @@ -827,7 +830,7 @@ def export( all user-defined namespaces are exported. Returns: - Mapping of namespace to ``{"nodes": [...], "generics": [...]}``. + A :class:`SchemaExport` containing user-defined schemas by namespace. """ branch = branch or self.client.default_branch schema_nodes = self.fetch(branch=branch, namespaces=namespaces, populate_cache=False) diff --git a/infrahub_sdk/schema/export.py b/infrahub_sdk/schema/export.py index 0d022354..d5a09c77 100644 --- a/infrahub_sdk/schema/export.py +++ b/infrahub_sdk/schema/export.py @@ -2,8 +2,28 @@ from typing import Any +from pydantic import BaseModel, Field + from .main import GenericSchemaAPI, NodeSchemaAPI + +class NamespaceExport(BaseModel): + """Export data for a single namespace.""" + + nodes: list[dict[str, Any]] = Field(default_factory=list) + generics: list[dict[str, Any]] = Field(default_factory=list) + + +class SchemaExport(BaseModel): + """Result of a schema export, organized by namespace.""" + + namespaces: dict[str, NamespaceExport] = Field(default_factory=dict) + + def to_dict(self) -> dict[str, dict[str, list[dict[str, Any]]]]: + """Convert to plain dict for YAML serialization.""" + return {ns: data.model_dump(exclude_defaults=True) for ns, data in self.namespaces.items()} + + # Namespaces reserved by the Infrahub server — mirrored from # backend/infrahub/core/constants/__init__.py in the opsmill/infrahub repo. RESTRICTED_NAMESPACES: list[str] = [ diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py index a7dd6317..ed2814e4 100644 --- a/tests/unit/sdk/test_schema_export.py +++ b/tests/unit/sdk/test_schema_export.py @@ -10,6 +10,7 @@ InfrahubSchemaBase, NodeSchemaAPI, ProfileSchemaAPI, + SchemaExport, TemplateSchemaAPI, ) @@ -112,11 +113,12 @@ def test_separates_nodes_and_generics(self) -> None: "InfraInterface": _make_generic_schema("Infra", "Interface"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes) - assert "Infra" in result - assert len(result["Infra"]["nodes"]) == 1 - assert len(result["Infra"]["generics"]) == 1 - assert result["Infra"]["nodes"][0]["name"] == "Device" - assert result["Infra"]["generics"][0]["name"] == "Interface" + assert isinstance(result, SchemaExport) + assert "Infra" in result.namespaces + assert len(result.namespaces["Infra"].nodes) == 1 + assert len(result.namespaces["Infra"].generics) == 1 + assert result.namespaces["Infra"].nodes[0]["name"] == "Device" + assert result.namespaces["Infra"].generics[0]["name"] == "Interface" def test_groups_by_namespace(self) -> None: schema_nodes = { @@ -124,7 +126,7 @@ def test_groups_by_namespace(self) -> None: "DcimRack": _make_node_schema("Dcim", "Rack"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes) - assert set(result.keys()) == {"Infra", "Dcim"} + assert set(result.namespaces.keys()) == {"Infra", "Dcim"} def test_filters_profiles_and_templates(self) -> None: schema_nodes = { @@ -133,9 +135,9 @@ def test_filters_profiles_and_templates(self) -> None: "TemplateInfraDevice": _make_template_schema("Template", "InfraDevice"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes) - assert "Infra" in result - assert "Profile" not in result - assert "Template" not in result + assert "Infra" in result.namespaces + assert "Profile" not in result.namespaces + assert "Template" not in result.namespaces def test_filters_restricted_namespaces(self) -> None: schema_nodes = { @@ -144,9 +146,9 @@ def test_filters_restricted_namespaces(self) -> None: "InfraDevice": _make_node_schema("Infra", "Device"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes) - assert "Core" not in result - assert "Builtin" not in result - assert "Infra" in result + assert "Core" not in result.namespaces + assert "Builtin" not in result.namespaces + assert "Infra" in result.namespaces def test_namespace_filter(self) -> None: schema_nodes = { @@ -154,15 +156,15 @@ def test_namespace_filter(self) -> None: "DcimRack": _make_node_schema("Dcim", "Rack"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes, namespaces=["Infra"]) - assert "Infra" in result - assert "Dcim" not in result + assert "Infra" in result.namespaces + assert "Dcim" not in result.namespaces def test_empty_when_no_user_schemas(self) -> None: schema_nodes = { "CoreRepository": _make_node_schema("Core", "Repository"), } result = InfrahubSchemaBase._build_export_schemas(schema_nodes) - assert result == {} + assert result.namespaces == {} def test_warns_on_restricted_namespaces(self) -> None: schema_nodes = { @@ -173,7 +175,20 @@ def test_warns_on_restricted_namespaces(self) -> None: result = InfrahubSchemaBase._build_export_schemas(schema_nodes, namespaces=["Infra", "Core"]) assert len(w) == 1 assert "Core" in str(w[0].message) - assert "Infra" in result + assert "Infra" in result.namespaces + + def test_to_dict(self) -> None: + schema_nodes = { + "InfraDevice": _make_node_schema("Infra", "Device"), + "InfraInterface": _make_generic_schema("Infra", "Interface"), + } + result = InfrahubSchemaBase._build_export_schemas(schema_nodes) + as_dict = result.to_dict() + assert isinstance(as_dict, dict) + assert "Infra" in as_dict + assert isinstance(as_dict["Infra"], dict) + assert len(as_dict["Infra"]["nodes"]) == 1 + assert len(as_dict["Infra"]["generics"]) == 1 # --------------------------------------------------------------------------- @@ -217,11 +232,12 @@ async def test_export_returns_user_schemas(httpx_mock: HTTPXMock, clients: BothC else: result = clients.sync.schema.export(branch="main") - assert "Infra" in result - assert "Dcim" in result - assert len(result["Infra"]["nodes"]) == 1 - assert len(result["Infra"]["generics"]) == 1 - assert len(result["Dcim"]["nodes"]) == 1 + assert isinstance(result, SchemaExport) + assert "Infra" in result.namespaces + assert "Dcim" in result.namespaces + assert len(result.namespaces["Infra"].nodes) == 1 + assert len(result.namespaces["Infra"].generics) == 1 + assert len(result.namespaces["Dcim"].nodes) == 1 @pytest.mark.parametrize("client_type", client_types) @@ -236,8 +252,8 @@ async def test_export_with_namespace_filter(httpx_mock: HTTPXMock, clients: Both else: result = clients.sync.schema.export(branch="main", namespaces=["Infra"]) - assert "Infra" in result - assert "Dcim" not in result + assert "Infra" in result.namespaces + assert "Dcim" not in result.namespaces @pytest.mark.parametrize("client_type", client_types) @@ -250,4 +266,4 @@ async def test_export_empty_when_only_restricted(httpx_mock: HTTPXMock, clients: else: result = clients.sync.schema.export(branch="main") - assert result == {} + assert result.namespaces == {} From 5046fea9c0fa650b3d094ddd9550e3c2887ad285 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Wed, 25 Feb 2026 21:45:26 +0100 Subject: [PATCH 12/16] Rename namespace parameter to namespaces in schema export CLI Co-Authored-By: Claude Opus 4.6 --- infrahub_sdk/ctl/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 2179da7d..9532959e 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -224,7 +224,7 @@ def _default_export_directory() -> Path: async def export( directory: Path = typer.Option(_default_export_directory, help="Directory path to store schema files"), branch: str = typer.Option(None, help="Branch from which to export the schema"), - namespace: list[str] = typer.Option([], help="Namespace(s) to export (default: all user-defined)"), + namespaces: list[str] = typer.Option([], help="Namespace(s) to export (default: all user-defined)"), debug: bool = False, _: str = CONFIG_PARAM, ) -> None: @@ -234,7 +234,7 @@ async def export( client = initialize_client() user_schemas = await client.schema.export( branch=branch, - namespaces=namespace or None, + namespaces=namespaces or None, ) if not user_schemas.namespaces: From 32b52cf7d5dd7283f0509deb2cbf1e4e00648657 Mon Sep 17 00:00:00 2001 From: Benoit Kohler Date: Wed, 25 Feb 2026 21:49:01 +0100 Subject: [PATCH 13/16] Regenerate infrahubctl schema docs after namespaces rename Co-Authored-By: Claude Opus 4.6 --- docs/docs/infrahubctl/infrahubctl-schema.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/infrahubctl/infrahubctl-schema.mdx b/docs/docs/infrahubctl/infrahubctl-schema.mdx index bee685b1..1467eae8 100644 --- a/docs/docs/infrahubctl/infrahubctl-schema.mdx +++ b/docs/docs/infrahubctl/infrahubctl-schema.mdx @@ -55,7 +55,7 @@ $ infrahubctl schema export [OPTIONS] * `--directory PATH`: Directory path to store schema files [default: (dynamic)] * `--branch TEXT`: Branch from which to export the schema -* `--namespace TEXT`: Namespace(s) to export (default: all user-defined) +* `--namespaces TEXT`: Namespace(s) to export (default: all user-defined) * `--debug / --no-debug`: [default: no-debug] * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. From 9b30a09b7900b0e98089f49ddbf37bf51621a740 Mon Sep 17 00:00:00 2001 From: Wim Van Deun Date: Mon, 16 Mar 2026 12:16:19 +0100 Subject: [PATCH 14/16] bump version to 1.19.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90ec4070..dcd62df5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "infrahub-sdk" -version = "1.19.0rc0" +version = "1.19.0" description = "Python Client to interact with Infrahub" authors = [ {name = "OpsMill", email = "info@opsmill.com"} diff --git a/uv.lock b/uv.lock index e7ee603b..b832ef39 100644 --- a/uv.lock +++ b/uv.lock @@ -686,7 +686,7 @@ wheels = [ [[package]] name = "infrahub-sdk" -version = "1.19.0rc0" +version = "1.19.0" source = { editable = "." } dependencies = [ { name = "dulwich" }, From 4814ebabca0f2331af4c12fce8aaa9cda3c98d88 Mon Sep 17 00:00:00 2001 From: Wim Van Deun Date: Mon, 16 Mar 2026 12:19:51 +0100 Subject: [PATCH 15/16] add CHANGELOG entry --- CHANGELOG.md | 18 ++++++++++++++++++ changelog/+5f3ef109.added.md | 1 - .../+infrahubctl-proposedchange.changed.md | 1 - changelog/201.added.md | 1 - changelog/265.fixed.md | 1 - changelog/497.fixed.md | 1 - changelog/ihs193.added.md | 1 - 7 files changed, 18 insertions(+), 6 deletions(-) delete mode 100644 changelog/+5f3ef109.added.md delete mode 100644 changelog/+infrahubctl-proposedchange.changed.md delete mode 100644 changelog/201.added.md delete mode 100644 changelog/265.fixed.md delete mode 100644 changelog/497.fixed.md delete mode 100644 changelog/ihs193.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c418e0..a3200c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the changes for the upcoming release can be found in . + +## [1.19.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.19.0) - 2026-03-16 + +### Added + +- Added support for FileObject nodes with file upload and download capabilities. New methods `upload_from_path(path)` and `upload_from_bytes(content, name)` allow setting file content before saving, while `download_file(dest)` enables downloading files to memory or streaming to disk for large files. ([#ihs193](https://github.com/opsmill/infrahub-sdk-python/issues/ihs193)) +- Python SDK API documentation is now generated directly from the docstrings of the classes, functions, and methods contained in the code. ([#201](https://github.com/opsmill/infrahub-sdk-python/issues/201)) +- Added a 'py.typed' file to the project. This is to enable type checking when the Infrahub SDK is imported from other projects. The addition of this file could cause new typing issues in external projects until all typing issues have been resolved. Adding it to the project now to better highlight remaining issues. + +### Changed + +- Updated branch report command to use node metadata for proposed change creator information instead of the deprecated relationship-based approach. Requires Infrahub 1.7 or above. + +### Fixed + +- Allow SDK tracking feature to continue after encountering delete errors due to impacted nodes having already been deleted by cascade delete. ([#265](https://github.com/opsmill/infrahub-sdk-python/issues/265)) +- Fixed Python SDK query generation regarding from_pool generated attribute value ([#497](https://github.com/opsmill/infrahub-sdk-python/issues/497)) + ## [1.18.1](https://github.com/opsmill/infrahub-sdk-python/tree/v1.18.1) - 2026-01-08 ### Fixed diff --git a/changelog/+5f3ef109.added.md b/changelog/+5f3ef109.added.md deleted file mode 100644 index f5964e3b..00000000 --- a/changelog/+5f3ef109.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a 'py.typed' file to the project. This is to enable type checking when the Infrahub SDK is imported from other projects. The addition of this file could cause new typing issues in external projects until all typing issues have been resolved. Adding it to the project now to better highlight remaining issues. diff --git a/changelog/+infrahubctl-proposedchange.changed.md b/changelog/+infrahubctl-proposedchange.changed.md deleted file mode 100644 index 9b75045d..00000000 --- a/changelog/+infrahubctl-proposedchange.changed.md +++ /dev/null @@ -1 +0,0 @@ -Updated branch report command to use node metadata for proposed change creator information instead of the deprecated relationship-based approach. Requires Infrahub 1.7 or above. diff --git a/changelog/201.added.md b/changelog/201.added.md deleted file mode 100644 index bb3fbf00..00000000 --- a/changelog/201.added.md +++ /dev/null @@ -1 +0,0 @@ -Python SDK API documentation is now generated directly from the docstrings of the classes, functions, and methods contained in the code. \ No newline at end of file diff --git a/changelog/265.fixed.md b/changelog/265.fixed.md deleted file mode 100644 index 4e3c43a9..00000000 --- a/changelog/265.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Allow SDK tracking feature to continue after encountering delete errors due to impacted nodes having already been deleted by cascade delete. diff --git a/changelog/497.fixed.md b/changelog/497.fixed.md deleted file mode 100644 index b32323d1..00000000 --- a/changelog/497.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed Python SDK query generation regarding from_pool generated attribute value diff --git a/changelog/ihs193.added.md b/changelog/ihs193.added.md deleted file mode 100644 index 61572618..00000000 --- a/changelog/ihs193.added.md +++ /dev/null @@ -1 +0,0 @@ -Added support for FileObject nodes with file upload and download capabilities. New methods `upload_from_path(path)` and `upload_from_bytes(content, name)` allow setting file content before saving, while `download_file(dest)` enables downloading files to memory or streaming to disk for large files. From fe5fe71579d2f5d7046316fdb41558828856df21 Mon Sep 17 00:00:00 2001 From: Wim Van Deun Date: Mon, 16 Mar 2026 14:27:53 +0100 Subject: [PATCH 16/16] exclude test_schema_export.py from ty linting test --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index dcd62df5..4e0716a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -239,6 +239,7 @@ include = [ "tests/unit/sdk/test_protocols_generator.py", "tests/unit/sdk/test_schema_sorter.py", "tests/unit/sdk/test_topological_sort.py", + "tests/unit/sdk/test_schema_export.py", ] [tool.ty.overrides.rules]