Skip to content

feat(python): Add better undiscriminated union request types#16189

Open
amckinney wants to merge 2 commits into
mainfrom
amckinney/python/union
Open

feat(python): Add better undiscriminated union request types#16189
amckinney wants to merge 2 commits into
mainfrom
amckinney/python/union

Conversation

@amckinney
Copy link
Copy Markdown
Contributor

@amckinney amckinney commented Jun 2, 2026

Description

This adds the inline_undiscriminated_union_request_params configuration option to the Python SDK generator. This significantly improves the generated method signature for inlined request types that contain undiscriminated unions of literal types.

In general, these undiscriminated unions are forward compatible, such that they accept any value on the response side. The problem is that this makes the request less specific than it can be, which yields a less than ideal UX. For example, consider the following before/after.

Before

# src/union/types/subscribed_event.py

SubscribedEvent = typing.Union[
    typing.Literal[
        "batch.succeeded",
        "batch.failed",
        "document.processed",
    ],
    typing.Any,
]
# src/union/client.py

class Client:
    def create(
        self,
        *,
        subscribed_events: typing.Sequence[SubscribedEvent],

After

# src/union/client.py

class Client:
    def create(
        self,
        *,
        subscribed_events: typing.Sequence[
            Literal[
                "batch.succeeded",
                "batch.failed",
                "document.processed",
            ]
        ],

In particular, the experience before allowed the user to specify anything and their code would compile. This more accurately represents what the user is expected to specify, which guides them through the experience.

Testing

  • Unit tests added/updated
  • Manual testing completed

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +331 to +336
for endpoint in service.endpoints:
for path_parameter in endpoint.all_path_parameters:
add(path_parameter.value_type)
for query_parameter in endpoint.query_parameters:
add(query_parameter.value_type)
for header in endpoint.response_headers or []:
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.

🔴 Missing endpoint request headers in _collect_endpoint_referenced_ids causes incorrect file skipping

The _collect_endpoint_referenced_ids method at pydantic_generator_context_impl.py:331-336 iterates endpoint.all_path_parameters, endpoint.query_parameters, and endpoint.response_headers, but does not iterate endpoint.headers (endpoint-level request headers). By contrast, the SDK's endpoint function generator at endpoint_function_generator.py:456 uses service.headers + endpoint.headers, showing that endpoint.headers is a real, populated field.

If an enum type is used only as an endpoint-specific request header AND as a member of an undiscriminated union, it will not appear in referenced_elsewhere and will be incorrectly added to the inline-eligible set. Its file will be skipped (should_inline_away_type returns True), but it's still referenced from the endpoint signature — producing a broken import in the generated code.

Suggested change
for endpoint in service.endpoints:
for path_parameter in endpoint.all_path_parameters:
add(path_parameter.value_type)
for query_parameter in endpoint.query_parameters:
add(query_parameter.value_type)
for header in endpoint.response_headers or []:
for endpoint in service.endpoints:
for path_parameter in endpoint.all_path_parameters:
add(path_parameter.value_type)
for query_parameter in endpoint.query_parameters:
add(query_parameter.value_type)
for header in endpoint.headers:
add(header.value_type)
for header in endpoint.response_headers or []:
add(header.value_type)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +383 to +385
union_member_ids.update(self._direct_named_ids(member.type))
elif shape.type == "object":
for extension in shape.extends:
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.

🔴 Container-wrapped enum members are marked eligible for inlining but never actually inlined, causing missing files

In _get_inline_eligible_enum_ids (pydantic_generator_context_impl.py:383-385), union_member_ids is populated using _direct_named_ids(member.type), which traverses into containers (optional, list, set, map, nullable). For example, a union member optional<SomeEnum> adds SomeEnum to union_member_ids. If SomeEnum isn't referenced elsewhere, should_inline_away_type(SomeEnum) returns True and its file is skipped.

However, _literal_value_reprs_for_member (pydantic_generator_context_impl.py:440-452) only handles direct named references and direct container/literal types — it returns None for optional<SomeEnum>. So the member falls through to the get_member_hint path which emits a type reference to SomeEnum by name, but the file was skipped → broken import in generated code.

Root cause: mismatch between eligibility detection and inlining logic

The eligibility check descends into containers to find named IDs, but the inlining check only handles top-level named/literal references. The fix should restrict union_member_ids collection to only add type IDs that would actually be inlined — i.e., direct named references at the member's top-level type.

Prompt for agents
The problem is in _get_inline_eligible_enum_ids in pydantic_generator_context_impl.py. When building union_member_ids for undiscriminated unions, _direct_named_ids is used which traverses through containers (optional, list, set, etc.). But _literal_value_reprs_for_member only handles direct named references and direct literal containers - it does NOT handle container-wrapped named types like optional<SomeEnum>.

This means an enum referenced as optional<SomeEnum> in an undiscriminated union gets added to union_member_ids (via _direct_named_ids), but when the union is actually generated, _literal_value_reprs_for_member returns None for the optional<SomeEnum> member, so SomeEnum is referenced by name in the generated code. However, SomeEnum's file was skipped because should_inline_away_type returned True.

The fix should restrict the union_member_ids collection to only include types that _literal_value_reprs_for_member would actually inline. For the undiscriminatedUnion case in the loop, instead of using _direct_named_ids(member.type), check member.type.get_as_union().type == 'named' and only add that type_id directly. This ensures only directly-referenced enums (not container-wrapped ones) are considered for inlining.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-06-02T05:32:59Z).

Fixture main PR Delta
docs 230.4s (n=5) 209.4s (35 versions) -21.0s (-9.1%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-06-02T05:32:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-06-02 22:11 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-06-02T05:32:59Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 74s (n=5) 110s (n=5) 65s -9s (-12.2%)
go-sdk square 136s (n=5) 282s (n=5) 127s -9s (-6.6%)
java-sdk square 225s (n=5) 272s (n=5) 187s -38s (-16.9%)
php-sdk square 59s (n=5) 81s (n=5) 54s -5s (-8.5%)
python-sdk square 135s (n=5) 236s (n=5) 127s -8s (-5.9%)
ruby-sdk-v2 square 89s (n=5) 124s (n=5) 82s -7s (-7.9%)
rust-sdk square 167s (n=5) 170s (n=5) 172s +5s (+3.0%)
swift-sdk square 55s (n=5) 746s (n=5) 53s -2s (-3.6%)
ts-sdk square 229s (n=5) 234s (n=5) 223s -6s (-2.6%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-06-02T05:32:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-06-02 22:14 UTC

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant