Skip to content

Commit 320f126

Browse files
devin-ai-integration[bot]jsklanbot_apk
authored
fix(python): deduplicate stream condition properties in discriminated union variants (#14874)
* feat(seed): augment server-sent-events-openapi fixture with x-fern-streaming test cases Add endpoints 8-14 covering x-fern-streaming extension patterns that have been the source of multiple regressions: - Endpoint 8: basic stream-condition with $ref request body (#13568) - Endpoint 9: stream-condition with x-fern-type-name (#14256) - Endpoints 10-11: shared request schema across streaming/non-streaming (#14291) - Endpoint 12: discriminated union request with allOf-inherited stream condition field, reproducing the Vectara regression (FER-9556, #14730) - Endpoint 13: nullable stream condition field (#13605) - Endpoint 14: x-fern-streaming with SSE format only (no stream-condition) * chore(seed): add debug script for SSE pipeline stage inspection Runs the locally-built Fern CLI against the server-sent-events-openapi fixture through each transformation stage (openapi-ir, write-definition, ir), collecting outputs in .local/results/ for inspection. Continues past failures so all stages produce output even when earlier ones error. Usage: pnpm tsx scripts/debug-sse-pipeline.ts * chore(seed): comment out endpoint 9 (x-fern-type-name collision) Endpoint 9's ChatCompletionRequest name collision blocks IR generation for the entire fixture. Comment it out so the remaining endpoints can be tested end-to-end. Uncomment when investigating the x-fern-type-name disambiguation fix (PR #14256) in isolation. * fix: deduplicate stream condition properties in discriminated union variants When a discriminated union's variants inherit the stream condition field from a base schema via extends, the property appeared twice in generated Python code — once from the union's base properties (pinned as a literal) and once from the variant's extended properties (as boolean). This caused SyntaxError: Duplicate keyword argument in generated wire tests and pydantic model definitions. Two fixes: 1. DynamicTypeLiteralMapper.ts: filter objectEntries that overlap with baseFields when building samePropertiesAsObject variant constructor args 2. simple_discriminated_union_generator.py: skip variant properties whose wire_value matches any union base property Fixes FER-9556 Co-Authored-By: bot_apk <apk@cognition.ai> * chore: update ir-to-jsonschema snapshots for server-sent-events-openapi fixture Co-Authored-By: bot_apk <apk@cognition.ai> * chore: add server-sent-events-openapi to go-sdk allowed failures Co-Authored-By: bot_apk <apk@cognition.ai> * fix: preserve AST references for streaming endpoint imports in wire tests Co-Authored-By: bot_apk <apk@cognition.ai> * style: fix biome formatting in WireTestGenerator.ts Co-Authored-By: bot_apk <apk@cognition.ai> * fix: escape triple quotes in docstrings and suppress mypy overload errors in exported client wrapper Co-Authored-By: judah <jsklan.development@gmail.com> * chore: update seed snapshot for exported client type ignore comment Co-Authored-By: judah <jsklan.development@gmail.com> * fix: use actual stream condition property name in WireMock body patterns Instead of hardcoding 'stream' in the matchesJsonPath body pattern, extract the actual stream condition property name from the SSE endpoint's example request body. This fixes WireMock stub routing for APIs that use a different property name (e.g. 'stream_response' in the Vectara API). Co-Authored-By: judah <jsklan.development@gmail.com> * fix: use explicit None check for mypy type narrowing in wrapper class Co-Authored-By: judah <jsklan.development@gmail.com> * fix: update CLI changelog version ordering and regenerate IR test snapshots Co-Authored-By: judah <jsklan.development@gmail.com> * nit * fix(python): replace type: ignore with proper @overload signatures in exported client wrapper Mirror the base client's @overload signatures on the wrapper class and use **kwargs pass-through for super().__init__(), so mypy is satisfied without suppressing the error. --------- Co-authored-by: jsklan <jsklan.development@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai> Co-authored-by: jsklan <100491078+jsklan@users.noreply.github.com>
1 parent d00b1f5 commit 320f126

48 files changed

Lines changed: 21343 additions & 3055 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

generators/python-v2/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,12 @@ export class DynamicTypeLiteralMapper {
271271
object_: named,
272272
value: discriminatedUnionTypeInstance.value
273273
});
274-
return [...baseFields, ...objectEntries];
274+
// Skip object entries that overlap with base fields to avoid
275+
// duplicate keyword arguments (e.g. stream condition properties
276+
// inherited via extends that are already pinned as literals).
277+
const baseFieldNames = new Set(baseFields.map((f) => f.name));
278+
const filteredObjectEntries = objectEntries.filter((entry) => !baseFieldNames.has(entry.name));
279+
return [...baseFields, ...filteredObjectEntries];
275280
}
276281
case "singleProperty": {
277282
try {

generators/python-v2/sdk/src/wire-tests/WireTestGenerator.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -368,22 +368,32 @@ export class WireTestGenerator {
368368
if (isErrorResponse) {
369369
// For streaming endpoints, we need to consume the iterator inside pytest.raises
370370
if (this.isStreamingEndpoint(endpoint)) {
371-
statements.push(
372-
python.codeBlock(
373-
`with pytest.raises(ApiError):\n for _ in ${apiCallAst.toString()}:\n pass`
374-
)
371+
const block = python.codeBlock(
372+
`with pytest.raises(ApiError):\n for _ in ${apiCallAst.toString()}:\n pass`
375373
);
374+
// Preserve import references from the AST that are lost during toString()
375+
for (const ref of apiCallAst.getReferences()) {
376+
block.addReference(ref);
377+
}
378+
statements.push(block);
376379
} else {
377-
statements.push(
378-
python.codeBlock(`with pytest.raises(ApiError):\n ${apiCallAst.toString()}`)
379-
);
380+
const block = python.codeBlock(`with pytest.raises(ApiError):\n ${apiCallAst.toString()}`);
381+
for (const ref of apiCallAst.getReferences()) {
382+
block.addReference(ref);
383+
}
384+
statements.push(block);
380385
}
381386
} else {
382387
// For streaming endpoints, wrap the call in a for loop to consume the iterator
383388
// This is necessary because streaming methods return lazy generators that don't
384389
// execute the HTTP request until iterated
385390
if (this.isStreamingEndpoint(endpoint)) {
386-
statements.push(python.codeBlock(`for _ in ${apiCallAst.toString()}:`));
391+
const block = python.codeBlock(`for _ in ${apiCallAst.toString()}:`);
392+
// Preserve import references from the AST that are lost during toString()
393+
for (const ref of apiCallAst.getReferences()) {
394+
block.addReference(ref);
395+
}
396+
statements.push(block);
387397
statements.push(python.codeBlock(" pass"));
388398
} else {
389399
statements.push(apiCallAst);

generators/python/sdk/versions.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
22
# For unreleased changes, use unreleased.yml
3+
- version: 5.3.11
4+
changelogEntry:
5+
- summary: |
6+
Fix duplicate keyword arguments in generated code for discriminated union
7+
request bodies with stream condition properties. When a union variant inherits
8+
the stream condition field from a base schema via extends, the property was
9+
emitted twice — once from the union's base properties and once from the
10+
variant's extended properties — causing SyntaxError in generated Python code.
11+
type: fix
12+
- summary: |
13+
Escape triple quotes in docstrings to prevent premature docstring termination
14+
when OpenAPI descriptions contain Python code examples with triple-quoted strings.
15+
type: fix
16+
- summary: |
17+
Fix mypy call-overload errors in exported client wrapper by mirroring the
18+
base client's @overload signatures and using **kwargs pass-through, instead
19+
of suppressing the error with a type: ignore comment.
20+
type: fix
21+
createdAt: "2026-04-10"
22+
irVersion: 65
23+
324
- version: 5.3.10
425
changelogEntry:
526
- summary: |

generators/python/src/fern_python/codegen/ast/nodes/docstring/docstring.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88

99
def escape_docstring(text: str) -> str:
1010
"""
11-
Escape backslashes in docstrings to avoid SyntaxWarning for invalid escape sequences.
12-
This is needed when docstrings contain backslashes in source text (e.g., FOO\\_BAR)
13-
that would otherwise produce invalid escape sequences.
11+
Escape special characters in docstrings to avoid syntax errors.
12+
- Backslashes are escaped to prevent invalid escape sequences (e.g., FOO\\_BAR).
13+
- Triple quotes are escaped to prevent premature docstring termination when
14+
descriptions contain code examples with triple-quoted strings.
1415
"""
15-
return text.replace("\\", "\\\\")
16+
result = text.replace("\\", "\\\\")
17+
result = result.replace('"""', '\\"""')
18+
return result
1619

1720

1821
class Docstring(CodeWriter):

generators/python/src/fern_python/generators/pydantic_model/type_declaration_handler/discriminated_union/simple_discriminated_union_generator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,15 @@ def generate(self) -> None:
138138
)
139139
]
140140
discriminant_wire_value = self._union.discriminant.wire_value
141+
base_property_wire_values = {bp.name.wire_value for bp in self._union.base_properties}
141142
object_properties = self._context.get_all_properties_including_extensions(shape.type_id)
142143
for object_property in object_properties:
143144
# Skip properties that match the discriminant field to avoid duplicate fields
144145
if object_property.name.wire_value == discriminant_wire_value:
145146
continue
147+
# Skip properties already declared in the union's base properties
148+
if object_property.name.wire_value in base_property_wire_values:
149+
continue
146150
self._all_referenced_types.append(object_property.value_type)
147151
same_properties_as_object_property_fields.append(
148152
FernAwarePydanticField(

generators/python/src/fern_python/generators/sdk/client_generator/generated_root_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class RootClient:
1212
class_reference: AST.ClassReference
1313
parameters: List[ConstructorParameter]
1414
init_parameters: Optional[List[ConstructorParameter]] = field(default=None)
15+
constructor_overloads: Optional[List[AST.FunctionSignature]] = field(default=None)
1516

1617

1718
@dataclass

generators/python/src/fern_python/generators/sdk/client_generator/root_client_generator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ def __init__(
184184
base_url_example_value=base_url_example_value,
185185
sync_init_parameters=self._get_constructor_parameters(is_async=False),
186186
async_init_parameters=self._get_constructor_parameters(is_async=True),
187+
sync_constructor_overloads=self._get_constructor_overloads(is_async=False),
188+
async_constructor_overloads=self._get_constructor_overloads(is_async=True),
187189
)
188190
self._generated_root_client = root_client_builder.build()
189191

@@ -1650,6 +1652,8 @@ def __init__(
16501652
base_url_example_value: Optional[AST.Expression] = None,
16511653
sync_init_parameters: Optional[Sequence[ConstructorParameter]] = None,
16521654
async_init_parameters: Optional[Sequence[ConstructorParameter]] = None,
1655+
sync_constructor_overloads: Optional[List[AST.FunctionSignature]] = None,
1656+
async_constructor_overloads: Optional[List[AST.FunctionSignature]] = None,
16531657
):
16541658
self._module_path = module_path
16551659
self._class_name = class_name
@@ -1664,6 +1668,8 @@ def __init__(
16641668
self._oauth_token_override = oauth_token_override
16651669
self._use_kwargs_snippets = use_kwargs_snippets
16661670
self._base_url_example_value = base_url_example_value
1671+
self._sync_constructor_overloads = sync_constructor_overloads
1672+
self._async_constructor_overloads = async_constructor_overloads
16671673

16681674
def build(self) -> GeneratedRootClient:
16691675
def create_class_reference(class_name: str) -> AST.ClassReference:
@@ -1831,11 +1837,13 @@ def build_default_snippet_kwargs() -> List[typing.Tuple[str, AST.Expression]]:
18311837
class_reference=async_class_reference,
18321838
parameters=self._constructor_parameters,
18331839
init_parameters=self._async_init_parameters,
1840+
constructor_overloads=self._async_constructor_overloads,
18341841
),
18351842
sync_instantiations=sync_instantiations,
18361843
sync_client=RootClient(
18371844
class_reference=sync_class_reference,
18381845
parameters=self._constructor_parameters,
18391846
init_parameters=self._sync_init_parameters,
1847+
constructor_overloads=self._sync_constructor_overloads,
18401848
),
18411849
)

generators/python/src/fern_python/generators/sdk/sdk_generator.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,23 @@ def _create_wrapper_class_declaration(
606606
) -> AST.ClassDeclaration:
607607
params = root_client.init_parameters if root_client.init_parameters is not None else root_client.parameters
608608

609+
if root_client.constructor_overloads is not None:
610+
# When the base class has overloaded __init__ (e.g. OAuth + token),
611+
# mirror the overloads on the wrapper and use **kwargs pass-through
612+
# so the call to super().__init__() satisfies mypy without type: ignore.
613+
def write_kwargs_super_init(writer: AST.NodeWriter) -> None:
614+
writer.write_line("super().__init__(**kwargs)")
615+
616+
return AST.ClassDeclaration(
617+
name=class_name,
618+
extends=[base_class_ref],
619+
constructor=AST.ClassConstructor(
620+
signature=AST.FunctionSignature(include_kwargs=True),
621+
body=AST.CodeWriter(write_kwargs_super_init),
622+
overloads=root_client.constructor_overloads,
623+
),
624+
)
625+
609626
named_params = [
610627
AST.NamedFunctionParameter(
611628
name=param.constructor_parameter_name,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"type": "object",
3+
"properties": {
4+
"answer": {
5+
"oneOf": [
6+
{
7+
"type": "string"
8+
},
9+
{
10+
"type": "null"
11+
}
12+
]
13+
},
14+
"finishReason": {
15+
"oneOf": [
16+
{
17+
"$ref": "#/definitions/CompletionFullResponseFinishReason"
18+
},
19+
{
20+
"type": "null"
21+
}
22+
]
23+
}
24+
},
25+
"additionalProperties": false,
26+
"definitions": {
27+
"CompletionFullResponseFinishReason": {
28+
"type": "string",
29+
"enum": [
30+
"complete",
31+
"length",
32+
"error"
33+
]
34+
}
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "string",
3+
"enum": [
4+
"complete",
5+
"length",
6+
"error"
7+
],
8+
"definitions": {}
9+
}

0 commit comments

Comments
 (0)