Skip to content

Commit cdf9717

Browse files
authored
Merge pull request #363 from opsmill/dga-20250416-fix-menu-cmd
Add `menu validate` command to validate the format of menu files.
2 parents d9bfef9 + 247d209 commit cdf9717

File tree

13 files changed

+306
-31
lines changed

13 files changed

+306
-31
lines changed

.yamllint.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ ignore: |
77
tests/unit/sdk/test_data/schema_encoding_error.yml
88
/**/node_modules/**
99
tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml
10+
tests/fixtures/menus/invalid_yaml.yml
1011
1112
rules:
1213
new-lines: disable

changelog/+menu-validate.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `menu validate` command to validate the format of menu files.

docs/docs/infrahubctl/infrahubctl-menu.mdx

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

1919
* `load`: Load one or multiple menu files into...
20+
* `validate`: Validate one or multiple menu files.
2021

2122
## `infrahubctl menu load`
2223

@@ -38,3 +39,24 @@ $ infrahubctl menu load [OPTIONS] MENUS...
3839
* `--branch TEXT`: Branch on which to load the menu.
3940
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
4041
* `--help`: Show this message and exit.
42+
43+
## `infrahubctl menu validate`
44+
45+
Validate one or multiple menu files.
46+
47+
**Usage**:
48+
49+
```console
50+
$ infrahubctl menu validate [OPTIONS] PATHS...
51+
```
52+
53+
**Arguments**:
54+
55+
* `PATHS...`: [required]
56+
57+
**Options**:
58+
59+
* `--debug / --no-debug`: [default: no-debug]
60+
* `--branch TEXT`: Branch on which to validate the objects.
61+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
62+
* `--help`: Show this message and exit.

infrahub_sdk/ctl/menu.py

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
from ..async_typer import AsyncTyper
88
from ..ctl.client import initialize_client
99
from ..ctl.utils import catch_exception, init_logging
10-
from ..exceptions import ObjectValidationError
10+
from ..exceptions import ObjectValidationError, ValidationError
1111
from ..spec.menu import MenuFile
1212
from .parameters import CONFIG_PARAM
13-
from .utils import load_yamlfile_from_disk_and_exit
13+
from .utils import (
14+
display_object_validate_format_error,
15+
display_object_validate_format_success,
16+
load_yamlfile_from_disk_and_exit,
17+
)
1418

1519
app = AsyncTyper()
1620
console = Console()
@@ -40,21 +44,54 @@ async def load(
4044
files = load_yamlfile_from_disk_and_exit(paths=menus, file_type=MenuFile, console=console)
4145
client = initialize_client()
4246

47+
has_errors = False
48+
49+
for file in files:
50+
try:
51+
await file.validate_format(client=client, branch=branch)
52+
except ValidationError as exc:
53+
has_errors = True
54+
display_object_validate_format_error(file=file, error=exc, console=console)
55+
56+
if has_errors:
57+
raise typer.Exit(1)
58+
4359
for file in files:
44-
file.validate_content()
45-
schema = await client.schema.get(kind=file.spec.kind, branch=branch)
46-
47-
for idx, item in enumerate(file.spec.data):
48-
try:
49-
await file.spec.create_node(
50-
client=client,
51-
schema=schema,
52-
position=[idx + 1],
53-
data=item,
54-
branch=branch,
55-
default_schema_kind=file.spec.kind,
56-
context={"list_index": idx},
57-
)
58-
except ObjectValidationError as exc:
59-
console.print(f"[red] {exc!s}")
60-
raise typer.Exit(1)
60+
try:
61+
await file.process(client=client, branch=branch)
62+
except ObjectValidationError as exc:
63+
has_errors = True
64+
console.print(f"[red] {exc!s}")
65+
66+
if has_errors:
67+
raise typer.Exit(1)
68+
69+
70+
@app.command()
71+
@catch_exception(console=console)
72+
async def validate(
73+
paths: list[Path],
74+
debug: bool = False,
75+
branch: str = typer.Option(None, help="Branch on which to validate the objects."),
76+
_: str = CONFIG_PARAM,
77+
) -> None:
78+
"""Validate one or multiple menu files."""
79+
80+
init_logging(debug=debug)
81+
82+
logging.getLogger("infrahub_sdk").setLevel(logging.INFO)
83+
84+
files = load_yamlfile_from_disk_and_exit(paths=paths, file_type=MenuFile, console=console)
85+
client = initialize_client()
86+
87+
has_errors = False
88+
for file in files:
89+
try:
90+
await file.validate_format(client=client, branch=branch)
91+
display_object_validate_format_success(file=file, console=console)
92+
except ValidationError as exc:
93+
has_errors = True
94+
display_object_validate_format_error(file=file, error=exc, console=console)
95+
96+
if has_errors:
97+
raise typer.Exit(1)

infrahub_sdk/spec/menu.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from ..yaml import InfrahubFile, InfrahubFileKind
4-
from .object import InfrahubObjectFileData
4+
from .object import InfrahubObjectFileData, ObjectFile
55

66

77
class InfrahubMenuFileData(InfrahubObjectFileData):
@@ -18,7 +18,7 @@ def enrich_node(cls, data: dict, context: dict) -> dict:
1818
return data
1919

2020

21-
class MenuFile(InfrahubFile):
21+
class MenuFile(ObjectFile):
2222
_spec: InfrahubMenuFileData | None = None
2323

2424
@property
@@ -28,7 +28,7 @@ def spec(self) -> InfrahubMenuFileData:
2828
return self._spec
2929

3030
def validate_content(self) -> None:
31-
super().validate_content()
31+
InfrahubFile.validate_content(self)
3232
if self.kind != InfrahubFileKind.MENU:
3333
raise ValueError("File is not an Infrahub Menu file")
3434
self._spec = InfrahubMenuFileData(**self.data.spec)

infrahub_sdk/spec/object.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import BaseModel, Field
77

88
from ..exceptions import ObjectValidationError, ValidationError
9-
from ..schema import RelationshipSchema
9+
from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema
1010
from ..yaml import InfrahubFile, InfrahubFileKind
1111

1212
if TYPE_CHECKING:
@@ -59,6 +59,11 @@ def is_bidirectional(self) -> bool:
5959
def is_mandatory(self) -> bool:
6060
if not self.peer_rel:
6161
return False
62+
# For hierarchical node, currently the relationship to the parent is always optional in the schema even if it's mandatory
63+
# In order to build the tree from top to bottom, we need to consider it as mandatory
64+
# While it should technically work bottom-up, it created some unexpected behavior while loading the menu
65+
if self.peer_rel.cardinality == "one" and self.peer_rel.kind == RelationshipKind.HIERARCHY:
66+
return True
6267
return not self.peer_rel.optional
6368

6469
@property
@@ -168,14 +173,28 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non
168173
schema = await client.schema.get(kind=self.kind, branch=branch)
169174
for idx, item in enumerate(self.data):
170175
errors.extend(
171-
await self.validate_object(client=client, position=[idx + 1], schema=schema, data=item, branch=branch)
176+
await self.validate_object(
177+
client=client,
178+
position=[idx + 1],
179+
schema=schema,
180+
data=item,
181+
branch=branch,
182+
default_schema_kind=self.kind,
183+
)
172184
)
173185
return errors
174186

175187
async def process(self, client: InfrahubClient, branch: str | None = None) -> None:
176188
schema = await client.schema.get(kind=self.kind, branch=branch)
177189
for idx, item in enumerate(self.data):
178-
await self.create_node(client=client, schema=schema, data=item, position=[idx + 1], branch=branch)
190+
await self.create_node(
191+
client=client,
192+
schema=schema,
193+
data=item,
194+
position=[idx + 1],
195+
branch=branch,
196+
default_schema_kind=self.kind,
197+
)
179198

180199
@classmethod
181200
async def validate_object(
@@ -186,6 +205,7 @@ async def validate_object(
186205
position: list[int | str],
187206
context: dict | None = None,
188207
branch: str | None = None,
208+
default_schema_kind: str | None = None,
189209
) -> list[ObjectValidationError]:
190210
errors: list[ObjectValidationError] = []
191211
context = context.copy() if context else {}
@@ -234,6 +254,7 @@ async def validate_object(
234254
data=value,
235255
context=context,
236256
branch=branch,
257+
default_schema_kind=default_schema_kind,
237258
)
238259
)
239260

@@ -248,6 +269,7 @@ async def validate_related_nodes(
248269
data: dict | list[dict],
249270
context: dict | None = None,
250271
branch: str | None = None,
272+
default_schema_kind: str | None = None,
251273
) -> list[ObjectValidationError]:
252274
context = context.copy() if context else {}
253275
errors: list[ObjectValidationError] = []
@@ -260,7 +282,9 @@ async def validate_related_nodes(
260282

261283
if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.ONE_OBJ:
262284
peer_kind = data.get("kind") or rel_info.peer_kind
263-
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
285+
peer_schema = await cls.get_peer_schema(
286+
client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind
287+
)
264288

265289
rel_info.find_matching_relationship(peer_schema=peer_schema)
266290
context.update(rel_info.get_context(value="placeholder"))
@@ -273,13 +297,16 @@ async def validate_related_nodes(
273297
data=data["data"],
274298
context=context,
275299
branch=branch,
300+
default_schema_kind=default_schema_kind,
276301
)
277302
)
278303
return errors
279304

280305
if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.MANY_OBJ_DICT_LIST:
281306
peer_kind = data.get("kind") or rel_info.peer_kind
282-
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
307+
peer_schema = await cls.get_peer_schema(
308+
client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind
309+
)
283310

284311
rel_info.find_matching_relationship(peer_schema=peer_schema)
285312
context.update(rel_info.get_context(value="placeholder"))
@@ -294,6 +321,7 @@ async def validate_related_nodes(
294321
data=peer_data,
295322
context=context,
296323
branch=branch,
324+
default_schema_kind=default_schema_kind,
297325
)
298326
)
299327
return errors
@@ -302,7 +330,9 @@ async def validate_related_nodes(
302330
for idx, item in enumerate(data):
303331
context["list_index"] = idx
304332
peer_kind = item.get("kind") or rel_info.peer_kind
305-
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
333+
peer_schema = await cls.get_peer_schema(
334+
client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind
335+
)
306336

307337
rel_info.find_matching_relationship(peer_schema=peer_schema)
308338
context.update(rel_info.get_context(value="placeholder"))
@@ -315,6 +345,7 @@ async def validate_related_nodes(
315345
data=item["data"],
316346
context=context,
317347
branch=branch,
348+
default_schema_kind=default_schema_kind,
318349
)
319350
)
320351
return errors
@@ -345,7 +376,13 @@ async def create_node(
345376
context = context.copy() if context else {}
346377

347378
errors = await cls.validate_object(
348-
client=client, position=position, schema=schema, data=data, context=context, branch=branch
379+
client=client,
380+
position=position,
381+
schema=schema,
382+
data=data,
383+
context=context,
384+
branch=branch,
385+
default_schema_kind=default_schema_kind,
349386
)
350387
if errors:
351388
messages = [str(error) for error in errors]
@@ -480,7 +517,9 @@ async def create_related_nodes(
480517

481518
if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.MANY_OBJ_DICT_LIST:
482519
peer_kind = data.get("kind") or rel_info.peer_kind
483-
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
520+
peer_schema = await cls.get_peer_schema(
521+
client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind
522+
)
484523

485524
if parent_node:
486525
rel_info.find_matching_relationship(peer_schema=peer_schema)
@@ -506,7 +545,9 @@ async def create_related_nodes(
506545
context["list_index"] = idx
507546

508547
peer_kind = item.get("kind") or rel_info.peer_kind
509-
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
548+
peer_schema = await cls.get_peer_schema(
549+
client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind
550+
)
510551

511552
if parent_node:
512553
rel_info.find_matching_relationship(peer_schema=peer_schema)
@@ -529,6 +570,23 @@ async def create_related_nodes(
529570
f"Relationship {rel_info.rel_schema.name} doesn't have the right format {rel_info.rel_schema.cardinality} / {type(data)}"
530571
)
531572

573+
@classmethod
574+
async def get_peer_schema(
575+
cls, client: InfrahubClient, peer_kind: str, branch: str | None = None, default_schema_kind: str | None = None
576+
) -> MainSchemaTypesAPI:
577+
peer_schema = await client.schema.get(kind=peer_kind, branch=branch)
578+
if not isinstance(peer_schema, GenericSchemaAPI):
579+
return peer_schema
580+
581+
if not default_schema_kind:
582+
raise ValueError(f"Found a peer schema as a generic {peer_kind} but no default value was provided")
583+
584+
# if the initial peer_kind was a generic, we try the default_schema_kind
585+
peer_schema = await client.schema.get(kind=default_schema_kind, branch=branch)
586+
if isinstance(peer_schema, GenericSchemaAPI):
587+
raise ValueError(f"Default schema kind {default_schema_kind} can't be a generic")
588+
return peer_schema
589+
532590

533591
class ObjectFile(InfrahubFile):
534592
_spec: InfrahubObjectFileData | None = None
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
apiVersion: infrahub.app/v1
3+
kind: Menu
4+
spec:
5+
data:
6+
- nampace: Testing
7+
name: Animal
8+
label: Animals
9+
kind: TestingAnimal
10+
children:
11+
data:
12+
- namespace: Testing
13+
name: Dog
14+
label: Dog
15+
kind: TestingDog
16+
17+
- namespace: Testing
18+
name: Cat
19+
label: Cat
20+
kind: TestingCat
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
apiVersion: infrahub.app/v1
3+
kind: Menu
4+
spec:
5+
- namespace: Testing
6+
- not valid
7+
name: Animal
8+
label: Animals
9+
kind: TestingAnimal
10+
children:
11+
data:
12+
- namespace: Testing
13+
name: Dog
14+
label: Dog
15+
kind: TestingDog
16+
17+
- namespace: Testing
18+
name: Cat
19+
label: Cat
20+
kind: TestingCat

0 commit comments

Comments
 (0)