From 8fd9bce0bf34476b3735d196b9def111f175eeff Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 12:39:57 -0600 Subject: [PATCH 1/6] fix(infrahub adapter): stop stringifying non-IP attribute values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InfrahubAdapter.infrahub_node_to_diffsync() was passing every non-string attribute value through str(). For attributes of kind: List, that turned the real list (e.g. []) into the string literal "[]", which then failed Pydantic validation on DiffSync model fields typed as list[str]. The same bug affected kind: Number (int → string) and kind: Boolean (bool → string), though Pydantic 2 sometimes coerces those back. Restrict the stringification to the originally-intended ipaddress types. The Infrahub SDK already returns DateTime values as ISO-8601 strings, so no extra coercion is needed for that kind either. Verified end-to-end against Infrahub 1.9.6 with a minimal reproduction (see issue): a Bookmark node with a List-kind `tags` attribute now round-trips through the adapter as a real list and constructs the DiffSync model without error. Closes #129 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- infrahub_sync/adapters/infrahub.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/infrahub_sync/adapters/infrahub.py b/infrahub_sync/adapters/infrahub.py index 6f351f2..573b118 100644 --- a/infrahub_sync/adapters/infrahub.py +++ b/infrahub_sync/adapters/infrahub.py @@ -343,11 +343,17 @@ def infrahub_node_to_diffsync(self, node: InfrahubNodeSync) -> dict[str, Any]: if has_field(config=self.config, name=node._schema.kind, field=attr_name): attr = getattr(node, attr_name) val = attr.value - # Convert IP types and other non-string values to strings for DiffSync models + # IP types come back from the Infrahub SDK as ipaddress + # objects; DiffSync models store them as their string form + # (e.g. "10.0.0.1/32"), so normalise here. Other non-string + # kinds — List, Number, Boolean, DateTime — pass through + # unchanged: stringifying them turns a real list `[]` into + # the four-character literal `"[]"`, which then fails + # Pydantic validation on `list[str]`-typed fields. if isinstance( val, (ipaddress.IPv4Interface, ipaddress.IPv6Interface, ipaddress.IPv4Network, ipaddress.IPv6Network), - ) or (val is not None and not isinstance(val, str)): + ): data[attr_name] = str(val) else: data[attr_name] = val From 769635cff417cfaf8cd750343263ee2a64fd589c Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 12:53:35 -0600 Subject: [PATCH 2/6] test(infrahub adapter): cover attribute-kind round-trips through infrahub_node_to_diffsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first test coverage for InfrahubAdapter.infrahub_node_to_diffsync. Tests bypass network setup by constructing the adapter via __new__ and supplying only self.config plus a node-like fixture with one attribute per kind. Relationship handling is out of scope here. Coverage: - Text, Number, Boolean, List (incl. empty), JSON-as-dict, DateTime-as-str, None — must pass through unchanged with their original Python type. - IPv4Interface, IPv6Interface, IPv4Network, IPv6Network — must be stringified (parametrised). - Fields absent from schema_mapping are skipped (has_field filter). - Mixed-kind round-trip on a realistic certificate-shaped node. Six of these tests fail on the pre-fix code (List, empty List, Number, Boolean, dict, mixed) and pass on the fixed code. The IP-stringification tests pass on both, locking in the intended behavior. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_infrahub_node_to_diffsync.py | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/adapters/test_infrahub_node_to_diffsync.py diff --git a/tests/adapters/test_infrahub_node_to_diffsync.py b/tests/adapters/test_infrahub_node_to_diffsync.py new file mode 100644 index 0000000..a9c91fa --- /dev/null +++ b/tests/adapters/test_infrahub_node_to_diffsync.py @@ -0,0 +1,255 @@ +"""Tests for InfrahubAdapter.infrahub_node_to_diffsync attribute serialisation. + +These tests exercise the value-transformation logic in +``infrahub_sync/adapters/infrahub.py``: which attribute kinds round-trip +unchanged, which get string-coerced (e.g. ipaddress objects), and which +must NOT be coerced (e.g. ``kind: List``). + +The tests bypass network setup by constructing the adapter via +``__new__`` and supplying only the bits the method touches: ``config``, +plus a node-like object exposing ``id``, ``_schema``, and one attribute +per kind. Relationship handling is out of scope here — the fixtures use +empty ``relationships`` so the method's relationship branch is a no-op. +""" + +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from infrahub_sync import ( + SchemaMappingField, + SchemaMappingModel, + SyncAdapter, + SyncConfig, +) +from infrahub_sync.adapters.infrahub import InfrahubAdapter + + +# --------------------------------------------------------------------------- +# Lightweight stand-ins for ``InfrahubNodeSync`` and its schema. We only +# need the bits ``infrahub_node_to_diffsync`` reads — no SDK plumbing. +# --------------------------------------------------------------------------- + + +@dataclass +class FakeAttr: + """Stand-in for ``InfrahubNodeSync``'s attribute manager — only ``.value`` is read.""" + + value: Any + + +@dataclass +class FakeSchema: + """Stand-in for ``node._schema``.""" + + kind: str + attribute_names: list[str] = field(default_factory=list) + relationships: list = field(default_factory=list) + relationship_names: list[str] = field(default_factory=list) + + +class FakeNode: + """Stand-in for ``InfrahubNodeSync`` exposing the attributes by name.""" + + def __init__(self, node_id: str, kind: str, attrs: dict[str, Any]) -> None: + self.id = node_id + self._schema = FakeSchema(kind=kind, attribute_names=list(attrs.keys())) + for name, value in attrs.items(): + setattr(self, name, FakeAttr(value=value)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_config(kind: str, field_names: list[str]) -> SyncConfig: + """SyncConfig with one schema_mapping entry covering ``field_names``.""" + + return SyncConfig( + name="test", + source=SyncAdapter(name="src", adapter="x:x"), + destination=SyncAdapter(name="dst", adapter="x:x"), + order=[kind], + schema_mapping=[ + SchemaMappingModel( + name=kind, + mapping=kind, + identifiers=["name"], + fields=[SchemaMappingField(name=n, mapping=n) for n in field_names], + ), + ], + ) + + +def _make_adapter(kind: str, field_names: list[str]) -> InfrahubAdapter: + """Build an InfrahubAdapter that bypasses network setup. + + ``__init__`` requires live Infrahub plumbing (client, schema fetch, + source/owner lookup). We don't need any of that for value-transform + tests — only ``self.config`` is touched. Using ``__new__`` skips + ``__init__`` entirely. + """ + adapter = InfrahubAdapter.__new__(InfrahubAdapter) + adapter.config = _make_config(kind, field_names) + return adapter + + +# --------------------------------------------------------------------------- +# Tests — one per attribute kind. The pass-through cases all assert both +# the value AND the resulting Python type, because the original bug was a +# type change (list → str) that Pydantic later rejected — value equality +# alone (``[] == "[]"`` is False but ``str([]) == "[]"``) wouldn't catch +# every regression cleanly. +# --------------------------------------------------------------------------- + + +def test_always_sets_local_id_from_node_id() -> None: + """``local_id`` must be the stringified node id, regardless of attrs.""" + adapter = _make_adapter("Thing", []) + node = FakeNode(node_id="abc-123", kind="Thing", attrs={}) + data = adapter.infrahub_node_to_diffsync(node) + assert data == {"local_id": "abc-123"} + + +def test_text_value_passes_through_unchanged() -> None: + """``kind: Text`` is already str — no transformation needed.""" + adapter = _make_adapter("Thing", ["label"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"label": "hello"}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["label"] == "hello" + assert isinstance(data["label"], str) + + +def test_list_value_passes_through_as_list() -> None: + """``kind: List`` must arrive as a real list. + + Regression guard for the original bug: prior to this fix, lists were + coerced via ``str()`` to ``"[]"`` / ``"['a']"``, which then failed + Pydantic validation against ``list[str]``-typed DiffSync fields. + """ + adapter = _make_adapter("Thing", ["tags"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"tags": ["foo", "bar"]}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["tags"] == ["foo", "bar"] + assert isinstance(data["tags"], list) + + +def test_empty_list_value_passes_through_as_empty_list() -> None: + """Empty list is the most common failure mode in the wild — exercise it explicitly.""" + adapter = _make_adapter("Thing", ["tags"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"tags": []}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["tags"] == [] + assert isinstance(data["tags"], list) + + +def test_number_value_passes_through_as_int() -> None: + """``kind: Number`` must stay numeric, not become a stringified int.""" + adapter = _make_adapter("Thing", ["port"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"port": 443}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["port"] == 443 + assert isinstance(data["port"], int) + + +def test_boolean_value_passes_through_as_bool() -> None: + """``kind: Boolean`` must stay bool, not become ``"True"`` / ``"False"``.""" + adapter = _make_adapter("Thing", ["enabled"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"enabled": True}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["enabled"] is True + assert isinstance(data["enabled"], bool) + + +def test_dict_value_passes_through_as_dict() -> None: + """``kind: JSON`` arrives from the SDK as a dict — must not be stringified.""" + adapter = _make_adapter("Thing", ["payload"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"payload": {"a": 1, "b": [2, 3]}}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["payload"] == {"a": 1, "b": [2, 3]} + assert isinstance(data["payload"], dict) + + +def test_datetime_string_value_passes_through() -> None: + """``kind: DateTime`` already arrives from the SDK as an ISO-8601 string.""" + adapter = _make_adapter("Thing", ["expires_at"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"expires_at": "2027-08-21T08:21:16Z"}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["expires_at"] == "2027-08-21T08:21:16Z" + assert isinstance(data["expires_at"], str) + + +def test_none_value_passes_through_as_none() -> None: + """A null attribute must stay None, not become the string ``"None"``.""" + adapter = _make_adapter("Thing", ["nickname"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"nickname": None}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["nickname"] is None + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (ipaddress.IPv4Interface("10.0.0.1/24"), "10.0.0.1/24"), + (ipaddress.IPv6Interface("2001:db8::1/64"), "2001:db8::1/64"), + (ipaddress.IPv4Network("10.0.0.0/24"), "10.0.0.0/24"), + (ipaddress.IPv6Network("2001:db8::/64"), "2001:db8::/64"), + ], +) +def test_ip_types_are_stringified(raw: Any, expected: str) -> None: + """IP types: stringified intentionally — DiffSync models store them as str.""" + adapter = _make_adapter("Thing", ["address"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"address": raw}) + data = adapter.infrahub_node_to_diffsync(node) + assert data["address"] == expected + assert isinstance(data["address"], str) + + +def test_field_not_in_schema_mapping_is_skipped() -> None: + """``has_field`` filters out attributes not in the schema_mapping config.""" + adapter = _make_adapter("Thing", ["wanted"]) # only "wanted" is in the mapping + node = FakeNode(node_id="1", kind="Thing", attrs={"wanted": "yes", "ignored": "no"}) + data = adapter.infrahub_node_to_diffsync(node) + assert "wanted" in data + assert "ignored" not in data + + +def test_mixed_kinds_round_trip_together() -> None: + """End-to-end sanity: a node with one attribute of each kind survives intact. + + This is what the real ``CertificateCertificate`` shape looks like: + serial (str), expiration (datetime as str), alternative_name (list), + key_size (str via Dropdown), and a None optional field. + """ + field_names = ["serial", "expiration", "sans", "key_size", "validation", "is_revoked"] + adapter = _make_adapter("Cert", field_names) + node = FakeNode( + node_id="cert-1", + kind="Cert", + attrs={ + "serial": "abc123", + "expiration": "2027-08-21T08:21:16Z", + "sans": ["a.com", "b.com"], + "key_size": "2048", + "validation": None, + "is_revoked": False, + }, + ) + data = adapter.infrahub_node_to_diffsync(node) + assert data == { + "local_id": "cert-1", + "serial": "abc123", + "expiration": "2027-08-21T08:21:16Z", + "sans": ["a.com", "b.com"], + "key_size": "2048", + "validation": None, + "is_revoked": False, + } + # Explicit type checks on the ones that the original bug mangled. + assert isinstance(data["sans"], list) + assert isinstance(data["is_revoked"], bool) From afe75f0b251b4319a5964c13cc69e05d19bed897 Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 13:06:24 -0600 Subject: [PATCH 3/6] test(infrahub adapter): assert DiffSync model construction, not just dict shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tests checked the dict returned by infrahub_node_to_diffsync. That misses the contract that actually matters in practice: the dict gets fed straight into DiffSyncModel(**data) by InfrahubAdapter.load, and Pydantic validates the kwargs against the model's typed fields. The original bug surfaced there — not on the dict shape. Add two end-to-end tests that define a Pydantic-typed DiffSync model shaped like the F5 CertificateCertificate (the model that triggered the report) and feed the adapter's output into it directly. Both tests fail on the pre-fix code with the user's original error (`Input should be a valid list, input_value='[]', input_type=str`) and pass after the fix. Notably, the failures Pydantic raises are scoped to list-typed and dict- typed fields only — Pydantic 2 silently coerces "443" to int and "True" to bool, so the bug never manifested for Number/Boolean kinds in practice. The broad fix is correctness-not-regression for those kinds. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_infrahub_node_to_diffsync.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/adapters/test_infrahub_node_to_diffsync.py b/tests/adapters/test_infrahub_node_to_diffsync.py index a9c91fa..6837a9f 100644 --- a/tests/adapters/test_infrahub_node_to_diffsync.py +++ b/tests/adapters/test_infrahub_node_to_diffsync.py @@ -253,3 +253,133 @@ def test_mixed_kinds_round_trip_together() -> None: # Explicit type checks on the ones that the original bug mangled. assert isinstance(data["sans"], list) assert isinstance(data["is_revoked"], bool) + + +# --------------------------------------------------------------------------- +# End-to-end: the real consumer of this method's output is +# ``DiffSyncModel(**data)`` inside ``InfrahubAdapter.load``. Validating the +# dict shape alone misses the layer that actually breaks in practice — +# Pydantic typed-field validation on the DiffSync model. These tests feed +# the adapter's output straight into a model and prove the model +# constructs without error, with values of the right Python type. +# --------------------------------------------------------------------------- + + +from diffsync import DiffSyncModel # noqa: E402 + +from infrahub_sync import DiffSyncModelMixin # noqa: E402 + + +class TypedCertModel(DiffSyncModelMixin, DiffSyncModel): + """A Pydantic-typed DiffSync model shaped like the real F5 + ``CertificateCertificate`` that triggered the original bug. + + The point: every attribute is typed *exactly* (``list[str]`` not + ``list``; ``int`` not ``int | str``). Before the fix, the adapter's + output failed Pydantic validation on the ``sans`` field. After the + fix it constructs cleanly. + """ + + _modelname = "TypedCert" + _identifiers = ("serial",) + _attributes = ( + "subject_dn", + "expiration", + "port", + "enabled", + "sans", + "metadata", + "validation", + ) + + serial: str + subject_dn: str + expiration: str + port: int + enabled: bool + sans: list[str] + metadata: dict[str, Any] + validation: str | None = None + local_id: str | None = None + local_data: Any | None = None + + +def test_adapter_output_constructs_pydantic_diffsync_model() -> None: + """The output of ``infrahub_node_to_diffsync`` must be directly + consumable by ``DiffSyncModel(**data)``. + + This is what ``InfrahubAdapter.load`` does at runtime and the layer + that the original bug actually broke. The other tests in this module + check dict shape; this one checks the contract that matters: + Pydantic-typed model construction. + """ + field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata", "validation"] + adapter = _make_adapter("TypedCert", field_names) + node = FakeNode( + node_id="cert-real", + kind="TypedCert", + attrs={ + "serial": "abc123", + "subject_dn": "CN=app.example.com", + "expiration": "2027-08-21T08:21:16Z", + "port": 443, + "enabled": True, + "sans": ["app.example.com", "alt.example.com"], + "metadata": {"issuer": "demo-ca", "chain_depth": 2}, + "validation": None, + }, + ) + + data = adapter.infrahub_node_to_diffsync(node) + + # This is the line that raises pydantic.ValidationError before the + # fix and succeeds after. + instance = TypedCertModel(**data) + + # Values survive intact. + assert instance.serial == "abc123" + assert instance.subject_dn == "CN=app.example.com" + assert instance.port == 443 + assert instance.enabled is True + assert instance.sans == ["app.example.com", "alt.example.com"] + assert instance.metadata == {"issuer": "demo-ca", "chain_depth": 2} + assert instance.validation is None + + # Types survive intact on the previously-mangled fields. + assert isinstance(instance.sans, list) + assert isinstance(instance.port, int) + assert isinstance(instance.enabled, bool) + assert isinstance(instance.metadata, dict) + + +def test_adapter_output_constructs_model_with_empty_list() -> None: + """The original reported failure: empty list arriving as ``"[]"``. + + With the buggy adapter, ``TypedCertModel(**data)`` raises: + ``ValidationError: 1 validation error for TypedCertModel + sans: Input should be a valid list [type=list_type, + input_value='[]', input_type=str]`` + """ + field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata"] + adapter = _make_adapter("TypedCert", field_names) + node = FakeNode( + node_id="cert-empty", + kind="TypedCert", + attrs={ + "serial": "empty-sans", + "subject_dn": "CN=no-sans.example.com", + "expiration": "2027-08-21T08:21:16Z", + "port": 443, + "enabled": True, + "sans": [], + "metadata": {}, + }, + ) + + data = adapter.infrahub_node_to_diffsync(node) + instance = TypedCertModel(**data) + + assert instance.sans == [] + assert isinstance(instance.sans, list) + assert instance.metadata == {} + assert isinstance(instance.metadata, dict) From 7a07d36ce72d28425218d83b23396350180f9d41 Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 14:48:06 -0600 Subject: [PATCH 4/6] test(infrahub adapter): add live-SDK integration test + enable unit tests in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two pieces of test infrastructure that the repo previously lacked: 1. Live-SDK integration test (`tests/integration/`) -------------------------------------------------- The unit tests use lightweight `FakeNode` stand-ins; they catch the behavior under test but can't catch drift between the fixture and the real `InfrahubNodeSync` shape. This new integration test exercises `InfrahubAdapter.infrahub_node_to_diffsync` against a live Infrahub: applies a throwaway `TestAdapterProbe` schema with one attribute of each kind (Text, List, Number, Boolean, DateTime, JSON, IPHost), creates a node, runs the adapter, feeds the result into a Pydantic- typed DiffSync model, asserts both value and type per field, and tears down. Marked `@pytest.mark.integration` and skipped when INFRAHUB_ADDRESS+INFRAHUB_API_TOKEN aren't set, so it's a no-op in environments without an Infrahub. Verified against an Infrahub 1.9.6 lab: passes on the fixed adapter in ~13s, fails on the pre-fix adapter with the user-reported error. 2. CI plumbing (workflow-tests.yml + tasks/tests.py) -------------------------------------------------- The repo had a `workflow-tests.yml` that did file-change detection but skipped running any tests (the unit test job was commented out with a TODO). And `tasks/tests.py` had empty pass-through stubs. Neither of those would have gated the bug being fixed in this PR. - `tasks.tests_unit` now runs `pytest -m "not integration"`. - `tasks.tests_integration` runs `pytest -m integration` (opt-in). - Registered the `integration` marker in pyproject.toml. - Uncommented and rewrote the unit-tests job to install via uv and invoke `tests.tests-unit` across Python 3.10–3.13. - Integration tests are not gated by CI yet — that would need an Infrahub service container or compose stack and is left as a follow-up. The integration test runs locally / in any environment that points at a real Infrahub. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/workflow-tests.yml | 68 ++--- pyproject.toml | 3 + tasks/tests.py | 12 +- tests/integration/__init__.py | 9 + ...t_infrahub_node_to_diffsync_integration.py | 269 ++++++++++++++++++ 5 files changed, 326 insertions(+), 35 deletions(-) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_infrahub_node_to_diffsync_integration.py diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index ce9cd5d..f3afb3d 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -23,7 +23,7 @@ jobs: sync: ${{ steps.changes.outputs.sync_all }} steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Check for file changes uses: dorny/paths-filter@v3 id: changes @@ -31,35 +31,37 @@ jobs: token: ${{ github.token }} filters: .github/file-filters.yml - # TODO: Create the unit tests... - # unit-tests: - # name: Unit Tests (py${{ matrix.python-version }}) - # strategy: - # matrix: - # python-version: - # - "3.9" - # - "3.10" - # - "3.11" - # - "3.12" - # if: needs.files-changed.outputs.sync == 'true' - # runs-on: ubuntu-latest - # timeout-minutes: 30 - # defaults: - # run: - # working-directory: sync/ - # steps: - # - name: "Check out repository code" - # uses: "actions/checkout@v4" - # - name: Set up Python ${{ matrix.python-version }} - # uses: actions/setup-python@v5 - # with: - # python-version: ${{ matrix.python-version }} - # - name: "Setup environment" - # run: | - # pipx install poetry - # poetry config virtualenvs.prefer-active-python true - # pip install toml invoke - # - name: "Install Package" - # run: "poetry install" - # - name: "markdownlint-cli2, ruff, and pylint Tests" - # run: "poetry run invoke tests.tests-unit" + unit-tests: + name: "Unit Tests (py${{ matrix.python-version }})" + needs: ["files-changed"] + if: needs.files-changed.outputs.sync == 'true' + runs-on: "ubuntu-22.04" + timeout-minutes: 15 + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + steps: + - name: "Check out repository code" + uses: "actions/checkout@v5" + + - name: "Install uv" + uses: "astral-sh/setup-uv@v4" + with: + version: "latest" + + - name: "Set up Python ${{ matrix.python-version }}" + run: uv python install ${{ matrix.python-version }} + + - name: "Install dependencies" + run: uv sync --frozen --extra dev + + # Runs only `-m "not integration"`. Integration tests need a live + # Infrahub instance and are opted into via `invoke tests-integration` + # (run locally; not gated here yet — TODO: add Infrahub service + # container or compose stack and a separate integration-tests job). + - name: "pytest: unit tests" + run: uv run invoke tests.tests-unit diff --git a/pyproject.toml b/pyproject.toml index 7c5a72a..a8ba0bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,9 @@ asyncio_mode = "auto" testpaths = [ "tests" ] +markers = [ + "integration: tests that require a running Infrahub instance. Skipped by default; opt in with -m integration and INFRAHUB_ADDRESS + INFRAHUB_API_TOKEN set.", +] filterwarnings = [ "ignore:Module already imported so cannot be rewritten", "ignore:the imp module is deprecated", diff --git a/tasks/tests.py b/tasks/tests.py index 7c6278f..572748a 100644 --- a/tasks/tests.py +++ b/tasks/tests.py @@ -13,9 +13,17 @@ @task def tests_unit(context: Context) -> None: - pass + """Run unit tests — everything under tests/ except integration-marked tests.""" + with context.cd(MAIN_DIRECTORY): + context.run('pytest -m "not integration"', pty=True) @task def tests_integration(context: Context) -> None: - pass + """Run integration tests against a live Infrahub. + + Requires INFRAHUB_ADDRESS and INFRAHUB_API_TOKEN in the environment; + tests skip themselves when those aren't set. + """ + with context.cd(MAIN_DIRECTORY): + context.run("pytest -m integration", pty=True) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..5393696 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,9 @@ +# Integration tests for infrahub-sync. +# +# These tests require a running Infrahub instance and are skipped when +# `INFRAHUB_ADDRESS` + `INFRAHUB_API_TOKEN` aren't set in the environment. +# Run locally with: +# +# INFRAHUB_ADDRESS=http://localhost:8000 \ +# INFRAHUB_API_TOKEN= \ +# pytest tests/integration -m integration diff --git a/tests/integration/test_infrahub_node_to_diffsync_integration.py b/tests/integration/test_infrahub_node_to_diffsync_integration.py new file mode 100644 index 0000000..87fb171 --- /dev/null +++ b/tests/integration/test_infrahub_node_to_diffsync_integration.py @@ -0,0 +1,269 @@ +"""Integration tests for InfrahubAdapter.infrahub_node_to_diffsync. + +The unit tests in ``tests/adapters/`` use lightweight ``FakeNode`` stand-ins +that match the shape ``infrahub_node_to_diffsync`` reads. That catches the +behavior under test but can't catch drift between our fixture and the real +``InfrahubNodeSync`` shape returned by the SDK — e.g. if a future SDK +version starts returning ``DateTime`` as a real ``datetime`` object instead +of an ISO string, the unit tests would still pass but the contract with +downstream DiffSync models would break. + +These integration tests exercise the same code path against a live +Infrahub. They apply a throwaway schema with one attribute per kind, +create a node, run the adapter, feed the result into a Pydantic-typed +DiffSync model, and tear everything down. + +Skipped automatically when ``INFRAHUB_ADDRESS`` + ``INFRAHUB_API_TOKEN`` +are not set. Run locally with:: + + INFRAHUB_ADDRESS=http://localhost:8000 \\ + INFRAHUB_API_TOKEN= \\ + pytest tests/integration -m integration +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from typing import Any + +import pytest +import requests +from diffsync import DiffSyncModel + +from infrahub_sync import ( + DiffSyncModelMixin, + SchemaMappingField, + SchemaMappingModel, + SyncAdapter, + SyncConfig, +) +from infrahub_sync.adapters.infrahub import InfrahubAdapter + +pytestmark = pytest.mark.integration + + +# --------------------------------------------------------------------------- +# Setup / teardown helpers +# --------------------------------------------------------------------------- + + +_SCHEMA = { + "version": "1.0", + "nodes": [ + { + "name": "AdapterProbe", + "namespace": "Test", + "include_in_menu": False, + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "tags", "kind": "List", "optional": True}, + {"name": "port", "kind": "Number", "optional": True}, + {"name": "enabled", "kind": "Boolean", "optional": True}, + {"name": "issued_at", "kind": "DateTime", "optional": True}, + {"name": "payload", "kind": "JSON", "optional": True}, + {"name": "address", "kind": "IPHost", "optional": True}, + ], + }, + ], +} + +_NODE_VALUES: dict[str, Any] = { + "name": "adapter-probe-1", + "tags": ["alpha", "beta"], + "port": 443, + "enabled": True, + "issued_at": "2027-08-21T08:21:16Z", + "payload": {"k": "v", "n": 7}, + "address": "10.10.10.10/24", +} + + +def _env_or_skip() -> tuple[str, str]: + address = os.environ.get("INFRAHUB_ADDRESS") + token = os.environ.get("INFRAHUB_API_TOKEN") + if not address or not token: + pytest.skip("INFRAHUB_ADDRESS and INFRAHUB_API_TOKEN must be set") + return address, token + + +def _graphql(address: str, token: str, query: str) -> dict[str, Any]: + response = requests.post( + f"{address}/graphql", + headers={"X-INFRAHUB-KEY": token, "Content-Type": "application/json"}, + json={"query": query}, + timeout=30, + ) + response.raise_for_status() + body = response.json() + if body.get("errors"): + raise RuntimeError(f"GraphQL errors: {body['errors']}") + return body + + +@pytest.fixture +def live_probe_node() -> Iterator[tuple[str, str, str]]: + """Apply the throwaway schema, create one node, yield ids for the test, tear down.""" + address, token = _env_or_skip() + + # Load the test schema via the schema API (matches what infrahubctl does). + schema_response = requests.post( + f"{address}/api/schema/load?branch=main", + headers={"X-INFRAHUB-KEY": token, "Content-Type": "application/json"}, + json={"schemas": [_SCHEMA]}, + timeout=60, + ) + schema_response.raise_for_status() + + # Create one probe node with one attribute of each kind populated. + inputs = ", ".join( + f"{name}: {{value: {_graphql_literal(value)}}}" + for name, value in _NODE_VALUES.items() + ) + created = _graphql( + address, + token, + f"mutation {{ TestAdapterProbeCreate(data: {{{inputs}}}) {{ ok object {{ id }} }} }}", + ) + node_id = created["data"]["TestAdapterProbeCreate"]["object"]["id"] + + try: + yield address, token, node_id + finally: + _graphql( + address, + token, + f'mutation {{ TestAdapterProbeDelete(data: {{id: "{node_id}"}}) {{ ok }} }}', + ) + + +def _graphql_literal(value: Any) -> str: + """Render a Python value as a GraphQL literal for our input shape. + + Handles only the kinds the test fixture uses; the dict / list / scalar + cases are sufficient. Keeps the fixture free of an external GraphQL + library dependency. + """ + if isinstance(value, str): + return f'"{value}"' + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return "[" + ", ".join(_graphql_literal(v) for v in value) + "]" + if isinstance(value, dict): + items = ", ".join(f'{k}: {_graphql_literal(v)}' for k, v in value.items()) + return "{" + items + "}" + raise TypeError(f"Unsupported literal kind: {type(value).__name__}") + + +# --------------------------------------------------------------------------- +# Pydantic-typed DiffSync model — the actual consumer of the adapter output. +# --------------------------------------------------------------------------- + + +class AdapterProbe(DiffSyncModelMixin, DiffSyncModel): + _modelname = "TestAdapterProbe" + _identifiers = ("name",) + _attributes = ("tags", "port", "enabled", "issued_at", "payload", "address") + + name: str + tags: list[str] + port: int + enabled: bool + issued_at: str + payload: dict[str, Any] + address: str + local_id: str | None = None + local_data: Any | None = None + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + + +def test_real_sdk_node_round_trips_into_typed_diffsync_model( + live_probe_node: tuple[str, str, str], +) -> None: + """End-to-end: load a real SDK node, run it through the adapter, feed + into a Pydantic-typed DiffSync model. Asserts both per-field type and + value survive the trip — which is the contract that broke for the + original bug. + """ + address, token, _ = live_probe_node + + cfg = SyncConfig( + name="integration", + source=SyncAdapter(name="src", adapter="x:x"), + destination=SyncAdapter( + name="dst", adapter="x:x", settings={"url": address, "token": token}, + ), + order=["TestAdapterProbe"], + schema_mapping=[ + SchemaMappingModel( + name="TestAdapterProbe", + mapping="TestAdapterProbe", + identifiers=["name"], + fields=[ + SchemaMappingField(name=n, mapping=n) + for n in ("name", "tags", "port", "enabled", "issued_at", "payload", "address") + ], + ), + ], + ) + + # Wire a real InfrahubAdapter against the lab. We don't need a real + # source — the adapter loads from Infrahub itself, which is the side + # under test. ``__new__`` skips the source-node/owner setup that + # would otherwise require a CoreAccount lookup. + adapter = InfrahubAdapter.__new__(InfrahubAdapter) + adapter.config = cfg + adapter.target = "dst" + adapter.client = _make_infrahub_client(address, token) + adapter.schema = adapter.client.schema.all(branch="main") + adapter.store = type("S", (), {"get": lambda self, **k: None})() + adapter.source_node = None + adapter.owner_node = None + + nodes = adapter.client.filters( + kind="TestAdapterProbe", + name__value=_NODE_VALUES["name"], + populate_store=True, + ) + assert nodes, "Probe node should be visible to the SDK after creation" + node = nodes[0] + + data = adapter.infrahub_node_to_diffsync(node=node) + + # Feeding the adapter's output into a Pydantic-typed model is the + # contract that the original bug broke. It must not raise. + instance = AdapterProbe(**data) + + # Value + type checks per kind. The original bug surfaced as + # `Input should be a valid list ... input_value='[]', input_type=str` + # — both halves of that contract (value AND type) are asserted here. + assert instance.name == _NODE_VALUES["name"] + assert instance.tags == _NODE_VALUES["tags"] + assert isinstance(instance.tags, list) + assert instance.port == _NODE_VALUES["port"] + assert isinstance(instance.port, int) + assert instance.enabled is _NODE_VALUES["enabled"] + assert isinstance(instance.enabled, bool) + assert instance.issued_at == _NODE_VALUES["issued_at"] + assert isinstance(instance.issued_at, str) + assert instance.payload == _NODE_VALUES["payload"] + assert isinstance(instance.payload, dict) + # IPHost intentionally stringifies — the SDK returns an + # ``IPv4Interface``; the adapter normalises to its str form. + assert instance.address == _NODE_VALUES["address"] + assert isinstance(instance.address, str) + + +def _make_infrahub_client(address: str, token: str) -> Any: + """Build a sync Infrahub client. Imported lazily so unit-only test + runs aren't forced to install the SDK extras.""" + from infrahub_sdk import Config, InfrahubClientSync + + return InfrahubClientSync(config=Config(address=address, api_token=token)) From 74888b5336b09d49a2d4270d64f878e7bc2912ca Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 14:55:30 -0600 Subject: [PATCH 5/6] test(infrahub adapter): rephrase test docstrings around current behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop bug-history phrasing ("original bug", "regression guard", "prior to this fix", etc.) from test docstrings and inline comments. Tests describe what they ensure about current behavior — Pydantic strict-mode rejecting stringified lists/dicts, value+type contracts at the dict boundary, etc. — rather than narrating how the code got here. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_infrahub_node_to_diffsync.py | 66 +++++++++---------- ...t_infrahub_node_to_diffsync_integration.py | 14 ++-- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/adapters/test_infrahub_node_to_diffsync.py b/tests/adapters/test_infrahub_node_to_diffsync.py index 6837a9f..5a7fff6 100644 --- a/tests/adapters/test_infrahub_node_to_diffsync.py +++ b/tests/adapters/test_infrahub_node_to_diffsync.py @@ -100,11 +100,12 @@ def _make_adapter(kind: str, field_names: list[str]) -> InfrahubAdapter: # --------------------------------------------------------------------------- -# Tests — one per attribute kind. The pass-through cases all assert both -# the value AND the resulting Python type, because the original bug was a -# type change (list → str) that Pydantic later rejected — value equality -# alone (``[] == "[]"`` is False but ``str([]) == "[]"``) wouldn't catch -# every regression cleanly. +# Tests — one per attribute kind. The pass-through cases assert both the +# value AND the resulting Python type. Pydantic's downstream validation +# rejects a stringified list (``"[]"``) against a ``list[str]`` field, so +# locking the type at the dict boundary is the contract that matters; +# value equality alone (``[] == "[]"`` is False but ``str([]) == "[]"``) +# is a weaker invariant. # --------------------------------------------------------------------------- @@ -128,9 +129,9 @@ def test_text_value_passes_through_unchanged() -> None: def test_list_value_passes_through_as_list() -> None: """``kind: List`` must arrive as a real list. - Regression guard for the original bug: prior to this fix, lists were - coerced via ``str()`` to ``"[]"`` / ``"['a']"``, which then failed - Pydantic validation against ``list[str]``-typed DiffSync fields. + Stringifying turns a list into its ``repr`` (e.g. ``"[]"`` / ``"['a']"``), + which then fails Pydantic validation against ``list[str]``-typed DiffSync + fields. """ adapter = _make_adapter("Thing", ["tags"]) node = FakeNode(node_id="1", kind="Thing", attrs={"tags": ["foo", "bar"]}) @@ -140,7 +141,7 @@ def test_list_value_passes_through_as_list() -> None: def test_empty_list_value_passes_through_as_empty_list() -> None: - """Empty list is the most common failure mode in the wild — exercise it explicitly.""" + """Empty list is a separate code path under ``str()`` — exercise it explicitly.""" adapter = _make_adapter("Thing", ["tags"]) node = FakeNode(node_id="1", kind="Thing", attrs={"tags": []}) data = adapter.infrahub_node_to_diffsync(node) @@ -250,18 +251,20 @@ def test_mixed_kinds_round_trip_together() -> None: "validation": None, "is_revoked": False, } - # Explicit type checks on the ones that the original bug mangled. + # Explicit type checks on the kinds where Pydantic won't coerce a + # stringified value back (list, bool) — locks in correct typing at + # the dict boundary. assert isinstance(data["sans"], list) assert isinstance(data["is_revoked"], bool) # --------------------------------------------------------------------------- # End-to-end: the real consumer of this method's output is -# ``DiffSyncModel(**data)`` inside ``InfrahubAdapter.load``. Validating the -# dict shape alone misses the layer that actually breaks in practice — -# Pydantic typed-field validation on the DiffSync model. These tests feed -# the adapter's output straight into a model and prove the model -# constructs without error, with values of the right Python type. +# ``DiffSyncModel(**data)`` inside ``InfrahubAdapter.load``. Validating +# the dict shape alone misses the layer that matters in practice — +# Pydantic typed-field validation on the DiffSync model. These tests +# feed the adapter's output straight into a model and prove construction +# succeeds with values of the right Python type. # --------------------------------------------------------------------------- @@ -271,13 +274,11 @@ def test_mixed_kinds_round_trip_together() -> None: class TypedCertModel(DiffSyncModelMixin, DiffSyncModel): - """A Pydantic-typed DiffSync model shaped like the real F5 - ``CertificateCertificate`` that triggered the original bug. + """A Pydantic-typed DiffSync model representative of real downstream usage. - The point: every attribute is typed *exactly* (``list[str]`` not - ``list``; ``int`` not ``int | str``). Before the fix, the adapter's - output failed Pydantic validation on the ``sans`` field. After the - fix it constructs cleanly. + Every attribute is typed *exactly* (``list[str]`` not ``list``; + ``int`` not ``int | str``). The adapter's output must construct this + model cleanly without Pydantic coercing values across types. """ _modelname = "TypedCert" @@ -308,10 +309,9 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: """The output of ``infrahub_node_to_diffsync`` must be directly consumable by ``DiffSyncModel(**data)``. - This is what ``InfrahubAdapter.load`` does at runtime and the layer - that the original bug actually broke. The other tests in this module - check dict shape; this one checks the contract that matters: - Pydantic-typed model construction. + This is what ``InfrahubAdapter.load`` does at runtime. The other + tests in this module check dict shape; this one checks the contract + that matters downstream: Pydantic-typed model construction. """ field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata", "validation"] adapter = _make_adapter("TypedCert", field_names) @@ -332,8 +332,8 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: data = adapter.infrahub_node_to_diffsync(node) - # This is the line that raises pydantic.ValidationError before the - # fix and succeeds after. + # The adapter's dict must satisfy strict Pydantic field types — any + # cross-type coercion here would surface as ``ValidationError``. instance = TypedCertModel(**data) # Values survive intact. @@ -345,7 +345,8 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: assert instance.metadata == {"issuer": "demo-ca", "chain_depth": 2} assert instance.validation is None - # Types survive intact on the previously-mangled fields. + # Each non-string kind must arrive with the exact Python type the + # model annotates — Pydantic strict-mode would reject a coerced str. assert isinstance(instance.sans, list) assert isinstance(instance.port, int) assert isinstance(instance.enabled, bool) @@ -353,12 +354,11 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: def test_adapter_output_constructs_model_with_empty_list() -> None: - """The original reported failure: empty list arriving as ``"[]"``. + """Empty-list and empty-dict edge cases must construct the model. - With the buggy adapter, ``TypedCertModel(**data)`` raises: - ``ValidationError: 1 validation error for TypedCertModel - sans: Input should be a valid list [type=list_type, - input_value='[]', input_type=str]`` + ``str([])`` and ``str({})`` produce ``"[]"`` / ``"{}"`` — distinct + Python literals that look list/dict-ish but fail Pydantic validation + against ``list[str]`` / ``dict[str, Any]`` fields. """ field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata"] adapter = _make_adapter("TypedCert", field_names) diff --git a/tests/integration/test_infrahub_node_to_diffsync_integration.py b/tests/integration/test_infrahub_node_to_diffsync_integration.py index 87fb171..35ab70d 100644 --- a/tests/integration/test_infrahub_node_to_diffsync_integration.py +++ b/tests/integration/test_infrahub_node_to_diffsync_integration.py @@ -189,8 +189,8 @@ def test_real_sdk_node_round_trips_into_typed_diffsync_model( ) -> None: """End-to-end: load a real SDK node, run it through the adapter, feed into a Pydantic-typed DiffSync model. Asserts both per-field type and - value survive the trip — which is the contract that broke for the - original bug. + value survive the trip — that's the contract ``InfrahubAdapter.load`` + relies on at runtime. """ address, token, _ = live_probe_node @@ -237,13 +237,13 @@ def test_real_sdk_node_round_trips_into_typed_diffsync_model( data = adapter.infrahub_node_to_diffsync(node=node) - # Feeding the adapter's output into a Pydantic-typed model is the - # contract that the original bug broke. It must not raise. + # Pydantic-typed DiffSync model construction is the consumer contract + # under test. Construction must not raise. instance = AdapterProbe(**data) - # Value + type checks per kind. The original bug surfaced as - # `Input should be a valid list ... input_value='[]', input_type=str` - # — both halves of that contract (value AND type) are asserted here. + # Value + type checks per kind. Pydantic ``list[str]`` and + # ``dict[str, Any]`` fields reject string inputs outright, so both + # halves of the contract (value AND Python type) are asserted here. assert instance.name == _NODE_VALUES["name"] assert instance.tags == _NODE_VALUES["tags"] assert isinstance(instance.tags, list) From 60f5cd4fd9cdaf287da85f838cb44f3d0e50aa58 Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Fri, 29 May 2026 15:56:04 -0600 Subject: [PATCH 6/6] test(infrahub adapter): satisfy ruff + ty in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs ruff (check + format) and ty across Python 3.10–3.13. The new test files tripped 16 ruff errors and 14 ty diagnostics; fix them: - I001: sort imports. - PLR2004: extract magic literal 443 to a module-level HTTPS_PORT. - ANN401: scoped `# noqa: ANN401` on heterogeneous-by-design typing.Any params (parametrised IP types, GraphQL literal renderer, SDK client factory). - ERA001: rewrite tests/integration/__init__.py comment as a module docstring (the `pytest tests/integration -m integration` example was getting flagged as commented-out code). - TC003: move `from collections.abc import Iterator` into TYPE_CHECKING. - TRY003 / EM102: assign exception messages to a `msg` local before `raise`. - Q000: double-quote inside f-string. - ARG005: underscore-prefix unused lambda args in the store stub. - ty invalid-argument-type: introduce `_serialise(adapter, node)` helper that suppresses the FakeNode-vs-InfrahubNodeSync mismatch once, instead of `# ty: ignore` on every call. The body still passes the fake straight through — it duck-types the bits the method reads. Tests still pass: 25/25 unit, 1/1 integration against a live Infrahub. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_infrahub_node_to_diffsync.py | 57 ++++++++++++------- tests/integration/__init__.py | 19 ++++--- ...t_infrahub_node_to_diffsync_integration.py | 29 +++++----- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/tests/adapters/test_infrahub_node_to_diffsync.py b/tests/adapters/test_infrahub_node_to_diffsync.py index 5a7fff6..8d557f7 100644 --- a/tests/adapters/test_infrahub_node_to_diffsync.py +++ b/tests/adapters/test_infrahub_node_to_diffsync.py @@ -28,6 +28,11 @@ ) from infrahub_sync.adapters.infrahub import InfrahubAdapter +# Test constant — extracted to avoid PLR2004 (magic-value) warnings on the +# repeated literal. 443 is just a representative HTTPS port for the Number +# kind; no significance beyond exercising int round-trip. +HTTPS_PORT = 443 + # --------------------------------------------------------------------------- # Lightweight stand-ins for ``InfrahubNodeSync`` and its schema. We only @@ -99,6 +104,18 @@ def _make_adapter(kind: str, field_names: list[str]) -> InfrahubAdapter: return adapter +def _serialise(adapter: InfrahubAdapter, node: FakeNode) -> dict[str, Any]: + """Call ``adapter.infrahub_node_to_diffsync`` on a duck-typed fake node. + + The method's parameter annotation is ``InfrahubNodeSync``, but the + function body only reads ``node.id``, ``node._schema``, and per-name + attribute managers — all of which ``FakeNode`` provides. The type + suppression below is a single, scoped location rather than once per + test. + """ + return adapter.infrahub_node_to_diffsync(node=node) # ty: ignore[invalid-argument-type] + + # --------------------------------------------------------------------------- # Tests — one per attribute kind. The pass-through cases assert both the # value AND the resulting Python type. Pydantic's downstream validation @@ -113,7 +130,7 @@ def test_always_sets_local_id_from_node_id() -> None: """``local_id`` must be the stringified node id, regardless of attrs.""" adapter = _make_adapter("Thing", []) node = FakeNode(node_id="abc-123", kind="Thing", attrs={}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data == {"local_id": "abc-123"} @@ -121,7 +138,7 @@ def test_text_value_passes_through_unchanged() -> None: """``kind: Text`` is already str — no transformation needed.""" adapter = _make_adapter("Thing", ["label"]) node = FakeNode(node_id="1", kind="Thing", attrs={"label": "hello"}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["label"] == "hello" assert isinstance(data["label"], str) @@ -135,7 +152,7 @@ def test_list_value_passes_through_as_list() -> None: """ adapter = _make_adapter("Thing", ["tags"]) node = FakeNode(node_id="1", kind="Thing", attrs={"tags": ["foo", "bar"]}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["tags"] == ["foo", "bar"] assert isinstance(data["tags"], list) @@ -144,7 +161,7 @@ def test_empty_list_value_passes_through_as_empty_list() -> None: """Empty list is a separate code path under ``str()`` — exercise it explicitly.""" adapter = _make_adapter("Thing", ["tags"]) node = FakeNode(node_id="1", kind="Thing", attrs={"tags": []}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["tags"] == [] assert isinstance(data["tags"], list) @@ -152,9 +169,9 @@ def test_empty_list_value_passes_through_as_empty_list() -> None: def test_number_value_passes_through_as_int() -> None: """``kind: Number`` must stay numeric, not become a stringified int.""" adapter = _make_adapter("Thing", ["port"]) - node = FakeNode(node_id="1", kind="Thing", attrs={"port": 443}) - data = adapter.infrahub_node_to_diffsync(node) - assert data["port"] == 443 + node = FakeNode(node_id="1", kind="Thing", attrs={"port": HTTPS_PORT}) + data = _serialise(adapter, node) + assert data["port"] == HTTPS_PORT assert isinstance(data["port"], int) @@ -162,7 +179,7 @@ def test_boolean_value_passes_through_as_bool() -> None: """``kind: Boolean`` must stay bool, not become ``"True"`` / ``"False"``.""" adapter = _make_adapter("Thing", ["enabled"]) node = FakeNode(node_id="1", kind="Thing", attrs={"enabled": True}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["enabled"] is True assert isinstance(data["enabled"], bool) @@ -171,7 +188,7 @@ def test_dict_value_passes_through_as_dict() -> None: """``kind: JSON`` arrives from the SDK as a dict — must not be stringified.""" adapter = _make_adapter("Thing", ["payload"]) node = FakeNode(node_id="1", kind="Thing", attrs={"payload": {"a": 1, "b": [2, 3]}}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["payload"] == {"a": 1, "b": [2, 3]} assert isinstance(data["payload"], dict) @@ -180,7 +197,7 @@ def test_datetime_string_value_passes_through() -> None: """``kind: DateTime`` already arrives from the SDK as an ISO-8601 string.""" adapter = _make_adapter("Thing", ["expires_at"]) node = FakeNode(node_id="1", kind="Thing", attrs={"expires_at": "2027-08-21T08:21:16Z"}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["expires_at"] == "2027-08-21T08:21:16Z" assert isinstance(data["expires_at"], str) @@ -189,7 +206,7 @@ def test_none_value_passes_through_as_none() -> None: """A null attribute must stay None, not become the string ``"None"``.""" adapter = _make_adapter("Thing", ["nickname"]) node = FakeNode(node_id="1", kind="Thing", attrs={"nickname": None}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["nickname"] is None @@ -202,11 +219,11 @@ def test_none_value_passes_through_as_none() -> None: (ipaddress.IPv6Network("2001:db8::/64"), "2001:db8::/64"), ], ) -def test_ip_types_are_stringified(raw: Any, expected: str) -> None: +def test_ip_types_are_stringified(raw: Any, expected: str) -> None: # noqa: ANN401 — heterogeneous ipaddress types """IP types: stringified intentionally — DiffSync models store them as str.""" adapter = _make_adapter("Thing", ["address"]) node = FakeNode(node_id="1", kind="Thing", attrs={"address": raw}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data["address"] == expected assert isinstance(data["address"], str) @@ -215,7 +232,7 @@ def test_field_not_in_schema_mapping_is_skipped() -> None: """``has_field`` filters out attributes not in the schema_mapping config.""" adapter = _make_adapter("Thing", ["wanted"]) # only "wanted" is in the mapping node = FakeNode(node_id="1", kind="Thing", attrs={"wanted": "yes", "ignored": "no"}) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert "wanted" in data assert "ignored" not in data @@ -241,7 +258,7 @@ def test_mixed_kinds_round_trip_together() -> None: "is_revoked": False, }, ) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) assert data == { "local_id": "cert-1", "serial": "abc123", @@ -322,7 +339,7 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: "serial": "abc123", "subject_dn": "CN=app.example.com", "expiration": "2027-08-21T08:21:16Z", - "port": 443, + "port": HTTPS_PORT, "enabled": True, "sans": ["app.example.com", "alt.example.com"], "metadata": {"issuer": "demo-ca", "chain_depth": 2}, @@ -330,7 +347,7 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: }, ) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) # The adapter's dict must satisfy strict Pydantic field types — any # cross-type coercion here would surface as ``ValidationError``. @@ -339,7 +356,7 @@ def test_adapter_output_constructs_pydantic_diffsync_model() -> None: # Values survive intact. assert instance.serial == "abc123" assert instance.subject_dn == "CN=app.example.com" - assert instance.port == 443 + assert instance.port == HTTPS_PORT assert instance.enabled is True assert instance.sans == ["app.example.com", "alt.example.com"] assert instance.metadata == {"issuer": "demo-ca", "chain_depth": 2} @@ -369,14 +386,14 @@ def test_adapter_output_constructs_model_with_empty_list() -> None: "serial": "empty-sans", "subject_dn": "CN=no-sans.example.com", "expiration": "2027-08-21T08:21:16Z", - "port": 443, + "port": HTTPS_PORT, "enabled": True, "sans": [], "metadata": {}, }, ) - data = adapter.infrahub_node_to_diffsync(node) + data = _serialise(adapter, node) instance = TypedCertModel(**data) assert instance.sans == [] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 5393696..3a3109d 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,9 +1,10 @@ -# Integration tests for infrahub-sync. -# -# These tests require a running Infrahub instance and are skipped when -# `INFRAHUB_ADDRESS` + `INFRAHUB_API_TOKEN` aren't set in the environment. -# Run locally with: -# -# INFRAHUB_ADDRESS=http://localhost:8000 \ -# INFRAHUB_API_TOKEN= \ -# pytest tests/integration -m integration +"""Integration tests for infrahub-sync. + +These tests require a running Infrahub instance and are skipped when +``INFRAHUB_ADDRESS`` and ``INFRAHUB_API_TOKEN`` are not set in the +environment. Run locally with:: + + INFRAHUB_ADDRESS=http://localhost:8000 \\ + INFRAHUB_API_TOKEN= \\ + pytest tests/integration -m integration +""" diff --git a/tests/integration/test_infrahub_node_to_diffsync_integration.py b/tests/integration/test_infrahub_node_to_diffsync_integration.py index 35ab70d..4ba08a0 100644 --- a/tests/integration/test_infrahub_node_to_diffsync_integration.py +++ b/tests/integration/test_infrahub_node_to_diffsync_integration.py @@ -24,8 +24,7 @@ from __future__ import annotations import os -from collections.abc import Iterator -from typing import Any +from typing import TYPE_CHECKING, Any import pytest import requests @@ -40,6 +39,9 @@ ) from infrahub_sync.adapters.infrahub import InfrahubAdapter +if TYPE_CHECKING: + from collections.abc import Iterator + pytestmark = pytest.mark.integration @@ -97,7 +99,8 @@ def _graphql(address: str, token: str, query: str) -> dict[str, Any]: response.raise_for_status() body = response.json() if body.get("errors"): - raise RuntimeError(f"GraphQL errors: {body['errors']}") + msg = f"GraphQL errors: {body['errors']}" + raise RuntimeError(msg) return body @@ -116,10 +119,7 @@ def live_probe_node() -> Iterator[tuple[str, str, str]]: schema_response.raise_for_status() # Create one probe node with one attribute of each kind populated. - inputs = ", ".join( - f"{name}: {{value: {_graphql_literal(value)}}}" - for name, value in _NODE_VALUES.items() - ) + inputs = ", ".join(f"{name}: {{value: {_graphql_literal(value)}}}" for name, value in _NODE_VALUES.items()) created = _graphql( address, token, @@ -137,7 +137,7 @@ def live_probe_node() -> Iterator[tuple[str, str, str]]: ) -def _graphql_literal(value: Any) -> str: +def _graphql_literal(value: Any) -> str: # noqa: ANN401 — heterogeneous Python types by design """Render a Python value as a GraphQL literal for our input shape. Handles only the kinds the test fixture uses; the dict / list / scalar @@ -153,9 +153,10 @@ def _graphql_literal(value: Any) -> str: if isinstance(value, list): return "[" + ", ".join(_graphql_literal(v) for v in value) + "]" if isinstance(value, dict): - items = ", ".join(f'{k}: {_graphql_literal(v)}' for k, v in value.items()) + items = ", ".join(f"{k}: {_graphql_literal(v)}" for k, v in value.items()) return "{" + items + "}" - raise TypeError(f"Unsupported literal kind: {type(value).__name__}") + msg = f"Unsupported literal kind: {type(value).__name__}" + raise TypeError(msg) # --------------------------------------------------------------------------- @@ -198,7 +199,9 @@ def test_real_sdk_node_round_trips_into_typed_diffsync_model( name="integration", source=SyncAdapter(name="src", adapter="x:x"), destination=SyncAdapter( - name="dst", adapter="x:x", settings={"url": address, "token": token}, + name="dst", + adapter="x:x", + settings={"url": address, "token": token}, ), order=["TestAdapterProbe"], schema_mapping=[ @@ -223,7 +226,7 @@ def test_real_sdk_node_round_trips_into_typed_diffsync_model( adapter.target = "dst" adapter.client = _make_infrahub_client(address, token) adapter.schema = adapter.client.schema.all(branch="main") - adapter.store = type("S", (), {"get": lambda self, **k: None})() + adapter.store = type("S", (), {"get": lambda _self, **_k: None})() adapter.source_node = None adapter.owner_node = None @@ -261,7 +264,7 @@ def test_real_sdk_node_round_trips_into_typed_diffsync_model( assert isinstance(instance.address, str) -def _make_infrahub_client(address: str, token: str) -> Any: +def _make_infrahub_client(address: str, token: str) -> Any: # noqa: ANN401 — SDK client is dynamically typed """Build a sync Infrahub client. Imported lazily so unit-only test runs aren't forced to install the SDK extras.""" from infrahub_sdk import Config, InfrahubClientSync