diff --git a/.yamllint.yml b/.yamllint.yml index bff226cd..65716d9a 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -7,6 +7,7 @@ ignore: | tests/unit/sdk/test_data/schema_encoding_error.yml /**/node_modules/** tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml + tests/fixtures/menus/invalid_yaml.yml rules: new-lines: disable diff --git a/changelog/+menu-validate.added.md b/changelog/+menu-validate.added.md new file mode 100644 index 00000000..0f24afc4 --- /dev/null +++ b/changelog/+menu-validate.added.md @@ -0,0 +1 @@ +Add `menu validate` command to validate the format of menu files. \ No newline at end of file diff --git a/docs/docs/infrahubctl/infrahubctl-menu.mdx b/docs/docs/infrahubctl/infrahubctl-menu.mdx index ef08ce5d..890156d0 100644 --- a/docs/docs/infrahubctl/infrahubctl-menu.mdx +++ b/docs/docs/infrahubctl/infrahubctl-menu.mdx @@ -17,6 +17,7 @@ $ infrahubctl menu [OPTIONS] COMMAND [ARGS]... **Commands**: * `load`: Load one or multiple menu files into... +* `validate`: Validate one or multiple menu files. ## `infrahubctl menu load` @@ -38,3 +39,24 @@ $ infrahubctl menu load [OPTIONS] MENUS... * `--branch TEXT`: Branch on which to load the menu. * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. + +## `infrahubctl menu validate` + +Validate one or multiple menu files. + +**Usage**: + +```console +$ infrahubctl menu validate [OPTIONS] PATHS... +``` + +**Arguments**: + +* `PATHS...`: [required] + +**Options**: + +* `--debug / --no-debug`: [default: no-debug] +* `--branch TEXT`: Branch on which to validate the objects. +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. diff --git a/infrahub_sdk/ctl/menu.py b/infrahub_sdk/ctl/menu.py index ca80f4be..ff702f8d 100644 --- a/infrahub_sdk/ctl/menu.py +++ b/infrahub_sdk/ctl/menu.py @@ -7,10 +7,14 @@ from ..async_typer import AsyncTyper from ..ctl.client import initialize_client from ..ctl.utils import catch_exception, init_logging -from ..exceptions import ObjectValidationError +from ..exceptions import ObjectValidationError, ValidationError from ..spec.menu import MenuFile from .parameters import CONFIG_PARAM -from .utils import load_yamlfile_from_disk_and_exit +from .utils import ( + display_object_validate_format_error, + display_object_validate_format_success, + load_yamlfile_from_disk_and_exit, +) app = AsyncTyper() console = Console() @@ -40,21 +44,54 @@ async def load( files = load_yamlfile_from_disk_and_exit(paths=menus, file_type=MenuFile, console=console) client = initialize_client() + has_errors = False + + for file in files: + try: + await file.validate_format(client=client, branch=branch) + except ValidationError as exc: + has_errors = True + display_object_validate_format_error(file=file, error=exc, console=console) + + if has_errors: + raise typer.Exit(1) + for file in files: - file.validate_content() - schema = await client.schema.get(kind=file.spec.kind, branch=branch) - - for idx, item in enumerate(file.spec.data): - try: - await file.spec.create_node( - client=client, - schema=schema, - position=[idx + 1], - data=item, - branch=branch, - default_schema_kind=file.spec.kind, - context={"list_index": idx}, - ) - except ObjectValidationError as exc: - console.print(f"[red] {exc!s}") - raise typer.Exit(1) + try: + await file.process(client=client, branch=branch) + except ObjectValidationError as exc: + has_errors = True + console.print(f"[red] {exc!s}") + + if has_errors: + raise typer.Exit(1) + + +@app.command() +@catch_exception(console=console) +async def validate( + paths: list[Path], + debug: bool = False, + branch: str = typer.Option(None, help="Branch on which to validate the objects."), + _: str = CONFIG_PARAM, +) -> None: + """Validate one or multiple menu files.""" + + init_logging(debug=debug) + + logging.getLogger("infrahub_sdk").setLevel(logging.INFO) + + files = load_yamlfile_from_disk_and_exit(paths=paths, file_type=MenuFile, console=console) + client = initialize_client() + + has_errors = False + for file in files: + try: + await file.validate_format(client=client, branch=branch) + display_object_validate_format_success(file=file, console=console) + except ValidationError as exc: + has_errors = True + display_object_validate_format_error(file=file, error=exc, console=console) + + if has_errors: + raise typer.Exit(1) diff --git a/infrahub_sdk/spec/menu.py b/infrahub_sdk/spec/menu.py index 87b73354..d49985f0 100644 --- a/infrahub_sdk/spec/menu.py +++ b/infrahub_sdk/spec/menu.py @@ -1,7 +1,7 @@ from __future__ import annotations from ..yaml import InfrahubFile, InfrahubFileKind -from .object import InfrahubObjectFileData +from .object import InfrahubObjectFileData, ObjectFile class InfrahubMenuFileData(InfrahubObjectFileData): @@ -18,7 +18,7 @@ def enrich_node(cls, data: dict, context: dict) -> dict: return data -class MenuFile(InfrahubFile): +class MenuFile(ObjectFile): _spec: InfrahubMenuFileData | None = None @property @@ -28,7 +28,7 @@ def spec(self) -> InfrahubMenuFileData: return self._spec def validate_content(self) -> None: - super().validate_content() + InfrahubFile.validate_content(self) if self.kind != InfrahubFileKind.MENU: raise ValueError("File is not an Infrahub Menu file") self._spec = InfrahubMenuFileData(**self.data.spec) diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index ab08ce33..acbf1551 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from ..exceptions import ObjectValidationError, ValidationError -from ..schema import RelationshipSchema +from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema from ..yaml import InfrahubFile, InfrahubFileKind if TYPE_CHECKING: @@ -59,6 +59,11 @@ def is_bidirectional(self) -> bool: def is_mandatory(self) -> bool: if not self.peer_rel: return False + # For hierarchical node, currently the relationship to the parent is always optional in the schema even if it's mandatory + # In order to build the tree from top to bottom, we need to consider it as mandatory + # While it should technically work bottom-up, it created some unexpected behavior while loading the menu + if self.peer_rel.cardinality == "one" and self.peer_rel.kind == RelationshipKind.HIERARCHY: + return True return not self.peer_rel.optional @property @@ -168,14 +173,28 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non schema = await client.schema.get(kind=self.kind, branch=branch) for idx, item in enumerate(self.data): errors.extend( - await self.validate_object(client=client, position=[idx + 1], schema=schema, data=item, branch=branch) + await self.validate_object( + client=client, + position=[idx + 1], + schema=schema, + data=item, + branch=branch, + default_schema_kind=self.kind, + ) ) return errors async def process(self, client: InfrahubClient, branch: str | None = None) -> None: schema = await client.schema.get(kind=self.kind, branch=branch) for idx, item in enumerate(self.data): - await self.create_node(client=client, schema=schema, data=item, position=[idx + 1], branch=branch) + await self.create_node( + client=client, + schema=schema, + data=item, + position=[idx + 1], + branch=branch, + default_schema_kind=self.kind, + ) @classmethod async def validate_object( @@ -186,6 +205,7 @@ async def validate_object( position: list[int | str], context: dict | None = None, branch: str | None = None, + default_schema_kind: str | None = None, ) -> list[ObjectValidationError]: errors: list[ObjectValidationError] = [] context = context.copy() if context else {} @@ -234,6 +254,7 @@ async def validate_object( data=value, context=context, branch=branch, + default_schema_kind=default_schema_kind, ) ) @@ -248,6 +269,7 @@ async def validate_related_nodes( data: dict | list[dict], context: dict | None = None, branch: str | None = None, + default_schema_kind: str | None = None, ) -> list[ObjectValidationError]: context = context.copy() if context else {} errors: list[ObjectValidationError] = [] @@ -260,7 +282,9 @@ async def validate_related_nodes( if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.ONE_OBJ: peer_kind = data.get("kind") or rel_info.peer_kind - peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + peer_schema = await cls.get_peer_schema( + client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind + ) rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value="placeholder")) @@ -273,13 +297,16 @@ async def validate_related_nodes( data=data["data"], context=context, branch=branch, + default_schema_kind=default_schema_kind, ) ) return errors if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.MANY_OBJ_DICT_LIST: peer_kind = data.get("kind") or rel_info.peer_kind - peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + peer_schema = await cls.get_peer_schema( + client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind + ) rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value="placeholder")) @@ -294,6 +321,7 @@ async def validate_related_nodes( data=peer_data, context=context, branch=branch, + default_schema_kind=default_schema_kind, ) ) return errors @@ -302,7 +330,9 @@ async def validate_related_nodes( for idx, item in enumerate(data): context["list_index"] = idx peer_kind = item.get("kind") or rel_info.peer_kind - peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + peer_schema = await cls.get_peer_schema( + client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind + ) rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value="placeholder")) @@ -315,6 +345,7 @@ async def validate_related_nodes( data=item["data"], context=context, branch=branch, + default_schema_kind=default_schema_kind, ) ) return errors @@ -345,7 +376,13 @@ async def create_node( context = context.copy() if context else {} errors = await cls.validate_object( - client=client, position=position, schema=schema, data=data, context=context, branch=branch + client=client, + position=position, + schema=schema, + data=data, + context=context, + branch=branch, + default_schema_kind=default_schema_kind, ) if errors: messages = [str(error) for error in errors] @@ -480,7 +517,9 @@ async def create_related_nodes( if isinstance(data, dict) and rel_info.format == RelationshipDataFormat.MANY_OBJ_DICT_LIST: peer_kind = data.get("kind") or rel_info.peer_kind - peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + peer_schema = await cls.get_peer_schema( + client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind + ) if parent_node: rel_info.find_matching_relationship(peer_schema=peer_schema) @@ -506,7 +545,9 @@ async def create_related_nodes( context["list_index"] = idx peer_kind = item.get("kind") or rel_info.peer_kind - peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + peer_schema = await cls.get_peer_schema( + client=client, peer_kind=peer_kind, branch=branch, default_schema_kind=default_schema_kind + ) if parent_node: rel_info.find_matching_relationship(peer_schema=peer_schema) @@ -529,6 +570,23 @@ async def create_related_nodes( f"Relationship {rel_info.rel_schema.name} doesn't have the right format {rel_info.rel_schema.cardinality} / {type(data)}" ) + @classmethod + async def get_peer_schema( + cls, client: InfrahubClient, peer_kind: str, branch: str | None = None, default_schema_kind: str | None = None + ) -> MainSchemaTypesAPI: + peer_schema = await client.schema.get(kind=peer_kind, branch=branch) + if not isinstance(peer_schema, GenericSchemaAPI): + return peer_schema + + if not default_schema_kind: + raise ValueError(f"Found a peer schema as a generic {peer_kind} but no default value was provided") + + # if the initial peer_kind was a generic, we try the default_schema_kind + peer_schema = await client.schema.get(kind=default_schema_kind, branch=branch) + if isinstance(peer_schema, GenericSchemaAPI): + raise ValueError(f"Default schema kind {default_schema_kind} can't be a generic") + return peer_schema + class ObjectFile(InfrahubFile): _spec: InfrahubObjectFileData | None = None diff --git a/tests/fixtures/menus/invalid_menu.yml b/tests/fixtures/menus/invalid_menu.yml new file mode 100644 index 00000000..57aa1d10 --- /dev/null +++ b/tests/fixtures/menus/invalid_menu.yml @@ -0,0 +1,20 @@ +--- +apiVersion: infrahub.app/v1 +kind: Menu +spec: + data: + - nampace: Testing + name: Animal + label: Animals + kind: TestingAnimal + children: + data: + - namespace: Testing + name: Dog + label: Dog + kind: TestingDog + + - namespace: Testing + name: Cat + label: Cat + kind: TestingCat diff --git a/tests/fixtures/menus/invalid_yaml.yml b/tests/fixtures/menus/invalid_yaml.yml new file mode 100644 index 00000000..a7d0cc5e --- /dev/null +++ b/tests/fixtures/menus/invalid_yaml.yml @@ -0,0 +1,20 @@ +--- +apiVersion: infrahub.app/v1 +kind: Menu +spec: + - namespace: Testing + - not valid + name: Animal + label: Animals + kind: TestingAnimal + children: + data: + - namespace: Testing + name: Dog + label: Dog + kind: TestingDog + + - namespace: Testing + name: Cat + label: Cat + kind: TestingCat diff --git a/tests/fixtures/menus/valid_menu.yml b/tests/fixtures/menus/valid_menu.yml new file mode 100644 index 00000000..cba1824c --- /dev/null +++ b/tests/fixtures/menus/valid_menu.yml @@ -0,0 +1,20 @@ +--- +apiVersion: infrahub.app/v1 +kind: Menu +spec: + data: + - namespace: Testing + name: Animal + label: Animals + kind: TestingAnimal + children: + data: + - namespace: Testing + name: Dog + label: Dog + kind: TestingDog + + - namespace: Testing + name: Cat + label: Cat + kind: TestingCat diff --git a/tests/fixtures/spec_objects/animal_menu01.yml b/tests/fixtures/spec_objects/animal_menu01.yml new file mode 100644 index 00000000..cba1824c --- /dev/null +++ b/tests/fixtures/spec_objects/animal_menu01.yml @@ -0,0 +1,20 @@ +--- +apiVersion: infrahub.app/v1 +kind: Menu +spec: + data: + - namespace: Testing + name: Animal + label: Animals + kind: TestingAnimal + children: + data: + - namespace: Testing + name: Dog + label: Dog + kind: TestingDog + + - namespace: Testing + name: Cat + label: Cat + kind: TestingCat diff --git a/tests/integration/test_spec_object.py b/tests/integration/test_spec_object.py index df94741b..6b0c7b15 100644 --- a/tests/integration/test_spec_object.py +++ b/tests/integration/test_spec_object.py @@ -4,6 +4,7 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.schema import SchemaRoot +from infrahub_sdk.spec.menu import MenuFile from infrahub_sdk.spec.object import ObjectFile from infrahub_sdk.testing.docker import TestInfrahubDockerClient from infrahub_sdk.testing.schemas.animal import SchemaAnimal @@ -16,6 +17,12 @@ def load_object_file(name: str) -> ObjectFile: return files[0] +def load_menu_file(name: str) -> MenuFile: + files = MenuFile.load_from_disk(paths=[get_fixtures_dir() / "spec_objects" / name]) + assert len(files) == 1 + return files[0] + + class TestSpecObject(TestInfrahubDockerClient, SchemaAnimal): @pytest.fixture(scope="class") def branch_name(self) -> str: @@ -115,3 +122,17 @@ async def test_load_persons02(self, client: InfrahubClient, branch_name: str, in await person_by_name["Emily Parker"].animals.fetch() animals_emily = [animal.display_label for animal in person_by_name["Emily Parker"].animals.peers] assert sorted(animals_emily) == sorted(["Max Golden Retriever", "Whiskers Siamese #FFD700"]) + + async def test_load_menu(self, client: InfrahubClient, branch_name: str, initial_schema: None): + menu_file = load_menu_file("animal_menu01.yml") + await menu_file.validate_format(client=client, branch=branch_name) + + await menu_file.process(client=client, branch=branch_name) + + menu = await client.filters(kind=menu_file.spec.kind, protected__value=False, branch=branch_name) + assert len(menu) == 3 + + menu_by_name = {menu.display_label: menu for menu in menu} + await menu_by_name["Animals"].children.fetch() + peer_labels = [peer.display_label for peer in menu_by_name["Animals"].children.peers] + assert sorted(peer_labels) == sorted(["Dog", "Cat"]) diff --git a/tests/unit/ctl/conftest.py b/tests/unit/ctl/conftest.py index ea4ce43a..e63efec1 100644 --- a/tests/unit/ctl/conftest.py +++ b/tests/unit/ctl/conftest.py @@ -1,9 +1,28 @@ import pytest +import ujson from pytest_httpx import HTTPXMock +from infrahub_sdk.utils import get_fixtures_dir from tests.unit.sdk.conftest import mock_query_infrahub_user, mock_query_infrahub_version # noqa: F401 +@pytest.fixture +async def schema_query_05_data() -> dict: + response_text = (get_fixtures_dir() / "schema_05.json").read_text(encoding="UTF-8") + return ujson.loads(response_text) + + +@pytest.fixture +async def mock_schema_query_05(httpx_mock: HTTPXMock, schema_query_05_data: dict) -> HTTPXMock: + httpx_mock.add_response( + method="GET", + url="http://mock/api/schema?branch=main", + json=schema_query_05_data, + is_reusable=True, + ) + return httpx_mock + + @pytest.fixture async def mock_branches_list_query(httpx_mock: HTTPXMock) -> HTTPXMock: response = { diff --git a/tests/unit/ctl/test_menu_app.py b/tests/unit/ctl/test_menu_app.py new file mode 100644 index 00000000..9b09949b --- /dev/null +++ b/tests/unit/ctl/test_menu_app.py @@ -0,0 +1,36 @@ +from pytest_httpx import HTTPXMock +from typer.testing import CliRunner + +from infrahub_sdk.ctl.menu import app +from infrahub_sdk.ctl.utils import get_fixtures_dir +from tests.helpers.cli import remove_ansi_color + +runner = CliRunner() + + +def test_menu_validate_bad_yaml() -> None: + fixture_file = get_fixtures_dir() / "menus" / "invalid_yaml.yml" + result = runner.invoke(app=app, args=["load", str(fixture_file)]) + + assert result.exit_code == 1 + assert "Invalid YAML/JSON file" in result.stdout + + +def test_menu_validate_valid(mock_schema_query_05: HTTPXMock) -> None: + fixture_file = get_fixtures_dir() / "menus" / "valid_menu.yml" + + result = runner.invoke(app=app, args=["validate", str(fixture_file)]) + + assert result.exit_code == 0 + assert f"File '{fixture_file}' is Valid!" in remove_ansi_color(result.stdout.replace("\n", "")) + + +def test_menu_validate_invalid(mock_schema_query_05: HTTPXMock) -> None: + fixture_file = get_fixtures_dir() / "menus" / "invalid_menu.yml" + + result = runner.invoke(app=app, args=["validate", str(fixture_file)]) + + assert result.exit_code == 1 + assert f"File '{fixture_file}' is not valid! 1.namespace: namespace is mandatory" in remove_ansi_color( + result.stdout.replace("\n", "") + )