From 725c18fecc671a8363854de39110420a366bd94a Mon Sep 17 00:00:00 2001 From: Daryna Ishchenko Date: Wed, 5 Aug 2026 13:41:36 +0300 Subject: [PATCH 1/2] fix(low-code): make Spec.generate_spec idempotent and non-mutating Previously generate_spec() converted advanced_auth enum fields (auth_flow_type, scopes_join_strategy) to strings by assigning the converted values back onto the typed model. A second call in the same process then raised AttributeError ('.value' on a str), and any other reader of advanced_auth saw a string where an enum is expected. Now the model is serialized to a dict first and enum values are normalized only in that throwaway copy, so repeated calls are idempotent and the typed model is never mutated. Adds a regression test that calls generate_spec() twice and asserts the model keeps its enum. Split out of #1066 per review feedback, where this fix was bundled with an unrelated feature. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- airbyte_cdk/sources/declarative/spec/spec.py | 23 +++++++++---------- .../sources/declarative/spec/test_spec.py | 18 +++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/airbyte_cdk/sources/declarative/spec/spec.py b/airbyte_cdk/sources/declarative/spec/spec.py index 9d328023e7..eaacfde812 100644 --- a/airbyte_cdk/sources/declarative/spec/spec.py +++ b/airbyte_cdk/sources/declarative/spec/spec.py @@ -3,6 +3,7 @@ # from dataclasses import InitVar, dataclass, field +from enum import Enum from typing import Any, List, Mapping, MutableMapping, Optional from airbyte_cdk.models import ( @@ -54,19 +55,17 @@ def generate_spec(self) -> ConnectorSpecification: if self.documentation_url: obj["documentationUrl"] = self.documentation_url if self.advanced_auth: - self.advanced_auth.auth_flow_type = self.advanced_auth.auth_flow_type.value # type: ignore # We know this is always assigned to an AuthFlow which has the auth_flow_type field - # Convert scopes_join_strategy enum to its string value (same pattern as auth_flow_type above) - oauth_spec = getattr(self.advanced_auth, "oauth_config_specification", None) - if oauth_spec: - oauth_input = getattr(oauth_spec, "oauth_connector_input_specification", None) - if ( - oauth_input - and hasattr(oauth_input, "scopes_join_strategy") - and oauth_input.scopes_join_strategy is not None - ): - oauth_input.scopes_join_strategy = oauth_input.scopes_join_strategy.value # type: ignore + # Serialize to a dict and normalize enum values there so the typed model is + # never mutated and repeated calls produce the same result + advanced_auth = self.advanced_auth.dict() + if isinstance(advanced_auth.get("auth_flow_type"), Enum): + advanced_auth["auth_flow_type"] = advanced_auth["auth_flow_type"].value + oauth_spec = advanced_auth.get("oauth_config_specification") or {} + oauth_input = oauth_spec.get("oauth_connector_input_specification") or {} + if isinstance(oauth_input.get("scopes_join_strategy"), Enum): + oauth_input["scopes_join_strategy"] = oauth_input["scopes_join_strategy"].value # Map CDK AuthFlow model to protocol AdvancedAuth model - obj["advanced_auth"] = self.advanced_auth.dict() + obj["advanced_auth"] = advanced_auth # We remap these keys to camel case because that's the existing format expected by the rest of the platform return ConnectorSpecificationSerializer.load(obj) diff --git a/unit_tests/sources/declarative/spec/test_spec.py b/unit_tests/sources/declarative/spec/test_spec.py index 6516c146d1..9fab84d792 100644 --- a/unit_tests/sources/declarative/spec/test_spec.py +++ b/unit_tests/sources/declarative/spec/test_spec.py @@ -162,6 +162,24 @@ def test_spec(spec, expected_connection_specification) -> None: assert spec.generate_spec() == expected_connection_specification +def test_generate_spec_is_idempotent_and_does_not_mutate_the_model() -> None: + spec = component_spec( + connection_specification={"client_id": "my_client_id"}, + parameters={}, + advanced_auth=component_auth_flow( + auth_flow_type=component_auth_flow_type.oauth2_0, + predicate_key=None, + predicate_value=None, + ), + ) + + first = spec.generate_spec() + second = spec.generate_spec() + + assert first == second + assert spec.advanced_auth.auth_flow_type is component_auth_flow_type.oauth2_0 + + def test_given_list_of_transformations_when_transform_config_then_config_is_transformed() -> None: input_config = {"planet_code": "CRSC"} expected_config = { From cdcbb8db3650bb61d4fd0b697fbdffc1bba42ccf Mon Sep 17 00:00:00 2001 From: Daryna Ishchenko <80129833+darynaishchenko@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:28:20 +0300 Subject: [PATCH 2/2] fix(low-code): normalize advanced_auth enums generically and pin None semantics Address review feedback on #1103: - Replace the hand-written walk over the two known enum fields with a single JSON round-trip, so enums are normalized at any depth. declarative_component_schema.py is code-generated and already defines 12 Enum classes; the explicit form would silently pass an Enum through to ConnectorSpecificationSerializer the next time an enum field is added under the advanced_auth subtree. - Rename the local to advanced_auth_dict to make clear it is the serialized copy. - Parametrize the idempotency test over the top-level, nested and no-auth_flow_type shapes, and assert the emitted scopes_join_strategy is the plain string the protocol declares (it is typed Optional[str], so an un-normalized enum would otherwise slip past both the serializer and the first == second check). - Add a test pinning the auth_flow_type=None passthrough, which previously raised AttributeError for a manifest carrying only a predicate. Co-Authored-By: Claude Opus 5 (1M context) --- airbyte_cdk/sources/declarative/spec/spec.py | 18 ++-- .../sources/declarative/spec/test_spec.py | 85 +++++++++++++++++-- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/airbyte_cdk/sources/declarative/spec/spec.py b/airbyte_cdk/sources/declarative/spec/spec.py index eaacfde812..728ad3aa72 100644 --- a/airbyte_cdk/sources/declarative/spec/spec.py +++ b/airbyte_cdk/sources/declarative/spec/spec.py @@ -2,8 +2,8 @@ # Copyright (c) 2025 Airbyte, Inc., all rights reserved. # +import json from dataclasses import InitVar, dataclass, field -from enum import Enum from typing import Any, List, Mapping, MutableMapping, Optional from airbyte_cdk.models import ( @@ -55,17 +55,13 @@ def generate_spec(self) -> ConnectorSpecification: if self.documentation_url: obj["documentationUrl"] = self.documentation_url if self.advanced_auth: - # Serialize to a dict and normalize enum values there so the typed model is - # never mutated and repeated calls produce the same result - advanced_auth = self.advanced_auth.dict() - if isinstance(advanced_auth.get("auth_flow_type"), Enum): - advanced_auth["auth_flow_type"] = advanced_auth["auth_flow_type"].value - oauth_spec = advanced_auth.get("oauth_config_specification") or {} - oauth_input = oauth_spec.get("oauth_connector_input_specification") or {} - if isinstance(oauth_input.get("scopes_join_strategy"), Enum): - oauth_input["scopes_join_strategy"] = oauth_input["scopes_join_strategy"].value + # Serialize through JSON so enum values are normalized at any depth and the typed + # model is never mutated — repeated calls produce the same result. + # Note: an AuthFlow without an auth_flow_type (e.g. only a predicate) is passed + # through as auth_flow_type=None, which the protocol AdvancedAuth allows. + advanced_auth_dict = json.loads(self.advanced_auth.json()) # Map CDK AuthFlow model to protocol AdvancedAuth model - obj["advanced_auth"] = advanced_auth + obj["advanced_auth"] = advanced_auth_dict # We remap these keys to camel case because that's the existing format expected by the rest of the platform return ConnectorSpecificationSerializer.load(obj) diff --git a/unit_tests/sources/declarative/spec/test_spec.py b/unit_tests/sources/declarative/spec/test_spec.py index 9fab84d792..c0c45ca6e6 100644 --- a/unit_tests/sources/declarative/spec/test_spec.py +++ b/unit_tests/sources/declarative/spec/test_spec.py @@ -36,6 +36,9 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( OauthConnectorInputSpecification as component_declarative_oauth_connector_input_spec, ) +from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( + ScopesJoinStrategy as component_declarative_oauth_scopes_join_strategy, +) from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( State as component_declarative_oauth_state, ) @@ -162,22 +165,90 @@ def test_spec(spec, expected_connection_specification) -> None: assert spec.generate_spec() == expected_connection_specification -def test_generate_spec_is_idempotent_and_does_not_mutate_the_model() -> None: +@pytest.mark.parametrize( + "advanced_auth", + [ + pytest.param( + component_auth_flow( + auth_flow_type=component_auth_flow_type.oauth2_0, + predicate_key=None, + predicate_value=None, + ), + id="top_level_auth_flow_type_enum", + ), + pytest.param( + component_auth_flow( + auth_flow_type=component_auth_flow_type.oauth2_0, + predicate_key=None, + predicate_value=None, + oauth_config_specification=component_declarative_oauth_config_spec( + oauth_connector_input_specification=component_declarative_oauth_connector_input_spec( + consent_url="https://domain.host.com/endpoint/oauth", + access_token_url="https://domain.host.com/endpoint/v1/oauth2/access_token/", + scope="reports:read campaigns:read", + scopes_join_strategy=component_declarative_oauth_scopes_join_strategy.comma, + extract_output=["data.access_token"], + ), + ), + ), + id="nested_scopes_join_strategy_enum", + ), + pytest.param( + component_auth_flow( + auth_flow_type=None, + predicate_key=["credentials", "auth_type"], + predicate_value="oauth2.0", + ), + id="no_auth_flow_type", + ), + ], +) +def test_generate_spec_is_idempotent_and_does_not_mutate_the_model(advanced_auth) -> None: spec = component_spec( connection_specification={"client_id": "my_client_id"}, parameters={}, - advanced_auth=component_auth_flow( - auth_flow_type=component_auth_flow_type.oauth2_0, - predicate_key=None, - predicate_value=None, - ), + advanced_auth=advanced_auth, ) first = spec.generate_spec() second = spec.generate_spec() assert first == second - assert spec.advanced_auth.auth_flow_type is component_auth_flow_type.oauth2_0 + # identity, not equality: catches an in-place mutation to the plain string "oauth2.0" + assert spec.advanced_auth.auth_flow_type is advanced_auth.auth_flow_type + + oauth_spec = spec.advanced_auth.oauth_config_specification + if oauth_spec and oauth_spec.oauth_connector_input_specification: + # the model keeps its enum... + assert ( + oauth_spec.oauth_connector_input_specification.scopes_join_strategy + is component_declarative_oauth_scopes_join_strategy.comma + ) + # ...while the emitted spec carries the plain string the protocol declares. + # `scopes_join_strategy` is typed `Optional[str]`, so an un-normalized enum would + # otherwise slip through both the serializer and the `first == second` check. + emitted = first.advanced_auth.oauth_config_specification.oauth_connector_input_specification.scopes_join_strategy + assert emitted == "comma" and type(emitted) is str + + +def test_generate_spec_without_auth_flow_type_emits_advanced_auth_with_none() -> None: + """An AuthFlow carrying only a predicate is valid: both the component model and the + protocol AdvancedAuth declare auth_flow_type as optional, so it is passed through as None.""" + spec = component_spec( + connection_specification={}, + parameters={}, + advanced_auth=component_auth_flow( + auth_flow_type=None, + predicate_key=["credentials", "auth_type"], + predicate_value="oauth2.0", + ), + ) + + assert spec.generate_spec().advanced_auth == model_advanced_auth( + auth_flow_type=None, + predicate_key=["credentials", "auth_type"], + predicate_value="oauth2.0", + ) def test_given_list_of_transformations_when_transform_config_then_config_is_transformed() -> None: