Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
87 changes: 79 additions & 8 deletions generators/python/core_utilities/shared/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,75 @@ def __init__(self, *, alias: str) -> None:
self.alias = alias


# Resolving type hints (typing.get_type_hints) is expensive because it eval/compiles
# forward-reference annotations. The result is constant for a given type, so we cache it.
# This is critical for hot paths like SSE event parsing, where the same (often large
# discriminated-union) type is converted on every single event.
_type_hints_cache: typing.Dict[typing.Any, typing.Dict[str, typing.Any]] = {}


def _get_cached_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]:
try:
cached = _type_hints_cache.get(expected_type)
except TypeError:
# Unhashable type; resolve without caching.
return _resolve_type_hints(expected_type)
if cached is None:
cached = _resolve_type_hints(expected_type)
_type_hints_cache[expected_type] = cached
return cached


def _resolve_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]:
try:
return typing_extensions.get_type_hints(expected_type, include_extras=True)
except NameError:
# The type contains a circular reference, so we use the __annotations__ attribute directly.
return getattr(expected_type, "__annotations__", {})


# Whether convert_and_respect_annotation_metadata can possibly rewrite anything for a given
# annotation, i.e. whether any reachable model/TypedDict field carries a FieldMetadata alias.
# This is constant per type, so we cache it and use it to short-circuit the recursive walk.
_requires_conversion_cache: typing.Dict[typing.Any, bool] = {}


def _requires_conversion(type_: typing.Any) -> bool:
try:
cached = _requires_conversion_cache.get(type_)
except TypeError:
# Unhashable annotation; compute without caching.
return _compute_requires_conversion(type_, set())
if cached is None:
cached = _compute_requires_conversion(type_, set())
_requires_conversion_cache[type_] = cached
return cached


def _compute_requires_conversion(type_: typing.Any, seen: typing.Set[typing.Any]) -> bool:
clean_type = _remove_annotations(type_)

try:
if clean_type in seen:
return False
seen = seen | {clean_type}
except TypeError:
# Unhashable type; skip cycle tracking (the type graph is finite in practice).
pass

# Models / TypedDicts: a field alias here means we must dealias; otherwise recurse into fields.
if (inspect.isclass(clean_type) and issubclass(clean_type, pydantic.BaseModel)) or typing_extensions.is_typeddict(
clean_type
):
annotations = _get_cached_type_hints(clean_type)
if _get_alias_to_field_name(annotations):
return True
return any(_compute_requires_conversion(hint, seen) for hint in annotations.values())

# Containers / unions: recurse into the type arguments (List/Set/Sequence/Dict/Union/etc.).
return any(_compute_requires_conversion(arg, seen) for arg in typing_extensions.get_args(clean_type))


def convert_and_respect_annotation_metadata(
*,
object_: typing.Any,
Expand Down Expand Up @@ -55,6 +124,13 @@ def convert_and_respect_annotation_metadata(
return None
if inner_type is None:
inner_type = annotation
# The only thing this function ever rewrites is keys that carry a FieldMetadata
# alias. If nothing in the (cached) type graph has such an alias, the conversion is
# a content-identity transform, so we can skip the entire recursive walk. This is
# the hot path for SSE streaming, where a large discriminated union would otherwise
# be traversed on every single event.
if not _requires_conversion(annotation):
return object_

clean_type = _remove_annotations(inner_type)
# Pydantic models
Expand Down Expand Up @@ -158,12 +234,7 @@ def _convert_mapping(
direction: typing.Literal["read", "write"],
) -> typing.Mapping[str, object]:
converted_object: typing.Dict[str, object] = {}
try:
annotations = typing_extensions.get_type_hints(expected_type, include_extras=True)
except NameError:
# The TypedDict contains a circular reference, so
# we use the __annotations__ attribute directly.
annotations = getattr(expected_type, "__annotations__", {})
annotations = _get_cached_type_hints(expected_type)
aliases_to_field_names = _get_alias_to_field_name(annotations)
for key, value in object_.items():
if direction == "read" and key in aliases_to_field_names:
Expand Down Expand Up @@ -219,12 +290,12 @@ def _remove_annotations(type_: typing.Any) -> typing.Any:


def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]:
annotations = typing_extensions.get_type_hints(type_, include_extras=True)
annotations = _get_cached_type_hints(type_)
return _get_alias_to_field_name(annotations)


def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]:
annotations = typing_extensions.get_type_hints(type_, include_extras=True)
annotations = _get_cached_type_hints(type_)
return _get_field_to_alias_name(annotations)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Fix TypedDict alias generation when `use_typeddict_requests: true` so that
container element types (`List[T]`, `Dict[K, V]`, `Set[T]`) and direct aliases
(`Alias = T`) reach for the request-side `TParams` variant rather than the
Pydantic model `T`. Without this, request parameters typed as such aliases
rejected dict-literal values at type-check time even though the runtime accepted
them.

Note: list-typed alias bodies now resolve to `typing.Sequence[…]` instead of
`typing.List[…]` under `use_typeddict_requests`, consistent with how endpoint
parameters are already typed. Existing call sites that pass lists continue to
work.
type: fix
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Improve SSE event parsing and deserialization performance by caching resolved type hints
and short-circuiting `convert_and_respect_annotation_metadata` when a type has no aliased
fields. Previously every call recomputed `typing.get_type_hints` and walked the entire type
graph, which was very expensive for streams parsing many events against large discriminated
unions. Output is unchanged.
type: fix
28 changes: 28 additions & 0 deletions generators/python/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 5.14.6
changelogEntry:
- summary: |
Improve SSE event parsing and deserialization performance by caching resolved type hints
and short-circuiting `convert_and_respect_annotation_metadata` when a type has no aliased
fields. Previously every call recomputed `typing.get_type_hints` and walked the entire type
graph, which was very expensive for streams parsing many events against large discriminated
unions. Output is unchanged.
type: fix
createdAt: "2026-05-28"
irVersion: 67
- version: 5.14.5
changelogEntry:
- summary: |
Fix TypedDict alias generation when `use_typeddict_requests: true` so that
container element types (`List[T]`, `Dict[K, V]`, `Set[T]`) and direct aliases
(`Alias = T`) reach for the request-side `TParams` variant rather than the
Pydantic model `T`. Without this, request parameters typed as such aliases
rejected dict-literal values at type-check time even though the runtime accepted
them.

Note: list-typed alias bodies now resolve to `typing.Sequence[…]` instead of
`typing.List[…]` under `use_typeddict_requests`, consistent with how endpoint
parameters are already typed. Existing call sites that pass lists continue to
work.
type: fix
createdAt: "2026-05-28"
irVersion: 67
- version: 5.14.4
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def __init__(
docs=docs,
snippet=snippet,
)
# Resolve the alias body with request-side semantics so that container
# element types (List[T], Dict[K, V], Set[T]) reach for the TypedDict
# `TParams` variant rather than the Pydantic model `T`.
self._type_hint = self._context.get_type_hint_for_type_reference(self._alias.alias_of, in_endpoint=True)

def generate(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@
"docs": "Plant created"
},
"v2Examples": {
"autogeneratedExamples": {
"autogeneratedExamples": {},
"userSpecifiedExamples": {
"A fern plant_createPlantExample_201": {
"displayName": "A fern plant",
"request": {
Expand All @@ -355,8 +356,7 @@
}
}
}
},
"userSpecifiedExamples": {}
}
},
"v2Responses": {
"responses": [
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/auth/src/orgs/checkOrganizationMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export type OrganizationCheckResult =

export async function checkOrganizationMembership({
organization,
token
token,
headers
}: {
organization: string;
token: FernUserToken;
headers?: Record<string, string>;
}): Promise<OrganizationCheckResult> {
const venus = createVenusService({ token: token.value });
const venus = createVenusService({ token: token.value, headers });

// First check if the user is a member of the organization.
const isMemberResponse = await venus.organization.isMember(organization);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { getOrganizationNameValidationError } from "./getOrganizationNameValidat
export async function createOrganizationIfDoesNotExist({
organization,
token,
context
context,
headers
}: {
organization: string;
token: FernUserToken;
context: TaskContext;
headers?: Record<string, string>;
}): Promise<boolean> {
const venus = createVenusService({ token: token.value });
const venus = createVenusService({ token: token.value, headers });
const getOrganizationResponse = await venus.organization.get(organization);

if (getOrganizationResponse.ok) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/cli-v2/src/auth/TokenService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ export class TokenService {
// Tracks whether or not we've already performed the migration.
private migrationPromise: Promise<void> | null = null;

constructor({ credential }: { credential: CredentialStore }) {
constructor({ credential, headers }: { credential: CredentialStore; headers?: Record<string, string> }) {
this.credential = credential;

const loader = new FernRcSchemaLoader();
this.accountManager = new FernRcAccountManager({ loader });
this.migrator = new LegacyTokenMigrator({ loader });
this.migrator = new LegacyTokenMigrator({ loader, headers });
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/cli-v2/src/commands/auth/token/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ export class TokenCommand {
await createOrganizationIfDoesNotExist({
organization: orgId,
token,
context: new TaskContextAdapter({ context })
context: new TaskContextAdapter({ context }),
headers: context.headers
});
}

const venus = createVenusService({ token: token.value });
const venus = createVenusService({ token: token.value, headers: context.headers });
const response = await venus.registry.generateRegistryTokens({
organizationId: orgId
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createFdrService } from "@fern-api/core";
import { buildPreviewDomain, isPreviewUrl as isPreviewUrlUtil } from "@fern-api/docs-preview";
import { CliError } from "@fern-api/task-context";

import chalk from "chalk";
import type { Argv } from "yargs";
import type { Context } from "../../../../context/Context.js";
Expand Down Expand Up @@ -30,7 +29,7 @@ export class DeleteCommand {
}

const token = await context.getTokenOrPrompt();
const fdr = createFdrService({ token: token.value });
const fdr = createFdrService({ token: token.value, headers: context.headers });

context.stderr.debug(`Deleting preview site: ${resolvedUrl}`);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Logger } from "@fern-api/logger";
import { CliError } from "@fern-api/task-context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CreateCommand } from "../command.js";

vi.mock("@fern-api/auth", () => ({
createOrganizationIfDoesNotExist: vi.fn(),
getOrganizationNameValidationError: vi.fn().mockReturnValue(null)
}));

vi.mock("../../../../ui/withSpinner.js", () => ({
withSpinner: vi.fn(({ operation }: { operation: () => Promise<unknown> }) => operation())
}));

vi.mock("../../../../context/adapter/TaskContextAdapter.js", () => ({
TaskContextAdapter: vi.fn()
}));

function createMockLogger(): Logger {
return {
disable: vi.fn(),
enable: vi.fn(),
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn()
};
}

function createMockContext(tokenType: "user" | "organization" = "user") {
return {
stdout: createMockLogger(),
stderr: createMockLogger(),
headers: { "X-Request-Id": "test-request-id" },
getTokenOrPrompt: vi.fn().mockResolvedValue({ type: tokenType, value: "test-token" })
} as unknown as import("../../../../context/Context.js").Context;
}

describe("CreateCommand", () => {
let cmd: CreateCommand;

beforeEach(() => {
vi.clearAllMocks();
cmd = new CreateCommand();
});

it("should create an organization successfully", async () => {
const { createOrganizationIfDoesNotExist } = await import("@fern-api/auth");
vi.mocked(createOrganizationIfDoesNotExist).mockResolvedValue(true);

const context = createMockContext();
await cmd.handle(context, { name: "acme" } as CreateCommand.Args);

expect(createOrganizationIfDoesNotExist).toHaveBeenCalledWith(
expect.objectContaining({ organization: "acme" })
);
expect(context.stderr.info).toHaveBeenCalledWith(expect.stringContaining('Created organization "acme"'));
});

it("should show message when organization already exists", async () => {
const { createOrganizationIfDoesNotExist } = await import("@fern-api/auth");
vi.mocked(createOrganizationIfDoesNotExist).mockResolvedValue(false);

const context = createMockContext();
await cmd.handle(context, { name: "acme" } as CreateCommand.Args);

expect(context.stderr.info).toHaveBeenCalledWith(expect.stringContaining('Organization "acme" already exists'));
});

it("should reject organization tokens", async () => {
const context = createMockContext("organization");

await expect(cmd.handle(context, { name: "acme" } as CreateCommand.Args)).rejects.toThrow(CliError);

expect(context.stderr.error).toHaveBeenCalledWith(
expect.stringContaining("Organization tokens cannot create organizations")
);
});

it("should throw on invalid organization name", async () => {
const { getOrganizationNameValidationError } = await import("@fern-api/auth");
vi.mocked(getOrganizationNameValidationError).mockReturnValue("Name must not contain spaces");

const context = createMockContext();
await expect(cmd.handle(context, { name: "bad name" } as CreateCommand.Args)).rejects.toThrow(CliError);
});
});
3 changes: 2 additions & 1 deletion packages/cli/cli-v2/src/commands/org/create/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export class CreateCommand {
createOrganizationIfDoesNotExist({
organization: args.name,
token,
context: new TaskContextAdapter({ context })
context: new TaskContextAdapter({ context }),
headers: context.headers
})
});

Expand Down
Loading
Loading