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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docker/seed/Dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ RUN corepack prepare yarn@1.22.22

RUN pnpm add -g typescript@~5.7.2 \
prettier@3.7.4 \
oxfmt@0.27.0 \
oxfmt@0.35.0 \
@biomejs/biome@2.4.3 \
oxlint@1.42.0 \
oxlint-tsgolint@0.11.4 \
oxlint@1.50.0 \
oxlint-tsgolint@0.14.2 \
@types/node@^18.19.70 \
webpack@^5.97.1 \
msw@2.11.2 \
Expand Down
1 change: 1 addition & 0 deletions generators/csharp/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@fern-fern/ir-sdk": "^62.3.0",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"eta": "^4.5.1",
"lodash-es": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
Expand Down
42 changes: 23 additions & 19 deletions generators/csharp/base/src/project/CsharpProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { AbstractProject, FernGeneratorExec, File, SourceFetcher } from "@fern-a
import { Generation, WithGeneration } from "@fern-api/csharp-codegen";
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";
import { loggingExeca } from "@fern-api/logging-execa";
import { Eta } from "eta";
import { access, mkdir, readFile, unlink, writeFile } from "fs/promises";
import { template } from "lodash-es";
import path from "path";
import { AsIsFiles } from "../AsIs.js";
import { GeneratorContext } from "../context/GeneratorContext.js";
import { findDotnetToolPath } from "../findDotNetToolPath.js";
import { CSharpFile } from "./CSharpFile.js";

const eta = new Eta({ autoEscape: false, useWith: true, autoTrim: false });

export const CORE_DIRECTORY_NAME = "Core";
export const PUBLIC_CORE_DIRECTORY_NAME = "Public";
/**
Expand Down Expand Up @@ -285,22 +287,24 @@ export class CsharpProject extends AbstractProject<GeneratorContext> {
}

const githubWorkflowTemplate = (await readFile(getAsIsFilepath(AsIsFiles.CiYaml))).toString();
const githubWorkflow = template(githubWorkflowTemplate)({
projectName: this.name,
libraryPath: path.posix.join(libraryPath, this.name),
libraryProjectFilePath: path.posix.join(libraryPath, this.name, `${this.name}.csproj`),
testProjectFilePath: path.posix.join(
testPath,
this.names.files.testProject,
`${this.names.files.testProject}.csproj`
),
shouldWritePublishBlock: this.context.publishConfig != null,
nugetTokenEnvvar:
this.context.publishConfig?.apiKeyEnvironmentVariable == null ||
this.context.publishConfig?.apiKeyEnvironmentVariable === ""
? "NUGET_API_TOKEN"
: this.context.publishConfig.apiKeyEnvironmentVariable
}).replaceAll("\\{", "{");
const githubWorkflow = eta
.renderString(githubWorkflowTemplate, {
projectName: this.name,
libraryPath: path.posix.join(libraryPath, this.name),
libraryProjectFilePath: path.posix.join(libraryPath, this.name, `${this.name}.csproj`),
testProjectFilePath: path.posix.join(
testPath,
this.names.files.testProject,
`${this.names.files.testProject}.csproj`
),
shouldWritePublishBlock: this.context.publishConfig != null,
nugetTokenEnvvar:
this.context.publishConfig?.apiKeyEnvironmentVariable == null ||
this.context.publishConfig?.apiKeyEnvironmentVariable === ""
? "NUGET_API_TOKEN"
: this.context.publishConfig.apiKeyEnvironmentVariable
})
.replaceAll("\\{", "{");
const ghDir = join(this.absolutePathToOutputDirectory, RelativeFilePath.of(".github/workflows"));
await mkdir(ghDir, { recursive: true });
await writeFile(join(ghDir, RelativeFilePath.of("ci.yml")), githubWorkflow);
Expand Down Expand Up @@ -436,7 +440,7 @@ dotnet_diagnostic.IDE0005.severity = error
const testCsProjTemplateContents = (
await readFile(getAsIsFilepath(AsIsFiles.Test.TemplateTestCsProj))
).toString();
const testCsProjContents = template(testCsProjTemplateContents)({
const testCsProjContents = eta.renderString(testCsProjTemplateContents, {
projectName: this.name,
testProjectName
});
Expand Down Expand Up @@ -699,7 +703,7 @@ dotnet_diagnostic.IDE0005.severity = error
}

function replaceTemplate({ contents, variables }: { contents: string; variables: Record<string, unknown> }): string {
return template(contents)(variables);
return eta.renderString(contents, variables);
}

function getAsIsFilepath(filename: string): string {
Expand Down
11 changes: 11 additions & 0 deletions generators/csharp/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 2.22.3
changelogEntry:
- summary: |
Replace lodash-es template engine with Eta for processing template files.
Eta is a modern, lightweight, TypeScript-native engine with zero dependencies
that uses the same `<% %>` / `<%= %>` syntax. This resolves crashes when
template files contain backticks (template literals).
type: fix
createdAt: "2026-02-24"
irVersion: 62

- version: 2.22.2
changelogEntry:
- summary: |
Expand Down
11 changes: 11 additions & 0 deletions generators/python/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
# For unreleased changes, use unreleased.yml
- version: 4.59.2
changelogEntry:
- summary: |
Generate an appropriate `client.py` file when `client.exported_filename` differs from `client.filename`.
Previously, the `__init__.py` would import from the exported filename (e.g., `client.py`) but
that file was never created, causing mypy `import-not-found` errors.
type: fix
createdAt: "2026-02-20"
irVersion: 65

- version: 4.59.1
changelogEntry:
- summary: Fix wire test imports to respect package_name custom config, preventing import errors when users specify custom package names
type: fix
createdAt: "2026-02-23"
irVersion: 65

- version: 4.59.0
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import List
from dataclasses import dataclass, field
from typing import List, Optional

from fern_python.codegen import AST
from fern_python.generators.sdk.core_utilities.client_wrapper_generator import (
Expand All @@ -11,6 +11,7 @@
class RootClient:
class_reference: AST.ClassReference
parameters: List[ConstructorParameter]
init_parameters: Optional[List[ConstructorParameter]] = field(default=None)


@dataclass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def __init__(
# like os.getenv("...").
use_kwargs_snippets=(has_inferred_auth or is_oauth_client_credentials),
base_url_example_value=base_url_example_value,
sync_init_parameters=self._get_constructor_parameters(is_async=False),
async_init_parameters=self._get_constructor_parameters(is_async=True),
)
self._generated_root_client = root_client_builder.build()

Expand Down Expand Up @@ -1565,11 +1567,19 @@ def __init__(
oauth_token_override: bool = False,
use_kwargs_snippets: bool = False,
base_url_example_value: Optional[AST.Expression] = None,
sync_init_parameters: Optional[Sequence[ConstructorParameter]] = None,
async_init_parameters: Optional[Sequence[ConstructorParameter]] = None,
):
self._module_path = module_path
self._class_name = class_name
self._async_class_name = async_class_name
self._constructor_parameters: List[ConstructorParameter] = list(constructor_parameters)
self._sync_init_parameters: Optional[List[ConstructorParameter]] = (
list(sync_init_parameters) if sync_init_parameters is not None else None
)
self._async_init_parameters: Optional[List[ConstructorParameter]] = (
list(async_init_parameters) if async_init_parameters is not None else None
)
self._oauth_token_override = oauth_token_override
self._use_kwargs_snippets = use_kwargs_snippets
self._base_url_example_value = base_url_example_value
Expand Down Expand Up @@ -1726,7 +1736,15 @@ def build_default_snippet_kwargs() -> List[typing.Tuple[str, AST.Expression]]:

return GeneratedRootClient(
async_instantiations=async_instantiations,
async_client=RootClient(class_reference=async_class_reference, parameters=self._constructor_parameters),
async_client=RootClient(
class_reference=async_class_reference,
parameters=self._constructor_parameters,
init_parameters=self._async_init_parameters,
),
sync_instantiations=sync_instantiations,
sync_client=RootClient(class_reference=sync_class_reference, parameters=self._constructor_parameters),
sync_client=RootClient(
class_reference=sync_class_reference,
parameters=self._constructor_parameters,
init_parameters=self._sync_init_parameters,
),
)
102 changes: 101 additions & 1 deletion generators/python/src/fern_python/generators/sdk/sdk_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Literal, Optional, Sequence, Tuple, Union, cast

from .client_generator.client_generator import ClientGenerator
from .client_generator.generated_root_client import GeneratedRootClient
from .client_generator.generated_root_client import GeneratedRootClient, RootClient
from .client_generator.inferred_auth_token_provider_generator import InferredAuthTokenProviderGenerator
from .client_generator.oauth_token_provider_generator import OAuthTokenProviderGenerator
from .client_generator.raw_client_generator import RawClientGenerator
Expand Down Expand Up @@ -250,6 +250,17 @@ def run(
oauth_scheme=oauth_scheme,
)

# If exported_filename differs from filename, generate an inheritance-based wrapper
actual_filename = custom_config.client_filename or custom_config.client.filename
if custom_config.client.exported_filename != actual_filename:
self._generate_exported_client_wrapper(
context=context,
custom_config=custom_config,
project=project,
generated_root_client=generated_root_client,
generator_exec_wrapper=generator_exec_wrapper,
)

# Since you can customize the client export, we handle it here to capture the generated
# and non-generated cases. If we were to base this off exporting the class declaration
# we would have to handle the case where the exported client is not generated.
Expand Down Expand Up @@ -558,6 +569,95 @@ def _generate_root_client(
project.write_source_file(source_file=raw_client_source_file, filepath=raw_client_filepath)
return generated_root_client

def _generate_exported_client_wrapper(
self,
context: SdkGeneratorContext,
custom_config: SDKCustomConfig,
project: Project,
generated_root_client: GeneratedRootClient,
generator_exec_wrapper: GeneratorExecWrapper,
) -> None:
exported_module = custom_config.client.exported_filename.removesuffix(".py")
exported_sync_class = context.get_class_name_for_exported_root_client()
exported_async_class = "Async" + exported_sync_class

filepath = Filepath(
directories=(),
file=Filepath.FilepathPart(module_name=exported_module),
)
source_file = context.source_file_factory.create(
project=project, filepath=filepath, generator_exec_wrapper=generator_exec_wrapper
)

generated_filepath = context.get_filepath_for_generated_root_client()
generated_sync_name = context.get_class_name_for_generated_root_client()
generated_async_name = "Async" + generated_sync_name

sync_base_class_ref = AST.ClassReference(
import_=AST.ReferenceImport(
module=generated_filepath.to_module(),
named_import=generated_sync_name,
),
qualified_name_excluding_import=(),
)
async_base_class_ref = AST.ClassReference(
import_=AST.ReferenceImport(
module=generated_filepath.to_module(),
named_import=generated_async_name,
),
qualified_name_excluding_import=(),
)

sync_class = self._create_wrapper_class_declaration(
class_name=exported_sync_class,
base_class_ref=sync_base_class_ref,
root_client=generated_root_client.sync_client,
)
async_class = self._create_wrapper_class_declaration(
class_name=exported_async_class,
base_class_ref=async_base_class_ref,
root_client=generated_root_client.async_client,
)

source_file.add_class_declaration(declaration=sync_class, should_export=True)
source_file.add_class_declaration(declaration=async_class, should_export=True)

project.write_source_file(source_file=source_file, filepath=filepath)

@staticmethod
def _create_wrapper_class_declaration(
*,
class_name: str,
base_class_ref: AST.ClassReference,
root_client: "RootClient",
) -> AST.ClassDeclaration:
params = root_client.init_parameters if root_client.init_parameters is not None else root_client.parameters

named_params = [
AST.NamedFunctionParameter(
name=param.constructor_parameter_name,
type_hint=param.type_hint,
initializer=param.initializer,
)
for param in params
]

def write_super_init(writer: AST.NodeWriter) -> None:
writer.write_line("super().__init__(")
with writer.indent():
for param in params:
writer.write_line(f"{param.constructor_parameter_name}={param.constructor_parameter_name},")
writer.write_line(")")

return AST.ClassDeclaration(
name=class_name,
extends=[base_class_ref],
constructor=AST.ClassConstructor(
signature=AST.FunctionSignature(named_parameters=named_params),
body=AST.CodeWriter(write_super_init),
),
)

def _generate_subpackage_client(
self,
context: SdkGeneratorContext,
Expand Down
Loading
Loading