Skip to content

fix(low-code): make Spec.generate_spec idempotent and non-mutating - #1103

Open
Daryna Ishchenko (darynaishchenko) wants to merge 2 commits into
mainfrom
daryna/spec-generate-spec-idempotency
Open

fix(low-code): make Spec.generate_spec idempotent and non-mutating#1103
Daryna Ishchenko (darynaishchenko) wants to merge 2 commits into
mainfrom
daryna/spec-generate-spec-idempotency

Conversation

@darynaishchenko

@darynaishchenko Daryna Ishchenko (darynaishchenko) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Spec.generate_spec() converted the advanced_auth enum fields (auth_flow_type, nested scopes_join_strategy) to their string values by assigning the converted values back onto the typed model. That in-place mutation meant:

  • a second generate_spec() call in the same process raised AttributeError (.value on an already-converted str) — hit by any flow that generates the spec more than once (Connector Builder, tests);
  • any other reader of self.advanced_auth afterwards saw a string where the type system promises an enum.

This change serializes the model to a dict first and normalizes enum values only in that throwaway copy, so repeated calls are idempotent and the typed model is never mutated. Normalization happens via a single JSON round-trip (json.loads(self.advanced_auth.json())) rather than a hand-written walk over the two known enum fields — declarative_component_schema.py is code-generated and already defines 12 Enum classes, so an explicit walk would silently pass an Enum through to ConnectorSpecificationSerializer the next time an enum field is added anywhere under the advanced_auth subtree.

Behavior change: advanced_auth without an auth_flow_type

AuthFlow is a pydantic model, so if self.advanced_auth: is always truthy. A manifest that sets advanced_auth with only a predicate_key (no auth_flow_type) previously hard-failed with AttributeError: 'NoneType' object has no attribute 'value'; it now emits AdvancedAuth(auth_flow_type=None, ...).

Passing None through is the more correct behavior — both AuthFlow.auth_flow_type (declarative_component_schema.py:1618) and the protocol AdvancedAuth.auth_flow_type (airbyte_cdk/models/airbyte_protocol.py:119) are Optional. This PR pins that semantic with a dedicated test rather than leaving it implicit.

Context

Split out of #1066, where this fix was bundled with the (unrelated) RateLimitedMultipleTokenAuthenticator feature; review feedback there asked for it to be its own PR. The fix is being removed from #1066.

Testing

unit_tests/sources/declarative/spec/test_spec.py — 15 passed. The idempotency test is parametrized over three advanced_auth shapes so both normalization branches and the None case are covered:

  • top_level_auth_flow_type_enum
  • nested_scopes_join_strategy_enum — asserts the emitted scopes_join_strategy is the plain string the protocol declares. That field is typed Optional[str], so an un-normalized enum would otherwise slip past both the serializer and the first == second check.
  • no_auth_flow_type

Verified the new assertions actually bite: reverting generate_spec to a plain .dict() with no normalization fails 4 tests, including both new parametrized cases.

mypy, ruff check, and ruff format --check are clean on both touched files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved authentication specification generation for enum-based authentication flows and OAuth scope joining.
    • Ensured repeated specification generation produces consistent results without altering configured authentication settings.
    • Improved handling of authentication flows without a specified flow type.
  • Tests

    • Added regression coverage for repeatable specification generation, plain protocol values, configuration immutability, and flow-type edge cases.

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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This CDK Version

You can test this version of the CDK using the following:

# Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@daryna/spec-generate-spec-idempotency#egg=airbyte-python-cdk[dev]' --help

# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch daryna/spec-generate-spec-idempotency

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /autofix - Fixes most formatting and linting issues
  • /poetry-lock - Updates poetry.lock file
  • /test - Runs connector tests with the updated CDK
  • /prerelease - Triggers a prerelease publish with default arguments
  • /poe build - Regenerate git-committed build artifacts, such as the pydantic models which are generated from the manifest JSON schema in YAML.
  • /poe <command> - Runs any poe command in the CDK environment
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

Daryna Ishchenko (darynaishchenko) added a commit that referenced this pull request Aug 5, 2026
Reverts the spec.py enum-handling change and its test to the main
version; the fix now lands separately via #1103, as requested in
review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c719b6e-39a0-40d7-84c3-6989ab535581

📥 Commits

Reviewing files that changed from the base of the PR and between 725c18f and cdcbb8d.

📒 Files selected for processing (2)
  • airbyte_cdk/sources/declarative/spec/spec.py
  • unit_tests/sources/declarative/spec/test_spec.py

📝 Walkthrough

Walkthrough

Spec.generate_spec now uses JSON round-trip serialization to copy AdvancedAuth data and normalize nested enum values. Tests cover idempotence, mutation safety, plain string output, and authentication flows without an auth-flow type.

Changes

Specification generation

Layer / File(s) Summary
Non-mutating authentication serialization
airbyte_cdk/sources/declarative/spec/spec.py, unit_tests/sources/declarative/spec/test_spec.py
Spec.generate_spec copies and normalizes AdvancedAuth data before protocol mapping. Tests verify repeated results, preserved enum values, plain string output, and None auth-flow types.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: tolik0

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: making Spec.generate_spec idempotent and non-mutating.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch daryna/spec-generate-spec-idempotency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
unit_tests/sources/declarative/spec/test_spec.py (1)

165-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the nested enum branch too.

airbyte_cdk/sources/declarative/spec/spec.py normalizes scopes_join_strategy at Lines 65-66, but this test only sets and checks auth_flow_type at Lines 170 and 180. A regression in the nested branch would still pass. Could we add an OAuth input with a non-default scopes_join_strategy, then verify repeated output and preservation of the original nested enum, wdyt?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unit_tests/sources/declarative/spec/test_spec.py` around lines 165 - 180, The
test_generate_spec_is_idempotent_and_does_not_mutate_the_model test should also
exercise the nested OAuth scopes_join_strategy normalization path. Configure
advanced_auth with a non-default scopes_join_strategy, then assert repeated
generate_spec outputs remain equal and the original nested scopes_join_strategy
enum is unchanged.
airbyte_cdk/sources/declarative/spec/spec.py (1)

58-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid using the deprecated Pydantic v2 dump API.

The project declares Pydantic ^2.7 and only ignores ExperimentalClassWarning in tests, so this dict() call can emit deprecation noise. Use model_dump(mode="python") here, wdyt?

Proposed change
-            advanced_auth = self.advanced_auth.dict()
+            advanced_auth = self.advanced_auth.model_dump(mode="python")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airbyte_cdk/sources/declarative/spec/spec.py` around lines 58 - 68, Replace
the deprecated dict() call in the advanced_auth serialization flow with
Pydantic’s model_dump(mode="python"), preserving the existing Enum normalization
and subsequent oauth configuration handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@airbyte_cdk/sources/declarative/spec/spec.py`:
- Around line 58-68: Replace the deprecated dict() call in the advanced_auth
serialization flow with Pydantic’s model_dump(mode="python"), preserving the
existing Enum normalization and subsequent oauth configuration handling.

In `@unit_tests/sources/declarative/spec/test_spec.py`:
- Around line 165-180: The
test_generate_spec_is_idempotent_and_does_not_mutate_the_model test should also
exercise the nested OAuth scopes_join_strategy normalization path. Configure
advanced_auth with a non-default scopes_join_strategy, then assert repeated
generate_spec outputs remain equal and the original nested scopes_join_strategy
enum is unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5efc3719-ad5c-43b6-b52f-96d9bf8ee265

📥 Commits

Reviewing files that changed from the base of the PR and between 013316a and 725c18f.

📒 Files selected for processing (2)
  • airbyte_cdk/sources/declarative/spec/spec.py
  • unit_tests/sources/declarative/spec/test_spec.py

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

PyTest Results (Fast)

4 187 tests  +29   4 175 ✅ +29   7m 7s ⏱️ -52s
    1 suites ± 0      12 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit cdcbb8d. ± Comparison against base commit 013316a.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

PyTest Results (Full)

4 190 tests   4 178 ✅  12m 36s ⏱️
    1 suites     12 💤
    1 files        0 ❌

Results for commit cdcbb8d.

♻️ This comment has been updated with latest results.

@tolik0 Anatolii Yatsuk (tolik0) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, well-scoped fix — thanks for splitting it out of #1066.

I verified this locally against the PR head:

  • The bug reproduces on main: a second generate_spec() raises AttributeError: 'str' object has no attribute 'value'.
  • On this branch, two calls compare equal, the model keeps its enum, and the nested scopes_join_strategy path also round-trips correctly ('comma' in the output, ScopesJoinStrategy.comma still on the model).
  • pytest unit_tests/sources/declarative/spec/test_spec.py → 12 passed; mypy, ruff check, and ruff format --check all clean.

The approach is sound. Pydantic v1 .dict() preserves Enum members (no use_enum_values on these models) and builds fresh nested dicts, so the isinstance(..., Enum) guards fire and the in-place mutation of oauth_input correctly propagates into the parent dict. Nothing else in the CDK reads advanced_auth.auth_flow_type, so no caller depended on the old mutation side effect. Dropping the two # type: ignore comments is a nice bonus.

Two things worth addressing before merge (inline), plus one optional simplification.

Comment thread airbyte_cdk/sources/declarative/spec/spec.py Outdated
Comment thread airbyte_cdk/sources/declarative/spec/spec.py Outdated
Comment thread unit_tests/sources/declarative/spec/test_spec.py Outdated
… 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) <noreply@anthropic.com>
@darynaishchenko

Copy link
Copy Markdown
Contributor Author

CodeRabbit (@coderabbitai) addressing your two nitpicks — one applied, one doesn't hold:

scopes_join_strategy coverage — applied. Same point Anatolii Yatsuk (@tolik0) raised; the idempotency test is now parametrized over the top-level, nested and None shapes. Details in that thread.

dict()model_dump(mode="python") — not applicable here. These aren't Pydantic v2 models. declarative_component_schema.py is generated against the v1 compat shim:

# airbyte_cdk/sources/declarative/models/declarative_component_schema.py:9
from pydantic.v1 import BaseModel, Extra, Field

pydantic.v1.BaseModel has no model_dump — verified on the pinned version:

$ python -c "from pydantic.v1 import BaseModel; print([m for m in dir(BaseModel) if 'dump' in m])"
[]

So the proposed diff would be an AttributeError at runtime, not a deprecation cleanup. .dict() is the correct v1 API and emits no deprecation warning from a pydantic.v1 model. Moot in the end — the call was replaced with json.loads(self.advanced_auth.json()) (also v1 API) for unrelated reasons.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@darynaishchenko Thanks for the detailed clarification.

The scopes_join_strategy coverage now covers the required top-level, nested, and None cases. That concern is addressed.

You are correct about pydantic.v1.BaseModel. model_dump(mode="python") is not available on this model type. My deprecation-cleanup suggestion does not apply here, so I withdraw it. The JSON round-trip also fits the requirement to normalize enum values without mutating self.advanced_auth.

🐇


✏️ Learnings added
Learnt from: darynaishchenko
URL: https://github.com/airbytehq/airbyte-python-cdk/pull/1103

Timestamp: 2026-08-07T14:30:59.207Z
Learning: In `airbyte_cdk/sources/declarative/models/declarative_component_schema.py`, generated declarative schema models inherit from `pydantic.v1.BaseModel`. These models use Pydantic v1 APIs such as `.dict()` and `.json()` and do not support Pydantic v2 `model_dump()`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants