Skip to content

fix(python): handle explode=false for array query parameters#12731

Merged
jsklan merged 26 commits intomainfrom
devin/1771969395-fix-python-query-param-explode
Feb 27, 2026
Merged

fix(python): handle explode=false for array query parameters#12731
jsklan merged 26 commits intomainfrom
devin/1771969395-fix-python-query-param-explode

Conversation

@jsklan
Copy link
Copy Markdown
Contributor

@jsklan jsklan commented Feb 24, 2026

Description

Refs: Requested by @jsklan
Link to Devin run: https://app.devin.ai/sessions/13529ce4c3a84afc96b17b669e2e27dc

When an OpenAPI spec sets explode: false on an array query parameter, the generated Python SDK now serializes values as comma-separated strings (tags=A,B,C) instead of repeated keys (tags=A&tags=B&tags=C). This applies to both regular and streaming endpoints (i.e. those using httpx_client.stream() for SSE).

The root cause was that the explode field was silently dropped when flowing through the old OpenAPI importer pipeline (OpenAPI IR → Fern Definition → Fern IR). The field existed in the parse IR but was never carried into the final IR or downstream types.

Note on cross-generator consistency: Neither the TypeScript nor Java SDK generators currently implement explode: false comma-separated serialization. This PR makes Python the first generator to support it per the OpenAPI spec (style: form, explode: false).

Changes Made

Pipeline propagation (CLI):

  • Added explode field to the OpenAPI IR final QueryParameter type (finalIr.yml + regenerated TS SDK types)
  • Preserved explode during parse IR → final IR conversion (generateIr.ts)
  • Passed explode from OpenAPI IR to Fern Definition (buildQueryParameter.ts)
  • Added explode to Fern Definition QueryParameterTypeReferenceDetailed (API + serialization types)
  • Read explode from Fern Definition in IR generator (convertQueryParameter.ts) instead of hardcoding undefined
  • Added explode: noop to visitHttpService.ts validator visitor

Python generator:

  • Added _should_comma_join_query_parameter() — checks explode is False + allow_multiple or list/set type
  • Added _wrap_with_comma_join() — wraps array values with ",".join(map(str, x)) if isinstance(x, list) else x
  • Modified _get_query_parameters_for_endpoint() to apply comma-join wrapping
  • The comma-join logic is applied in _get_query_parameters_for_endpoint(), which is shared by both .request() and .stream() code paths — no separate streaming fix was needed

Test fixture (query-parameters-openapi):

  • Added tags (required, explode: false) and optionalTags (optional, explode: false) to the existing search endpoint
  • Added a new streaming SSE endpoint /stream/events with tags and ids query params (both explode: false) to verify streaming support
  • Added StreamEvent schema to components
  • Regenerated seed snapshot showing correct comma-join output for both regular and streaming endpoints

Snapshot updates:

  • Updated openapi-ir-to-fern-tests snapshots for parameter-serialization-edge-cases and switchboard (both openapi-ir and openapi variants)
  • Updated v3-importer-tests baseline snapshot for parameter-style-deepobject
  • Updated ir-generator-tests snapshots for query-parameters-openapi and query-parameters-openapi-as-objects (both IR and dynamic-snippets)

Testing

  • Seed test query-parameters-openapi passes (build + test scripts)
  • Pre-commit hooks pass (poetry run pre-commit run -a)
  • openapi-ir-to-fern-tests snapshots updated and passing
  • v3-importer-tests baseline snapshot updated and passing
  • ir-generator-tests snapshots updated and passing (313 passed)
  • Generated raw_client.py confirmed: streaming endpoint uses ",".join(map(str, tags)) if isinstance(tags, list) else tags inside httpx_client.stream() params
  • No new unit tests added (covered by seed snapshot)

Updates Since Last Revision

  • Streaming endpoint support: Added /stream/events SSE endpoint to test fixture with explode: false query params. Verified that the existing comma-join logic correctly applies to streaming endpoints (both sync and async) via httpx_client.stream().
  • Version bumps: Python SDK version updated to 4.60.2 (from 4.59.5), CLI version updated to 3.90.2 (from 3.88.2) to account for main branch advancement.
  • IR generator test snapshots: Updated snapshots for query-parameters-openapi and query-parameters-openapi-as-objects fixtures to include the new streaming endpoint.

Human Review Checklist

Critical items to verify:

  1. isinstance(tags, list) check: The comma-join logic only handles list but the type hint is Union[str, Sequence[str]]. If users pass a tuple or other Sequence, it won't be comma-joined. Should this be isinstance(tags, (list, tuple)) or check for Sequence protocol?

  2. Code duplication in _wrap_with_comma_join: The allow_multiple branch has identical code for both is_optional=True and is_optional=False cases. The isinstance check handles both, so the optional branch doesn't add a None guard. Is this intentional (httpx handles None)?

  3. WebSocket query parameters: The summary mentioned checking websocket_connect_method_generator.py but no changes were made. Do WebSocket connections need the same fix?

  4. Auto-generated type modifications: QueryParameterTypeReferenceDetailed.ts files are marked auto-generated but were manually edited. Will this cause issues if someone regenerates the Fern Definition schema?

  5. Dead code path: The direct list/set type checking in _should_comma_join_query_parameter (after the allow_multiple check) may never trigger in the old pipeline since it converts arrays to scalars with allow_multiple=True. Is this code path needed for the v3 importer?

  6. Streaming endpoint verification: The streaming endpoint test fixture proves the fix works, but the generated code shows the same isinstance(tags, list) pattern. Confirm this is sufficient for real-world SSE streaming use cases.


Open with Devin

Propagate the OpenAPI 'explode' field through the old importer pipeline
(OpenAPI IR -> Fern Definition -> Fern IR) and generate comma-separated
serialization in the Python SDK when explode=false.

Co-Authored-By: unknown <>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 10 commits February 26, 2026 17:23
…i fixture

The streaming SSE endpoint was breaking java-spring and ts-express generators
which don't support streaming responses. The comma-join logic for streaming
endpoints was already verified to work since the code path is shared between
streaming and non-streaming endpoints in the Python generator.

Co-Authored-By: unknown <>
devin-ai-integration[bot]

This comment was marked as resolved.

@jsklan jsklan enabled auto-merge (squash) February 26, 2026 21:47
Fix issue with `explode` field propagation in OpenAPI query parameters.
devin-ai-integration[bot]

This comment was marked as resolved.

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 new potential issues.

View 10 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1447 to +1489
def _wrap_with_comma_join(
self, reference: AST.Expression, query_parameter: ir_types.QueryParameter
) -> AST.Expression:
"""Wrap a query parameter reference with comma-joining for explode=False.

When explode=False, array values should be serialized as comma-separated
strings (e.g. "a,b,c") instead of repeated keys (e.g. "k=a&k=b&k=c").

The parameter may be:
- A list/sequence value that needs joining
- Optional, in which case we need a None check
- A scalar with allow_multiple (Union[str, Sequence[str]]), which also
needs to handle both single values and sequences
"""
param_name = get_parameter_name(query_parameter.name.name)
value_type = query_parameter.value_type.get_as_union()
is_optional = value_type.type == "container" and value_type.container.get_as_union().type in (
"optional",
"nullable",
)

if query_parameter.allow_multiple:

def write_comma_join(writer: AST.NodeWriter) -> None:
writer.write(
f'",".join(map(str, {param_name})) if isinstance({param_name}, (list, tuple, set)) else {param_name}'
)

return AST.Expression(AST.CodeWriter(write_comma_join))
else:
# Direct list/set type
if is_optional:

def write_comma_join(writer: AST.NodeWriter) -> None:
writer.write(f'",".join(map(str, {param_name})) if {param_name} is not None else None')

return AST.Expression(AST.CodeWriter(write_comma_join))
else:

def write_comma_join(writer: AST.NodeWriter) -> None:
writer.write(f'",".join(map(str, {param_name}))')

return AST.Expression(AST.CodeWriter(write_comma_join))
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.

🟡 _wrap_with_comma_join ignores the reference parameter, bypassing datetime/date/annotation serialization

The _wrap_with_comma_join method accepts a reference parameter (which is the output of _get_query_parameter_reference, containing datetime serialization, date formatting, and annotation metadata conversion), but completely ignores it. Instead, it reconstructs the raw parameter name via get_parameter_name(query_parameter.name.name) and uses that directly in the generated code.

Root Cause and Impact

At endpoint_function_generator.py:1497-1498, the caller passes self._get_query_parameter_reference(query_parameter) as reference, but then inside _wrap_with_comma_join at line 1461, the method uses param_name = get_parameter_name(query_parameter.name.name) instead of the reference expression.

This means if an explode: false array query parameter has a type that requires special serialization (e.g., datetime, date, or a Fern model type requiring convert_and_respect_annotation_metadata), those transformations will be silently skipped. The raw Python variable name will be used instead of the serialized form.

For the current test cases (string arrays), this doesn't matter since strings don't need special serialization. But if a user defines an explode: false array of datetime values or model objects, the generated code would pass raw Python objects instead of serialized values to the comma-join, producing incorrect wire output.

Open in Devin Review

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

Comment on lines +123 to +124
"tags": ",".join(map(str, tags)) if isinstance(tags, list) else tags,
"optionalTags": ",".join(map(str, optional_tags)) if isinstance(optional_tags, list) else optional_tags,
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.

🟡 Generated code uses isinstance(x, list) instead of isinstance(x, (list, tuple, set)) due to stale seed snapshot

The generator code at endpoint_function_generator.py:1472 correctly generates isinstance({param_name}, (list, tuple, set)) for the allow_multiple branch, but the seed test snapshot at seed/python-sdk/query-parameters-openapi/no-custom-config/src/seed/raw_client.py:123 shows isinstance(tags, list) — checking only list.

Stale Snapshot Details

The seed snapshot was not regenerated after the generator code was updated to check (list, tuple, set). The snapshot file on disk shows:

"tags": ",".join(map(str, tags)) if isinstance(tags, list) else tags,

But the generator would now produce:

"tags": ",".join(map(str, tags)) if isinstance(tags, (list, tuple, set)) else tags,

This means the snapshot tests are passing against stale output. The stale snapshot itself (if shipped) would fail to comma-join tuple or set inputs, silently passing them through as-is to httpx, which would likely produce incorrect query string serialization.

Prompt for agents
Regenerate the seed snapshot for the query-parameters-openapi fixture by running: pnpm seed test --generator python-sdk --fixture query-parameters-openapi. The generated raw_client.py at seed/python-sdk/query-parameters-openapi/no-custom-config/src/seed/raw_client.py lines 123-124 and 252-253 should then show isinstance(tags, (list, tuple, set)) instead of isinstance(tags, list), matching the generator code at generators/python/src/fern_python/generators/sdk/client_generator/endpoint_function_generator.py line 1472.
Open in Devin Review

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

@jsklan jsklan merged commit 9a67417 into main Feb 27, 2026
311 checks passed
@jsklan jsklan deleted the devin/1771969395-fix-python-query-param-explode branch February 27, 2026 03:25
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.

3 participants