From 89e406ad43d9cc007acbfd8d877df69ae8868a1a Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Tue, 28 Jul 2026 21:04:59 -0700 Subject: [PATCH 1/4] feat(node): return bare IP addresses for attributes declaring allow_prefix [INFP-551] An IPHost attribute whose schema sets parameters.allow_prefix to false stores a bare address, so parsing it as an interface re-attached the host mask at the SDK boundary. Value coercion and the generated-protocol annotation now read the flag, tolerantly defaulting to the prefixed behaviour when a server does not publish it. Removes the unreachable IPAddress attribute-kind branch: no such attribute kind exists on the server, and the bare-address coercion it referenced is what the flagged IPHost branch now uses. Co-Authored-By: Claude Opus 5 (1M context) --- changelog/+bare-iphost-attribute.added.md | 8 ++ changelog/+ipaddress-attribute-kind.added.md | 1 - docs/_templates/sdk_compatibility.j2 | 10 ++ .../python-sdk/reference/compatibility.mdx | 11 +++ docs/docs_generation/compatibility.py | 1 + infrahub_sdk/node/attribute.py | 22 +++-- infrahub_sdk/protocols_generator/generator.py | 7 ++ infrahub_sdk/schema/generated/read.py | 28 +++++- infrahub_sdk/schema/generated/write.py | 28 +++++- tests/unit/sdk/conftest.py | 2 +- tests/unit/sdk/test_node.py | 93 ++++++++++++++++--- tests/unit/sdk/test_protocols_generator.py | 85 +++++++++++++++++ 12 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 changelog/+bare-iphost-attribute.added.md delete mode 100644 changelog/+ipaddress-attribute-kind.added.md diff --git a/changelog/+bare-iphost-attribute.added.md b/changelog/+bare-iphost-attribute.added.md new file mode 100644 index 000000000..f984938c5 --- /dev/null +++ b/changelog/+bare-iphost-attribute.added.md @@ -0,0 +1,8 @@ +Added support for `IPHost` attributes declaring `allow_prefix: false`. Their values are exposed as bare +`ipaddress.IPv4Address`/`IPv6Address` objects (no prefix), serialized back as a bare-address string, and +typed as `IPAddress` in generated protocols. `IPHost` attributes that do not declare the parameter keep +returning `ipaddress.IPv4Interface`/`IPv6Interface`, as does any attribute read from a server that does +not publish the parameter. + +This replaces the `IPAddress` attribute kind announced earlier in this release cycle: no such attribute +kind exists on the Infrahub server, so the code paths handling it were unreachable and have been removed. diff --git a/changelog/+ipaddress-attribute-kind.added.md b/changelog/+ipaddress-attribute-kind.added.md deleted file mode 100644 index e7e9b2192..000000000 --- a/changelog/+ipaddress-attribute-kind.added.md +++ /dev/null @@ -1 +0,0 @@ -Added support for the new `IPAddress` attribute kind. Values are exposed as bare `ipaddress.IPv4Address`/`IPv6Address` objects (no prefix) and serialized to a bare-address string when writing, alongside the existing `IPHost` and `IPNetwork` kinds. diff --git a/docs/_templates/sdk_compatibility.j2 b/docs/_templates/sdk_compatibility.j2 index 360d08b6e..c4060fad3 100644 --- a/docs/_templates/sdk_compatibility.j2 +++ b/docs/_templates/sdk_compatibility.j2 @@ -55,6 +55,16 @@ Some SDK features require a minimum Infrahub version: {% endfor %} +### Bare IP address attributes + +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 onwards, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. + +:::warning +Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. +::: + +A newer SDK against an older Infrahub is safe: when the server does not publish `allow_prefix`, the SDK keeps returning interface objects. + ## General guidance - **Use the SDK version that matches your Infrahub release.** The version mapping table above shows which SDK version was tested and shipped with each Infrahub release. diff --git a/docs/docs/python-sdk/reference/compatibility.mdx b/docs/docs/python-sdk/reference/compatibility.mdx index 586e5f9ec..ca0d12321 100644 --- a/docs/docs/python-sdk/reference/compatibility.mdx +++ b/docs/docs/python-sdk/reference/compatibility.mdx @@ -153,12 +153,23 @@ Some SDK features require a minimum Infrahub version: | Feature | Minimum SDK | Minimum Infrahub | | --- | --- | --- | +| Bare IP address attributes | 1.23.0 | 1.11 | | infrahubctl branch report | 1.19.0 | 1.7 | | FileObject support | 1.19.0 | 1.8 | | NumberPool support | 1.13.0 | 1.3 | +### Bare IP address attributes + +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 onwards, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. + +:::warning +Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. +::: + +A newer SDK against an older Infrahub is safe: when the server does not publish `allow_prefix`, the SDK keeps returning interface objects. + ## General guidance - **Use the SDK version that matches your Infrahub release.** The version mapping table above shows which SDK version was tested and shipped with each Infrahub release. diff --git a/docs/docs_generation/compatibility.py b/docs/docs_generation/compatibility.py index 0f9c6f253..76ae42b8c 100644 --- a/docs/docs_generation/compatibility.py +++ b/docs/docs_generation/compatibility.py @@ -192,6 +192,7 @@ class FeatureRequirement: # Features that require specific minimum versions of both SDK and Infrahub. FEATURE_REQUIREMENTS: list[FeatureRequirement] = [ + FeatureRequirement(feature="Bare IP address attributes", min_sdk="1.23.0", min_infrahub="1.11"), FeatureRequirement(feature="infrahubctl branch report", min_sdk="1.19.0", min_infrahub="1.7"), FeatureRequirement(feature="FileObject support", min_sdk="1.19.0", min_infrahub="1.8"), FeatureRequirement(feature="NumberPool support", min_sdk="1.13.0", min_infrahub="1.3"), diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 5dbb82085..6e6dabfe2 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -75,7 +75,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N """Build an ``Attribute`` from raw GraphQL data. IP-typed attributes (``IPHost``, ``IPNetwork``) are parsed via the standard - ``ipaddress`` module so the in-memory value is a network/interface object. + ``ipaddress`` module so the in-memory value is an address/interface/network object. Args: name (str): The name of the attribute. @@ -109,13 +109,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N self.is_from_profile: bool | None = data.get("is_from_profile") if self._value: - value_mapper: dict[str, Callable] = { - "IPHost": ipaddress.ip_interface, - "IPNetwork": ipaddress.ip_network, - "IPAddress": ipaddress.ip_address, - } - mapper = value_mapper.get(schema.kind, lambda value: value) - self._value = mapper(data.get("value")) + self._value = self._value_coercer()(data.get("value")) self.is_inherited: bool | None = data.get("is_inherited") self.updated_at: str | None = data.get("updated_at") @@ -134,6 +128,18 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N if data.get(prop_name): setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type] + def _value_coercer(self) -> Callable[[Any], Any]: + if self._schema.kind == "IPHost": + # An attribute declaring ``allow_prefix: false`` holds a bare address, so parsing it as an + # interface would re-attach the host mask. Absent parameters mean an older server that does + # not publish the flag, which keeps the historical prefixed behaviour. + if (self._schema.parameters or {}).get("allow_prefix", True): + return ipaddress.ip_interface + return ipaddress.ip_address + if self._schema.kind == "IPNetwork": + return ipaddress.ip_network + return lambda value: value + @property def value(self) -> Any: return self._value diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index e0a4c61cc..ad79f2d73 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -18,6 +18,7 @@ RelationshipSchemaAPI, TemplateSchemaAPI, ) +from ..schema.main import AttributeKind from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, TEMPLATE_FILE_NAME @@ -118,6 +119,12 @@ def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: def _jinja2_filter_render_attribute(value: AttributeSchemaAPI) -> str: attribute_kind: str = ATTRIBUTE_KIND_MAP[value.kind] + # An attribute declaring ``allow_prefix: false`` holds a bare address rather than an interface. + # Absent parameters mean an older server that does not publish the flag, which keeps the + # historical prefixed annotation. + if value.kind == AttributeKind.IPHOST and not (value.parameters or {}).get("allow_prefix", True): + attribute_kind = "IPAddress" + if value.optional and value.default_value is None: attribute_kind += "Optional" diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index 1b0eee5c8..547036903 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -81,6 +81,14 @@ class NumberPoolParametersRead(AttributeParametersRead): ) +class IPHostAttributeParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + allow_prefix: bool = Field( + default=True, + description="When false, this attribute holds a bare IP address: a value with a subnet prefix is rejected and a host prefix is dropped.", + ) + + class DropdownChoiceRead(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) name: str = Field( @@ -284,6 +292,18 @@ class NumberPoolAttributeRead(AttributeSchemaBaseRead): ) +class IPHostAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.IPHOST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: IPHostAttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + class GenericAttributeRead(AttributeSchemaBaseRead): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ @@ -298,7 +318,6 @@ class GenericAttributeRead(AttributeSchemaBaseRead): AttributeKind.MAC_ADDRESS, AttributeKind.COLOR, AttributeKind.BANDWIDTH, - AttributeKind.IPHOST, AttributeKind.IPNETWORK, AttributeKind.BOOLEAN, AttributeKind.CHECKBOX, @@ -320,7 +339,12 @@ class GenericAttributeRead(AttributeSchemaBaseRead): ] AttributeSchemaRead = Annotated[ - TextAttributeRead | NumberAttributeRead | ListAttributeRead | NumberPoolAttributeRead | GenericAttributeRead, + TextAttributeRead + | NumberAttributeRead + | ListAttributeRead + | NumberPoolAttributeRead + | IPHostAttributeRead + | GenericAttributeRead, Field(discriminator="kind"), ] diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py index 8d10710ab..5735d0a20 100644 --- a/infrahub_sdk/schema/generated/write.py +++ b/infrahub_sdk/schema/generated/write.py @@ -81,6 +81,14 @@ class NumberPoolParametersWrite(AttributeParametersWrite): ) +class IPHostAttributeParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + allow_prefix: bool = Field( + default=True, + description="When false, this attribute holds a bare IP address: a value with a subnet prefix is rejected and a host prefix is dropped.", + ) + + class DropdownChoiceWrite(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) name: str = Field( @@ -280,6 +288,18 @@ class NumberPoolAttributeWrite(AttributeSchemaBaseWrite): ) +class IPHostAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.IPHOST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: IPHostAttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + class GenericAttributeWrite(AttributeSchemaBaseWrite): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ @@ -294,7 +314,6 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite): AttributeKind.MAC_ADDRESS, AttributeKind.COLOR, AttributeKind.BANDWIDTH, - AttributeKind.IPHOST, AttributeKind.IPNETWORK, AttributeKind.BOOLEAN, AttributeKind.CHECKBOX, @@ -316,7 +335,12 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite): ] AttributeSchemaWrite = Annotated[ - TextAttributeWrite | NumberAttributeWrite | ListAttributeWrite | NumberPoolAttributeWrite | GenericAttributeWrite, + TextAttributeWrite + | NumberAttributeWrite + | ListAttributeWrite + | NumberPoolAttributeWrite + | IPHostAttributeWrite + | GenericAttributeWrite, Field(discriminator="kind"), ] diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 896a45fb1..c4acf866c 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -895,7 +895,7 @@ async def bare_ipaddress_schema() -> NodeSchemaAPI: "display_labels": ["address_value"], "order_by": ["address_value"], "attributes": [ - {"name": "address", "kind": "IPAddress"}, + {"name": "address", "kind": "IPHost", "parameters": {"allow_prefix": False}}, ], } return NodeSchema(**data).convert_api() diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index f6b0ec67b..9259f7a71 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -4,6 +4,7 @@ import ipaddress import json import tempfile +from dataclasses import dataclass from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING @@ -24,6 +25,7 @@ from infrahub_sdk.node.metadata import NodeMetadata, RelationshipMetadata from infrahub_sdk.node.property import NodeProperty from infrahub_sdk.node.related_node import RelatedNode, RelatedNodeSync +from infrahub_sdk.schema import AttributeSchema, NodeSchema if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -1787,13 +1789,8 @@ async def test_create_input_data_with_IPHost_attribute( } -@pytest.mark.skip( - reason="The IPAddress attribute kind is not yet defined in the Infrahub backend, so the generated " - "AttributeKind enum omits it and the schema fixture cannot be built. Re-enable once the backend " - "adds the IPAddress attribute type." -) @pytest.mark.parametrize("client_type", client_types) -async def test_create_input_data_with_IPAddress_attribute( +async def test_create_input_data_with_bare_IPHost_attribute( client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str ) -> None: data = {"address": {"value": ipaddress.ip_address("1.1.1.1"), "is_protected": True}} @@ -2202,13 +2199,85 @@ async def test_node_IPHost_deserialization( assert ip_address.address.value == ipaddress.ip_interface("1.1.1.1/24") -@pytest.mark.skip( - reason="The IPAddress attribute kind is not yet defined in the Infrahub backend, so the generated " - "AttributeKind enum omits it and the schema fixture cannot be built. Re-enable once the backend " - "adds the IPAddress attribute type." -) +@dataclass +class IPHostCoercionCase: + name: str + parameters: dict[str, Any] | None + stored_value: str + expected: Any + + +IPHOST_COERCION_CASES = [ + IPHostCoercionCase( + name="bare-ipv4", + parameters={"allow_prefix": False}, + stored_value="1.1.1.1", + expected=ipaddress.ip_address("1.1.1.1"), + ), + IPHostCoercionCase( + name="bare-ipv6", + parameters={"allow_prefix": False}, + stored_value="2001:db8::1", + expected=ipaddress.ip_address("2001:db8::1"), + ), + IPHostCoercionCase( + name="prefixed-ipv4", + parameters={"allow_prefix": True}, + stored_value="1.1.1.1/24", + expected=ipaddress.ip_interface("1.1.1.1/24"), + ), + IPHostCoercionCase( + name="prefixed-ipv6", + parameters={"allow_prefix": True}, + stored_value="2001:db8::1/64", + expected=ipaddress.ip_interface("2001:db8::1/64"), + ), + # A server that does not publish the parameter must keep the historical prefixed behaviour, + # including re-attaching the host mask to a value that arrives bare. + IPHostCoercionCase( + name="parameters-absent-prefixed-value", + parameters=None, + stored_value="1.1.1.1/24", + expected=ipaddress.ip_interface("1.1.1.1/24"), + ), + IPHostCoercionCase( + name="parameters-absent-bare-value", + parameters=None, + stored_value="1.1.1.1", + expected=ipaddress.ip_interface("1.1.1.1/32"), + ), + IPHostCoercionCase( + name="parameters-empty-bare-value", + parameters={}, + stored_value="1.1.1.1", + expected=ipaddress.ip_interface("1.1.1.1/32"), + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in IPHOST_COERCION_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_node_IPHost_deserialization_honours_allow_prefix( + client: InfrahubClient, case: IPHostCoercionCase, client_type: str +) -> None: + schema = NodeSchema( + name="DnsRecord", + namespace="Infra", + attributes=[AttributeSchema(name="address", kind="IPHost", parameters=case.parameters)], + ).convert_api() + data = {"id": "aaaaaaaaaaaaaa", "address": {"value": case.stored_value, "is_protected": True}} + + if client_type == "standard": + dns_record = InfrahubNode(client=client, schema=schema, data=data) + else: + dns_record = InfrahubNodeSync(client=client, schema=schema, data=data) + + assert dns_record.address.value == case.expected + assert type(dns_record.address.value) is type(case.expected) + + @pytest.mark.parametrize("client_type", client_types) -async def test_node_IPAddress_deserialization( +async def test_node_bare_IPHost_deserialization( client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str ) -> None: data = { diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 531556bcd..c7a0dde3b 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -73,6 +73,76 @@ class RenderAttributeTestCase: ] +@dataclass +class RenderIPHostAttributeTestCase: + name: str + parameters: dict[str, Any] | None + optional: bool + default_value: Any + expected: str + + +RENDER_IPHOST_ATTRIBUTE_TEST_CASES = [ + RenderIPHostAttributeTestCase( + name="bare-required", + parameters={"allow_prefix": False}, + optional=False, + default_value=None, + expected="address: IPAddress", + ), + RenderIPHostAttributeTestCase( + name="bare-optional-no-default", + parameters={"allow_prefix": False}, + optional=True, + default_value=None, + expected="address: IPAddressOptional", + ), + RenderIPHostAttributeTestCase( + name="bare-optional-with-default", + parameters={"allow_prefix": False}, + optional=True, + default_value="10.0.0.1", + expected="address: IPAddress", + ), + RenderIPHostAttributeTestCase( + name="prefixed-required", + parameters={"allow_prefix": True}, + optional=False, + default_value=None, + expected="address: IPHost", + ), + RenderIPHostAttributeTestCase( + name="prefixed-optional-no-default", + parameters={"allow_prefix": True}, + optional=True, + default_value=None, + expected="address: IPHostOptional", + ), + # A server that does not publish the parameter keeps the historical prefixed annotation. + RenderIPHostAttributeTestCase( + name="parameters-absent-required", + parameters=None, + optional=False, + default_value=None, + expected="address: IPHost", + ), + RenderIPHostAttributeTestCase( + name="parameters-absent-optional-no-default", + parameters=None, + optional=True, + default_value=None, + expected="address: IPHostOptional", + ), + RenderIPHostAttributeTestCase( + name="parameters-empty-required", + parameters={}, + optional=False, + default_value=None, + expected="address: IPHost", + ), +] + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in RENDER_ATTRIBUTE_TEST_CASES], @@ -87,6 +157,21 @@ async def test_filter_render_attribute(test_case: RenderAttributeTestCase) -> No assert CodeGenerator._jinja2_filter_render_attribute(attr) == test_case.expected +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in RENDER_IPHOST_ATTRIBUTE_TEST_CASES], +) +async def test_filter_render_iphost_attribute(test_case: RenderIPHostAttributeTestCase) -> None: + attr = AttributeSchemaAPI( + name="address", + kind="IPHost", + optional=test_case.optional, + default_value=test_case.default_value, + parameters=test_case.parameters, + ) + assert CodeGenerator._jinja2_filter_render_attribute(attr) == test_case.expected + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in SYNCIFY_TEST_CASES], From d3c0f6eefbac8c97f04280f18c240cd04ad99939 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 29 Jul 2026 17:20:31 -0700 Subject: [PATCH 2/4] refactor(node): coerce attribute values directly rather than returning a coercer _value_coercer returned a callable that was invoked immediately at its only call site, so the indirection bought nothing. Coerce in place instead, and drop the now-unused Callable import. Also trims the parameter comments to a single line. Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sdk/node/attribute.py | 13 ++++++------- infrahub_sdk/protocols_generator/generator.py | 4 +--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 6e6dabfe2..324cccedb 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -1,7 +1,6 @@ from __future__ import annotations import ipaddress -from collections.abc import Callable from typing import TYPE_CHECKING, Any, NamedTuple, get_args from ..uuidt import UUIDT @@ -109,7 +108,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N self.is_from_profile: bool | None = data.get("is_from_profile") if self._value: - self._value = self._value_coercer()(data.get("value")) + self._value = self._coerce_value(data.get("value")) self.is_inherited: bool | None = data.get("is_inherited") self.updated_at: str | None = data.get("updated_at") @@ -128,17 +127,17 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N if data.get(prop_name): setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type] - def _value_coercer(self) -> Callable[[Any], Any]: + def _coerce_value(self, value: Any) -> Any: if self._schema.kind == "IPHost": # An attribute declaring ``allow_prefix: false`` holds a bare address, so parsing it as an # interface would re-attach the host mask. Absent parameters mean an older server that does # not publish the flag, which keeps the historical prefixed behaviour. if (self._schema.parameters or {}).get("allow_prefix", True): - return ipaddress.ip_interface - return ipaddress.ip_address + return ipaddress.ip_interface(value) + return ipaddress.ip_address(value) if self._schema.kind == "IPNetwork": - return ipaddress.ip_network - return lambda value: value + return ipaddress.ip_network(value) + return value @property def value(self) -> Any: diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index ad79f2d73..e855db1c5 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -119,9 +119,7 @@ def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: def _jinja2_filter_render_attribute(value: AttributeSchemaAPI) -> str: attribute_kind: str = ATTRIBUTE_KIND_MAP[value.kind] - # An attribute declaring ``allow_prefix: false`` holds a bare address rather than an interface. - # Absent parameters mean an older server that does not publish the flag, which keeps the - # historical prefixed annotation. + # An attribute declaring ``allow_prefix: false`` holds a bare address rather than an interface if value.kind == AttributeKind.IPHOST and not (value.parameters or {}).get("allow_prefix", True): attribute_kind = "IPAddress" From 151350bb89440c053cba894c74a0ef71502da28a Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 29 Jul 2026 17:22:02 -0700 Subject: [PATCH 3/4] docs(compatibility): reword the SDK version floor for the style checker vale rejects 'onwards'. The warning admonition below already says 'or later', so this matches it. Edited in the Jinja2 template, since compatibility.mdx is generated. Co-Authored-By: Claude Opus 5 (1M context) --- docs/_templates/sdk_compatibility.j2 | 2 +- docs/docs/python-sdk/reference/compatibility.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_templates/sdk_compatibility.j2 b/docs/_templates/sdk_compatibility.j2 index c4060fad3..79fdd0029 100644 --- a/docs/_templates/sdk_compatibility.j2 +++ b/docs/_templates/sdk_compatibility.j2 @@ -57,7 +57,7 @@ Some SDK features require a minimum Infrahub version: ### Bare IP address attributes -An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 onwards, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 or later, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. :::warning Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. diff --git a/docs/docs/python-sdk/reference/compatibility.mdx b/docs/docs/python-sdk/reference/compatibility.mdx index ca0d12321..88f5e7069 100644 --- a/docs/docs/python-sdk/reference/compatibility.mdx +++ b/docs/docs/python-sdk/reference/compatibility.mdx @@ -162,7 +162,7 @@ Some SDK features require a minimum Infrahub version: ### Bare IP address attributes -An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 onwards, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 or later, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. :::warning Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. From 525b28fcc2253e2d1442e24d4ad8e68500d828ed Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 29 Jul 2026 18:30:08 -0700 Subject: [PATCH 4/4] chore(schema): regenerate models for the reworded allow_prefix description Co-Authored-By: Claude Opus 5 (1M context) --- infrahub_sdk/schema/generated/read.py | 2 +- infrahub_sdk/schema/generated/write.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index 547036903..84c4979a3 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -85,7 +85,7 @@ class IPHostAttributeParametersRead(AttributeParametersRead): model_config = ConfigDict(extra="ignore", use_enum_values=True) allow_prefix: bool = Field( default=True, - description="When false, this attribute holds a bare IP address: a value with a subnet prefix is rejected and a host prefix is dropped.", + description="When false, this attribute holds a bare IP address rather than an interface.", ) diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py index 5735d0a20..66fe92dca 100644 --- a/infrahub_sdk/schema/generated/write.py +++ b/infrahub_sdk/schema/generated/write.py @@ -85,7 +85,7 @@ class IPHostAttributeParametersWrite(AttributeParametersWrite): model_config = ConfigDict(extra="ignore", use_enum_values=True) allow_prefix: bool = Field( default=True, - description="When false, this attribute holds a bare IP address: a value with a subnet prefix is rejected and a host prefix is dropped.", + description="When false, this attribute holds a bare IP address rather than an interface.", )