From 2d9f982b4fbcf7aeaab038b4c3c8f603a69f8f2a Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Wed, 23 Apr 2025 09:43:49 +0200 Subject: [PATCH 1/3] Add branch parameter to clone methods --- changelog/+398b0883.added.md | 1 + infrahub_sdk/client.py | 13 +++++++++---- infrahub_sdk/generator.py | 4 +--- infrahub_sdk/recorder.py | 3 +++ tests/unit/sdk/test_client.py | 28 +++++++++++++++++++++++++++- 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 changelog/+398b0883.added.md diff --git a/changelog/+398b0883.added.md b/changelog/+398b0883.added.md new file mode 100644 index 00000000..f9554fab --- /dev/null +++ b/changelog/+398b0883.added.md @@ -0,0 +1 @@ +Added a "branch" parameter to the client.clone() method to allow properly cloning a client that targets another branch. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index fffa8164..8d9de6d2 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -271,6 +271,11 @@ def _build_ip_prefix_allocation_query( input_data={"data": input_data}, ) + def _clone_config(self, branch: str | None = None) -> Config: + config = copy.deepcopy(self.config) + config.default_branch = branch or config.default_branch + return config + class InfrahubClient(BaseClient): """GraphQL Client to interact with Infrahub.""" @@ -847,9 +852,9 @@ async def process_non_batch() -> tuple[list[InfrahubNode], list[InfrahubNode]]: self.store.set(node=node) return nodes - def clone(self) -> InfrahubClient: + def clone(self, branch: str | None = None) -> InfrahubClient: """Return a cloned version of the client using the same configuration""" - return InfrahubClient(config=self.config) + return InfrahubClient(config=self._clone_config(branch=branch)) async def execute_graphql( self, @@ -1591,9 +1596,9 @@ def delete(self, kind: str | type[SchemaTypeSync], id: str, branch: str | None = node = InfrahubNodeSync(client=self, schema=schema, branch=branch, data={"id": id}) node.delete() - def clone(self) -> InfrahubClientSync: + def clone(self, branch: str | None = None) -> InfrahubClientSync: """Return a cloned version of the client using the same configuration""" - return InfrahubClientSync(config=self.config) + return InfrahubClientSync(config=self._clone_config(branch=branch)) def execute_graphql( self, diff --git a/infrahub_sdk/generator.py b/infrahub_sdk/generator.py index 854b3cb4..98fa689f 100644 --- a/infrahub_sdk/generator.py +++ b/infrahub_sdk/generator.py @@ -38,9 +38,7 @@ def __init__( self.params = params or {} self.root_directory = root_directory or os.getcwd() self.generator_instance = generator_instance - self._init_client = client.clone() - self._init_client.config.default_branch = self._init_client.default_branch = self.branch_name - self._init_client.store._default_branch = self.branch_name + self._init_client = client.clone(branch=self.branch_name) self._client: InfrahubClient | None = None self._nodes: list[InfrahubNode] = [] self._related_nodes: list[InfrahubNode] = [] diff --git a/infrahub_sdk/recorder.py b/infrahub_sdk/recorder.py index bf2a715a..40c45dd3 100644 --- a/infrahub_sdk/recorder.py +++ b/infrahub_sdk/recorder.py @@ -31,6 +31,9 @@ def record(response: httpx.Response) -> None: def default(cls) -> NoRecorder: return cls() + def __eq__(self, other: object) -> bool: + return isinstance(other, NoRecorder) + class JSONRecorder(BaseSettings): model_config = SettingsConfigDict(env_prefix="INFRAHUB_JSON_RECORDER_") diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index 4f7f3ac3..31c38294 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -6,6 +6,7 @@ from infrahub_sdk import InfrahubClient, InfrahubClientSync from infrahub_sdk.exceptions import NodeNotFoundError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from tests.unit.sdk.conftest import BothClients pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) @@ -761,12 +762,37 @@ async def test_query_echo(httpx_mock: HTTPXMock, echo_clients, client_type): @pytest.mark.parametrize("client_type", client_types) -async def test_clone(clients, client_type): +async def test_clone(clients: BothClients, client_type: str) -> None: + """Validate that the configuration of a cloned client is a replica of the original client""" if client_type == "standard": clone = clients.standard.clone() assert clone.config == clients.standard.config assert isinstance(clone, InfrahubClient) + assert clients.standard.default_branch == clone.default_branch else: clone = clients.sync.clone() assert clone.config == clients.sync.config assert isinstance(clone, InfrahubClientSync) + assert clients.sync.default_branch == clone.default_branch + + +@pytest.mark.parametrize("client_type", client_types) +async def test_clone_define_branch(clients: BothClients, client_type: str) -> None: + """Validate that the clone branch parameter sets the correct branch of the cloned client""" + clone_branch = "my_other_branch" + if client_type == "standard": + original_branch = clients.standard.default_branch + clone = clients.standard.clone(branch=clone_branch) + assert clients.standard.store._default_branch == original_branch + assert isinstance(clone, InfrahubClient) + assert clients.standard.default_branch != clone.default_branch + else: + original_branch = clients.standard.default_branch + clone = clients.sync.clone(branch="my_other_branch") + assert clients.sync.store._default_branch == original_branch + assert isinstance(clone, InfrahubClientSync) + assert clients.sync.default_branch != clone.default_branch + + assert clone.default_branch == clone_branch + assert original_branch != clone_branch + assert clone.store._default_branch == clone_branch From 4f7a377ac401818e4a9762fa68f371cde95c6745 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Thu, 24 Apr 2025 16:22:35 +0200 Subject: [PATCH 2/3] Add ability to use convert_query_response with Python Transforms Fixes #281 --- changelog/281.added.md | 1 + infrahub_sdk/client.py | 9 +- infrahub_sdk/config.py | 17 ++ infrahub_sdk/ctl/cli_commands.py | 8 +- infrahub_sdk/ctl/generator.py | 4 +- infrahub_sdk/generator.py | 76 ++------ infrahub_sdk/operation.py | 80 +++++++++ infrahub_sdk/protocols.py | 12 ++ infrahub_sdk/schema/repository.py | 4 + infrahub_sdk/transforms.py | 42 ++--- .../repos/ctl_integration/.infrahub.yml | 32 ++++ .../generators/tag_generator.py | 15 ++ .../generators/tag_generator_convert.py | 16 ++ .../ctl_integration/queries/animal_person.gql | 27 +++ .../transforms/animal_person.py | 16 ++ .../ctl_integration/transforms/converted.py | 18 ++ tests/integration/test_infrahubctl.py | 163 ++++++++++++++++++ 17 files changed, 439 insertions(+), 101 deletions(-) create mode 100644 changelog/281.added.md create mode 100644 infrahub_sdk/operation.py create mode 100644 tests/fixtures/repos/ctl_integration/.infrahub.yml create mode 100644 tests/fixtures/repos/ctl_integration/generators/tag_generator.py create mode 100644 tests/fixtures/repos/ctl_integration/generators/tag_generator_convert.py create mode 100644 tests/fixtures/repos/ctl_integration/queries/animal_person.gql create mode 100644 tests/fixtures/repos/ctl_integration/transforms/animal_person.py create mode 100644 tests/fixtures/repos/ctl_integration/transforms/converted.py create mode 100644 tests/integration/test_infrahubctl.py diff --git a/changelog/281.added.md b/changelog/281.added.md new file mode 100644 index 00000000..00338f66 --- /dev/null +++ b/changelog/281.added.md @@ -0,0 +1 @@ +Added ability to convert the query response to InfrahubNode objects when using Python Transforms in the same way you can with Generators. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 8d9de6d2..4f6aa8a2 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -271,11 +271,6 @@ def _build_ip_prefix_allocation_query( input_data={"data": input_data}, ) - def _clone_config(self, branch: str | None = None) -> Config: - config = copy.deepcopy(self.config) - config.default_branch = branch or config.default_branch - return config - class InfrahubClient(BaseClient): """GraphQL Client to interact with Infrahub.""" @@ -854,7 +849,7 @@ async def process_non_batch() -> tuple[list[InfrahubNode], list[InfrahubNode]]: def clone(self, branch: str | None = None) -> InfrahubClient: """Return a cloned version of the client using the same configuration""" - return InfrahubClient(config=self._clone_config(branch=branch)) + return InfrahubClient(config=self.config.clone(branch=branch)) async def execute_graphql( self, @@ -1598,7 +1593,7 @@ def delete(self, kind: str | type[SchemaTypeSync], id: str, branch: str | None = def clone(self, branch: str | None = None) -> InfrahubClientSync: """Return a cloned version of the client using the same configuration""" - return InfrahubClientSync(config=self._clone_config(branch=branch)) + return InfrahubClientSync(config=self.config.clone(branch=branch)) def execute_graphql( self, diff --git a/infrahub_sdk/config.py b/infrahub_sdk/config.py index 51c790dc..b0a2402a 100644 --- a/infrahub_sdk/config.py +++ b/infrahub_sdk/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from typing import Any from pydantic import Field, field_validator, model_validator @@ -158,3 +159,19 @@ def set_custom_recorder(cls, values: dict[str, Any]) -> dict[str, Any]: elif values.get("recorder") == RecorderType.JSON and "custom_recorder" not in values: values["custom_recorder"] = JSONRecorder() return values + + def clone(self, branch: str | None = None) -> Config: + config: dict[str, Any] = { + "default_branch": branch or self.default_branch, + "recorder": self.recorder, + "custom_recorder": self.custom_recorder, + "requester": self.requester, + "sync_requester": self.sync_requester, + "log": self.log, + } + covered_keys = list(config.keys()) + for field in Config.model_fields.keys(): + if field not in covered_keys: + config[field] = deepcopy(getattr(self, field)) + + return Config(**config) diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 13910621..605743fa 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -41,6 +41,7 @@ ) from ..ctl.validate import app as validate_app from ..exceptions import GraphQLError, ModuleImportError +from ..node import InfrahubNode from ..protocols_generator.generator import CodeGenerator from ..schema import MainSchemaTypesAll, SchemaRoot from ..template import Jinja2Template @@ -330,7 +331,12 @@ def transform( console.print(f"[red]{exc.message}") raise typer.Exit(1) from exc - transform = transform_class(client=client, branch=branch) + transform = transform_class( + client=client, + branch=branch, + infrahub_node=InfrahubNode, + convert_query_response=transform_config.convert_query_response, + ) # Get data query_str = repository_config.get_query(name=transform.query).load_query() data = asyncio.run( diff --git a/infrahub_sdk/ctl/generator.py b/infrahub_sdk/ctl/generator.py index 22501568..49019196 100644 --- a/infrahub_sdk/ctl/generator.py +++ b/infrahub_sdk/ctl/generator.py @@ -62,7 +62,7 @@ async def run( generator = generator_class( query=generator_config.query, client=client, - branch=branch, + branch=branch or "", params=variables_dict, convert_query_response=generator_config.convert_query_response, infrahub_node=InfrahubNode, @@ -91,7 +91,7 @@ async def run( generator = generator_class( query=generator_config.query, client=client, - branch=branch, + branch=branch or "", params=params, convert_query_response=generator_config.convert_query_response, infrahub_node=InfrahubNode, diff --git a/infrahub_sdk/generator.py b/infrahub_sdk/generator.py index 98fa689f..3c9d26d7 100644 --- a/infrahub_sdk/generator.py +++ b/infrahub_sdk/generator.py @@ -1,22 +1,19 @@ from __future__ import annotations import logging -import os from abc import abstractmethod from typing import TYPE_CHECKING -from infrahub_sdk.repository import GitRepoManager - from .exceptions import UninitializedError +from .operation import InfrahubOperation if TYPE_CHECKING: from .client import InfrahubClient from .context import RequestContext from .node import InfrahubNode - from .store import NodeStore -class InfrahubGenerator: +class InfrahubGenerator(InfrahubOperation): """Infrahub Generator class""" def __init__( @@ -24,7 +21,7 @@ def __init__( query: str, client: InfrahubClient, infrahub_node: type[InfrahubNode], - branch: str | None = None, + branch: str = "", root_directory: str = "", generator_instance: str = "", params: dict | None = None, @@ -33,35 +30,21 @@ def __init__( request_context: RequestContext | None = None, ) -> None: self.query = query - self.branch = branch - self.git: GitRepoManager | None = None + + super().__init__( + client=client, + infrahub_node=infrahub_node, + convert_query_response=convert_query_response, + branch=branch, + root_directory=root_directory, + ) + self.params = params or {} - self.root_directory = root_directory or os.getcwd() self.generator_instance = generator_instance - self._init_client = client.clone(branch=self.branch_name) self._client: InfrahubClient | None = None - self._nodes: list[InfrahubNode] = [] - self._related_nodes: list[InfrahubNode] = [] - self.infrahub_node = infrahub_node - self.convert_query_response = convert_query_response self.logger = logger if logger else logging.getLogger("infrahub.tasks") self.request_context = request_context - @property - def store(self) -> NodeStore: - """The store will be populated with nodes based on the query during the collection of data if activated""" - return self._init_client.store - - @property - def nodes(self) -> list[InfrahubNode]: - """Returns nodes collected and parsed during the data collection process if this feature is enables""" - return self._nodes - - @property - def related_nodes(self) -> list[InfrahubNode]: - """Returns nodes collected and parsed during the data collection process if this feature is enables""" - return self._related_nodes - @property def subscribers(self) -> list[str] | None: if self.generator_instance: @@ -78,20 +61,6 @@ def client(self) -> InfrahubClient: def client(self, value: InfrahubClient) -> None: self._client = value - @property - def branch_name(self) -> str: - """Return the name of the current git branch.""" - - if self.branch: - return self.branch - - if not self.git: - self.git = GitRepoManager(self.root_directory) - - self.branch = str(self.git.active_branch) - - return self.branch - async def collect_data(self) -> dict: """Query the result of the GraphQL Query defined in self.query and return the result""" @@ -117,27 +86,6 @@ async def run(self, identifier: str, data: dict | None = None) -> None: ) as self.client: await self.generate(data=unpacked) - async def process_nodes(self, data: dict) -> None: - if not self.convert_query_response: - return - - await self._init_client.schema.all(branch=self.branch_name) - - for kind in data: - if kind in self._init_client.schema.cache[self.branch_name].nodes.keys(): - for result in data[kind].get("edges", []): - node = await self.infrahub_node.from_graphql( - client=self._init_client, branch=self.branch_name, data=result - ) - self._nodes.append(node) - await node._process_relationships( - node_data=result, branch=self.branch_name, related_nodes=self._related_nodes - ) - - for node in self._nodes + self._related_nodes: - if node.id: - self._init_client.store.set(node=node) - @abstractmethod async def generate(self, data: dict) -> None: """Code to run the generator diff --git a/infrahub_sdk/operation.py b/infrahub_sdk/operation.py new file mode 100644 index 00000000..f52db43d --- /dev/null +++ b/infrahub_sdk/operation.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from .repository import GitRepoManager + +if TYPE_CHECKING: + from . import InfrahubClient + from .node import InfrahubNode + from .store import NodeStore + + +class InfrahubOperation: + def __init__( + self, + client: InfrahubClient, + infrahub_node: type[InfrahubNode], + convert_query_response: bool, + branch: str, + root_directory: str, + ): + self.branch = branch + self.convert_query_response = convert_query_response + self.root_directory = root_directory or os.getcwd() + self.infrahub_node = infrahub_node + self._nodes: list[InfrahubNode] = [] + self._related_nodes: list[InfrahubNode] = [] + self._init_client = client.clone(branch=self.branch_name) + self.git: GitRepoManager | None = None + + @property + def branch_name(self) -> str: + """Return the name of the current git branch.""" + + if self.branch: + return self.branch + + if not hasattr(self, "git") or not self.git: + self.git = GitRepoManager(self.root_directory) + + self.branch = str(self.git.active_branch) + + return self.branch + + @property + def store(self) -> NodeStore: + """The store will be populated with nodes based on the query during the collection of data if activated""" + return self._init_client.store + + @property + def nodes(self) -> list[InfrahubNode]: + """Returns nodes collected and parsed during the data collection process if this feature is enabled""" + return self._nodes + + @property + def related_nodes(self) -> list[InfrahubNode]: + """Returns nodes collected and parsed during the data collection process if this feature is enabled""" + return self._related_nodes + + async def process_nodes(self, data: dict) -> None: + if not self.convert_query_response: + return + + await self._init_client.schema.all(branch=self.branch_name) + + for kind in data: + if kind in self._init_client.schema.cache[self.branch_name].nodes.keys(): + for result in data[kind].get("edges", []): + node = await self.infrahub_node.from_graphql( + client=self._init_client, branch=self.branch_name, data=result + ) + self._nodes.append(node) + await node._process_relationships( + node_data=result, branch=self.branch_name, related_nodes=self._related_nodes + ) + + for node in self._nodes + self._related_nodes: + if node.id: + self._init_client.store.set(node=node) diff --git a/infrahub_sdk/protocols.py b/infrahub_sdk/protocols.py index 2ec1d0f3..7a69b5f8 100644 --- a/infrahub_sdk/protocols.py +++ b/infrahub_sdk/protocols.py @@ -154,6 +154,10 @@ class CoreMenu(CoreNode): children: RelationshipManager +class CoreObjectComponentTemplate(CoreNode): + template_name: String + + class CoreObjectTemplate(CoreNode): template_name: String @@ -205,6 +209,7 @@ class CoreWebhook(CoreNode): name: String event_type: Enum branch_scope: Dropdown + node_kind: StringOptional description: StringOptional url: URL validate_certificates: BooleanOptional @@ -479,6 +484,7 @@ class CoreTransformJinja2(CoreTransformation): class CoreTransformPython(CoreTransformation): file_path: String class_name: String + convert_query_response: BooleanOptional class CoreUserValidator(CoreValidator): @@ -625,6 +631,10 @@ class CoreMenuSync(CoreNodeSync): children: RelationshipManagerSync +class CoreObjectComponentTemplateSync(CoreNodeSync): + template_name: String + + class CoreObjectTemplateSync(CoreNodeSync): template_name: String @@ -676,6 +686,7 @@ class CoreWebhookSync(CoreNodeSync): name: String event_type: Enum branch_scope: Dropdown + node_kind: StringOptional description: StringOptional url: URL validate_certificates: BooleanOptional @@ -950,6 +961,7 @@ class CoreTransformJinja2Sync(CoreTransformationSync): class CoreTransformPythonSync(CoreTransformationSync): file_path: String class_name: String + convert_query_response: BooleanOptional class CoreUserValidatorSync(CoreValidatorSync): diff --git a/infrahub_sdk/schema/repository.py b/infrahub_sdk/schema/repository.py index 1628fd6d..b5c58d2f 100644 --- a/infrahub_sdk/schema/repository.py +++ b/infrahub_sdk/schema/repository.py @@ -117,6 +117,10 @@ class InfrahubPythonTransformConfig(InfrahubRepositoryConfigElement): name: str = Field(..., description="The name of the Transform") file_path: Path = Field(..., description="The file within the repository with the transform code.") class_name: str = Field(default="Transform", description="The name of the transform class to run.") + convert_query_response: bool = Field( + default=False, + description="Decide if the transform should convert the result of the GraphQL query to SDK InfrahubNode objects.", + ) def load_class(self, import_root: str | None = None, relative_path: str | None = None) -> type[InfrahubTransform]: module = import_module(module_path=self.file_path, import_root=import_root, relative_path=relative_path) diff --git a/infrahub_sdk/transforms.py b/infrahub_sdk/transforms.py index 79ad74f5..29ed1136 100644 --- a/infrahub_sdk/transforms.py +++ b/infrahub_sdk/transforms.py @@ -5,34 +5,38 @@ from abc import abstractmethod from typing import TYPE_CHECKING, Any -from infrahub_sdk.repository import GitRepoManager - -from .exceptions import UninitializedError +from .operation import InfrahubOperation if TYPE_CHECKING: from . import InfrahubClient + from .node import InfrahubNode INFRAHUB_TRANSFORM_VARIABLE_TO_IMPORT = "INFRAHUB_TRANSFORMS" -class InfrahubTransform: +class InfrahubTransform(InfrahubOperation): name: str | None = None query: str timeout: int = 10 def __init__( self, + client: InfrahubClient, + infrahub_node: type[InfrahubNode], + convert_query_response: bool = False, branch: str = "", root_directory: str = "", server_url: str = "", - client: InfrahubClient | None = None, ): - self.git: GitRepoManager + super().__init__( + client=client, + infrahub_node=infrahub_node, + convert_query_response=convert_query_response, + branch=branch, + root_directory=root_directory, + ) - self.branch = branch self.server_url = server_url or os.environ.get("INFRAHUB_URL", "http://127.0.0.1:8000") - self.root_directory = root_directory or os.getcwd() - self._client = client if not self.name: @@ -43,24 +47,7 @@ def __init__( @property def client(self) -> InfrahubClient: - if self._client: - return self._client - - raise UninitializedError("The client has not been initialized") - - @property - def branch_name(self) -> str: - """Return the name of the current git branch.""" - - if self.branch: - return self.branch - - if not hasattr(self, "git") or not self.git: - self.git = GitRepoManager(self.root_directory) - - self.branch = str(self.git.active_branch) - - return self.branch + return self._init_client @abstractmethod def transform(self, data: dict) -> Any: @@ -86,6 +73,7 @@ async def run(self, data: dict | None = None) -> Any: data = await self.collect_data() unpacked = data.get("data") or data + await self.process_nodes(data=unpacked) if asyncio.iscoroutinefunction(self.transform): return await self.transform(data=unpacked) diff --git a/tests/fixtures/repos/ctl_integration/.infrahub.yml b/tests/fixtures/repos/ctl_integration/.infrahub.yml new file mode 100644 index 00000000..605cdff4 --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/.infrahub.yml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://schema.infrahub.app/python-sdk/repository-config/develop.json +--- +python_transforms: + - name: animal_person + class_name: AnimalPerson + file_path: "transforms/animal_person.py" + convert_query_response: false + - name: animal_person_converted + class_name: ConvertedAnimalPerson + file_path: "transforms/converted.py" + convert_query_response: true + +generator_definitions: + - name: animal_tags + file_path: "generators/tag_generator.py" + targets: pet_owners + query: animal_person + convert_query_response: false + parameters: + name: "name__value" + - name: animal_tags_convert + file_path: "generators/tag_generator_convert.py" + targets: pet_owners + query: animal_person + convert_query_response: true + parameters: + name: "name__value" + + +queries: + - name: animal_person + file_path: queries/animal_person.gql diff --git a/tests/fixtures/repos/ctl_integration/generators/tag_generator.py b/tests/fixtures/repos/ctl_integration/generators/tag_generator.py new file mode 100644 index 00000000..50641402 --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/generators/tag_generator.py @@ -0,0 +1,15 @@ +from infrahub_sdk.generator import InfrahubGenerator + + +class Generator(InfrahubGenerator): + async def generate(self, data: dict) -> None: + response_person = data["TestingPerson"]["edges"][0]["node"] + name: str = response_person["name"]["value"] + + for animal in data["TestingPerson"]["edges"][0]["node"]["animals"]["edges"]: + payload = { + "name": f"raw-{name.lower().replace(' ', '-')}-{animal['node']['name']['value'].lower()}", + "description": "Without converting query response", + } + obj = await self.client.create(kind="BuiltinTag", data=payload) + await obj.save(allow_upsert=True) diff --git a/tests/fixtures/repos/ctl_integration/generators/tag_generator_convert.py b/tests/fixtures/repos/ctl_integration/generators/tag_generator_convert.py new file mode 100644 index 00000000..a0ac6d4b --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/generators/tag_generator_convert.py @@ -0,0 +1,16 @@ +from infrahub_sdk.generator import InfrahubGenerator + + +class Generator(InfrahubGenerator): + async def generate(self, data: dict) -> None: + response_person = data["TestingPerson"]["edges"][0]["node"] + name: str = response_person["name"]["value"] + person = self.store.get(key=name, kind="TestingPerson") + + for animal in person.animals.peers: + payload = { + "name": f"converted-{name.lower().replace(' ', '-')}-{animal.peer.name.value.lower()}", + "description": "Using convert_query_response", + } + obj = await self.client.create(kind="BuiltinTag", data=payload) + await obj.save(allow_upsert=True) diff --git a/tests/fixtures/repos/ctl_integration/queries/animal_person.gql b/tests/fixtures/repos/ctl_integration/queries/animal_person.gql new file mode 100644 index 00000000..c8e4ab86 --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/queries/animal_person.gql @@ -0,0 +1,27 @@ +query TestPersonQuery($name: String!) { + TestingPerson(name__value: $name) { + edges { + node { + __typename + id + name { + value + } + height { + value + } + animals { + edges { + node { + __typename + id + name { + value + } + } + } + } + } + } + } +} diff --git a/tests/fixtures/repos/ctl_integration/transforms/animal_person.py b/tests/fixtures/repos/ctl_integration/transforms/animal_person.py new file mode 100644 index 00000000..667dbaaa --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/transforms/animal_person.py @@ -0,0 +1,16 @@ +from typing import Any + +from infrahub_sdk.transforms import InfrahubTransform + + +class AnimalPerson(InfrahubTransform): + query = "animal_person" + + async def transform(self, data) -> dict[str, Any]: + response_person = data["TestingPerson"]["edges"][0]["node"] + name: str = response_person["name"]["value"] + animal_names = sorted( + animal["node"]["name"]["value"] for animal in data["TestingPerson"]["edges"][0]["node"]["animals"]["edges"] + ) + + return {"person": name, "pets": animal_names} diff --git a/tests/fixtures/repos/ctl_integration/transforms/converted.py b/tests/fixtures/repos/ctl_integration/transforms/converted.py new file mode 100644 index 00000000..fd4eadb9 --- /dev/null +++ b/tests/fixtures/repos/ctl_integration/transforms/converted.py @@ -0,0 +1,18 @@ +from operator import itemgetter +from typing import Any + +from infrahub_sdk.transforms import InfrahubTransform + + +class ConvertedAnimalPerson(InfrahubTransform): + query = "animal_person" + + async def transform(self, data) -> dict[str, Any]: + response_person = data["TestingPerson"]["edges"][0]["node"] + name: str = response_person["name"]["value"] + person = self.store.get(key=name, kind="TestingPerson") + + animals = [{"type": animal.peer.typename, "name": animal.peer.name.value} for animal in person.animals.peers] + animals.sort(key=itemgetter("name")) + + return {"person": person.name.value, "herd_size": len(animals), "animals": animals} diff --git a/tests/integration/test_infrahubctl.py b/tests/integration/test_infrahubctl.py new file mode 100644 index 00000000..9d58d5ce --- /dev/null +++ b/tests/integration/test_infrahubctl.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from typer.testing import CliRunner + +from infrahub_sdk.ctl import config +from infrahub_sdk.ctl.cli_commands import app, generator +from infrahub_sdk.ctl.parameters import load_configuration +from infrahub_sdk.repository import GitRepoManager +from infrahub_sdk.testing.docker import TestInfrahubDockerClient +from infrahub_sdk.testing.schemas.animal import SchemaAnimal +from tests.helpers.utils import change_directory, strip_color + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient + +FIXTURE_BASE_DIR = Path(Path(os.path.abspath(__file__)).parent / ".." / "fixtures") + + +runner = CliRunner() + + +class TestInfrahubCtl(TestInfrahubDockerClient, SchemaAnimal): + @pytest.fixture(scope="class") + async def base_dataset( + self, + client: InfrahubClient, + load_schema, + person_liam, + person_ethan, + person_sophia, + cat_luna, + cat_bella, + dog_daisy, + dog_rocky, + ctl_client_config, + ): + await client.branch.create(branch_name="branch01") + + @pytest.fixture(scope="class") + def repository(self) -> Generator[str]: + temp_dir = tempfile.mkdtemp() + + try: + fixture_path = Path(FIXTURE_BASE_DIR / "repos" / "ctl_integration") + shutil.copytree(fixture_path, temp_dir, dirs_exist_ok=True) + # Initialize fixture as git repository. This is necessary to run some infrahubctl commands. + GitRepoManager(temp_dir) + + yield temp_dir + + finally: + shutil.rmtree(temp_dir) + + @pytest.fixture(scope="class") + def ctl_client_config(self, client: InfrahubClient) -> Generator: + load_configuration(value="infrahubctl.toml") + assert config.SETTINGS._settings + config.SETTINGS._settings.server_address = client.config.address + original_username = os.environ.get("INFRAHUB_USERNAME") + original_password = os.environ.get("INFRAHUB_PASSWORD") + if client.config.username and client.config.password: + os.environ["INFRAHUB_USERNAME"] = client.config.username + os.environ["INFRAHUB_PASSWORD"] = client.config.password + yield + if original_username: + os.environ["INFRAHUB_USERNAME"] = original_username + if original_password: + os.environ["INFRAHUB_PASSWORD"] = original_password + + def test_infrahubctl_transform_cmd_animal_person(self, repository: str, base_dataset: None) -> None: + """Test infrahubctl transform without converting nodes.""" + + with change_directory(repository): + ethans_output = runner.invoke(app, ["transform", "animal_person", "name=Ethan Carter"]) + structured_ethan_output = json.loads(strip_color(ethans_output.stdout)) + + liams_output = runner.invoke(app, ["transform", "animal_person", "name=Liam Walker"]) + structured_liam_output = json.loads(strip_color(liams_output.stdout)) + + assert structured_ethan_output == {"person": "Ethan Carter", "pets": ["Bella", "Daisy", "Luna"]} + assert structured_liam_output == {"person": "Liam Walker", "pets": []} + + def test_infrahubctl_transform_cmd_convert_animal_person(self, repository: str, base_dataset: None) -> None: + """Test infrahubctl transform when converting nodes.""" + + with change_directory(repository): + ethans_output = runner.invoke(app, ["transform", "animal_person_converted", "name=Ethan Carter"]) + structured_ethan_output = json.loads(strip_color(ethans_output.stdout)) + + liams_output = runner.invoke(app, ["transform", "animal_person_converted", "name=Liam Walker"]) + structured_liam_output = json.loads(strip_color(liams_output.stdout)) + + assert structured_ethan_output == { + "animals": [ + {"name": "Bella", "type": "TestingCat"}, + {"name": "Daisy", "type": "TestingDog"}, + {"name": "Luna", "type": "TestingCat"}, + ], + "herd_size": 3, + "person": "Ethan Carter", + } + assert structured_liam_output == { + "animals": [], + "herd_size": 0, + "person": "Liam Walker", + } + + async def test_infrahubctl_generator_cmd_animal_tags( + self, repository: str, base_dataset: None, client: InfrahubClient + ) -> None: + """Test infrahubctl generator without converting nodes.""" + + expected_generated_tags = ["raw-ethan-carter-bella", "raw-ethan-carter-daisy", "raw-ethan-carter-luna"] + initial_tags = await client.all(kind="BuiltinTag") + + with change_directory(repository): + await generator( + generator_name="animal_tags", variables=["name=Ethan Carter"], list_available=False, path="." + ) + + final_tags = await client.all(kind="BuiltinTag") + + initial_tag_names = [tag.name.value for tag in initial_tags] + final_tag_names = [tag.name.value for tag in final_tags] + + for tag in expected_generated_tags: + assert tag not in initial_tag_names + assert tag in final_tag_names + + async def test_infrahubctl_generator_cmd_animal_tags_convert_query( + self, repository: str, base_dataset: None, client: InfrahubClient + ) -> None: + """Test infrahubctl generator with conversion of nodes.""" + + expected_generated_tags = [ + "converted-ethan-carter-bella", + "converted-ethan-carter-daisy", + "converted-ethan-carter-luna", + ] + initial_tags = await client.all(kind="BuiltinTag") + + with change_directory(repository): + await generator( + generator_name="animal_tags_convert", variables=["name=Ethan Carter"], list_available=False, path="." + ) + + final_tags = await client.all(kind="BuiltinTag") + + initial_tag_names = [tag.name.value for tag in initial_tags] + final_tag_names = [tag.name.value for tag in final_tags] + + for tag in expected_generated_tags: + assert tag not in initial_tag_names + assert tag in final_tag_names From 795980a5a7c21b198e7f67e603818311cd23a825 Mon Sep 17 00:00:00 2001 From: Brett Lykins Date: Tue, 29 Apr 2025 11:23:06 -0400 Subject: [PATCH 3/3] v1.12 prep --- CHANGELOG.md | 7 +++++++ changelog/+398b0883.added.md | 1 - changelog/281.added.md | 1 - pyproject.toml | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 changelog/+398b0883.added.md delete mode 100644 changelog/281.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fe05e3ff..e7c2bdea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [1.12.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.12.0) - 2025-04-29 + +### Added + +- Added the ability to convert the query response to InfrahubNode objects when using Python Transforms in the same way you can with Generators. ([#281](https://github.com/opsmill/infrahub-sdk-python/issues/281)) +- Added a "branch" parameter to the client.clone() method to allow properly cloning a client that targets another branch. + ## [1.11.1](https://github.com/opsmill/infrahub-sdk-python/tree/v1.11.1) - 2025-04-28 ### Changed diff --git a/changelog/+398b0883.added.md b/changelog/+398b0883.added.md deleted file mode 100644 index f9554fab..00000000 --- a/changelog/+398b0883.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a "branch" parameter to the client.clone() method to allow properly cloning a client that targets another branch. diff --git a/changelog/281.added.md b/changelog/281.added.md deleted file mode 100644 index 00338f66..00000000 --- a/changelog/281.added.md +++ /dev/null @@ -1 +0,0 @@ -Added ability to convert the query response to InfrahubNode objects when using Python Transforms in the same way you can with Generators. diff --git a/pyproject.toml b/pyproject.toml index c3ccc170..d9073e38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "infrahub-sdk" -version = "1.11.1" +version = "1.12.0" description = "Python Client to interact with Infrahub" authors = ["OpsMill "] readme = "README.md"