Skip to content

Commit cb4feb8

Browse files
authored
fix(python): Fix python wire tests (#11284)
* interim * interim * finish fixying python wire tests * update versions.yml * format python * update snippets snapshot * fix versions * update python seed ir version * Fix buildEnvironmentSetup * nit * nit * Fix version
1 parent 8912cd4 commit cb4feb8

8 files changed

Lines changed: 205 additions & 43 deletions

File tree

generators/python-v2/ast/src/TypeInstantiation.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,13 @@ export class TypeInstantiation extends AstNode {
283283
case "date":
284284
writer.write(`date.fromisoformat("${this.internalType.value}")`);
285285
break;
286-
case "datetime":
287-
writer.write(`datetime.fromisoformat("${this.internalType.value}")`);
286+
case "datetime": {
287+
// Convert 'Z' suffix to '+00:00' for Python 3.8 compatibility
288+
// datetime.fromisoformat() doesn't support 'Z' until Python 3.11
289+
const datetimeValue = this.internalType.value.replace(/Z$/, "+00:00");
290+
writer.write(`datetime.fromisoformat("${datetimeValue}")`);
288291
break;
292+
}
289293
case "bytes":
290294
writer.write(`b"${this.internalType.value}"`);
291295
break;
@@ -519,36 +523,38 @@ export class TypeInstantiation extends AstNode {
519523
}
520524

521525
/**
522-
* Escapes certain special characters if they're NOT already preceded
523-
* by a backslash. Specifically:
526+
* Escapes special characters in a string for use in Python string literals.
524527
*
528+
* This function always escapes:
529+
* - \ -> \\ (backslash - MUST be escaped first!)
525530
* - " -> \"
526531
* - ' -> \'
527-
* - \ -> \\
528-
* - literal \t -> \t
529-
* - literal \n -> \n
530-
* - literal \r -> \r
532+
* - literal tab -> \t
533+
* - literal newline -> \n
534+
* - literal carriage return -> \r
535+
*
536+
* Important: Backslashes must be escaped first to avoid double-escaping.
537+
* For example, if we escaped quotes first:
538+
* Input: \" (backslash-quote, 2 chars)
539+
* After quote escape: \" stays \" (not matched if already escaped)
540+
* After backslash escape: \\" (wrong!)
531541
*
532-
* Uses a negative lookbehind `(?<!\\)` to handle consecutive matches like
533-
* \n\n correctly, since each \n is independently matched in the original string.
542+
* By escaping backslashes first:
543+
* Input: \" (backslash-quote, 2 chars)
544+
* After backslash escape: \\" (4 chars: \\, \, ", becomes \\\" in output)
545+
* After quote escape: \\\" (correct: escaped backslash + escaped quote)
534546
*
535547
* @param input The input string to be escaped
536548
*/
537549
private escapeString(input: string): string {
538-
// Negative lookbehind ensures the character is NOT preceded by a backslash
539-
// in the original string.
540-
const pattern = /(?<!\\)(["'\\\t\n\r])/g;
541-
542-
const replacements: Record<string, string> = {
543-
'"': '\\"',
544-
"'": "\\'",
545-
"\\": "\\\\",
546-
"\t": "\\t",
547-
"\n": "\\n",
548-
"\r": "\\r"
549-
};
550-
551-
return input.replace(pattern, (char) => replacements[char] ?? char);
550+
// Escape backslashes first, then other special characters
551+
return input
552+
.replace(/\\/g, "\\\\") // Escape backslashes first
553+
.replace(/"/g, '\\"') // Escape double quotes
554+
.replace(/'/g, "\\'") // Escape single quotes
555+
.replace(/\t/g, "\\t") // Escape tabs
556+
.replace(/\n/g, "\\n") // Escape newlines
557+
.replace(/\r/g, "\\r"); // Escape carriage returns
552558
}
553559

554560
private writeUnknown({ writer, value }: { writer: Writer; value: unknown }): void {

generators/python-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ client.endpoints.object.get_and_return_with_optional_field(
152152
long=1000000,
153153
double=1.1,
154154
bool=True,
155-
datetime=datetime.fromisoformat("2024-01-15T09:30:00Z"),
155+
datetime=datetime.fromisoformat("2024-01-15T09:30:00+00:00"),
156156
date=date.fromisoformat("2023-01-15"),
157157
uuid=UUID("d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32"),
158158
base64="SGVsbG8gd29ybGQh",
@@ -410,8 +410,8 @@ client.nullable.create_user(
410410
"admin"
411411
],
412412
metadata={
413-
"created_at": datetime.fromisoformat("1980-01-01T00:00:00Z"),
414-
"updated_at": datetime.fromisoformat("1980-01-01T00:00:00Z"),
413+
"created_at": datetime.fromisoformat("1980-01-01T00:00:00+00:00"),
414+
"updated_at": datetime.fromisoformat("1980-01-01T00:00:00+00:00"),
415415
"avatar": None,
416416
"activated": None
417417
},

generators/python-v2/sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@fern-api/base-generator": "workspace:*",
4343
"@fern-api/browser-compatible-base-generator": "workspace:*",
4444
"@fern-api/configs": "workspace:*",
45+
"@fern-api/core-utils": "workspace:*",
4546
"@fern-api/dynamic-ir-sdk": "^62.6.0",
4647
"@fern-api/fs-utils": "workspace:*",
4748
"@fern-api/logger": "workspace:*",

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

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export class WireTestGenerator {
117117
return undefined;
118118
}
119119

120+
/**
121+
* Checks if an example has an error response (non-2xx status code).
122+
*/
123+
private isErrorResponse(example: ExampleEndpointCall): boolean {
124+
return example.response.type === "error";
125+
}
126+
120127
/**
121128
* Converts a static IR example to a wire test example format.
122129
*/
@@ -176,6 +183,7 @@ export class WireTestGenerator {
176183
example: WireTestExample;
177184
service: HttpService;
178185
exampleIndex: number;
186+
isErrorResponse: boolean;
179187
}> = [];
180188

181189
for (const endpoint of endpoints) {
@@ -192,7 +200,14 @@ export class WireTestGenerator {
192200
const staticExample = this.getStaticIrExample(endpoint);
193201
if (staticExample) {
194202
const wireTestExample = this.convertStaticExampleToWireTest(endpoint, staticExample);
195-
endpointTestCases.push({ endpoint, example: wireTestExample, service, exampleIndex: 0 });
203+
const isErrorResponse = this.isErrorResponse(staticExample);
204+
endpointTestCases.push({
205+
endpoint,
206+
example: wireTestExample,
207+
service,
208+
exampleIndex: 0,
209+
isErrorResponse
210+
});
196211
}
197212
}
198213

@@ -224,6 +239,7 @@ export class WireTestGenerator {
224239
example: WireTestExample;
225240
service: HttpService;
226241
exampleIndex: number;
242+
isErrorResponse: boolean;
227243
}>
228244
): python.PythonFile {
229245
const statements: python.AstNode[] = [];
@@ -237,13 +253,14 @@ export class WireTestGenerator {
237253
statements.push(this.createImportRegistration());
238254

239255
// Add test functions for each endpoint
240-
for (const { endpoint, example, service, exampleIndex } of testCases) {
256+
for (const { endpoint, example, service, exampleIndex, isErrorResponse } of testCases) {
241257
const testFunction = this.generateEndpointTestFunction(
242258
serviceName,
243259
endpoint,
244260
example,
245261
service,
246-
exampleIndex
262+
exampleIndex,
263+
isErrorResponse
247264
);
248265
if (testFunction) {
249266
statements.push(testFunction);
@@ -269,6 +286,10 @@ export class WireTestGenerator {
269286
node.addReference(python.reference({ name: "get_client", modulePath: [".conftest"] }));
270287
node.addReference(python.reference({ name: "verify_request_count", modulePath: [".conftest"] }));
271288

289+
// Import ApiError from the SDK's core module for error response tests
290+
const orgName = this.context.config.organization;
291+
node.addReference(python.reference({ name: "ApiError", modulePath: [orgName, "core"] }));
292+
272293
return node;
273294
}
274295

@@ -281,7 +302,8 @@ export class WireTestGenerator {
281302
endpoint: HttpEndpoint,
282303
example: WireTestExample,
283304
service: HttpService,
284-
exampleIndex: number
305+
exampleIndex: number,
306+
isErrorResponse: boolean
285307
): python.Method | null {
286308
try {
287309
const testName = this.getTestFunctionName(serviceName, endpoint);
@@ -303,14 +325,30 @@ export class WireTestGenerator {
303325
// Generate the API call AST directly
304326
const apiCallAst = this.generateApiCallAst(endpoint, example);
305327

306-
// For streaming endpoints, wrap the call in a for loop to consume the iterator
307-
// This is necessary because streaming methods return lazy generators that don't
308-
// execute the HTTP request until iterated
309-
if (this.isStreamingEndpoint(endpoint)) {
310-
statements.push(python.codeBlock(`for _ in ${apiCallAst.toString()}:`));
311-
statements.push(python.codeBlock(" pass"));
328+
// For error responses, wrap in pytest.raises() to expect the exception
329+
if (isErrorResponse) {
330+
// For streaming endpoints, we need to consume the iterator inside pytest.raises
331+
if (this.isStreamingEndpoint(endpoint)) {
332+
statements.push(
333+
python.codeBlock(
334+
`with pytest.raises(ApiError):\n for _ in ${apiCallAst.toString()}:\n pass`
335+
)
336+
);
337+
} else {
338+
statements.push(
339+
python.codeBlock(`with pytest.raises(ApiError):\n ${apiCallAst.toString()}`)
340+
);
341+
}
312342
} else {
313-
statements.push(apiCallAst);
343+
// For streaming endpoints, wrap the call in a for loop to consume the iterator
344+
// This is necessary because streaming methods return lazy generators that don't
345+
// execute the HTTP request until iterated
346+
if (this.isStreamingEndpoint(endpoint)) {
347+
statements.push(python.codeBlock(`for _ in ${apiCallAst.toString()}:`));
348+
statements.push(python.codeBlock(" pass"));
349+
} else {
350+
statements.push(apiCallAst);
351+
}
314352
}
315353

316354
// Verify request count using test ID for filtering
@@ -529,7 +567,15 @@ export class WireTestGenerator {
529567
basePath = "/" + basePath;
530568
}
531569

570+
// Strip URL fragment - fragments are never sent to the server in HTTP requests
571+
// e.g., "/oauth2/token#refresh" -> "/oauth2/token"
572+
const fragmentIndex = basePath.indexOf("#");
573+
if (fragmentIndex !== -1) {
574+
basePath = basePath.substring(0, fragmentIndex);
575+
}
576+
532577
// Substitute path parameters with actual values from WireMock mapping
578+
// Use the path WITHOUT fragment to look up the mapping, since mock-utils strips fragments
533579
const mappingKey = this.wiremockMappingKey({
534580
requestMethod: endpoint.method,
535581
requestUrlPathTemplate: basePath

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { File } from "@fern-api/base-generator";
2+
import { assertNever } from "@fern-api/core-utils";
23
import { RelativeFilePath } from "@fern-api/fs-utils";
34
import { WireMock } from "@fern-api/mock-utils";
45
import { AuthScheme, IntermediateRepresentation } from "@fern-fern/ir-sdk/api";
@@ -108,6 +109,7 @@ export class WireTestSetupGenerator {
108109
const clientClassName = this.getClientClassName();
109110
const clientImport = this.getClientImport();
110111
const clientConstructorParams = this.buildClientConstructorParams();
112+
const environmentSetup = this.buildEnvironmentSetup();
111113

112114
return `"""
113115
Pytest configuration for wire tests.
@@ -124,7 +126,7 @@ from typing import Any, Dict, Optional
124126
import requests
125127
126128
${clientImport}
127-
129+
${environmentSetup.imports}
128130
129131
def get_client(test_id: str) -> ${clientClassName}:
130132
"""
@@ -137,7 +139,7 @@ def get_client(test_id: str) -> ${clientClassName}:
137139
A configured client instance with all required auth parameters.
138140
"""
139141
return ${clientClassName}(
140-
base_url="http://localhost:8080",
142+
${environmentSetup.param},
141143
headers={"X-Test-Id": test_id},
142144
${clientConstructorParams}
143145
)
@@ -355,6 +357,52 @@ def pytest_unconfigure(config: pytest.Config) -> None:
355357
return orgName;
356358
}
357359

360+
/**
361+
* Builds the environment setup for the conftest.py file.
362+
* Returns an object with imports and the parameter to use in the client constructor.
363+
*
364+
* If the IR has environments defined (single or multiple base URLs), we need to
365+
* create a custom environment instance that points all URLs to WireMock.
366+
* If no environments are defined, we use base_url directly.
367+
*/
368+
private buildEnvironmentSetup(): { imports: string; param: string } {
369+
const environments = this.ir.environments;
370+
371+
if (environments?.environments.type !== "multipleBaseUrls") {
372+
// No environments defined - use base_url directly
373+
return {
374+
imports: "",
375+
param: 'base_url="http://localhost:8080"'
376+
};
377+
}
378+
379+
// Handle multiple base URLs environment
380+
if (environments?.environments.type === "multipleBaseUrls") {
381+
const envConfig = environments.environments;
382+
const environmentClassName = this.getEnvironmentClassName();
383+
const modulePath = this.getModulePath();
384+
385+
// Build kwargs for all base URLs pointing to WireMock
386+
const baseUrlKwargs = envConfig.baseUrls
387+
.map((baseUrl) => `${baseUrl.name.snakeCase.safeName}="http://localhost:8080"`)
388+
.join(", ");
389+
390+
return {
391+
imports: `from ${modulePath}.environment import ${environmentClassName}`,
392+
param: `environment=${environmentClassName}(${baseUrlKwargs})`
393+
};
394+
}
395+
396+
assertNever(environments.environments);
397+
}
398+
399+
/**
400+
* Gets the environment class name based on the client class name.
401+
*/
402+
private getEnvironmentClassName(): string {
403+
return `${this.getClientClassName()}Environment`;
404+
}
405+
358406
/**
359407
* Builds the client constructor parameters based on the IR's auth schemes.
360408
* Returns a string of keyword arguments with fake values for all required auth parameters.

generators/python/src/fern_python/generators/sdk/client_generator/request_body_parameters/file_upload_request_body_parameters.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,65 @@ def write(writer: AST.NodeWriter) -> None:
124124
if property_as_union.type == "bodyProperty" and property_as_union.content_type is not None:
125125
continue
126126
elif property_as_union.type == "bodyProperty":
127-
writer.write_line(
128-
f'"{property_as_union.name.wire_value}": {self._get_body_property_name(property_as_union)},'
129-
)
127+
prop_name = self._get_body_property_name(property_as_union)
128+
# For multipart form data, httpx expects primitive types (str, int, etc.)
129+
# Object types need to be JSON serialized as strings
130+
if self._is_primitive_type(property_as_union.value_type):
131+
writer.write_line(f'"{property_as_union.name.wire_value}": {prop_name},')
132+
else:
133+
# JSON serialize complex types for multipart form compatibility
134+
writer.write(f'"{property_as_union.name.wire_value}": ')
135+
writer.write_node(
136+
AST.Expression(
137+
Json.dumps(
138+
AST.Expression(
139+
self._context.core_utilities.jsonable_encoder(AST.Expression(prop_name))
140+
)
141+
)
142+
)
143+
)
144+
writer.write_line(",")
130145
writer.write_line("}")
131146

132147
return AST.Expression(AST.CodeWriter(write))
133148

149+
def _is_primitive_type(self, type_reference: ir_types.TypeReference) -> bool:
150+
"""Check if a type is a primitive that can be used directly in multipart form data."""
151+
HTTPX_PRIMITIVE_TYPES = {
152+
ir_types.PrimitiveTypeV1.STRING,
153+
ir_types.PrimitiveTypeV1.INTEGER,
154+
ir_types.PrimitiveTypeV1.DOUBLE,
155+
ir_types.PrimitiveTypeV1.BOOLEAN,
156+
ir_types.PrimitiveTypeV1.LONG,
157+
ir_types.PrimitiveTypeV1.UINT,
158+
ir_types.PrimitiveTypeV1.UINT_64,
159+
ir_types.PrimitiveTypeV1.FLOAT,
160+
}
161+
162+
def check_type(tr: ir_types.TypeReference) -> bool:
163+
union = tr.get_as_union()
164+
if union.type == "primitive":
165+
return union.primitive.v_1 in HTTPX_PRIMITIVE_TYPES
166+
elif union.type == "container":
167+
container = union.container.get_as_union()
168+
if container.type == "optional":
169+
return check_type(container.optional)
170+
elif container.type == "nullable":
171+
return check_type(container.nullable)
172+
return False
173+
elif union.type == "named":
174+
# Named types (objects, enums, aliases) need to be checked further
175+
type_declaration = self._context.pydantic_generator_context.get_declaration_for_type_id(union.type_id)
176+
shape = type_declaration.shape.get_as_union()
177+
if shape.type == "alias":
178+
return check_type(shape.alias_of)
179+
elif shape.type == "enum":
180+
return True # Enums serialize to their string value
181+
return False # Objects and unions need JSON serialization
182+
return False
183+
184+
return check_type(type_reference)
185+
134186
def get_files(self) -> Optional[AST.Expression]:
135187
def write(writer: AST.NodeWriter) -> None:
136188
writer.write_line("{")

0 commit comments

Comments
 (0)