diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f879fec..58d680fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,7 +166,7 @@ jobs: python-version: "3.12" - name: "Setup Python environment" run: | - pipx install poetry==1.8.5 + pipx install poetry==2.1 poetry config virtualenvs.create true --local poetry env use 3.12 - name: "Install dependencies" @@ -230,7 +230,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: "Setup environment" run: | - pipx install poetry==1.8.5 --python python${{ matrix.python-version }} + pipx install poetry==2.1 --python python${{ matrix.python-version }} poetry config virtualenvs.create true --local pip install invoke toml codecov - name: "Install Package" @@ -283,7 +283,7 @@ jobs: echo "PYTEST_DEBUG_TEMPROOT=/var/lib/github/${RUNNER_NAME}/_temp" >> $GITHUB_ENV - name: "Setup environment" run: | - pipx install poetry==1.8.5 + pipx install poetry==2.1 poetry config virtualenvs.create true --local pip install invoke toml codecov - name: "Install Package" @@ -359,7 +359,7 @@ jobs: # - name: "Setup environment" # run: | - # pipx install poetry==1.8.5 + # pipx install poetry==2.1 # poetry config virtualenvs.create true --local # pip install invoke toml codecov diff --git a/.gitignore b/.gitignore index 6d21724f..dd88d992 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ dist/* **/*.csv # Generated files -generated/ \ No newline at end of file +generated/ +sandbox/ \ No newline at end of file diff --git a/.yamllint.yml b/.yamllint.yml index de7fcc7f..bff226cd 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -6,6 +6,7 @@ ignore: | /examples tests/unit/sdk/test_data/schema_encoding_error.yml /**/node_modules/** + tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml rules: new-lines: disable diff --git a/docs/docs/infrahubctl/infrahubctl-object.mdx b/docs/docs/infrahubctl/infrahubctl-object.mdx index 9ace8cda..ad607e7e 100644 --- a/docs/docs/infrahubctl/infrahubctl-object.mdx +++ b/docs/docs/infrahubctl/infrahubctl-object.mdx @@ -17,6 +17,7 @@ $ infrahubctl object [OPTIONS] COMMAND [ARGS]... **Commands**: * `load`: Load one or multiple objects files into... +* `validate`: Validate one or multiple objects files. ## `infrahubctl object load` @@ -38,3 +39,24 @@ $ infrahubctl object load [OPTIONS] PATHS... * `--branch TEXT`: Branch on which to load the objects. * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. + +## `infrahubctl object validate` + +Validate one or multiple objects files. + +**Usage**: + +```console +$ infrahubctl object 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/docs/docs/python-sdk/topics/object_file.mdx b/docs/docs/python-sdk/topics/object_file.mdx new file mode 100644 index 00000000..a383df56 --- /dev/null +++ b/docs/docs/python-sdk/topics/object_file.mdx @@ -0,0 +1,195 @@ +--- +title: Manage data with Object Files +--- + +# Manage data with Object files + +## Introduction + +An Object file is a YAML file that allows you to manage data to be loaded in Infrahub based on your own custom schema. It provides a declarative way to define and manage resources in your Infrahub instance. + +Object files work well for models that don't change too often and/or that need to be tracked in Git. Examples include: Groups, tags, Users, etc. +Below is an example of an Object file that defines tags (`BuiltinTag`). + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: BuiltinTag + data: + - name: Blue + - name: Yellow + - name: Red +``` + +Object files are meant to be used in an idempotent way and as such they work better for models with a Human Friendly ID (HFID) defined. An HFID is a unique identifier that makes it easier to reference objects across different files and operations. + +## Load Object files into Infrahub + +Object files can be loaded into Infrahub using the `infrahub object load` command. + +```bash +infrahub object load +``` + +Multiple object files can be loaded at once by specifying the path to multiple files or by specifying a directory. + +The `object load` command will create/update the objects using an `Upsert` operation. All objects previously loaded will NOT be deleted in the Infrahub instance. +Also, if some objects present in different files are identical and dependent on each other, the `object load` command will NOT calculate the dependencies between the objects and as such it's the responsibility of the users to execute the command in the right order. + +### Validate the format of object files + +The object file can be validated using the `infrahub object validate` command. + +```bash +infrahub object validate +``` + +## Object file format + +All object files must start with the following format, all other formats will be automatically ignored. +Each file is intended for one specific top level kind, but one file can include multiple nested objects of any kind. +The kind of the top level object must be defined in spec/kind. + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: + data: + - [...] +``` + +> Multiple documents in a single YAML file are also supported, each document will be loaded separately. Documents are separated by `---` + +### Relationship of cardinality one + +A relationship of cardinality one can either reference an existing node via its HFID or create a new node if it doesn't exist. +In the example below, both `site` and `primary_ip` are relationships of cardinality one. + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: InfraDevice + data: + - name: edge01 + site: "Paris" # Reference existing node via its HFID + primary_ip: # Nested object, will be created if it doesn't exist + data: + address: "192.168.1.1" +``` + +### Relationship of cardinality many + +A relationship of cardinality many can reference existing nodes via their HFID or define nested objects. + +#### Existing nodes referenced by their HFID + +Existing nodes can be referenced by their HFID in string format or in list format. +In the example below, both `best_friends` and `tags` are relationships of cardinality many. + +> An HFID is composed of a single value, it's possible to use a string instead of a list + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingPerson + data: + - name: Mike Johnson + height: 175 + best_friends: # Relationship of cardinality many that references existing nodes based on their HFID + - [Jane Smith, Max] + - [Sarah Williams, Charlie] + tags: + - Veterinarian # Existing Node referenced by its HFID in string format + - [Breeder] # Existing Node referenced by its HFID in list format +``` + +#### Nested objects + +When defining nested objects, the node will be automatically created if it doesn't exist and if the relationship between the parent object and the nested object exists, it will be automatically inserted. +For example, in the example below, the `owner` of a `TestingDog` doesn't need to be specified because it will be automatically inserted. + +Two different syntax are supported: + +- A dictionary with multiple values under data +- A list of objects + +##### Nested objects as a dictionary + +In the example below, `tags` is a relationship of cardinality many that is defined as a dictionary with multiple values under data. + +> The kind is optional here because there is only one option possible (not a generic) + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingPerson + data: + - name: Alex Thompson + tags: + data: + - name: dog-lover + description: "Dog Lover" + - name: veterinarian + description: "Veterinarian" +``` + +This format works well when all objects are of the same kind and when all objects are using the same properties. +For more complex cases, the list of objects format is more flexible. + +##### Nested objects as a list of objects + +In the example below, `animals` is a relationship of cardinality many that is defined as a list of objects. +Each object must contain a `data` key and each object can also define a specific `kind`. + +> If the kind is not specified, it will be inferred from schema + +```yaml +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingPerson + data: + - name: Alex Thompson + height: 180 + animals: + - kind: TestingDog + data: + name: Max + weight: 25 + breed: Golden Retriever + color: "#FFD700" + - kind: TestingCat + data: + name: Mimi + breed: Persian +``` + +### Support for metadata + +Metadata support is planned for future releases. Currently, the Object file does not support metadata on attributes or relationships. + +## Troubleshooting + +### Common issues + +1. **Objects not being created**: Ensure that the YAML syntax is correct and that the file follows the required format. +2. **Dependency errors**: When objects depend on each other, load them in the correct order (dependencies first). +3. **Validation errors**: Use the `infrahub object validate` command to check for syntax errors before loading. + +### Best practices + +1. Use Human Friendly IDs (HFIDs) for all objects to ensure consistent referencing. +2. Keep object files organized by model type or purpose. +3. Validate object files before loading them into production environments. +4. Use comments in your YAML files to document complex relationships or dependencies. diff --git a/docs/sidebars-python-sdk.ts b/docs/sidebars-python-sdk.ts index 354c9006..e2fc932c 100644 --- a/docs/sidebars-python-sdk.ts +++ b/docs/sidebars-python-sdk.ts @@ -32,6 +32,7 @@ const sidebars: SidebarsConfig = { label: 'Topics', items: [ 'topics/tracking', + 'topics/object_file', ], }, { diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index f503024e..13910621 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -61,7 +61,7 @@ app.add_typer(validate_app, name="validate") app.add_typer(repository_app, name="repository") app.add_typer(menu_app, name="menu") -app.add_typer(object_app, name="object", hidden=True) +app.add_typer(object_app, name="object") app.command(name="dump")(dump) app.command(name="load")(load) diff --git a/infrahub_sdk/ctl/menu.py b/infrahub_sdk/ctl/menu.py index 560564ed..ca80f4be 100644 --- a/infrahub_sdk/ctl/menu.py +++ b/infrahub_sdk/ctl/menu.py @@ -7,6 +7,7 @@ from ..async_typer import AsyncTyper from ..ctl.client import initialize_client from ..ctl.utils import catch_exception, init_logging +from ..exceptions import ObjectValidationError from ..spec.menu import MenuFile from .parameters import CONFIG_PARAM from .utils import load_yamlfile_from_disk_and_exit @@ -44,11 +45,16 @@ async def load( schema = await client.schema.get(kind=file.spec.kind, branch=branch) for idx, item in enumerate(file.spec.data): - await file.spec.create_node( - client=client, - schema=schema, - data=item, - branch=branch, - default_schema_kind=file.spec.kind, - context={"list_index": idx}, - ) + 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) diff --git a/infrahub_sdk/ctl/object.py b/infrahub_sdk/ctl/object.py index b589fcc2..79c6aa3d 100644 --- a/infrahub_sdk/ctl/object.py +++ b/infrahub_sdk/ctl/object.py @@ -7,9 +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, ValidationError from ..spec.object import ObjectFile 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() @@ -39,9 +44,54 @@ async def load( files = load_yamlfile_from_disk_and_exit(paths=paths, file_type=ObjectFile, 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: + 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 objects files.""" + + init_logging(debug=debug) + + logging.getLogger("infrahub_sdk").setLevel(logging.INFO) + + files = load_yamlfile_from_disk_and_exit(paths=paths, file_type=ObjectFile, console=console) + client = initialize_client() + + has_errors = False for file in files: - file.validate_content() - schema = await client.schema.get(kind=file.spec.kind, branch=branch) + 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) - for item in file.spec.data: - await file.spec.create_node(client=client, schema=schema, data=item, branch=branch) + if has_errors: + raise typer.Exit(1) diff --git a/infrahub_sdk/ctl/utils.py b/infrahub_sdk/ctl/utils.py index 898095c7..63d7dfb8 100644 --- a/infrahub_sdk/ctl/utils.py +++ b/infrahub_sdk/ctl/utils.py @@ -25,6 +25,7 @@ SchemaNotFoundError, ServerNotReachableError, ServerNotResponsiveError, + ValidationError, ) from ..yaml import YamlFile from .client import initialize_client_sync @@ -32,6 +33,7 @@ if TYPE_CHECKING: from ..schema.repository import InfrahubRepositoryConfig + from ..spec.object import ObjectFile YamlFileVar = TypeVar("YamlFileVar", bound=YamlFile) T = TypeVar("T") @@ -198,4 +200,23 @@ def load_yamlfile_from_disk_and_exit( if has_error: raise typer.Exit(1) - return data_files + return sorted(data_files, key=lambda x: x.location) + + +def display_object_validate_format_success(file: ObjectFile, console: Console) -> None: + if file.multiple_documents: + console.print(f"[green] File '{file.location}' [{file.document_position}] is Valid!") + else: + console.print(f"[green] File '{file.location}' is Valid!") + + +def display_object_validate_format_error(file: ObjectFile, error: ValidationError, console: Console) -> None: + if file.multiple_documents: + console.print(f"[red] File '{file.location}' [{file.document_position}] is not valid!") + else: + console.print(f"[red] File '{file.location}' is not valid!") + if error.messages: + for message in error.messages: + console.print(f"[red] {message}") + else: + console.print(f"[red] {error.message}") diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index f8a5b541..a8b1ef9b 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -113,11 +113,29 @@ def __init__(self, name: str, message: str | None = None): class ValidationError(Error): - def __init__(self, identifier: str, message: str): + def __init__(self, identifier: str, message: str | None = None, messages: list[str] | None = None): self.identifier = identifier self.message = message + self.messages = messages + if not messages and not message: + self.message = f"Validation Error for {self.identifier}" super().__init__(self.message) + def __str__(self) -> str: + if self.messages: + return f"{self.identifier}: {', '.join(self.messages)}" + return f"{self.identifier}: {self.message}" + + +class ObjectValidationError(Error): + def __init__(self, position: list[int | str], message: str): + self.position = position + self.message = message + super().__init__(self.message) + + def __str__(self) -> str: + return f"{'.'.join(map(str, self.position))}: {self.message}" + class AuthenticationError(Error): def __init__(self, message: str | None = None): diff --git a/infrahub_sdk/schema/main.py b/infrahub_sdk/schema/main.py index af5556b3..b3f174b0 100644 --- a/infrahub_sdk/schema/main.py +++ b/infrahub_sdk/schema/main.py @@ -231,7 +231,11 @@ def mandatory_input_names(self) -> list[str]: @property def mandatory_attribute_names(self) -> list[str]: - return [item.name for item in self.attributes if not item.optional and item.default_value is None] + return [ + item.name + for item in self.attributes + if (not item.optional and item.default_value is None) and not item.read_only + ] @property def mandatory_relationship_names(self) -> list[str]: diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index 1445a5eb..ab08ce33 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -1,20 +1,332 @@ from __future__ import annotations +from enum import Enum from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field +from ..exceptions import ObjectValidationError, ValidationError +from ..schema import RelationshipSchema from ..yaml import InfrahubFile, InfrahubFileKind if TYPE_CHECKING: from ..client import InfrahubClient - from ..schema import MainSchemaTypesAPI + from ..node import InfrahubNode + from ..schema import MainSchemaTypesAPI, RelationshipSchema + + +def validate_list_of_scalars(value: list[Any]) -> bool: + return all(isinstance(item, (str, int, float, bool)) for item in value) + + +def validate_list_of_hfids(value: list[Any]) -> bool: + return all(isinstance(item, (str, list)) for item in value) + + +def validate_list_of_data_dicts(value: list[Any]) -> bool: + return all(isinstance(item, dict) and "data" in item for item in value) + + +def validate_list_of_objects(value: list[Any]) -> bool: + return all(isinstance(item, dict) for item in value) + + +class RelationshipDataFormat(str, Enum): + UNKNOWN = "unknown" + + ONE_REF = "one_ref" + ONE_OBJ = "one_obj" + + MANY_OBJ_DICT_LIST = "many_obj_dict_list" + MANY_OBJ_LIST_DICT = "many_obj_list_dict" + MANY_REF = "many_ref_list" + + +class RelationshipInfo(BaseModel): + name: str + rel_schema: RelationshipSchema + peer_kind: str + peer_rel: RelationshipSchema | None = None + reason_relationship_not_valid: str | None = None + format: RelationshipDataFormat = RelationshipDataFormat.UNKNOWN + + @property + def is_bidirectional(self) -> bool: + """Indicate if a relationship with the same identifier exists on the other side""" + return bool(self.peer_rel) + + @property + def is_mandatory(self) -> bool: + if not self.peer_rel: + return False + return not self.peer_rel.optional + + @property + def is_valid(self) -> bool: + return not self.reason_relationship_not_valid + + @property + def is_reference(self) -> bool: + return self.format in [RelationshipDataFormat.ONE_REF, RelationshipDataFormat.MANY_REF] + + def get_context(self, value: Any) -> dict: + """Return a dict to insert to the context if the relationship is mandatory""" + if self.peer_rel and self.is_mandatory and self.peer_rel.cardinality == "one": + return {self.peer_rel.name: value} + if self.peer_rel and self.is_mandatory and self.peer_rel.cardinality == "many": + return {self.peer_rel.name: [value]} + return {} + + def find_matching_relationship( + self, peer_schema: MainSchemaTypesAPI, force: bool = False + ) -> RelationshipSchema | None: + """Find the matching relationship on the other side of the relationship""" + if self.peer_rel and not force: + return self.peer_rel + + try: + self.peer_rel = peer_schema.get_matching_relationship( + id=self.rel_schema.identifier or "", direction=self.rel_schema.direction + ) + except ValueError: + pass + + return self.peer_rel + + +async def get_relationship_info( + client: InfrahubClient, schema: MainSchemaTypesAPI, name: str, value: Any, branch: str | None = None +) -> RelationshipInfo: + """ + Get the relationship info for a given relationship name. + """ + rel_schema = schema.get_relationship(name=name) + + info = RelationshipInfo(name=name, peer_kind=rel_schema.peer, rel_schema=rel_schema) + + if isinstance(value, dict) and "data" not in value: + info.reason_relationship_not_valid = f"Relationship {name} must be a dict with 'data'" + return info + + if isinstance(value, dict) and "kind" in value: + info.peer_kind = value["kind"] + + peer_schema = await client.schema.get(kind=info.peer_kind, branch=branch) + + try: + info.peer_rel = peer_schema.get_matching_relationship( + id=rel_schema.identifier or "", direction=rel_schema.direction + ) + except ValueError: + pass + + if rel_schema.cardinality == "one" and isinstance(value, list): + # validate the list is composed of string + if validate_list_of_scalars(value): + info.format = RelationshipDataFormat.ONE_REF + else: + info.reason_relationship_not_valid = "Too many objects provided for a relationship of cardinality one" + + elif rel_schema.cardinality == "one" and isinstance(value, str): + info.format = RelationshipDataFormat.ONE_REF + + elif rel_schema.cardinality == "one" and isinstance(value, dict) and "data" in value: + info.format = RelationshipDataFormat.ONE_OBJ + + elif ( + rel_schema.cardinality == "many" + and isinstance(value, dict) + and "data" in value + and validate_list_of_objects(value["data"]) + ): + # Initial format, we need to support it for backward compatibility for menu + # it's helpful if there is only one type of object to manage + info.format = RelationshipDataFormat.MANY_OBJ_DICT_LIST + + elif rel_schema.cardinality == "many" and isinstance(value, dict) and "data" not in value: + info.reason_relationship_not_valid = "Invalid structure for a relationship of cardinality many," + " either provide a dict with data as a list or a list of objects" + + elif rel_schema.cardinality == "many" and isinstance(value, list): + if validate_list_of_data_dicts(value): + info.format = RelationshipDataFormat.MANY_OBJ_LIST_DICT + elif validate_list_of_hfids(value): + info.format = RelationshipDataFormat.MANY_REF + else: + info.reason_relationship_not_valid = "Invalid structure for a relationship of cardinality many," + " either provide a list of dict with data or a list of hfids" + + return info class InfrahubObjectFileData(BaseModel): kind: str data: list[dict[str, Any]] = Field(default_factory=list) + async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]: + errors: list[ObjectValidationError] = [] + 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) + ) + 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) + + @classmethod + async def validate_object( + cls, + client: InfrahubClient, + schema: MainSchemaTypesAPI, + data: dict, + position: list[int | str], + context: dict | None = None, + branch: str | None = None, + ) -> list[ObjectValidationError]: + errors: list[ObjectValidationError] = [] + context = context.copy() if context else {} + + # First validate if all mandatory fields are present + for element in schema.mandatory_input_names: + if not any([element in data.keys(), element in context.keys()]): + errors.append(ObjectValidationError(position=position + [element], message=f"{element} is mandatory")) + + # Validate if all attributes are valid + for key, value in data.items(): + if key not in schema.attribute_names and key not in schema.relationship_names: + errors.append( + ObjectValidationError( + position=position + [key], + message=f"{key} is not a valid attribute or relationship for {schema.kind}", + ) + ) + + if key in schema.attribute_names: + if not isinstance(value, (str, int, float, bool, list, dict)): + errors.append( + ObjectValidationError( + position=position + [key], + message=f"{key} must be a string, int, float, bool, list, or dict", + ) + ) + + if key in schema.relationship_names: + rel_info = await get_relationship_info( + client=client, schema=schema, name=key, value=value, branch=branch + ) + if not rel_info.is_valid: + errors.append( + ObjectValidationError( + position=position + [key], + message=rel_info.reason_relationship_not_valid or "Invalid relationship", + ) + ) + + errors.extend( + await cls.validate_related_nodes( + client=client, + position=position + [key], + rel_info=rel_info, + data=value, + context=context, + branch=branch, + ) + ) + + return errors + + @classmethod + async def validate_related_nodes( + cls, + client: InfrahubClient, + position: list[int | str], + rel_info: RelationshipInfo, + data: dict | list[dict], + context: dict | None = None, + branch: str | None = None, + ) -> list[ObjectValidationError]: + context = context.copy() if context else {} + errors: list[ObjectValidationError] = [] + + if isinstance(data, (list, str)) and rel_info.format == RelationshipDataFormat.ONE_REF: + return errors + + if isinstance(data, list) and rel_info.format == RelationshipDataFormat.MANY_REF: + return errors + + 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) + + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value="placeholder")) + + errors.extend( + await cls.validate_object( + client=client, + position=position, + schema=peer_schema, + data=data["data"], + context=context, + branch=branch, + ) + ) + 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) + + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value="placeholder")) + + for idx, peer_data in enumerate(data["data"]): + context["list_index"] = idx + errors.extend( + await cls.validate_object( + client=client, + position=position + [idx + 1], + schema=peer_schema, + data=peer_data, + context=context, + branch=branch, + ) + ) + return errors + + if isinstance(data, list) and rel_info.format == RelationshipDataFormat.MANY_OBJ_LIST_DICT: + 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) + + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value="placeholder")) + + errors.extend( + await cls.validate_object( + client=client, + position=position + [idx + 1], + schema=peer_schema, + data=item["data"], + context=context, + branch=branch, + ) + ) + return errors + + errors.append( + ObjectValidationError( + position=position, + message=f"Relationship {rel_info.rel_schema.name} doesn't have the right format {rel_info.rel_schema.cardinality} / {type(data)}", + ) + ) + return errors + @classmethod def enrich_node(cls, data: dict, context: dict) -> dict: # noqa: ARG003 return data @@ -25,35 +337,76 @@ async def create_node( client: InfrahubClient, schema: MainSchemaTypesAPI, data: dict, + position: list[int | str], context: dict | None = None, branch: str | None = None, default_schema_kind: str | None = None, - ) -> None: - # First validate of all mandatory fields are present - for element in schema.mandatory_attribute_names + schema.mandatory_relationship_names: - if element not in data.keys(): - raise ValueError(f"{element} is mandatory") + ) -> InfrahubNode: + context = context.copy() if context else {} + + errors = await cls.validate_object( + client=client, position=position, schema=schema, data=data, context=context, branch=branch + ) + if errors: + messages = [str(error) for error in errors] + raise ObjectValidationError(position=position, message="Object is not valid - " + ", ".join(messages)) clean_data: dict[str, Any] = {} + # List of relationships that need to be processed after the current object has been created remaining_rels = [] + rels_info: dict[str, RelationshipInfo] = {} + for key, value in data.items(): if key in schema.attribute_names: clean_data[key] = value + continue if key in schema.relationship_names: - rel_schema = schema.get_relationship(name=key) + rel_info = await get_relationship_info( + client=client, schema=schema, name=key, value=value, branch=branch + ) + rels_info[key] = rel_info - if isinstance(value, dict) and "data" not in value: - raise ValueError(f"Relationship {key} must be a dict with 'data'") + if not rel_info.is_valid: + client.log.info(rel_info.reason_relationship_not_valid) + continue - # This is a simple implementation for now, need to revisit once we have the integration tests - if isinstance(value, (list)): + # We need to determine if the related object depend on this object or if this is the other way around. + # - if the relationship is bidirectional and is mandatory on the other side, then we need to create this object First + # - if the relationship is bidirectional and is not mandatory on the other side, then we need should create the related object First + # - if the relationship is not bidirectional, then we need to create the related object First + if rel_info.is_reference and isinstance(value, list): clean_data[key] = value - elif rel_schema.cardinality == "one" and isinstance(value, str): + elif rel_info.format == RelationshipDataFormat.ONE_REF and isinstance(value, str): clean_data[key] = [value] - else: + elif not rel_info.is_reference and rel_info.is_bidirectional and rel_info.is_mandatory: remaining_rels.append(key) + elif not rel_info.is_reference and not rel_info.is_mandatory: + if rel_info.format == RelationshipDataFormat.ONE_OBJ: + nodes = await cls.create_related_nodes( + client=client, + position=position, + rel_info=rel_info, + data=value, + branch=branch, + default_schema_kind=default_schema_kind, + ) + clean_data[key] = nodes[0] + + else: + nodes = await cls.create_related_nodes( + client=client, + position=position, + rel_info=rel_info, + data=value, + branch=branch, + default_schema_kind=default_schema_kind, + ) + clean_data[key] = nodes + + else: + raise ValueError(f"Situation unaccounted for: {rel_info}") if context: clean_context = { @@ -67,53 +420,114 @@ async def create_node( node = await client.create(kind=schema.kind, branch=branch, data=clean_data) await node.save(allow_upsert=True) + display_label = node.get_human_friendly_id_as_string() or f"{node.get_kind()} : {node.id}" client.log.info(f"Node: {display_label}") for rel in remaining_rels: - # identify what is the name of the relationship on the other side - if not isinstance(data[rel], dict) and "data" in data[rel]: - raise ValueError(f"relationship {rel} must be a dict with 'data'") + context = {} + + # If there is a peer relationship, we add the node id to the context + rel_info = rels_info[rel] + context.update(rel_info.get_context(value=node.id)) + + await cls.create_related_nodes( + client=client, + parent_node=node, + rel_info=rel_info, + position=position, + data=data[rel], + context=context, + branch=branch, + default_schema_kind=default_schema_kind, + ) + + return node + + @classmethod + async def create_related_nodes( + cls, + client: InfrahubClient, + rel_info: RelationshipInfo, + position: list[int | str], + data: dict | list[dict], + parent_node: InfrahubNode | None = None, + context: dict | None = None, + branch: str | None = None, + default_schema_kind: str | None = None, + ) -> list[InfrahubNode]: + nodes: list[InfrahubNode] = [] + context = context.copy() if context else {} - rel_schema = schema.get_relationship(name=rel) - peer_kind = data[rel].get("kind", default_schema_kind) or rel_schema.peer + 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) - if rel_schema.identifier is None: - raise ValueError("identifier must be defined") + if parent_node: + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value=parent_node.id)) - peer_rel = peer_schema.get_matching_relationship(id=rel_schema.identifier, direction=rel_schema.direction) + new_node = await cls.create_node( + client=client, + schema=peer_schema, + position=position, + data=data["data"], + context=context, + branch=branch, + default_schema_kind=default_schema_kind, + ) + return [new_node] - rel_data = data[rel]["data"] - context = {} - if peer_rel: - context[peer_rel.name] = node.id + 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) - if rel_schema.cardinality == "one" and isinstance(rel_data, dict): - await cls.create_node( - client=client, - schema=peer_schema, - data=rel_data, - context=context, - branch=branch, - default_schema_kind=default_schema_kind, - ) + if parent_node: + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value=parent_node.id)) - elif rel_schema.cardinality == "many" and isinstance(rel_data, list): - for idx, peer_data in enumerate(rel_data): - context["list_index"] = idx - await cls.create_node( + for idx, peer_data in enumerate(data["data"]): + context["list_index"] = idx + if isinstance(peer_data, dict): + node = await cls.create_node( client=client, schema=peer_schema, + position=position + [rel_info.name, idx + 1], data=peer_data, context=context, branch=branch, default_schema_kind=default_schema_kind, ) - else: - raise ValueError( - f"Relationship {rel_schema.name} doesn't have the right format {rel_schema.cardinality} / {type(rel_data)}" + nodes.append(node) + return nodes + + if isinstance(data, list) and rel_info.format == RelationshipDataFormat.MANY_OBJ_LIST_DICT: + 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) + + if parent_node: + rel_info.find_matching_relationship(peer_schema=peer_schema) + context.update(rel_info.get_context(value=parent_node.id)) + + node = await cls.create_node( + client=client, + schema=peer_schema, + position=position + [rel_info.name, idx + 1], + data=item["data"], + context=context, + branch=branch, + default_schema_kind=default_schema_kind, ) + nodes.append(node) + + return nodes + + raise ValueError( + f"Relationship {rel_info.rel_schema.name} doesn't have the right format {rel_info.rel_schema.cardinality} / {type(data)}" + ) class ObjectFile(InfrahubFile): @@ -130,3 +544,12 @@ def validate_content(self) -> None: if self.kind != InfrahubFileKind.OBJECT: raise ValueError("File is not an Infrahub Object file") self._spec = InfrahubObjectFileData(**self.data.spec) + + async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> None: + self.validate_content() + errors = await self.spec.validate_format(client=client, branch=branch) + if errors: + raise ValidationError(identifier=str(self.location), messages=[str(error) for error in errors]) + + async def process(self, client: InfrahubClient, branch: str | None = None) -> None: + await self.spec.process(client=client, branch=branch) diff --git a/infrahub_sdk/testing/schemas/animal.py b/infrahub_sdk/testing/schemas/animal.py index b0e288d3..559e75d4 100644 --- a/infrahub_sdk/testing/schemas/animal.py +++ b/infrahub_sdk/testing/schemas/animal.py @@ -20,6 +20,7 @@ TESTING_CAT = f"{NAMESPACE}Cat" TESTING_DOG = f"{NAMESPACE}Dog" TESTING_PERSON = f"{NAMESPACE}Person" +BUILTIN_TAG = "BuiltinTag" class SchemaAnimal: @@ -125,6 +126,12 @@ def schema_person(self) -> NodeSchema: cardinality="many", direction=RelationshipDirection.INBOUND, ), + Rel( + name="tags", + optional=True, + peer=BUILTIN_TAG, + cardinality="many", + ), ], ) diff --git a/infrahub_sdk/yaml.py b/infrahub_sdk/yaml.py index a0cd159d..7a0a3334 100644 --- a/infrahub_sdk/yaml.py +++ b/infrahub_sdk/yaml.py @@ -7,6 +7,7 @@ import yaml from pydantic import BaseModel, Field from typing_extensions import Self +from yaml.parser import ParserError from .exceptions import FileNotValidError from .utils import find_files, read_file @@ -31,6 +32,8 @@ class InfrahubFileData(BaseModel): class LocalFile(BaseModel): identifier: str | None = None location: Path + multiple_documents: bool = False + document_position: int | None = None content: dict | None = None valid: bool = True error_message: str | None = None @@ -57,20 +60,73 @@ def load_content(self) -> None: def validate_content(self) -> None: pass + @classmethod + def init(cls, location: Path, multiple_documents: bool, content: dict | None) -> Self: + if not content: + return cls._file_is_empty(path=location, has_multiple_document=multiple_documents) + + return cls(location=location, multiple_documents=multiple_documents, content=content) + + @classmethod + def _file_is_empty(cls, path: Path, has_multiple_document: bool) -> Self: + return cls( + location=path, multiple_documents=has_multiple_document, error_message="Invalid YAML/JSON file", valid=False + ) + + @classmethod + def _file_is_invalid(cls, path: Path, has_multiple_document: bool) -> Self: + return cls( + location=path, + multiple_documents=has_multiple_document, + error_message="Invalid YAML/JSON file", + valid=False, + ) + + @classmethod + def load_file_from_disk(cls, path: Path) -> list[Self]: + yaml_files: list[Self] = [] + + try: + file_content = read_file(path) + + has_multiple_document = bool(file_content.count("---") > 1) + + if has_multiple_document: + for content in yaml.safe_load_all(file_content): + yaml_files.append( + cls.init(location=path, multiple_documents=has_multiple_document, content=content) + ) + + else: + yaml_files.append( + cls.init( + location=path, multiple_documents=has_multiple_document, content=yaml.safe_load(file_content) + ) + ) + + except FileNotValidError as exc: + yaml_files.append( + cls(location=path, multiple_documents=has_multiple_document, error_message=exc.message, valid=False) + ) + except (yaml.YAMLError, ParserError): + yaml_files.append(cls._file_is_invalid(path, has_multiple_document)) + + if has_multiple_document: + for idx, file in enumerate(yaml_files): + file.document_position = idx + 1 + + return yaml_files + @classmethod def load_from_disk(cls, paths: list[Path]) -> list[Self]: yaml_files: list[Self] = [] for file_path in paths: if file_path.is_file(): - yaml_file = cls(location=file_path) - yaml_file.load_content() - yaml_files.append(yaml_file) + yaml_files.extend(cls.load_file_from_disk(path=file_path)) elif file_path.is_dir(): files = find_files(extension=["yaml", "yml", "json"], directory=file_path) for item in files: - yaml_file = cls(location=item) - yaml_file.load_content() - yaml_files.append(yaml_file) + yaml_files.extend(cls.load_file_from_disk(path=item)) else: raise FileNotValidError(name=str(file_path), message=f"{file_path} does not exist!") diff --git a/poetry.lock b/poetry.lock index f8ca5a2e..f60815ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -676,14 +676,14 @@ type = ["pytest-mypy"] [[package]] name = "infrahub-testcontainers" -version = "1.1.0b2" +version = "1.1.9" description = "Testcontainers instance for Infrahub to easily build integration tests" optional = false python-versions = "<4.0,>=3.9" groups = ["dev"] files = [ - {file = "infrahub_testcontainers-1.1.0b2-py3-none-any.whl", hash = "sha256:40a4f735b988db0f20eeedc68eab2fc40dcfba37382d9836a49bd6dbc282b80a"}, - {file = "infrahub_testcontainers-1.1.0b2.tar.gz", hash = "sha256:fd3738a8f6588c16a8d88944b8f0c9faaa3a9f390cd2817bdabc8e08d4dae6a6"}, + {file = "infrahub_testcontainers-1.1.9-py3-none-any.whl", hash = "sha256:3b86954baa3103bbdeb8e74d10befd7cc2859b7a8ddea89138abe231a054ec85"}, + {file = "infrahub_testcontainers-1.1.9.tar.gz", hash = "sha256:4b42f4928d406bc9f6b00b3f91d24f3c2a3bd2a8f2950ad0aa0abe1e4685dcfe"}, ] [package.dependencies] @@ -1271,125 +1271,133 @@ test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"] [[package]] name = "pydantic" -version = "2.9.1" +version = "2.11.1" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"}, - {file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"}, + {file = "pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8"}, + {file = "pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.23.3" -typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, -] +pydantic-core = "2.33.0" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" -version = "2.23.3" +version = "2.33.0" description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"}, - {file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"}, - {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"}, - {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"}, - {file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"}, - {file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"}, - {file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"}, - {file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"}, - {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"}, - {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"}, - {file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"}, - {file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"}, - {file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"}, - {file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"}, - {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"}, - {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"}, - {file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"}, - {file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"}, - {file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"}, - {file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"}, - {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"}, - {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"}, - {file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"}, - {file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"}, - {file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"}, - {file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"}, - {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"}, - {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"}, - {file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"}, - {file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"}, - {file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"}, - {file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"}, - {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"}, - {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"}, - {file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"}, - {file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"}, - {file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"}, + {file = "pydantic_core-2.33.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71dffba8fe9ddff628c68f3abd845e91b028361d43c5f8e7b3f8b91d7d85413e"}, + {file = "pydantic_core-2.33.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:abaeec1be6ed535a5d7ffc2e6c390083c425832b20efd621562fbb5bff6dc518"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759871f00e26ad3709efc773ac37b4d571de065f9dfb1778012908bcc36b3a73"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcfebee69cd5e1c0b76a17e17e347c84b00acebb8dd8edb22d4a03e88e82a207"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b1262b912435a501fa04cd213720609e2cefa723a07c92017d18693e69bf00b"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4726f1f3f42d6a25678c67da3f0b10f148f5655813c5aca54b0d1742ba821b8f"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e790954b5093dff1e3a9a2523fddc4e79722d6f07993b4cd5547825c3cbf97b5"}, + {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34e7fb3abe375b5c4e64fab75733d605dda0f59827752debc99c17cb2d5f3276"}, + {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ecb158fb9b9091b515213bed3061eb7deb1d3b4e02327c27a0ea714ff46b0760"}, + {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:4d9149e7528af8bbd76cc055967e6e04617dcb2a2afdaa3dea899406c5521faa"}, + {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e81a295adccf73477220e15ff79235ca9dcbcee4be459eb9d4ce9a2763b8386c"}, + {file = "pydantic_core-2.33.0-cp310-cp310-win32.whl", hash = "sha256:f22dab23cdbce2005f26a8f0c71698457861f97fc6318c75814a50c75e87d025"}, + {file = "pydantic_core-2.33.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cb2390355ba084c1ad49485d18449b4242da344dea3e0fe10babd1f0db7dcfc"}, + {file = "pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef"}, + {file = "pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd"}, + {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453"}, + {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b"}, + {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915"}, + {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2"}, + {file = "pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86"}, + {file = "pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b"}, + {file = "pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a"}, + {file = "pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43"}, + {file = "pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862"}, + {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a"}, + {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099"}, + {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6"}, + {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3"}, + {file = "pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2"}, + {file = "pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48"}, + {file = "pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6"}, + {file = "pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555"}, + {file = "pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9"}, + {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181"}, + {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d"}, + {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3"}, + {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b"}, + {file = "pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585"}, + {file = "pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606"}, + {file = "pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225"}, + {file = "pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87"}, + {file = "pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b"}, + {file = "pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7"}, + {file = "pydantic_core-2.33.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7c9c84749f5787781c1c45bb99f433402e484e515b40675a5d121ea14711cf61"}, + {file = "pydantic_core-2.33.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64672fa888595a959cfeff957a654e947e65bbe1d7d82f550417cbd6898a1d6b"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bc7367c0961dec292244ef2549afa396e72e28cc24706210bd44d947582c59"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce72d46eb201ca43994303025bd54d8a35a3fc2a3495fac653d6eb7205ce04f4"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14229c1504287533dbf6b1fc56f752ce2b4e9694022ae7509631ce346158de11"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:085d8985b1c1e48ef271e98a658f562f29d89bda98bf120502283efbc87313eb"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31860fbda80d8f6828e84b4a4d129fd9c4535996b8249cfb8c720dc2a1a00bb8"}, + {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f200b2f20856b5a6c3a35f0d4e344019f805e363416e609e9b47c552d35fd5ea"}, + {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f72914cfd1d0176e58ddc05c7a47674ef4222c8253bf70322923e73e14a4ac3"}, + {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91301a0980a1d4530d4ba7e6a739ca1a6b31341252cb709948e0aca0860ce0ae"}, + {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7419241e17c7fbe5074ba79143d5523270e04f86f1b3a0dff8df490f84c8273a"}, + {file = "pydantic_core-2.33.0-cp39-cp39-win32.whl", hash = "sha256:7a25493320203005d2a4dac76d1b7d953cb49bce6d459d9ae38e30dd9f29bc9c"}, + {file = "pydantic_core-2.33.0-cp39-cp39-win_amd64.whl", hash = "sha256:82a4eba92b7ca8af1b7d5ef5f3d9647eee94d1f74d21ca7c21e3a2b92e008358"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2762c568596332fdab56b07060c8ab8362c56cf2a339ee54e491cd503612c50"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bf637300ff35d4f59c006fff201c510b2b5e745b07125458a5389af3c0dff8c"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c151ce3d59ed56ebd7ce9ce5986a409a85db697d25fc232f8e81f195aa39a1"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee65f0cc652261744fd07f2c6e6901c914aa6c5ff4dcfaf1136bc394d0dd26b"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:024d136ae44d233e6322027bbf356712b3940bee816e6c948ce4b90f18471b3d"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e37f10f6d4bc67c58fbd727108ae1d8b92b397355e68519f1e4a7babb1473442"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:502ed542e0d958bd12e7c3e9a015bce57deaf50eaa8c2e1c439b512cb9db1e3a"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:715c62af74c236bf386825c0fdfa08d092ab0f191eb5b4580d11c3189af9d330"}, + {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bccc06fa0372151f37f6b69834181aa9eb57cf8665ed36405fb45fbf6cac3bae"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936"}, + {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba95691cf25f63df53c1d342413b41bd7762d9acb425df8858d7efa616c0870e"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f1ab031feb8676f6bd7c85abec86e2935850bf19b84432c64e3e239bffeb1ec"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1151827eef98b83d49b6ca6065575876a02d2211f259fb1a6b7757bd24dd8"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a66d931ea2c1464b738ace44b7334ab32a2fd50be023d863935eb00f42be1778"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0bcf0bab28995d483f6c8d7db25e0d05c3efa5cebfd7f56474359e7137f39856"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:89670d7a0045acb52be0566df5bc8b114ac967c662c06cf5e0c606e4aadc964b"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:b716294e721d8060908dbebe32639b01bfe61b15f9f57bcc18ca9a0e00d9520b"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fc53e05c16697ff0c1c7c2b98e45e131d4bfb78068fffff92a82d169cbb4c7b7"}, + {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:68504959253303d3ae9406b634997a2123a0b0c1da86459abbd0ffc921695eac"}, + {file = "pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3"}, ] [package.dependencies] @@ -1980,6 +1988,21 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.0" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, + {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" version = "2024.1" @@ -2339,4 +2362,4 @@ tests = ["Jinja2", "pytest", "pyyaml", "rich"] [metadata] lock-version = "2.1" python-versions = "^3.9, <3.14" -content-hash = "b2747ad942541d2b546562e33cc9cb6d84b26f3d5ca10d72e8f24f55e2a9492e" +content-hash = "3f22f4c4d4aefaa0e51fd28a5e9f9c185b146e6261af38072bc7eae4b1ff0680" diff --git a/pyproject.toml b/pyproject.toml index 1e482843..4f14a11f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,8 @@ pytest-xdist = "^3.3.1" types-python-slugify = "^8.0.0.3" invoke = "^2.2.0" towncrier = "^24.8.0" -infrahub-testcontainers = "^1.1.0b2" +infrahub-testcontainers = "~1.1.9" + astroid = "~3.1" [tool.poetry.extras] diff --git a/tasks.py b/tasks.py index 15e004b2..62ad2299 100644 --- a/tasks.py +++ b/tasks.py @@ -30,14 +30,23 @@ def _generate_infrahubctl_documentation(context: Context) -> None: output_dir = DOCUMENTATION_DIRECTORY / "docs" / "infrahubctl" output_dir.mkdir(parents=True, exist_ok=True) + + # Delete any existing infrahubctl- files in output dir + for file in output_dir.glob("infrahubctl-*"): + file.unlink() + print(" - Generate infrahubctl CLI documentation") for cmd in app.registered_commands: + if cmd.hidden: + continue exec_cmd = f'poetry run typer --func {cmd.name} infrahub_sdk.ctl.cli_commands utils docs --name "infrahubctl {cmd.name}"' exec_cmd += f" --output docs/docs/infrahubctl/infrahubctl-{cmd.name}.mdx" with context.cd(MAIN_DIRECTORY_PATH): context.run(exec_cmd) for cmd in app.registered_groups: + if cmd.hidden: + continue exec_cmd = f"poetry run typer infrahub_sdk.ctl.{cmd.name} utils docs" exec_cmd += f' --name "infrahubctl {cmd.name}" --output docs/docs/infrahubctl/infrahubctl-{cmd.name}.mdx' with context.cd(MAIN_DIRECTORY_PATH): diff --git a/tests/fixtures/spec_objects/animal_dog01.yml b/tests/fixtures/spec_objects/animal_dog01.yml new file mode 100644 index 00000000..63e8272f --- /dev/null +++ b/tests/fixtures/spec_objects/animal_dog01.yml @@ -0,0 +1,31 @@ +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingDog + data: + - name: Max + weight: 25 + breed: Golden Retriever + color: "#FFD700" + owner: "Jane Smith" + - name: Luna + weight: 18 + breed: Husky + color: "#C0C0C0" + owner: "Mike Johnson" + - name: Charlie + weight: 12 + breed: Beagle + color: "#8B4513" + owner: ["Sarah Williams"] + - name: Bella + weight: 20 + breed: Labrador + color: "#8B4513" + owner: + data: + name: Bill Smith + height: 180 + tags: + - Pet Groomer diff --git a/tests/fixtures/spec_objects/animal_person01.yml b/tests/fixtures/spec_objects/animal_person01.yml new file mode 100644 index 00000000..b13ed634 --- /dev/null +++ b/tests/fixtures/spec_objects/animal_person01.yml @@ -0,0 +1,21 @@ +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingPerson + data: + - name: Jane Smith + height: 165 + tags: + data: + - name: Dog Lover + - name: Veterinarian + + - name: Mike Johnson + height: 175 + tags: + data: + - name: Professional Trainer + + - name: Sarah Williams + height: 170 diff --git a/tests/fixtures/spec_objects/animal_person02.yml b/tests/fixtures/spec_objects/animal_person02.yml new file mode 100644 index 00000000..18632925 --- /dev/null +++ b/tests/fixtures/spec_objects/animal_person02.yml @@ -0,0 +1,44 @@ +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: TestingPerson + data: + - name: Alex Thompson + height: 180 + tags: + data: + - name: Dog Lover + + animals: + kind: TestingDog + data: + - name: Max + weight: 25 + breed: Golden Retriever + color: "#FFD700" + + - name: Emily Parker + height: 165 + animals: + - kind: TestingDog + data: + name: Max + weight: 25 + breed: Golden Retriever + color: "#FFD700" + - kind: TestingCat + data: + name: Whiskers + weight: 10 + breed: Siamese + color: "#FFD700" + + - name: Mike Johnson + height: 175 + best_friends: # Relationship of cardinality many that referenced existing nodes based on their HFID + - [Jane Smith, Max] + - [Sarah Williams, Charlie] + tags: + - Veterinarian # Existing Node referenced by its HFID in string format + - [Breeder] # Existing Node referenced by its HFID in list format diff --git a/tests/fixtures/spec_objects/animal_tags01.yml b/tests/fixtures/spec_objects/animal_tags01.yml new file mode 100644 index 00000000..aa5954e0 --- /dev/null +++ b/tests/fixtures/spec_objects/animal_tags01.yml @@ -0,0 +1,9 @@ +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: BuiltinTag + data: + - name: Professional Trainer + - name: Pet Groomer + - name: Veterinarian diff --git a/tests/fixtures/spec_objects/animal_tags02.yml b/tests/fixtures/spec_objects/animal_tags02.yml new file mode 100644 index 00000000..4f158c47 --- /dev/null +++ b/tests/fixtures/spec_objects/animal_tags02.yml @@ -0,0 +1,12 @@ +--- +apiVersion: infrahub.app/v1 +kind: Object +spec: + kind: BuiltinTag + data: + - name: Veterinarian + description: Licensed animal healthcare professional + - name: Pet Groomer + description: Professional pet grooming specialist + - name: Breeder + description: Licensed and ethical animal breeder diff --git a/tests/integration/test_spec_object.py b/tests/integration/test_spec_object.py new file mode 100644 index 00000000..df94741b --- /dev/null +++ b/tests/integration/test_spec_object.py @@ -0,0 +1,117 @@ +from pathlib import Path + +import pytest + +from infrahub_sdk import InfrahubClient +from infrahub_sdk.schema import SchemaRoot +from infrahub_sdk.spec.object import ObjectFile +from infrahub_sdk.testing.docker import TestInfrahubDockerClient +from infrahub_sdk.testing.schemas.animal import SchemaAnimal +from infrahub_sdk.utils import get_fixtures_dir + + +def load_object_file(name: str) -> ObjectFile: + files = ObjectFile.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: + return "branch2" + + @pytest.fixture(scope="class") + def spec_objects_fixtures_dir(self) -> Path: + return get_fixtures_dir() / "spec_objects" + + @pytest.fixture(scope="class") + async def initial_schema(self, default_branch: str, client: InfrahubClient, schema_base: SchemaRoot) -> None: + await client.schema.wait_until_converged(branch=default_branch) + + resp = await client.schema.load( + schemas=[schema_base.to_schema_dict()], branch=default_branch, wait_until_converged=True + ) + assert resp.errors == {} + + async def test_create_branch(self, client: InfrahubClient, initial_schema: None, branch_name: str): + await client.branch.create(branch_name=branch_name, sync_with_git=False) + + async def test_load_tags(self, client: InfrahubClient, branch_name: str, initial_schema: None): + obj_file = load_object_file("animal_tags01.yml") + await obj_file.validate_format(client=client, branch=branch_name) + + # Check that the nodes are not present in the database before loading the file + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 0 + + await obj_file.process(client=client, branch=branch_name) + + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 3 + + async def test_update_tags(self, client: InfrahubClient, branch_name: str, initial_schema: None): + obj_file = load_object_file("animal_tags02.yml") + await obj_file.validate_format(client=client, branch=branch_name) + + # Check that the nodes are not present in the database before loading the file + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 3 + + await obj_file.process(client=client, branch=branch_name) + + tags = await client.all(kind=obj_file.spec.kind, branch=branch_name) + tags_by_name = {"__".join(tag.get_human_friendly_id()): tag for tag in tags} + assert len(tags_by_name) == 4 + assert tags_by_name["Veterinarian"].description.value == "Licensed animal healthcare professional" + + async def test_load_persons(self, client: InfrahubClient, branch_name: str, initial_schema: None): + obj_file = load_object_file("animal_person01.yml") + await obj_file.validate_format(client=client, branch=branch_name) + + # Check that the nodes are not present in the database before loading the file + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 0 + + await obj_file.process(client=client, branch=branch_name) + + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 3 + + async def test_load_dogs(self, client: InfrahubClient, branch_name: str, initial_schema: None): + obj_file = load_object_file("animal_dog01.yml") + await obj_file.validate_format(client=client, branch=branch_name) + + # Check that the nodes are not present in the database before loading the file + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 0 + + await obj_file.process(client=client, branch=branch_name) + + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 4 + + async def test_load_persons02(self, client: InfrahubClient, branch_name: str, initial_schema: None): + obj_file = load_object_file("animal_person02.yml") + await obj_file.validate_format(client=client, branch=branch_name) + + # Check that the nodes are not present in the database before loading the file + assert len(await client.all(kind=obj_file.spec.kind, branch=branch_name)) == 4 + + await obj_file.process(client=client, branch=branch_name) + + persons = await client.all(kind=obj_file.spec.kind, branch=branch_name) + person_by_name = {"__".join(person.get_human_friendly_id()): person for person in persons} + assert len(persons) == 6 + + # Validate that the best_friends relationship is correctly populated + await person_by_name["Mike Johnson"].best_friends.fetch() + friends = [peer.hfid for peer in person_by_name["Mike Johnson"].best_friends.peers] + assert friends == [["Jane Smith", "Max"], ["Sarah Williams", "Charlie"]] + + # Validate the tags relationship is correctly populated for both Alex Thompson and Mike Johnson + await person_by_name["Alex Thompson"].tags.fetch() + tags_alex = [tag.hfid for tag in person_by_name["Alex Thompson"].tags.peers] + assert tags_alex == [["Dog Lover"]] + + await person_by_name["Mike Johnson"].tags.fetch() + tags_mike = [tag.hfid for tag in person_by_name["Mike Johnson"].tags.peers] + assert sorted(tags_mike) == sorted([["Veterinarian"], ["Breeder"]]) + + # Validate that animals for Emily Parler have been correctly created + 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"]) diff --git a/tests/unit/ctl/test_cli.py b/tests/unit/ctl/test_cli.py index 30d63e92..3aa56948 100644 --- a/tests/unit/ctl/test_cli.py +++ b/tests/unit/ctl/test_cli.py @@ -1,9 +1,12 @@ +import pytest from typer.testing import CliRunner from infrahub_sdk.ctl.cli import app runner = CliRunner() +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + def test_main_app(): result = runner.invoke(app, ["--help"]) diff --git a/tests/unit/ctl/test_schema_app.py b/tests/unit/ctl/test_schema_app.py index f3db9cae..9949eef7 100644 --- a/tests/unit/ctl/test_schema_app.py +++ b/tests/unit/ctl/test_schema_app.py @@ -14,7 +14,7 @@ def test_schema_load_empty(httpx_mock: HTTPXMock): result = runner.invoke(app=app, args=["load", str(fixture_file)]) assert result.exit_code == 1 - assert "Empty YAML/JSON file" in result.stdout + assert "Invalid YAML/JSON file" in result.stdout def test_schema_load_one_valid(httpx_mock: HTTPXMock): diff --git a/tests/unit/ctl/test_validate_app.py b/tests/unit/ctl/test_validate_app.py index 43d41ddb..c732d3e8 100644 --- a/tests/unit/ctl/test_validate_app.py +++ b/tests/unit/ctl/test_validate_app.py @@ -21,7 +21,7 @@ def test_validate_schema_empty(): result = runner.invoke(app=app, args=["schema", str(fixture_file)]) assert result.exit_code == 1 - assert "Empty YAML/JSON file" in remove_ansi_color(result.stdout) + assert "Invalid YAML/JSON file" in remove_ansi_color(result.stdout) def test_validate_schema_non_valid(): diff --git a/tests/unit/sdk/checks/test_checks.py b/tests/unit/sdk/checks/test_checks.py index 6dcfbea4..6cad162b 100644 --- a/tests/unit/sdk/checks/test_checks.py +++ b/tests/unit/sdk/checks/test_checks.py @@ -3,6 +3,8 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.checks import InfrahubCheck +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + async def test_class_init(): class IFCheckNoQuery(InfrahubCheck): diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index fdd63de1..cd710c87 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from pytest_httpx import HTTPXMock +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + @dataclass class BothClients: diff --git a/tests/unit/sdk/spec/test_object.py b/tests/unit/sdk/spec/test_object.py new file mode 100644 index 00000000..f097b119 --- /dev/null +++ b/tests/unit/sdk/spec/test_object.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.exceptions import ValidationError +from infrahub_sdk.spec.object import ObjectFile, RelationshipDataFormat, get_relationship_info + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.client import InfrahubClient + + +@pytest.fixture +def root_location() -> dict: + return {"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {"kind": "BuiltinLocation", "data": []}} + + +@pytest.fixture +def location_mexico_01(root_location: dict) -> dict: + data = [{"name": "Mexico", "type": "Country"}] + + location = root_location.copy() + location["spec"]["data"] = data + return location + + +@pytest.fixture +def location_bad_syntax01(root_location: dict) -> dict: + data = [{"notthename": "Mexico", "type": "Country"}] + location = root_location.copy() + location["spec"]["data"] = data + return location + + +@pytest.fixture +def location_bad_syntax02(root_location: dict) -> dict: + data = [{"name": "Mexico", "notvalidattribute": "notvalidattribute", "type": "Country"}] + location = root_location.copy() + location["spec"]["data"] = data + return location + + +async def test_validate_object(client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_mexico_01): + obj = ObjectFile(location="some/path", content=location_mexico_01) + await obj.validate_format(client=client) + + assert obj.spec.kind == "BuiltinLocation" + + +async def test_validate_object_bad_syntax01( + client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_bad_syntax01 +): + obj = ObjectFile(location="some/path", content=location_bad_syntax01) + with pytest.raises(ValidationError) as exc: + await obj.validate_format(client=client) + + assert "name" in str(exc.value) + + +async def test_validate_object_bad_syntax02( + client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_bad_syntax02 +): + obj = ObjectFile(location="some/path", content=location_bad_syntax02) + with pytest.raises(ValidationError) as exc: + await obj.validate_format(client=client) + + assert "notvalidattribute" in str(exc.value) + + +get_relationship_info_testdata = [ + pytest.param( + [ + {"data": {"name": "Blue"}}, + {"data": {"name": "Red"}}, + ], + True, + RelationshipDataFormat.MANY_OBJ_LIST_DICT, + id="many_obj_list_dict", + ), + pytest.param( + { + "data": [ + {"name": "Blue"}, + {"name": "Red"}, + ], + }, + True, + RelationshipDataFormat.MANY_OBJ_DICT_LIST, + id="many_obj_dict_list", + ), + pytest.param( + ["blue", "red"], + True, + RelationshipDataFormat.MANY_REF, + id="many_ref", + ), + pytest.param( + [ + {"name": "Blue"}, + {"name": "Red"}, + ], + False, + RelationshipDataFormat.UNKNOWN, + id="many_invalid_list_dict", + ), +] + + +@pytest.mark.parametrize("data,is_valid,format", get_relationship_info_testdata) +async def test_get_relationship_info_tags( + client: InfrahubClient, + mock_schema_query_01: HTTPXMock, + data: dict | list, + is_valid: bool, + format: RelationshipDataFormat, +): + location_schema = await client.schema.get(kind="BuiltinLocation") + + rel_info = await get_relationship_info(client, location_schema, "tags", data) + assert rel_info.is_valid == is_valid + assert rel_info.format == format diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index ab3f1bf3..4f7f3ac3 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -7,6 +7,8 @@ from infrahub_sdk.exceptions import NodeNotFoundError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + excluded_methods = ["request_context"] async_client_methods = [ diff --git a/tests/unit/sdk/test_data/multiple_files_valid.yml b/tests/unit/sdk/test_data/multiple_files_valid.yml new file mode 100644 index 00000000..81a35b8d --- /dev/null +++ b/tests/unit/sdk/test_data/multiple_files_valid.yml @@ -0,0 +1,10 @@ +--- +version: "1.0" +nodes: + - name: Node + namespace: Testing +--- +version: "1.0" +nodes: + - name: Node2 + namespace: Testing diff --git a/tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml b/tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml new file mode 100644 index 00000000..3921ad50 --- /dev/null +++ b/tests/unit/sdk/test_data/multiple_files_valid_not_valid.yml @@ -0,0 +1,11 @@ +--- +version: "1.0" +nodes: + - name: Node + namespace: Testing +--- +version: "1.0" +nodes: + - name: Node2 + namespace: Testing + - name: Node3 diff --git a/tests/unit/sdk/test_data/single_file.yml b/tests/unit/sdk/test_data/single_file.yml new file mode 100644 index 00000000..f0c22c16 --- /dev/null +++ b/tests/unit/sdk/test_data/single_file.yml @@ -0,0 +1,5 @@ +--- +version: "1.0" +nodes: + - name: Node + namespace: Testing diff --git a/tests/unit/sdk/test_yaml.py b/tests/unit/sdk/test_yaml.py index 8c2b9654..f596a088 100644 --- a/tests/unit/sdk/test_yaml.py +++ b/tests/unit/sdk/test_yaml.py @@ -19,3 +19,23 @@ def test_read_incorrect_encoding() -> None: yaml_file.load_content() assert not yaml_file.valid assert yaml_file.error_message == f"Unable to read {file} with utf-8 encoding" + + +def test_read_multiple_files() -> None: + file = here / "test_data/multiple_files_valid.yml" + yaml_files = YamlFile.load_file_from_disk(path=file) + assert len(yaml_files) == 2 + assert yaml_files[0].document_position == 1 + assert yaml_files[0].valid is True + assert yaml_files[1].document_position == 2 + assert yaml_files[1].valid is True + + +def test_read_multiple_files_invalid() -> None: + file = here / "test_data/multiple_files_valid_not_valid.yml" + yaml_files = YamlFile.load_file_from_disk(path=file) + assert len(yaml_files) == 2 + assert yaml_files[0].document_position == 1 + assert yaml_files[0].valid is True + assert yaml_files[1].document_position == 2 + assert yaml_files[1].valid is False