Skip to content

Commit 2f0ce37

Browse files
authored
Merge pull request #835 from opsmill/bkr-add-schema-exporter
Add the ability to export the schema in yaml
2 parents 6eb1a86 + 32b52cf commit 2f0ce37

6 files changed

Lines changed: 554 additions & 0 deletions

File tree

changelog/151.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `infrahubctl schema export` command to export schemas from Infrahub.

docs/docs/infrahubctl/infrahubctl-schema.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ $ infrahubctl schema [OPTIONS] COMMAND [ARGS]...
1717
**Commands**:
1818

1919
* `check`: Check if schema files are valid and what...
20+
* `export`: Export the schema from Infrahub as YAML...
2021
* `load`: Load one or multiple schema files into...
2122

2223
## `infrahubctl schema check`
@@ -40,6 +41,25 @@ $ infrahubctl schema check [OPTIONS] SCHEMAS...
4041
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
4142
* `--help`: Show this message and exit.
4243

44+
## `infrahubctl schema export`
45+
46+
Export the schema from Infrahub as YAML files, one per namespace.
47+
48+
**Usage**:
49+
50+
```console
51+
$ infrahubctl schema export [OPTIONS]
52+
```
53+
54+
**Options**:
55+
56+
* `--directory PATH`: Directory path to store schema files [default: (dynamic)]
57+
* `--branch TEXT`: Branch from which to export the schema
58+
* `--namespaces TEXT`: Namespace(s) to export (default: all user-defined)
59+
* `--debug / --no-debug`: [default: no-debug]
60+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
61+
* `--help`: Show this message and exit.
62+
4363
## `infrahubctl schema load`
4464

4565
Load one or multiple schema files into Infrahub.

infrahub_sdk/ctl/schema.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import time
5+
from datetime import datetime, timezone
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any
78

@@ -211,3 +212,49 @@ def _display_schema_warnings(console: Console, warnings: list[SchemaWarning]) ->
211212
console.print(
212213
f"[yellow] {warning.type.value}: {warning.message} [{', '.join([kind.display for kind in warning.kinds])}]"
213214
)
215+
216+
217+
def _default_export_directory() -> Path:
218+
timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S")
219+
return Path(f"infrahub-schema-export-{timestamp}")
220+
221+
222+
@app.command()
223+
@catch_exception(console=console)
224+
async def export(
225+
directory: Path = typer.Option(_default_export_directory, help="Directory path to store schema files"),
226+
branch: str = typer.Option(None, help="Branch from which to export the schema"),
227+
namespaces: list[str] = typer.Option([], help="Namespace(s) to export (default: all user-defined)"),
228+
debug: bool = False,
229+
_: str = CONFIG_PARAM,
230+
) -> None:
231+
"""Export the schema from Infrahub as YAML files, one per namespace."""
232+
init_logging(debug=debug)
233+
234+
client = initialize_client()
235+
user_schemas = await client.schema.export(
236+
branch=branch,
237+
namespaces=namespaces or None,
238+
)
239+
240+
if not user_schemas.namespaces:
241+
console.print("[yellow]No user-defined schema found to export.")
242+
return
243+
244+
directory.mkdir(parents=True, exist_ok=True)
245+
246+
for ns, data in sorted(user_schemas.namespaces.items()):
247+
payload: dict[str, Any] = {"version": "1.0"}
248+
if data.generics:
249+
payload["generics"] = data.generics
250+
if data.nodes:
251+
payload["nodes"] = data.nodes
252+
253+
output_file = directory / f"{ns.lower()}.yml"
254+
output_file.write_text(
255+
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
256+
encoding="utf-8",
257+
)
258+
console.print(f"[green] Exported namespace '{ns}' to {output_file}")
259+
260+
console.print(f"[green] Schema exported to {directory}")

infrahub_sdk/schema/__init__.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from ..graphql import Mutation
2424
from ..queries import SCHEMA_HASH_SYNC_STATUS
25+
from .export import RESTRICTED_NAMESPACES, NamespaceExport, SchemaExport, schema_to_export_dict
2526
from .main import (
2627
AttributeSchema,
2728
AttributeSchemaAPI,
@@ -54,16 +55,19 @@
5455
"BranchSupportType",
5556
"GenericSchema",
5657
"GenericSchemaAPI",
58+
"NamespaceExport",
5759
"NodeSchema",
5860
"NodeSchemaAPI",
5961
"ProfileSchemaAPI",
6062
"RelationshipCardinality",
6163
"RelationshipKind",
6264
"RelationshipSchema",
6365
"RelationshipSchemaAPI",
66+
"SchemaExport",
6467
"SchemaRoot",
6568
"SchemaRootAPI",
6669
"TemplateSchemaAPI",
70+
"schema_to_export_dict",
6771
]
6872

6973

@@ -118,6 +122,47 @@ def __init__(self, client: InfrahubClient | InfrahubClientSync) -> None:
118122
self.client = client
119123
self.cache = {}
120124

125+
@staticmethod
126+
def _build_export_schemas(
127+
schema_nodes: MutableMapping[str, MainSchemaTypesAPI],
128+
namespaces: list[str] | None = None,
129+
) -> SchemaExport:
130+
"""Organize fetched schemas into a per-namespace export structure.
131+
132+
Filters out system types (Profile/Template) and restricted namespaces
133+
(see :data:`RESTRICTED_NAMESPACES`), and optionally limits to specific
134+
namespaces. If the caller requests restricted namespaces they are
135+
silently excluded and a :func:`warnings.warn` is emitted.
136+
137+
Returns:
138+
A :class:`SchemaExport` containing user-defined schemas by namespace.
139+
"""
140+
if namespaces:
141+
restricted = set(namespaces) & set(RESTRICTED_NAMESPACES)
142+
if restricted:
143+
warnings.warn(
144+
f"Restricted namespace(s) {sorted(restricted)} requested but will be excluded from export",
145+
stacklevel=3,
146+
)
147+
148+
ns_map: dict[str, NamespaceExport] = {}
149+
for schema in schema_nodes.values():
150+
if isinstance(schema, (ProfileSchemaAPI, TemplateSchemaAPI)):
151+
continue
152+
if schema.namespace in RESTRICTED_NAMESPACES:
153+
continue
154+
if namespaces and schema.namespace not in namespaces:
155+
continue
156+
ns = schema.namespace
157+
if ns not in ns_map:
158+
ns_map[ns] = NamespaceExport()
159+
schema_dict = schema_to_export_dict(schema)
160+
if isinstance(schema, GenericSchemaAPI):
161+
ns_map[ns].generics.append(schema_dict)
162+
else:
163+
ns_map[ns].nodes.append(schema_dict)
164+
return SchemaExport(namespaces=ns_map)
165+
121166
def validate(self, data: dict[str, Any]) -> None:
122167
SchemaRoot(**data)
123168

@@ -497,6 +542,32 @@ async def fetch(
497542

498543
return branch_schema.nodes
499544

545+
async def export(
546+
self,
547+
branch: str | None = None,
548+
namespaces: list[str] | None = None,
549+
) -> SchemaExport:
550+
"""Export user-defined schemas organized by namespace.
551+
552+
Fetches schemas from the server, filters out system types and
553+
restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns
554+
a :class:`SchemaExport` object with per-namespace data. Restricted
555+
namespaces such as ``Core`` and ``Builtin`` are always excluded even if
556+
explicitly listed in *namespaces*; a warning is emitted when this
557+
happens.
558+
559+
Args:
560+
branch: Branch to export from. Defaults to default_branch.
561+
namespaces: Optional list of namespaces to include. If empty/None,
562+
all user-defined namespaces are exported.
563+
564+
Returns:
565+
A :class:`SchemaExport` containing user-defined schemas by namespace.
566+
"""
567+
branch = branch or self.client.default_branch
568+
schema_nodes = await self.fetch(branch=branch, namespaces=namespaces, populate_cache=False)
569+
return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces)
570+
500571
async def get_graphql_schema(self, branch: str | None = None) -> str:
501572
"""Get the GraphQL schema as a string.
502573
@@ -739,6 +810,32 @@ def fetch(
739810

740811
return branch_schema.nodes
741812

813+
def export(
814+
self,
815+
branch: str | None = None,
816+
namespaces: list[str] | None = None,
817+
) -> SchemaExport:
818+
"""Export user-defined schemas organized by namespace.
819+
820+
Fetches schemas from the server, filters out system types and
821+
restricted namespaces (see :data:`RESTRICTED_NAMESPACES`), and returns
822+
a :class:`SchemaExport` object with per-namespace data. Restricted
823+
namespaces such as ``Core`` and ``Builtin`` are always excluded even if
824+
explicitly listed in *namespaces*; a warning is emitted when this
825+
happens.
826+
827+
Args:
828+
branch: Branch to export from. Defaults to default_branch.
829+
namespaces: Optional list of namespaces to include. If empty/None,
830+
all user-defined namespaces are exported.
831+
832+
Returns:
833+
A :class:`SchemaExport` containing user-defined schemas by namespace.
834+
"""
835+
branch = branch or self.client.default_branch
836+
schema_nodes = self.fetch(branch=branch, namespaces=namespaces, populate_cache=False)
837+
return self._build_export_schemas(schema_nodes=schema_nodes, namespaces=namespaces)
838+
742839
def get_graphql_schema(self, branch: str | None = None) -> str:
743840
"""Get the GraphQL schema as a string.
744841

infrahub_sdk/schema/export.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, Field
6+
7+
from .main import GenericSchemaAPI, NodeSchemaAPI
8+
9+
10+
class NamespaceExport(BaseModel):
11+
"""Export data for a single namespace."""
12+
13+
nodes: list[dict[str, Any]] = Field(default_factory=list)
14+
generics: list[dict[str, Any]] = Field(default_factory=list)
15+
16+
17+
class SchemaExport(BaseModel):
18+
"""Result of a schema export, organized by namespace."""
19+
20+
namespaces: dict[str, NamespaceExport] = Field(default_factory=dict)
21+
22+
def to_dict(self) -> dict[str, dict[str, list[dict[str, Any]]]]:
23+
"""Convert to plain dict for YAML serialization."""
24+
return {ns: data.model_dump(exclude_defaults=True) for ns, data in self.namespaces.items()}
25+
26+
27+
# Namespaces reserved by the Infrahub server — mirrored from
28+
# backend/infrahub/core/constants/__init__.py in the opsmill/infrahub repo.
29+
RESTRICTED_NAMESPACES: list[str] = [
30+
"Account",
31+
"Branch",
32+
"Builtin",
33+
"Core",
34+
"Deprecated",
35+
"Diff",
36+
"Infrahub",
37+
"Internal",
38+
"Lineage",
39+
"Schema",
40+
"Profile",
41+
"Template",
42+
]
43+
44+
_SCHEMA_EXPORT_EXCLUDE: set[str] = {"hash", "hierarchy", "used_by", "id", "state"}
45+
# branch is inherited from the node and need not be repeated on each field
46+
_FIELD_EXPORT_EXCLUDE: set[str] = {"inherited", "allow_override", "hierarchical", "id", "state", "branch"}
47+
48+
# Attribute field values that match schema loading defaults — omitted for cleaner output
49+
_ATTR_EXPORT_DEFAULTS: dict[str, Any] = {
50+
"read_only": False,
51+
"optional": False,
52+
}
53+
54+
# Relationship field values that match schema loading defaults — omitted for cleaner output
55+
_REL_EXPORT_DEFAULTS: dict[str, Any] = {
56+
"direction": "bidirectional",
57+
"on_delete": "no-action",
58+
"cardinality": "many",
59+
"optional": True,
60+
"min_count": 0,
61+
"max_count": 0,
62+
"read_only": False,
63+
}
64+
65+
# Relationship kinds that Infrahub generates automatically — never user-defined
66+
_AUTO_GENERATED_REL_KINDS: frozenset[str] = frozenset({"Group", "Profile", "Hierarchy"})
67+
68+
69+
def schema_to_export_dict(schema: NodeSchemaAPI | GenericSchemaAPI) -> dict[str, Any]:
70+
"""Convert an API schema object to an export-ready dict (omits API-internal fields)."""
71+
data = schema.model_dump(exclude=_SCHEMA_EXPORT_EXCLUDE, exclude_none=True)
72+
73+
# Pop attrs/rels so they can be re-inserted last for better readability
74+
data.pop("attributes", None)
75+
data.pop("relationships", None)
76+
77+
# Generics with Hierarchy relationships were defined with `hierarchical: true`.
78+
# Restore that flag and drop the auto-generated rels so the schema round-trips cleanly.
79+
if isinstance(schema, GenericSchemaAPI) and any(
80+
rel.kind == "Hierarchy" for rel in schema.relationships if not rel.inherited
81+
):
82+
data["hierarchical"] = True
83+
84+
# Strip uniqueness_constraints that are auto-generated from `unique: true` attributes
85+
# (single-field entries of the form ["<attr>__value"]). User-defined multi-field
86+
# constraints are preserved.
87+
unique_attr_suffixes = {f"{attr.name}__value" for attr in schema.attributes if attr.unique}
88+
user_constraints = [
89+
c
90+
for c in (data.pop("uniqueness_constraints", None) or [])
91+
if not (len(c) == 1 and c[0] in unique_attr_suffixes)
92+
]
93+
if user_constraints:
94+
data["uniqueness_constraints"] = user_constraints
95+
96+
attributes = [
97+
{
98+
k: v
99+
for k, v in attr.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items()
100+
if k not in _ATTR_EXPORT_DEFAULTS or v != _ATTR_EXPORT_DEFAULTS[k]
101+
}
102+
for attr in schema.attributes
103+
if not attr.inherited
104+
]
105+
if attributes:
106+
data["attributes"] = attributes
107+
108+
relationships = [
109+
{
110+
k: v
111+
for k, v in rel.model_dump(exclude=_FIELD_EXPORT_EXCLUDE, exclude_none=True).items()
112+
if k not in _REL_EXPORT_DEFAULTS or v != _REL_EXPORT_DEFAULTS[k]
113+
}
114+
for rel in schema.relationships
115+
if not rel.inherited and rel.kind not in _AUTO_GENERATED_REL_KINDS
116+
]
117+
if relationships:
118+
data["relationships"] = relationships
119+
120+
return data

0 commit comments

Comments
 (0)