Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbstractAstNode } from "../ast/index.js";
import { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
import { AbstractEndpointSnippetGenerator } from "./AbstractEndpointSnippetGenerator.js";
import { InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
import { Options } from "./Options.js";
import { Result } from "./Result.js";

Expand Down Expand Up @@ -100,6 +101,73 @@ export abstract class AbstractDynamicSnippetsGenerator<
return result.getResponseOrThrow({ endpoint: request.endpoint });
}

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.plants.update(...)`), the imports the call requires, and the generated
* client class/type name.
*
* Returns undefined only if this generator does not support invocation-only snippets, so
* that callers can fall back to the complete snippet. This is the capability check the
* multi-language fan-out relies on. When the generator does support them, invocations that
* reference imported SDK types (e.g. branded string aliases) return those imports in the
* `imports` field rather than bailing out.
*/
public generateInvocationSync(
request: FernIr.dynamic.EndpointSnippetRequest,
options: Options = {}
): InvocationSnippetResponse | undefined {
const endpoints = this.resolveEndpoints({ request, options });
if (endpoints.length === 0) {
throw new Error(`No endpoints found that match "${request.endpoint.method} ${request.endpoint.path}"`);
}
let bestResponse: InvocationSnippetResponse | undefined = undefined;
let lastError: Error | undefined = undefined;
for (const endpoint of endpoints) {
const context = this.context.clone() as Context;
const snippetGenerator = this.createSnippetGenerator(context);
if (snippetGenerator.generateInvocationSnippetSync == null) {
return undefined;
}
try {
const response = snippetGenerator.generateInvocationSnippetSync({ endpoint, request, options });
if (response == null) {
return undefined;
}
if (context.errors.empty()) {
return response;
}
if (this.shouldUpdateInvocationResponse({ candidate: response, current: bestResponse })) {
bestResponse = response;
}
} catch (error) {
if (lastError == null) {
lastError = error as Error;
}
}
}
if (bestResponse != null) {
return bestResponse;
}
throw (
lastError ??
new Error(`Failed to generate snippet for endpoint "${request.endpoint.method} ${request.endpoint.path}"`)
);
}

private shouldUpdateInvocationResponse({
candidate,
current
}: {
candidate: InvocationSnippetResponse;
current: InvocationSnippetResponse | undefined;
}): boolean {
if (current == null) {
return true;
}
return candidate.snippet.length > current.snippet.length;
}

/**
* Resolves endpoints based on the request and options.
* If an endpointId is specified in options, returns only that specific endpoint.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbstractAstNode } from "../ast/index.js";
import { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
import { InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
import { Options } from "./Options.js";

export abstract class AbstractEndpointSnippetGenerator<Context extends AbstractDynamicSnippetsGeneratorContext> {
Expand Down Expand Up @@ -33,4 +34,24 @@ export abstract class AbstractEndpointSnippetGenerator<Context extends AbstractD
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): Promise<AbstractAstNode>;

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.plants.update(...)`), the imports the call requires, and the generated
* client class/type name.
*
* Implemented per generator; generators that do not implement it are detected via the
* absence of this method (the capability check callers rely on) and fall back to the
* complete snippet.
*/
public generateInvocationSnippetSync?({
endpoint,
request,
options
}: {
endpoint: FernIr.dynamic.Endpoint;
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): InvocationSnippetResponse | undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FernIr } from "@fern-api/dynamic-ir-sdk";

/**
* The structured result of an invocation-only snippet.
*
* Unlike {@link FernIr.dynamic.EndpointSnippetResponse}, which returns a single fully-formed
* snippet string, this exposes the individual pieces a docs template needs to render (and
* keep in sync) an invocation on its own: the bare call, the imports the call requires, and
* the generated client class/type name.
*/
export interface InvocationSnippetResponse {
/**
* The bare invocation/call (e.g. `client.plants.update(...)`) with no imports, no client
* instantiation, and no trailing statement terminator. Honors `options.clientVariableName`.
*/
snippet: string;
/**
* The import block the call requires (e.g. an SDK namespace import for a branded string
* alias). Empty string when the call references no imports.
*/
imports: string;
/**
* The generated client class/type name (e.g. `AcmeClient`), so docs can render
* `new {{clientName}}(...)` and track renames of the SDK client.
*/
clientName: string;
errors: FernIr.dynamic.Error_[] | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export interface Options {
// This is useful when multiple endpoints have the same HTTP method and path
// across different namespaces, and we need to generate a snippet for a specific one.
endpointId?: string;

// The name of the variable the endpoint is invoked on. Only used when generating
// an invocation-only snippet, where the client is instantiated by the caller.
clientVariableName?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { AbstractDynamicSnippetsGenerator } from "./AbstractDynamicSnippetsGener
export { AbstractDynamicSnippetsGeneratorContext } from "./AbstractDynamicSnippetsGeneratorContext.js";
export { type DiscriminatedUnionTypeInstance } from "./DiscriminatedUnionTypeInstance.js";
export { ErrorReporter, Severity } from "./ErrorReporter.js";
export { type InvocationSnippetResponse } from "./InvocationSnippetResponse.js";
export { type Options, Style } from "./Options.js";
export { Result } from "./Result.js";
export { Scope } from "./Scope.js";
Expand Down
16 changes: 16 additions & 0 deletions generators/python-v2/ast/src/PythonFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ export class PythonFile extends AstNode {
writer.unsetRefNameOverrides();
}

/**
* The rendered import block for the statements in this file, or an empty string when nothing
* references an import. This is the Python equivalent of separating a node's imports from its
* body: callers that render an invocation inside code they already own (e.g. a documentation
* code template) can surface the imports the call needs without the surrounding file scaffold.
* The trailing blank line that `write` places between the imports and the body is trimmed.
*/
public getImports(): string {
const writer = new Writer();
const uniqueReferences = this.deduplicateReferences();
this.updateWriterRefNameOverrides({ writer, uniqueReferences });
this.writeImports({ writer, uniqueReferences });
writer.unsetRefNameOverrides();
return writer.toString().trimEnd();
}
Comment on lines +82 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

This is a copy of the first half of write(). If the dedupe/override/import logic ever changes in one place it'll drift in the other. Consider having write() call a shared private helper (or getImports()) so there's a single source of truth.


/*******************************
* Helper Methods
*******************************/
Expand Down
25 changes: 25 additions & 0 deletions generators/python-v2/ast/src/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Class } from "./Class.js";
import { ClassInstantiation } from "./ClassInstantiation.js";
import { CodeBlock } from "./CodeBlock.js";
import { Comment } from "./Comment.js";
import { AstNode } from "./core/AstNode.js";
import { ModulePath } from "./core/types.js";
import { Decorator } from "./Decorator.js";
import { Field } from "./Field.js";
import { Lambda } from "./Lambda.js";
Expand Down Expand Up @@ -115,3 +117,26 @@ export function methodArgument(args: MethodArgument.Args): MethodArgument {
export function operator(args: Operator.Args): Operator {
return new Operator(args);
}

/**
* Renders a node separately from the imports it references, the Python analogue of the
* TypeScript AST's `toStringWithoutImports`. `code` is the node's body with no import lines,
* and `imports` is the rendered import block the body would otherwise need (empty string when
* none). This lets callers embed an invocation inside code they already own (e.g. a
* documentation code template) while surfacing the imports the call requires.
*
* `modulePath` is the module the code is imagined to live in; imports are relativized against it
* exactly as they would be in a generated file at that path, so the imports match what the full
* snippet would emit.
*/
export function renderNodeWithoutImports({ node, modulePath }: { node: AstNode; modulePath: ModulePath }): {
code: string;
imports: string;
} {
// A bare node's `toString()` only writes its own body — imports are emitted solely by
// PythonFile — so the body already excludes them. Wrapping the node in a file at the same
// path lets us compute just the import block the body references.
const code = node.toString();
const file = new PythonFile({ path: modulePath, statements: [node] });
return { code, imports: file.getImports() };
Comment on lines +139 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

code is rendered with a fresh writer that has no ref-name overrides, while imports is rendered inside PythonFile.getImports() after updateWriterRefNameOverrides runs. In PythonFile.write both the imports and the statements are written under the same overrides, so when two references collide the import block emits an alias (import x as x_1) and the body uses it consistently. Here they're independent: the import block can be aliased while node.toString() still writes the original name — silently broken output.

Suggest doing both in one pass, e.g. add a PythonFile.renderImportsAndBody(): { imports, body } that sets the overrides once, writes imports, writes statements, then unsets — and have renderNodeWithoutImports delegate to it instead of calling node.toString() separately.

Comment on lines +139 to +141

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.

🟡 Invocation-only snippets can name a type differently than the import line that provides it

The call text is produced (node.toString() at generators/python-v2/ast/src/python.ts:139) without the renaming rules that the accompanying import block is built with, so when two same-named types from different modules appear in one call the snippet uses a name the imports never define.
Impact: A documentation snippet can end up referring to a name that was renamed in its import line, producing code that fails to run.

Name-override map is applied only when rendering the import block, not the body

PythonFile.write (generators/python-v2/ast/src/PythonFile.ts:57-73) first computes deduplicated references and installs a name-override map on the writer (updateWriterRefNameOverrides, generators/python-v2/ast/src/PythonFile.ts:79-151), and only then writes both the imports and the statements with that same writer, so body and imports agree.

renderNodeWithoutImports instead renders the body with a fresh Writer that has no overrides (AstNode.toString, generators/python-v2/ast/src/core/AstNode.ts:31-35), while PythonFile.getImports() (generators/python-v2/ast/src/PythonFile.ts:82-89) installs overrides for the import block. When updateWriterRefNameOverrides hits a name collision (two references with the same name but different modulePath, e.g. Metadata from two subpackages of the SDK) it aliases the second one, so getImports() emits from seed.b import Metadata as BMetadata while the body written without overrides emits Metadata(...) for both references. The invocation returned by EndpointSnippetGenerator.generateInvocationSnippetSync (generators/python-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts:108-114) then references an undefined/incorrect name.

Prompt for agents
In generators/python-v2/ast/src/python.ts, renderNodeWithoutImports renders the body via node.toString(), which uses a fresh Writer with no reference-name overrides, while the imports are rendered by PythonFile.getImports(), which does install overrides computed by PythonFile.updateWriterRefNameOverrides. When two references share a name but come from different module paths, the override logic aliases one of them in the import line (e.g. `import Metadata as BMetadata`) but the body still writes the un-aliased name, so the emitted invocation references a name the imports do not define.

Fix by rendering both pieces from the same writer/override state. A natural approach is to add a method on PythonFile (e.g. `getBodyAndImports()` or extend getImports to also return the statement body) that deduplicates references, installs the overrides once, writes the imports into one buffer and the statements into another (or writes both and splits), then unsets the overrides — and have renderNodeWithoutImports delegate to it instead of calling node.toString() separately.
Open in Devin Review

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

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { AbstractAstNode, Scope, Severity } from "@fern-api/browser-compatible-base-generator";
import {
AbstractAstNode,
InvocationSnippetResponse,
Options,
Scope,
Severity
} from "@fern-api/browser-compatible-base-generator";
import { assertNever } from "@fern-api/core-utils";
import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { python } from "@fern-api/python-ast";
Expand Down Expand Up @@ -68,6 +74,43 @@ export class EndpointSnippetGenerator {
return this.callMethod({ endpoint, snippet: request });
}

/**
* Generates the structured pieces of an endpoint invocation for callers that render the
* invocation within code of their own (e.g. a documentation code template): the bare call
* (e.g. `client.plants.update(...)`), the imports the call requires, and the generated root
* client class name.
*/
public generateInvocationSnippetSync({
endpoint,
request,
options
}: {
endpoint: FernIr.dynamic.Endpoint;
request: FernIr.dynamic.EndpointSnippetRequest;
options?: Options;
}): InvocationSnippetResponse {
const invocation = this.callMethod({
endpoint,
snippet: request,
clientVariableName: options?.clientVariableName
});
// The caller supplies the client and terminates the statement themselves, so the
// invocation is emitted as a bare expression. When the call references SDK types (e.g.
// an enum or an aliased request value) the imports it needs are surfaced separately so
// the caller can render them rather than falling back to the complete snippet. Python
// statements have no terminator, so no trailing character needs stripping.
const { code, imports } = python.renderNodeWithoutImports({
node: invocation,
modulePath: SNIPPET_MODULE_PATH
});
return {
snippet: code.trim(),
imports,
clientName: this.context.getRootClientClassName(),
errors: this.context.errors.empty() ? undefined : this.context.errors.toDynamicSnippetErrors()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

this.context.errors is shared state on the generator context; if the caller reuses the same context across endpoints this will report errors accumulated from previous calls. Check that the surrounding generateInvocationSync wrapper scopes/clears errors the same way generateSnippetSync does.

};
}

private buildPythonFile({
endpoint,
snippet
Expand Down Expand Up @@ -443,13 +486,15 @@ export class EndpointSnippetGenerator {

private callMethod({
endpoint,
snippet
snippet,
clientVariableName
}: {
endpoint: FernIr.dynamic.Endpoint;
snippet: FernIr.dynamic.EndpointSnippetRequest;
clientVariableName?: string;
}): python.AstNode {
return python.invokeMethod({
on: python.reference({ name: CLIENT_VAR_NAME }),
on: python.reference({ name: clientVariableName ?? CLIENT_VAR_NAME }),
method: this.getMethod({ endpoint }),
arguments_: this.getMethodArgs({ endpoint, snippet })
.filter((arg) => !python.TypeInstantiation.isNop(arg.value))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { AbsoluteFilePath, join } from "@fern-api/path-utils";

import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator.js";
import { buildGeneratorConfig } from "./utils/buildGeneratorConfig.js";

const DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY = AbsoluteFilePath.of(
`${__dirname}/../../../../../packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions`
);

// invocation-only snippets are rendered inside code the caller already owns (e.g. a
// documentation code template), so they must not include imports or client instantiation
describe("invocation-only snippets", () => {
const generator = buildDynamicSnippetsGenerator({
irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "exhaustive.json")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

join() already returns an AbsoluteFilePath when given one; the extra AbsoluteFilePath.of(...) wrapper is redundant.

Suggested change
irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "exhaustive.json")),
irFilepath: join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, RelativeFilePath.of("exhaustive.json")),

(adjust the import if you take this — otherwise just drop the AbsoluteFilePath.of wrapper.)

config: buildGeneratorConfig({})
});

const request = {
endpoint: {
method: "GET" as const,
path: "/http-methods/{id}"
},
baseURL: undefined,
environment: undefined,
auth: {
type: "bearer" as const,
token: "<YOUR_API_KEY>"
},
pathParameters: {
id: "id"
},
queryParameters: undefined,
headers: undefined,
requestBody: undefined
};

it("generates the invocation without imports or client instantiation", () => {
const response = generator.generateInvocationSync(request);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

The tests call generator.generateInvocationSync(...), but the only new public method in the diff is EndpointSnippetGenerator.generateInvocationSnippetSync. No change to DynamicSnippetsGenerator is included — is that wiring inherited from the base class added in #17393, or is it missing from this PR? Worth confirming, otherwise these tests can't be green on main.


expect(response?.snippet).toBe('client.endpoints.http_methods.test_get(\n id="id",\n)');
expect(response?.imports).toBe("");
expect(response?.errors).toBeUndefined();
});

it("exposes the generated client class name so docs can render the client instantiation", () => {
const response = generator.generateInvocationSync(request);

expect(response?.clientName).toBe("Acme");
});

it("invokes the endpoint on the requested client variable", () => {
const response = generator.generateInvocationSync(request, { clientVariableName: "mailchimp" });

expect(response?.snippet).toBe('mailchimp.endpoints.http_methods.test_get(\n id="id",\n)');
});

it("returns the imports the invocation references instead of falling back to the full snippet", () => {
// This body references stdlib types the call constructs inline (a datetime and a UUID),
// so the invocation must carry `import datetime` / `import uuid`. The previous
// invocation-only contract had no way to express this; now the imports are surfaced
// separately so docs can regenerate both the call and the imports it needs.
const response = generator.generateInvocationSync({
endpoint: {
method: "POST" as const,
path: "/object/get-and-return-with-optional-field"
},
baseURL: undefined,
environment: undefined,
auth: {
type: "bearer" as const,
token: "<YOUR_API_KEY>"
},
pathParameters: undefined,
queryParameters: undefined,
headers: undefined,
requestBody: {
string: "string",
integer: 1,
long: 1000000,
double: 1.1,
bool: true,
datetime: "2024-01-15T09:30:00Z",
date: "2023-01-15",
uuid: "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32",
base64: "SGVsbG8gd29ybGQh",
list: ["list", "list"],
set: ["set"],
map: { 1: "map" },
bigint: "1000000"
}
});

expect(response).not.toBeUndefined();
expect(response?.snippet).toContain("datetime.datetime.fromisoformat");
expect(response?.snippet).toContain("uuid.UUID");
expect(response?.imports).toContain("import datetime");
expect(response?.imports).toContain("import uuid");
expect(response?.errors).toBeUndefined();
});
});
Loading