From 7e6c8d0e69b5241df132b884f562ba14c36b1d3c Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Mon, 24 Aug 2026 16:14:00 +0200 Subject: [PATCH 1/2] feat(protocols): support generating the SDK core protocols module [IFC-3054] The code generator could only render protocols for a user's own schema, importing the core kinds it referenced from infrahub_sdk.protocols. It could not render that module itself: every kind in it is local, so nothing can be imported from it, both the async and sync variants share one file, and the sync classes need a suffix to keep their names distinct. Add a ProtocolTarget to select between the two, splitting the per-file header out of the per-variant body so the core module can emit one header and two bodies. Output for a user schema is unchanged, with one exception: a hierarchical kind no longer declares parent and children twice when the schema already exposes them as relationships. Golden fixtures now pin the rendered output for both variants so any further change to what infrahubctl writes has to be accepted deliberately. --- ...protocols-hierarchical-duplicates.fixed.md | 1 + infrahub_sdk/protocols_generator/constants.py | 1 + infrahub_sdk/protocols_generator/generator.py | 109 ++++-- infrahub_sdk/protocols_generator/header.j2 | 92 +++++ infrahub_sdk/protocols_generator/target.py | 16 + infrahub_sdk/protocols_generator/template.j2 | 69 +--- .../protocols_generator/user_schema_async.txt | 358 ++++++++++++++++++ .../protocols_generator/user_schema_sync.txt | 358 ++++++++++++++++++ tests/unit/sdk/test_protocols_generator.py | 76 +++- 9 files changed, 999 insertions(+), 81 deletions(-) create mode 100644 changelog/+protocols-hierarchical-duplicates.fixed.md create mode 100644 infrahub_sdk/protocols_generator/header.j2 create mode 100644 infrahub_sdk/protocols_generator/target.py create mode 100644 tests/fixtures/protocols_generator/user_schema_async.txt create mode 100644 tests/fixtures/protocols_generator/user_schema_sync.txt diff --git a/changelog/+protocols-hierarchical-duplicates.fixed.md b/changelog/+protocols-hierarchical-duplicates.fixed.md new file mode 100644 index 000000000..c329d4e24 --- /dev/null +++ b/changelog/+protocols-hierarchical-duplicates.fixed.md @@ -0,0 +1 @@ +Protocols generated for a hierarchical kind no longer declare `parent` and `children` twice. When the schema already exposes them as relationships, which is the case for any schema read from the API, they were emitted once from the relationship list and once more from the hierarchy, and the second pair overrode the first. Regenerate with `infrahubctl protocols` to drop the duplicates. diff --git a/infrahub_sdk/protocols_generator/constants.py b/infrahub_sdk/protocols_generator/constants.py index 282217948..2c873645a 100644 --- a/infrahub_sdk/protocols_generator/constants.py +++ b/infrahub_sdk/protocols_generator/constants.py @@ -1,4 +1,5 @@ TEMPLATE_FILE_NAME = "template.j2" +HEADER_FILE_NAME = "header.j2" ATTRIBUTE_KIND_MAP = { "ID": "String", diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index e0a4c61cc..6b5ed4ccc 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -4,6 +4,7 @@ from pathlib import Path import jinja2 +from typing_extensions import assert_never from .. import protocols as sdk_protocols from ..schema import ( @@ -18,11 +19,12 @@ RelationshipSchemaAPI, TemplateSchemaAPI, ) -from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, TEMPLATE_FILE_NAME +from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, HEADER_FILE_NAME, TEMPLATE_FILE_NAME +from .target import ProtocolTarget -def load_template() -> str: - path = Path(__file__).parent / TEMPLATE_FILE_NAME +def load_template(file_name: str = TEMPLATE_FILE_NAME) -> str: + path = Path(__file__).parent / file_name return path.read_text() @@ -35,7 +37,10 @@ def move_to_end_of_list(lst: list, item: str) -> list: class CodeGenerator: - def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: + def __init__( + self, schema: dict[str, MainSchemaTypesAll], target: ProtocolTarget = ProtocolTarget.USER_SCHEMA + ) -> None: + self.target: ProtocolTarget = target self.generics: dict[str, GenericSchemaAPI | GenericSchema] = {} self.nodes: dict[str, NodeSchemaAPI | NodeSchema] = {} self.profiles: dict[str, ProfileSchemaAPI] = {} @@ -51,14 +56,29 @@ def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: if isinstance(schema_type, TemplateSchemaAPI): self.templates[name] = schema_type - self.base_protocols = [ - e - for e in dir(sdk_protocols) - if not e.startswith("__") - and not e.endswith("__") - and e - not in {"TYPE_CHECKING", "CoreNode", "Optional", "Protocol", "Union", "annotations", "runtime_checkable"} - ] + match self.target: + case ProtocolTarget.USER_SCHEMA: + self.base_protocols = [ + e + for e in dir(sdk_protocols) + if not e.startswith("__") + and not e.endswith("__") + and e + not in { + "TYPE_CHECKING", + "CoreNode", + "Optional", + "Protocol", + "Union", + "annotations", + "runtime_checkable", + } + ] + case ProtocolTarget.SDK_CORE: + # Nothing can be imported from the module being generated. + self.base_protocols = [] + case _: + assert_never(self.target) self.sorted_generics = self._sort_and_filter_models(self.generics, filters=["CoreNode", *self.base_protocols]) self.sorted_nodes = self._sort_and_filter_models(self.nodes, filters=["CoreNode", *self.base_protocols]) @@ -69,7 +89,29 @@ def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: self.templates, filters=["CoreObjectTemplate", *self.base_protocols] ) + # Which referenced names have a Sync counterpart to switch to when rendering sync output. + # The two positions resolve names differently for a user schema: a peer may be any core + # kind, while an inheritance list only ever switches the few base classes. Generating the + # core module makes both the same, since every class in it is local. + match self.target: + case ProtocolTarget.USER_SCHEMA: + self._inherited_sync_names = frozenset(CORE_BASE_CLASS_TO_SYNCIFY) + self._peer_sync_names = frozenset( + name for name in self.base_protocols if f"{name}Sync" in self.base_protocols + ) + case ProtocolTarget.SDK_CORE: + local_names = self._local_class_names() | {"CoreNode"} + self._inherited_sync_names = local_names + self._peer_sync_names = local_names + case _: + assert_never(self.target) + def render(self, sync: bool = True) -> str: + """Render the protocols module. + + ``sync`` selects which variant to render for ``USER_SCHEMA``. ``SDK_CORE`` renders both + variants into a single module, so it does not apply there. + """ jinja2_env = jinja2.Environment( loader=jinja2.BaseLoader(), trim_blocks=True, @@ -80,23 +122,45 @@ def render(self, sync: bool = True) -> str: jinja2_env.filters["render_relationship"] = self._jinja2_filter_render_relationship jinja2_env.filters["syncify"] = self._jinja2_filter_syncify - template = jinja2_env.from_string(load_template()) - return template.render( + header = jinja2_env.from_string(load_template(HEADER_FILE_NAME)) + body = jinja2_env.from_string(load_template(TEMPLATE_FILE_NAME)) + + match self.target: + case ProtocolTarget.USER_SCHEMA: + return header.render(sync=sync, base_protocols=self.base_protocols, core=False) + self._render_body( + body, sync=sync, suffix="" + ) + case ProtocolTarget.SDK_CORE: + return ( + header.render(sync=False, base_protocols=self.base_protocols, core=True) + + self._render_body(body, sync=False, suffix="") + + self._render_body(body, sync=True, suffix="Sync") + ) + case _: + assert_never(self.target) + + def _render_body(self, body: jinja2.Template, sync: bool, suffix: str) -> str: + return body.render( generics=self.sorted_generics, nodes=self.sorted_nodes, profiles=self.sorted_profiles, templates=self.sorted_templates, - base_protocols=self.base_protocols, core_node_name="CoreNodeSync" if sync else "CoreNode", sync=sync, + suffix=suffix, ) - @staticmethod - def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: + def _local_class_names(self) -> frozenset[str]: + return frozenset( + f"{model.namespace}{model.name}" + for model in (*self.sorted_generics, *self.sorted_nodes, *self.sorted_profiles, *self.sorted_templates) + ) + + def _jinja2_filter_syncify(self, value: str | list, sync: bool = False) -> str | list: """Filter to help with the convertion to sync. If a string is provided, append Sync to the end of the string - If a list is provided, search for CoreNode and replace it with CoreNodeSync + If a list is provided, append Sync to the items that have a Sync counterpart """ if isinstance(value, list): # Order the list based on the CORE_BASE_CLASS_TO_SYNCIFY list to ensure the base classes are always last @@ -110,7 +174,7 @@ def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: return f"{value}Sync" if isinstance(value, list): - return [f"{item}Sync" if item in CORE_BASE_CLASS_TO_SYNCIFY else item for item in value] + return [f"{item}Sync" if item in self._inherited_sync_names else item for item in value] return value @@ -136,10 +200,9 @@ def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync: if sync: type_ += "Sync" - # Core peer protocols expose a dedicated ``*Sync`` variant; reference it in sync - # output. Locally generated node/generic classes keep their name (they already - # inherit the sync base class), so only swap when a ``*Sync`` peer actually exists. - if f"{peer}Sync" in self.base_protocols: + # Peers with a dedicated ``*Sync`` variant are referenced by it in sync output. The + # rest keep their name, because they already are the sync class. + if peer in self._peer_sync_names: peer = f"{peer}Sync" return f"{name}: {type_}[{peer}]" diff --git a/infrahub_sdk/protocols_generator/header.j2 b/infrahub_sdk/protocols_generator/header.j2 new file mode 100644 index 000000000..67eeb2a83 --- /dev/null +++ b/infrahub_sdk/protocols_generator/header.j2 @@ -0,0 +1,92 @@ +{% if core %} +# Generated by "invoke backend.generate", do not edit directly + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .protocols_base import CoreNode, CoreNodeSync + +if TYPE_CHECKING: + from infrahub_sdk.node import ( + RelationshipAttribute, + RelationshipAttributeSync, + RelationshipManager, + RelationshipManagerSync, + ) + from .protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + +{% else %} +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }} + +if TYPE_CHECKING: + from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + +{% endif %} diff --git a/infrahub_sdk/protocols_generator/target.py b/infrahub_sdk/protocols_generator/target.py new file mode 100644 index 000000000..7dfcacadd --- /dev/null +++ b/infrahub_sdk/protocols_generator/target.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class ProtocolTarget(str, Enum): + """Which protocols module is being generated. + + ``USER_SCHEMA`` renders protocols for a user's own schema into a standalone module, one file + per async/sync variant, importing the core kinds it references from ``infrahub_sdk.protocols``. + + ``SDK_CORE`` renders ``infrahub_sdk.protocols`` itself. Every kind is local, so nothing can be + imported from that module, both variants share a single file, and the sync classes carry a + ``Sync`` suffix to keep their names distinct. + """ + + USER_SCHEMA = "user-schema" + SDK_CORE = "sdk-core" diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index a1c9fca53..917f5a530 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -1,49 +1,6 @@ -# -# Generated by "infrahubctl protocols" -# - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }} - -if TYPE_CHECKING: - from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} - from infrahub_sdk.protocols_base import ( - AnyAttribute, - AnyAttributeOptional, - String, - StringOptional, - Integer, - IntegerOptional, - Boolean, - BooleanOptional, - DateTime, - DateTimeOptional, - Dropdown, - DropdownOptional, - HashedPassword, - HashedPasswordOptional, - MacAddress, - MacAddressOptional, - IPHost, - IPHostOptional, - IPNetwork, - IPNetworkOptional, - IPAddress, - IPAddressOptional, - JSONAttribute, - JSONAttributeOptional, - ListAttribute, - ListAttributeOptional, - URL, - URLOptional, - ) - {% for generic in generics %} -class {{ generic.namespace + generic.name }}({{core_node_name}}): +class {{ generic.namespace + generic.name }}{{ suffix }}({{core_node_name}}): {% if not generic.attributes|default([]) and not generic.relationships|default([]) %} pass {% endif %} @@ -53,16 +10,16 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}): {% for relationship in generic.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if generic.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}] + {% if generic.hierarchical | default(false) and "parent" not in generic.relationships | default([]) | map(attribute="name") %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] {% endif %} {% endfor %} {% for node in nodes %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} @@ -72,9 +29,9 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for relationship in node.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if node.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] + {% if node.hierarchical | default(false) and "parent" not in node.relationships | default([]) | map(attribute="name") %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] {% endif %} {% endfor %} @@ -82,7 +39,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for node in profiles %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} @@ -92,9 +49,9 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for relationship in node.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if node.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] + {% if node.hierarchical | default(false) and "parent" not in node.relationships | default([]) | map(attribute="name") %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] {% endif %} {% endfor %} @@ -102,7 +59,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for node in templates %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} diff --git a/tests/fixtures/protocols_generator/user_schema_async.txt b/tests/fixtures/protocols_generator/user_schema_async.txt new file mode 100644 index 000000000..194c08dc0 --- /dev/null +++ b/tests/fixtures/protocols_generator/user_schema_async.txt @@ -0,0 +1,358 @@ +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import CoreNode, BuiltinIPAddress, BuiltinIPAddressSync, BuiltinIPNamespace, BuiltinIPNamespaceSync, BuiltinIPPrefix, BuiltinIPPrefixSync, BuiltinTag, BuiltinTagSync, CoreAccount, CoreAccountGroup, CoreAccountGroupSync, CoreAccountRole, CoreAccountRoleSync, CoreAccountSync, CoreAction, CoreActionSync, CoreArtifact, CoreArtifactCheck, CoreArtifactCheckSync, CoreArtifactDefinition, CoreArtifactDefinitionSync, CoreArtifactSync, CoreArtifactTarget, CoreArtifactTargetSync, CoreArtifactThread, CoreArtifactThreadSync, CoreArtifactValidator, CoreArtifactValidatorSync, CoreBasePermission, CoreBasePermissionSync, CoreChangeComment, CoreChangeCommentSync, CoreChangeThread, CoreChangeThreadSync, CoreCheck, CoreCheckDefinition, CoreCheckDefinitionSync, CoreCheckSync, CoreComment, CoreCommentSync, CoreCredential, CoreCredentialSync, CoreCustomWebhook, CoreCustomWebhookSync, CoreDataCheck, CoreDataCheckSync, CoreDataValidator, CoreDataValidatorSync, CoreEnvKeyValue, CoreEnvKeyValueSync, CoreFileCheck, CoreFileCheckSync, CoreFileObject, CoreFileObjectSync, CoreFileThread, CoreFileThreadSync, CoreGeneratorAction, CoreGeneratorActionSync, CoreGeneratorAwareGroup, CoreGeneratorAwareGroupSync, CoreGeneratorCheck, CoreGeneratorCheckSync, CoreGeneratorDefinition, CoreGeneratorDefinitionSync, CoreGeneratorGroup, CoreGeneratorGroupSync, CoreGeneratorInstance, CoreGeneratorInstanceSync, CoreGeneratorValidator, CoreGeneratorValidatorSync, CoreGenericAccount, CoreGenericAccountSync, CoreGenericRepository, CoreGenericRepositorySync, CoreGlobalPermission, CoreGlobalPermissionSync, CoreGraphQLQuery, CoreGraphQLQueryGroup, CoreGraphQLQueryGroupSync, CoreGraphQLQuerySync, CoreGroup, CoreGroupAction, CoreGroupActionSync, CoreGroupSync, CoreGroupTriggerRule, CoreGroupTriggerRuleSync, CoreIPAddressPool, CoreIPAddressPoolSync, CoreIPPool, CoreIPPoolSync, CoreIPPrefixPool, CoreIPPrefixPoolSync, CoreKeyValue, CoreKeyValueSync, CoreMenu, CoreMenuItem, CoreMenuItemSync, CoreMenuSync, CoreNodeSync, CoreNodeTriggerAttributeMatch, CoreNodeTriggerAttributeMatchSync, CoreNodeTriggerMatch, CoreNodeTriggerMatchSync, CoreNodeTriggerRelationshipMatch, CoreNodeTriggerRelationshipMatchSync, CoreNodeTriggerRule, CoreNodeTriggerRuleSync, CoreNumberPool, CoreNumberPoolSync, CoreObjectComponentTemplate, CoreObjectComponentTemplateSync, CoreObjectPermission, CoreObjectPermissionSync, CoreObjectTemplate, CoreObjectTemplateSync, CoreObjectThread, CoreObjectThreadSync, CorePasswordCredential, CorePasswordCredentialSync, CoreProfile, CoreProfileSync, CoreProposedChange, CoreProposedChangeSync, CoreReadOnlyRepository, CoreReadOnlyRepositorySync, CoreRepository, CoreRepositoryGroup, CoreRepositoryGroupSync, CoreRepositorySync, CoreRepositoryValidator, CoreRepositoryValidatorSync, CoreResourcePool, CoreResourcePoolSync, CoreSchemaCheck, CoreSchemaCheckSync, CoreSchemaValidator, CoreSchemaValidatorSync, CoreStandardCheck, CoreStandardCheckSync, CoreStandardGroup, CoreStandardGroupSync, CoreStandardWebhook, CoreStandardWebhookSync, CoreStaticKeyValue, CoreStaticKeyValueSync, CoreTaskTarget, CoreTaskTargetSync, CoreThread, CoreThreadComment, CoreThreadCommentSync, CoreThreadSync, CoreTransformJinja2, CoreTransformJinja2Sync, CoreTransformPython, CoreTransformPythonSync, CoreTransformation, CoreTransformationSync, CoreTriggerRule, CoreTriggerRuleSync, CoreUserValidator, CoreUserValidatorSync, CoreValidator, CoreValidatorSync, CoreWebhook, CoreWebhookSync, CoreWeightedPoolResource, CoreWeightedPoolResourceSync, InternalAccountToken, InternalAccountTokenSync, InternalExternalIdentity, InternalExternalIdentitySync, InternalIPPrefixAvailable, InternalIPPrefixAvailableSync, InternalIPRangeAvailable, InternalIPRangeAvailableSync, InternalRefreshToken, InternalRefreshTokenSync, IpamNamespace, IpamNamespaceSync, LineageOwner, LineageOwnerSync, LineageSource, LineageSourceSync + +if TYPE_CHECKING: + from infrahub_sdk.node import RelatedNode, RelationshipAttribute, RelationshipManager + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + + +class LocationGeneric(CoreNode): + description: StringOptional + name: String + shortname: String + children: RelationshipManager[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationGeneric] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + +class NetworkManagementServer(CoreNode): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + + +class LocationCountry(LocationGeneric): + description: StringOptional + name: String + shortname: String + timezone: StringOptional + children: RelationshipManager[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationGeneric] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + +class InfraDevice(CoreNode): + description: StringOptional + name: String + role: Dropdown + role_id: Integer + status: Dropdown + location: RelationshipAttribute[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + object_template: RelationshipAttribute[TemplateInfraDevice] + primary_ip: RelationshipAttribute[IpamIPAddress] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class NetworkDhcpServer(NetworkManagementServer): + description: StringOptional + lease_time: String + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class IpamIPAddress(BuiltinIPAddress): + address: IPHost + description: StringOptional + fqdn: StringOptional + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + ip_prefix: RelationshipAttribute[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class NetworkNTPServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class NetworkNameServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class IpamPrefix(BuiltinIPPrefix): + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + role: DropdownOptional + status: Dropdown + utilization: IntegerOptional + children: RelationshipManager[BuiltinIPPrefix] + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[BuiltinIPPrefix] + profiles: RelationshipManager[CoreProfile] + resource_pool: RelationshipManager[CoreIPAddressPool] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class LocationRack(LocationGeneric): + description: StringOptional + facility_id: StringOptional + height: Integer + name: String + shortname: String + children: RelationshipManager[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationSite] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + +class LocationSite(LocationGeneric): + description: StringOptional + facility_id: StringOptional + name: String + physical_address: StringOptional + shortname: String + children: RelationshipManager[LocationRack] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationCountry] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + + + +class ProfileBuiltinIPAddress(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinIPAddress] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileBuiltinIPPrefix(LineageSource, CoreProfile, CoreNode): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinIPPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileBuiltinTag(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinTag] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileInfraDevice(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + status: DropdownOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[InfraDevice] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileIpamIPAddress(LineageSource, CoreProfile, CoreNode): + description: StringOptional + fqdn: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamIPAddress] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileIpamNamespace(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamNamespace] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileIpamPrefix(LineageSource, CoreProfile, CoreNode): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationCountry(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + timezone: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationCountry] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationGeneric(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationGeneric] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationRack(LineageSource, CoreProfile, CoreNode): + description: StringOptional + facility_id: StringOptional + height: IntegerOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationRack] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationSite(LineageSource, CoreProfile, CoreNode): + description: StringOptional + facility_id: StringOptional + physical_address: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationSite] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkDhcpServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkDhcpServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkManagementServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkNTPServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkNTPServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkNameServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkNameServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + + + +class TemplateInfraDevice(LineageSource, CoreObjectTemplate, CoreNode): + description: StringOptional + role: Dropdown + role_id: IntegerOptional + status: Dropdown + template_name: String + location: RelationshipAttribute[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + primary_ip: RelationshipAttribute[IpamIPAddress] + related_nodes: RelationshipManager[InfraDevice] + subscriber_of_groups: RelationshipManager[CoreGroup] + diff --git a/tests/fixtures/protocols_generator/user_schema_sync.txt b/tests/fixtures/protocols_generator/user_schema_sync.txt new file mode 100644 index 000000000..46c274f0b --- /dev/null +++ b/tests/fixtures/protocols_generator/user_schema_sync.txt @@ -0,0 +1,358 @@ +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import CoreNodeSync, BuiltinIPAddress, BuiltinIPAddressSync, BuiltinIPNamespace, BuiltinIPNamespaceSync, BuiltinIPPrefix, BuiltinIPPrefixSync, BuiltinTag, BuiltinTagSync, CoreAccount, CoreAccountGroup, CoreAccountGroupSync, CoreAccountRole, CoreAccountRoleSync, CoreAccountSync, CoreAction, CoreActionSync, CoreArtifact, CoreArtifactCheck, CoreArtifactCheckSync, CoreArtifactDefinition, CoreArtifactDefinitionSync, CoreArtifactSync, CoreArtifactTarget, CoreArtifactTargetSync, CoreArtifactThread, CoreArtifactThreadSync, CoreArtifactValidator, CoreArtifactValidatorSync, CoreBasePermission, CoreBasePermissionSync, CoreChangeComment, CoreChangeCommentSync, CoreChangeThread, CoreChangeThreadSync, CoreCheck, CoreCheckDefinition, CoreCheckDefinitionSync, CoreCheckSync, CoreComment, CoreCommentSync, CoreCredential, CoreCredentialSync, CoreCustomWebhook, CoreCustomWebhookSync, CoreDataCheck, CoreDataCheckSync, CoreDataValidator, CoreDataValidatorSync, CoreEnvKeyValue, CoreEnvKeyValueSync, CoreFileCheck, CoreFileCheckSync, CoreFileObject, CoreFileObjectSync, CoreFileThread, CoreFileThreadSync, CoreGeneratorAction, CoreGeneratorActionSync, CoreGeneratorAwareGroup, CoreGeneratorAwareGroupSync, CoreGeneratorCheck, CoreGeneratorCheckSync, CoreGeneratorDefinition, CoreGeneratorDefinitionSync, CoreGeneratorGroup, CoreGeneratorGroupSync, CoreGeneratorInstance, CoreGeneratorInstanceSync, CoreGeneratorValidator, CoreGeneratorValidatorSync, CoreGenericAccount, CoreGenericAccountSync, CoreGenericRepository, CoreGenericRepositorySync, CoreGlobalPermission, CoreGlobalPermissionSync, CoreGraphQLQuery, CoreGraphQLQueryGroup, CoreGraphQLQueryGroupSync, CoreGraphQLQuerySync, CoreGroup, CoreGroupAction, CoreGroupActionSync, CoreGroupSync, CoreGroupTriggerRule, CoreGroupTriggerRuleSync, CoreIPAddressPool, CoreIPAddressPoolSync, CoreIPPool, CoreIPPoolSync, CoreIPPrefixPool, CoreIPPrefixPoolSync, CoreKeyValue, CoreKeyValueSync, CoreMenu, CoreMenuItem, CoreMenuItemSync, CoreMenuSync, CoreNodeSync, CoreNodeTriggerAttributeMatch, CoreNodeTriggerAttributeMatchSync, CoreNodeTriggerMatch, CoreNodeTriggerMatchSync, CoreNodeTriggerRelationshipMatch, CoreNodeTriggerRelationshipMatchSync, CoreNodeTriggerRule, CoreNodeTriggerRuleSync, CoreNumberPool, CoreNumberPoolSync, CoreObjectComponentTemplate, CoreObjectComponentTemplateSync, CoreObjectPermission, CoreObjectPermissionSync, CoreObjectTemplate, CoreObjectTemplateSync, CoreObjectThread, CoreObjectThreadSync, CorePasswordCredential, CorePasswordCredentialSync, CoreProfile, CoreProfileSync, CoreProposedChange, CoreProposedChangeSync, CoreReadOnlyRepository, CoreReadOnlyRepositorySync, CoreRepository, CoreRepositoryGroup, CoreRepositoryGroupSync, CoreRepositorySync, CoreRepositoryValidator, CoreRepositoryValidatorSync, CoreResourcePool, CoreResourcePoolSync, CoreSchemaCheck, CoreSchemaCheckSync, CoreSchemaValidator, CoreSchemaValidatorSync, CoreStandardCheck, CoreStandardCheckSync, CoreStandardGroup, CoreStandardGroupSync, CoreStandardWebhook, CoreStandardWebhookSync, CoreStaticKeyValue, CoreStaticKeyValueSync, CoreTaskTarget, CoreTaskTargetSync, CoreThread, CoreThreadComment, CoreThreadCommentSync, CoreThreadSync, CoreTransformJinja2, CoreTransformJinja2Sync, CoreTransformPython, CoreTransformPythonSync, CoreTransformation, CoreTransformationSync, CoreTriggerRule, CoreTriggerRuleSync, CoreUserValidator, CoreUserValidatorSync, CoreValidator, CoreValidatorSync, CoreWebhook, CoreWebhookSync, CoreWeightedPoolResource, CoreWeightedPoolResourceSync, InternalAccountToken, InternalAccountTokenSync, InternalExternalIdentity, InternalExternalIdentitySync, InternalIPPrefixAvailable, InternalIPPrefixAvailableSync, InternalIPRangeAvailable, InternalIPRangeAvailableSync, InternalRefreshToken, InternalRefreshTokenSync, IpamNamespace, IpamNamespaceSync, LineageOwner, LineageOwnerSync, LineageSource, LineageSourceSync + +if TYPE_CHECKING: + from infrahub_sdk.node import RelatedNodeSync, RelationshipAttributeSync, RelationshipManagerSync + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + + +class LocationGeneric(CoreNodeSync): + description: StringOptional + name: String + shortname: String + children: RelationshipManagerSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationGeneric] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + +class NetworkManagementServer(CoreNodeSync): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + + +class LocationCountry(LocationGeneric): + description: StringOptional + name: String + shortname: String + timezone: StringOptional + children: RelationshipManagerSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationGeneric] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + +class InfraDevice(CoreNodeSync): + description: StringOptional + name: String + role: Dropdown + role_id: Integer + status: Dropdown + location: RelationshipAttributeSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + object_template: RelationshipAttributeSync[TemplateInfraDevice] + primary_ip: RelationshipAttributeSync[IpamIPAddress] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class NetworkDhcpServer(NetworkManagementServer): + description: StringOptional + lease_time: String + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class IpamIPAddress(BuiltinIPAddress): + address: IPHost + description: StringOptional + fqdn: StringOptional + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + ip_prefix: RelationshipAttributeSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class NetworkNTPServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class NetworkNameServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class IpamPrefix(BuiltinIPPrefix): + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + role: DropdownOptional + status: Dropdown + utilization: IntegerOptional + children: RelationshipManagerSync[BuiltinIPPrefixSync] + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[BuiltinIPPrefixSync] + profiles: RelationshipManagerSync[CoreProfileSync] + resource_pool: RelationshipManagerSync[CoreIPAddressPoolSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class LocationRack(LocationGeneric): + description: StringOptional + facility_id: StringOptional + height: Integer + name: String + shortname: String + children: RelationshipManagerSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationSite] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + +class LocationSite(LocationGeneric): + description: StringOptional + facility_id: StringOptional + name: String + physical_address: StringOptional + shortname: String + children: RelationshipManagerSync[LocationRack] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationCountry] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + + + +class ProfileBuiltinIPAddress(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinIPAddressSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileBuiltinIPPrefix(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinIPPrefixSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileBuiltinTag(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinTagSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileInfraDevice(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + status: DropdownOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[InfraDevice] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamIPAddress(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + fqdn: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamIPAddress] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamNamespace(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamNamespaceSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamPrefix(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamPrefix] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationCountry(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + timezone: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationCountry] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationGeneric(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationGeneric] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationRack(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + facility_id: StringOptional + height: IntegerOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationRack] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationSite(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + facility_id: StringOptional + physical_address: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationSite] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkDhcpServer(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkDhcpServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkManagementServer(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkNTPServer(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkNTPServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkNameServer(LineageSource, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkNameServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + + + +class TemplateInfraDevice(LineageSource, CoreObjectTemplateSync, CoreNodeSync): + description: StringOptional + role: Dropdown + role_id: IntegerOptional + status: Dropdown + template_name: String + location: RelationshipAttributeSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + primary_ip: RelationshipAttributeSync[IpamIPAddress] + related_nodes: RelationshipManagerSync[InfraDevice] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 531556bcd..6e0c7ab2d 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -5,11 +5,15 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.protocols_generator.generator import CodeGenerator +from infrahub_sdk.protocols_generator.target import ProtocolTarget from infrahub_sdk.schema import AttributeSchemaAPI +from tests.helpers.fixtures import read_fixture if TYPE_CHECKING: from pytest_httpx import HTTPXMock +GOLDEN_SUBDIR = "protocols_generator" + @dataclass class SyncifyTestCase: @@ -73,6 +77,19 @@ class RenderAttributeTestCase: ] +@dataclass +class GoldenTestCase: + name: str + sync: bool + fixture: str + + +GOLDEN_TEST_CASES = [ + GoldenTestCase(name="async", sync=False, fixture="user_schema_async.txt"), + GoldenTestCase(name="sync", sync=True, fixture="user_schema_sync.txt"), +] + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in RENDER_ATTRIBUTE_TEST_CASES], @@ -92,8 +109,10 @@ async def test_filter_render_attribute(test_case: RenderAttributeTestCase) -> No [pytest.param(tc, id=tc.name) for tc in SYNCIFY_TEST_CASES], ) async def test_filter_syncify(test_case: SyncifyTestCase) -> None: - assert CodeGenerator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output - assert CodeGenerator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output + generator = CodeGenerator(schema={}) + + assert generator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output + assert generator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output async def test_generator(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: @@ -128,3 +147,56 @@ class LocationSite(LocationGeneric): assert "class LocationGeneric(CoreNode)" in async_protocols assert "class LocationCountry(LocationGeneric)" in async_protocols assert "class TemplateInfraDevice(LineageSource, CoreObjectTemplate, CoreNode)" in async_protocols + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in GOLDEN_TEST_CASES], +) +async def test_render_user_schema_matches_golden( + test_case: GoldenTestCase, client: InfrahubClient, mock_schema_query_05: "HTTPXMock" +) -> None: + """Rendering a user schema produces output byte-identical to the committed reference. + + The output of `infrahubctl protocols` is checked into user repositories and type-checked + there, so any change to it - a renamed annotation, a reordered member, a different import + line - is a change to something users depend on. Comparing the whole file makes that + surface as a diff a reviewer has to accept on purpose, rather than passing unnoticed + because the assertions only sampled a few lines. + """ + schemas = await client.schema.fetch(branch="main") + + rendered = CodeGenerator(schema=schemas).render(sync=test_case.sync) + + assert rendered == read_fixture(test_case.fixture, GOLDEN_SUBDIR) + + +async def test_render_sdk_core_emits_both_variants(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: + """Generating infrahub_sdk.protocols itself puts both variants in one module. + + Every kind is local there, so the module cannot import the kinds it defines, the sync classes + need a suffix to keep their names distinct, and a peer has to be referenced by its own sync + class rather than by an imported one. + """ + schemas = await client.schema.fetch(branch="main") + + rendered = CodeGenerator(schema=schemas, target=ProtocolTarget.SDK_CORE).render() + + assert "from infrahub_sdk.protocols import" not in rendered + assert "from .protocols_base import CoreNode, CoreNodeSync" in rendered + + assert "class LocationGeneric(CoreNode):" in rendered + assert "class LocationGenericSync(CoreNodeSync):" in rendered + assert "class LocationSite(LocationGeneric):" in rendered + assert "class LocationSiteSync(LocationGenericSync):" in rendered + + assert "parent: RelationshipAttribute[LocationCountry]" in rendered + assert "parent: RelationshipAttributeSync[LocationCountrySync]" in rendered + + +async def test_render_sdk_core_ignores_sync_argument(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: + """The async/sync choice does not apply when both variants land in the same module.""" + schemas = await client.schema.fetch(branch="main") + generator = CodeGenerator(schema=schemas, target=ProtocolTarget.SDK_CORE) + + assert generator.render(sync=True) == generator.render(sync=False) From 516f73528fb26be3d556f6bd97fc2bb26ff9d862 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Tue, 25 Aug 2026 13:27:05 +0200 Subject: [PATCH 2/2] fix(protocols): check parent and children independently in the hierarchy guard The guard suppressed both members when the schema already declared "parent" as a relationship. A schema declaring only one of the pair therefore either got a duplicate "children" or lost it entirely. Only generics carry "hierarchical", so the equivalent blocks in the node and profile loops are unreachable today, but the guard is kept identical across the three copies rather than reasoning about which one is live. --- infrahub_sdk/protocols_generator/template.j2 | 21 +++++++-- tests/unit/sdk/test_protocols_generator.py | 49 +++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index 917f5a530..e5258559c 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -10,10 +10,15 @@ class {{ generic.namespace + generic.name }}{{ suffix }}({{core_node_name}}): {% for relationship in generic.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if generic.hierarchical | default(false) and "parent" not in generic.relationships | default([]) | map(attribute="name") %} + {% if generic.hierarchical | default(false) %} + {% set relationship_names = generic.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] {% endif %} + {% endif %} {% endfor %} @@ -29,10 +34,15 @@ class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncif {% for relationship in node.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if node.hierarchical | default(false) and "parent" not in node.relationships | default([]) | map(attribute="name") %} + {% if node.hierarchical | default(false) %} + {% set relationship_names = node.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] {% endif %} + {% endif %} {% endfor %} @@ -49,10 +59,15 @@ class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncif {% for relationship in node.relationships | sort(attribute='name') | default([]) %} {{ relationship | render_relationship(sync) }} {% endfor %} - {% if node.hierarchical | default(false) and "parent" not in node.relationships | default([]) | map(attribute="name") %} + {% if node.hierarchical | default(false) %} + {% set relationship_names = node.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] {% endif %} + {% endif %} {% endfor %} diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 6e0c7ab2d..524e4b6cb 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -6,7 +6,12 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.protocols_generator.generator import CodeGenerator from infrahub_sdk.protocols_generator.target import ProtocolTarget -from infrahub_sdk.schema import AttributeSchemaAPI +from infrahub_sdk.schema import ( + AttributeSchemaAPI, + GenericSchemaAPI, + RelationshipCardinality, + RelationshipSchemaAPI, +) from tests.helpers.fixtures import read_fixture if TYPE_CHECKING: @@ -77,6 +82,20 @@ class RenderAttributeTestCase: ] +@dataclass +class HierarchyTestCase: + name: str + declared_relationships: list[str] + + +HIERARCHY_TEST_CASES = [ + HierarchyTestCase(name="neither-declared", declared_relationships=[]), + HierarchyTestCase(name="only-parent-declared", declared_relationships=["parent"]), + HierarchyTestCase(name="only-children-declared", declared_relationships=["children"]), + HierarchyTestCase(name="both-declared", declared_relationships=["parent", "children"]), +] + + @dataclass class GoldenTestCase: name: str @@ -171,6 +190,34 @@ async def test_render_user_schema_matches_golden( assert rendered == read_fixture(test_case.fixture, GOLDEN_SUBDIR) +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in HIERARCHY_TEST_CASES], +) +async def test_hierarchy_members_are_declared_once(test_case: HierarchyTestCase) -> None: + """A hierarchical kind declares `parent` and `children` exactly once. + + Both come from the hierarchy unless the schema already exposes them as relationships, which + it does for anything read from the API. Emitting either one twice is not a type error, so a + duplicate silently overrides the first declaration with whatever the second one says. + """ + relationships = [ + RelationshipSchemaAPI( + name=name, + peer="LocationSite", + cardinality=RelationshipCardinality.ONE if name == "parent" else RelationshipCardinality.MANY, + ) + for name in test_case.declared_relationships + ] + generic = GenericSchemaAPI(name="Site", namespace="Location", hierarchical=True, relationships=relationships) + + rendered = CodeGenerator(schema={"LocationSite": generic}).render(sync=False) + + body = rendered[rendered.index("class LocationSite") :] + assert body.count("parent:") == 1 + assert body.count("children:") == 1 + + async def test_render_sdk_core_emits_both_variants(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: """Generating infrahub_sdk.protocols itself puts both variants in one module.