//! Rendering the IR to Python source files with minijinja.
//!
//! Three layers: Rust computes the semantics (resolved type expressions, the
//! exact import block, per-field annotations and defaults); minijinja templates
//! (`templates/`) lay out the file shape — e.g. `class_body.py.j2` arranges a
//! pydantic model's docstring, fields, and config block, applying the Fern
//! blank-line rules `ruff` does not impose; and `ruff format` (see [`crate::pyfmt`])
//! handles the line wrapping. The template layout is covered directly by render
//! tests (see this module's `tests`), so it iterates without the full pipeline.
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use minijinja::{context, Environment};
use serde::Serialize;
use crate::error::{Error, Result};
use crate::ir::{
is_json_like_media_type, Auth, BodyField, BodySchemaShape, ClientPathParameter, Credential,
Endpoint, EndpointPagination, ErrorClass, Field, GlobalHeader, HeaderType, Ir, ObjectType,
PageAdvance, Prim, QueryParam, RequestBody, StreamProtocol, TagTypeDecl, TypeDecl, TypeRef,
};
use crate::naming;
use crate::settings::{EnumType, ExtraFields, Layout};
use crate::wrap::Doc;
mod xml;
/// The header comment crozier writes atop every generated file. It differs from
/// Fern's by design; the comment-stripping normalizer removes it before any
/// comparison, so the wording is free.
const HEADER: &str = "# This file was auto-generated by crozier from an OpenAPI document.";
/// Whether a string a YAML document supplied as an example would have been
/// resolved to a *timestamp* rather than to a string, had it been written
/// unquoted. Fern's YAML parser follows YAML 1.1 here, so an unquoted
/// `2022-01-23T19:00:00.000Z` reaches its example generator as a date and is not
/// a value a plain `str` field can take — VTEX writes its `dateRange` example
/// that way and its golden falls back to the field-name placeholder, while the
/// same document written as JSON keeps the string (probed at Fern 5.20.0 both
/// ways; not re-measured at the certified pair).
/// The quoting itself does not survive deserialization, so this reads the value.
fn yaml_resolves_as_timestamp(value: &str) -> bool {
let bytes = value.as_bytes();
// `yyyy-mm-dd`, then optionally `T`/space and at least `hh:mm`.
if bytes.len() < 10 {
return false;
}
let digits = |range: std::ops::Range<usize>| bytes[range].iter().all(u8::is_ascii_digit);
if !(digits(0..4) && bytes[4] == b'-' && digits(5..7) && bytes[7] == b'-' && digits(8..10)) {
return false;
}
match bytes.len() {
10 => true,
_ => {
matches!(bytes[10], b'T' | b't' | b' ')
&& bytes.len() >= 16
&& digits(11..13)
&& bytes[13] == b':'
&& digits(14..16)
}
}
}
/// Escape a spec-controlled value for embedding inside a double-quoted Python
/// string literal, so a `"`/`\`/newline in the input cannot break out of (or
/// inject into) the generated source. Ordinary values (URLs, header names) pass
/// through unchanged, so byte-exact output is unaffected.
fn escape_py_str(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// A generated file: a repo-relative path and its contents.
#[derive(Debug, Clone)]
pub struct GeneratedFile {
/// Path relative to the output root.
pub path: PathBuf,
/// File contents.
pub contents: String,
}
/// Python standard-library top-level modules crozier may import. Used to split
/// imports into Fern's two ordered groups (stdlib first, everything else second).
const STDLIB_MODULES: &[&str] = &[
"typing",
"datetime",
"uuid",
"importlib",
"enum",
"os",
"sys",
"json",
"base64",
"collections",
"decimal",
"abc",
"contextlib",
"logging",
];
/// The location of the file whose imports are being collected, relative to the
/// package root — it decides the relative path to a referenced generated type
/// (how many leading dots, and whether via a tag's `types/` subpackage).
#[derive(Clone, Default)]
enum RefLoc {
/// A generated file at the package root (`src/{pkg}/client.py` or
/// `src/{pkg}/raw_client.py`).
PackageRoot,
/// A module in the package-root `types/` package (`src/{pkg}/types/`).
#[default]
RootTypes,
/// A module in a tag's `types/` package (`src/{pkg}/{tag}/types/`).
TagTypes(String),
/// A tag client file (`src/{pkg}/{tag}/raw_client.py` or `client.py`).
Client(String),
/// The `errors/` package (`src/{pkg}/errors/`).
Errors,
}
/// How many directory segments a client module name holds. A module is normally
/// one (`videos`), but an `x-crozier-sdk-group-name` list nests it
/// (`catalogs/mcp_servers`), and the package root is zero.
fn module_depth(module: &str) -> usize {
if module.is_empty() {
0
} else {
module.split('/').count()
}
}
/// A module name as a dotted Python path (`catalogs/mcp_servers` ->
/// `catalogs.mcp_servers`).
fn module_path(module: &str) -> String {
module.replace('/', ".")
}
/// The last directory segment of a client module — the one its class names are
/// built from. Fern names a nested client after its own segment
/// (`catalogs/mcp_servers` -> `McpServersClient`), not after the whole path.
fn module_stem(module: &str) -> &str {
module.rsplit('/').next().unwrap_or(module)
}
/// Every client package the module list implies, child lists included: the modules
/// that own endpoints plus the *intermediate* packages a nested
/// `x-crozier-sdk-group-name` creates, which own none but still carry a client
/// exposing their children.
///
/// A package's children are ordered the way Fern writes them: those that own
/// endpoints first, in the order the document declares them, then the pure parents
/// sorted by name. `catalogs`, `internal` and `settings` land after `agents` …
/// `skills` in TrueForge's root client for exactly that reason.
fn module_children(endpoint_modules: &[String]) -> BTreeMap<String, Vec<String>> {
let owns_endpoints: BTreeSet<&str> = endpoint_modules.iter().map(String::as_str).collect();
let mut owning: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut parents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for module in endpoint_modules {
let mut path = String::new();
for segment in module.split('/') {
let parent = path.clone();
if !path.is_empty() {
path.push('/');
}
path.push_str(segment);
if owns_endpoints.contains(path.as_str()) {
let siblings = owning.entry(parent).or_default();
if !siblings.contains(&path) {
siblings.push(path.clone());
}
} else {
parents.entry(parent).or_default().insert(path.clone());
}
}
}
let mut children: BTreeMap<String, Vec<String>> = owning;
for (parent, pure) in parents {
children.entry(parent).or_default().extend(pure);
}
children
}
/// Parse an example literal as JSON, reading the Python spellings `True`, `False`
/// and `None` outside strings as JSON's: a field's example literal is rendered
/// for Python, and Zulip's `subscription_data` example carries `True` inside a
/// list of objects.
fn parse_example_value(example: &str) -> Option<serde_json::Value> {
if let Ok(value) = serde_json::from_str(example) {
return Some(value);
}
let mut json = String::with_capacity(example.len());
let mut in_string = false;
let mut escaped = false;
let mut rest = example;
while let Some(c) = rest.chars().next() {
if in_string {
json.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
rest = &rest[c.len_utf8()..];
continue;
}
let word = [("True", "true"), ("False", "false"), ("None", "null")]
.into_iter()
.find(|(python, _)| rest.starts_with(python));
if let Some((python, spelled)) = word {
json.push_str(spelled);
rest = &rest[python.len()..];
continue;
}
if c == '"' {
in_string = true;
}
json.push(c);
rest = &rest[c.len_utf8()..];
}
serde_json::from_str(&json).ok()
}
/// The value Fern substitutes for a `date-time` example it cannot read.
const FERN_DEFAULT_DATETIME: &str = "2024-01-15T09:30:00+00:00";
/// A declared `date-time` example as a worked call writes it: the instant it
/// names, in UTC, as `YYYY-MM-DDTHH:MM:SS+00:00` (a fraction of a second is
/// dropped). Measured against `fernapi/fern-python-sdk` 5.20.0 (at the certified
/// pair, Fergus's golden and the hand-written `unread-date-time-examples`
/// fixture re-verify an offset, a zone name and `Z`), Fern reads the value after
/// appending `Z` unless it already ends in `Z` or holds a `+`, so Fergus's
/// `2025-12-02T00:00:00+13:00` passes `2025-12-01 11:00:00+00:00`, a value with
/// no zone (`2023-03-04 05:06:07`, `2023-03-04`) is read as UTC and a day past
/// the month's end rolls into the next month. A value that reading rejects is
/// replaced by [`FERN_DEFAULT_DATETIME`]: a zone name (`… CDT`), but equally a
/// negative offset or a lower-case `z`, which the appended `Z` breaks. Every
/// value returned parses with `datetime.datetime.fromisoformat`.
fn datetime_example(value: &str) -> String {
let rewritten = if value.ends_with('Z') || value.contains('+') {
value.to_string()
} else {
format!("{value}Z")
};
let Some(seconds) = read_datetime(&rewritten) else {
return FERN_DEFAULT_DATETIME.to_string();
};
let (days, second_of_day) = (
seconds.div_euclid(86_400) + 719_468,
seconds.rem_euclid(86_400),
);
// `civil_from_days` (Howard Hinnant), from days since 0000-03-01.
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_index = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_index + 2) / 5 + 1;
let month = if month_index < 10 {
month_index + 3
} else {
month_index - 9
};
let year = year_of_era + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}+00:00",
second_of_day / 3600,
second_of_day / 60 % 60,
second_of_day % 60
)
}
/// The instant Fern reads from its rewritten `date-time` value, in whole seconds
/// since the Unix epoch, or `None` where it reads none. The grammar is the one
/// measured: a date alone or a date and a time (`T`, `t` or a space between, seconds
/// and a fraction optional, the fraction dropped), then `Z` or a `±HH:MM` or
/// `±HHMM` offset; any day up to 31, rolling past the month's end.
fn read_datetime(value: &str) -> Option<i64> {
let bytes = value.as_bytes();
let number = |range: std::ops::Range<usize>| value.get(range).and_then(number_of);
let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?);
if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
return None;
}
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let mut at = 10;
let (mut hour, mut minute, mut second) = (0, 0, 0);
if matches!(bytes.get(at), Some(b'T' | b't' | b' ')) {
hour = number(at + 1..at + 3)?;
minute = number(at + 4..at + 6)?;
if bytes.get(at + 3) != Some(&b':') {
return None;
}
at += 6;
if bytes.get(at) == Some(&b':') {
second = number(at + 1..at + 3)?;
at += 3;
if bytes.get(at) == Some(&b'.') {
let digits = bytes[at + 1..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digits == 0 {
return None;
}
at += 1 + digits;
}
}
}
if hour > 23 || minute > 59 || second > 59 {
return None;
}
let zone = value.get(at..)?;
let offset_minutes = if zone == "Z" {
0
} else {
let sign = match zone.as_bytes().first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let (hours, minutes) = match zone.len() {
6 if zone.as_bytes()[3] == b':' => (zone.get(1..3)?, zone.get(4..6)?),
5 => (zone.get(1..3)?, zone.get(3..5)?),
_ => return None,
};
let (hours, minutes) = (number_of(hours)?, number_of(minutes)?);
if hours > 23 || minutes > 59 {
return None;
}
sign * (hours * 60 + minutes)
};
// `days_from_civil` (Howard Hinnant); a day past the month's end simply
// counts on into the next month.
let shifted_year = if month <= 2 { year - 1 } else { year };
let era = shifted_year.div_euclid(400);
let year_of_era = shifted_year - era * 400;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
let days = era * 146_097 + day_of_era - 719_468;
Some(days * 86_400 + hour * 3600 + (minute - offset_minutes) * 60 + second)
}
/// `digits` as a number, when it is nothing but ASCII digits.
fn number_of(digits: &str) -> Option<i64> {
(!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
.then(|| digits.parse().ok())
.flatten()
}
/// Collects imports and renders them in Fern's order: group 1 is stdlib
/// (`import`s then `from`s), group 2 is everything else (`import`s then `from`s),
/// separated by a blank line. Names within a `from` and the statements within a
/// group are sorted.
#[derive(Default)]
struct Imports {
/// Plain `import module [as alias]`, keyed by module for sorting.
plain: BTreeMap<String, Option<String>>,
/// A multipart argument can shadow `json` in one method while another
/// method still needs the unaliased module in the same generated file.
json_module_alias: bool,
/// `from module import a, b`, module -> sorted names.
from: BTreeMap<String, BTreeSet<String>>,
/// This file's location, for resolving relative imports of generated types.
loc: RefLoc,
/// Names of tag-scoped (hoisted) types → their owning tag directory. A name
/// absent from this map is a package-root type.
tag_types: BTreeMap<String, String>,
/// Referenced type names that must render as string forward references
/// (`"TreeNode"`) because importing them eagerly would close an import cycle
/// (issue #84). A same-file self-reference needs no import at all; a
/// cross-module cycle import is deferred to [`Imports::deferred`].
forward: std::collections::HashSet<String>,
/// The module (file stem) of the type being emitted, so a forward reference to
/// a type in *this* file is recognised and left un-imported.
cur_module: String,
/// `from … import …` lines for forward references in *other* modules, emitted
/// after the class body (a deferred import) so the module loads without the
/// cycle firing at import time.
deferred: BTreeSet<String>,
/// First-registration order for deferred imports. Fern keeps direct field
/// references ahead of the additional namespace used to repair a cycle;
/// aliases still use [`Self::deferred`]'s sorted order under `TYPE_CHECKING`.
deferred_order: Vec<String>,
/// `(module, name)` → the local alias to import it under and to write at every
/// occurrence. Populated from [`Self::collision_aliases`] on a throwaway
/// first pass: two modules can supply the same simple name — Eozilla declares
/// a component schema called `ApiError` beside crozier's own core one — and
/// Python has no way to hold both, so Fern imports *both* under a
/// module-qualified alias (`core_api_error_ApiError`,
/// `types_api_error_ApiError`) rather than picking a winner.
aliases: BTreeMap<(String, String), String>,
}
impl Imports {
/// A fresh collector for a file at `loc`, aware of the tag-scoped type map so
/// references resolve to the right relative path.
fn at(loc: RefLoc, tag_types: &BTreeMap<String, String>) -> Self {
Imports {
loc,
tag_types: tag_types.clone(),
..Default::default()
}
}
/// The dotted prefix that reaches the generated package root from this file.
/// A module name may be a *nested* client path (`catalogs/mcp_servers`, from an
/// `x-crozier-sdk-group-name` list), so the dot count is measured from the
/// module's own depth rather than assumed to be one.
fn root_prefix(&self) -> String {
let depth = match &self.loc {
RefLoc::PackageRoot => 0,
RefLoc::RootTypes | RefLoc::Errors => 1,
RefLoc::Client(module) => module_depth(module),
RefLoc::TagTypes(module) => module_depth(module) + 1,
};
".".repeat(depth + 1)
}
/// The package-relative dotted module a generated type lives in —
/// `types.config`, or `{tag}.types.{module}` for a tag-scoped one — whatever
/// file imports it.
fn package_module_path(&self, class: &str) -> String {
let m = naming::module_name(class);
match self.tag_types.get(class) {
Some(tag) if !tag.is_empty() => format!("{}.types.{m}", module_path(tag)),
_ => format!("types.{m}"),
}
}
/// The relative module path a referenced generated type is imported from,
/// chosen from this file's [`RefLoc`] and whether the type is tag-scoped.
fn type_import_path(&self, class: &str) -> String {
let m = naming::module_name(class);
let root = self.root_prefix();
match self.tag_types.get(class) {
// A package-root type lives at `{pkg}.types.{m}`; so does a tag type
// whose owning tag is the package root itself.
Some(tag) if tag.is_empty() => match &self.loc {
RefLoc::RootTypes => format!(".{m}"),
_ => format!("{root}types.{m}"),
},
// A tag-scoped type lives at `{pkg}.{tag}.types.{m}`.
Some(tag) => match &self.loc {
RefLoc::TagTypes(cur) if cur == tag => format!(".{m}"),
RefLoc::Client(cur) if cur == tag => format!(".types.{m}"),
_ => format!("{root}{}.types.{m}", module_path(tag)),
},
// A package-root type lives at `{pkg}.types.{m}`.
None => match &self.loc {
RefLoc::RootTypes => format!(".{m}"),
_ => format!("{root}types.{m}"),
},
}
}
/// Register the import for a referenced generated type. A type flagged as a
/// forward reference (issue #84) is *not* imported eagerly: a same-file
/// reference needs no import, and a cross-module one is deferred to after the
/// class body so its cycle does not fire at import time.
fn add_type(&mut self, class: &str) -> String {
if self.forward.contains(class) {
if naming::module_name(class) != self.cur_module {
let path = self.type_import_path(class);
let line = format!("from {path} import {class}");
if self.deferred.insert(line.clone()) {
self.deferred_order.push(line);
}
}
return class.to_string();
}
let path = self.type_import_path(class);
self.add_from(&path, class);
self.local(&path, class)
}
/// The name `name` is written as in this file: itself, or the alias a
/// same-name collision forced on it.
fn local(&self, module: &str, name: &str) -> String {
self.aliases
.get(&(module.to_string(), name.to_string()))
.cloned()
.unwrap_or_else(|| name.to_string())
}
/// The name a `core` helper is written as, without importing it: the raw
/// clients import the response wrappers file by file (see
/// `raw_client_file`), so a method naming one only reads its alias.
fn core_local(&self, submodule: &str, name: &str) -> String {
self.local(&format!("{}.{submodule}", self.core_prefix()), name)
}
/// Register a `core` helper and return the name to write for it.
fn add_core_local(&mut self, submodule: &str, name: &str) -> String {
self.add_core(submodule, name);
self.local(&format!("{}.{submodule}", self.core_prefix()), name)
}
/// The aliases a second pass must use: every simple name this file would
/// import from more than one module, aliased in *all* of them (Fern renames
/// both sides, not just the later one).
fn collision_aliases(&self) -> BTreeMap<(String, String), String> {
let mut modules_by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (module, names) in &self.from {
for name in names {
modules_by_name
.entry(name.as_str())
.or_default()
.push(module.as_str());
}
}
let mut aliases = BTreeMap::new();
for (name, modules) in modules_by_name {
if modules.len() < 2 {
continue;
}
for module in modules {
let stem = module.trim_start_matches('.').replace('.', "_");
aliases.insert(
(module.to_string(), name.to_string()),
format!("{stem}_{name}"),
);
}
}
aliases
}
/// The dotted prefix that reaches the package-root `core` package from this
/// file's location. Most generated files sit one package below the root
/// (`{pkg}/types/`, `{pkg}/{tag}/`, `{pkg}/errors/`); a tag's `types/` is two
/// deep, and a nested client path (`{pkg}/catalogs/mcp_servers/`) is as deep as
/// its own segments, so the count comes from [`Imports::root_prefix`].
fn core_prefix(&self) -> String {
format!("{}core", self.root_prefix())
}
fn error_import_path(&self, module: &str) -> String {
format!("{}errors.{module}", self.root_prefix())
}
/// Register a `from {..}core.{submodule} import {name}` at the depth this
/// file's location requires — so `core.*` helpers resolve uniformly rather
/// than with a hardcoded dot count that only holds at the root nesting level
/// (issue #85).
fn add_core(&mut self, submodule: &str, name: &str) {
let module = format!("{}.{submodule}", self.core_prefix());
self.add_from(&module, name);
}
fn add_core_package(&mut self) {
let prefix = self.core_prefix();
let parent = prefix.trim_end_matches("core");
self.add_from(parent, "core");
}
fn add_plain(&mut self, module: &str) {
self.plain.entry(module.to_string()).or_insert(None);
}
fn add_plain_as(&mut self, module: &str, alias: &str) {
self.plain
.insert(module.to_string(), Some(alias.to_string()));
}
fn json_module(&mut self, shadowed: bool) -> &'static str {
if shadowed {
self.json_module_alias = true;
"_json"
} else {
self.add_plain("json");
"json"
}
}
fn add_from(&mut self, module: &str, name: &str) {
self.from
.entry(module.to_string())
.or_default()
.insert(name.to_string());
}
/// Register a `from module import name` and return the name to write for it.
fn add_from_local(&mut self, module: &str, name: &str) -> String {
self.add_from(module, name);
self.local(module, name)
}
/// Is a module (or a `from` path's leading segment) part of the stdlib group?
/// Relative imports (leading `.`) are never stdlib.
fn is_stdlib(module: &str) -> bool {
if module.starts_with('.') {
return false;
}
let top = module.split('.').next().unwrap_or(module);
STDLIB_MODULES.contains(&top)
}
fn render(&self) -> String {
let (mut g1, mut g2): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
// Fern orders generated modules naturally (`type9` before `type10`), while
// the maps provide deterministic collection and de-duplication.
let mut plain: Vec<_> = self
.plain
.iter()
.map(|(module, alias)| (module.as_str(), alias.as_deref()))
.collect();
if self.json_module_alias {
plain.push(("json", Some("_json")));
}
plain.sort_by(|(a, alias_a), (b, alias_b)| {
natural_cmp(a, b).then_with(|| alias_a.cmp(alias_b))
});
plain.dedup();
for (module, alias) in plain {
let line = match alias {
Some(a) => format!("import {module} as {a}"),
None => format!("import {module}"),
};
if Self::is_stdlib(module) {
g1.push(line);
} else {
g2.push(line);
}
}
// Then `from` imports within each group.
let mut from: Vec<_> = self.from.iter().collect();
from.sort_by(|(a, _), (b, _)| natural_cmp(a, b));
for (module, names) in from {
// An aliased name takes a line of its own after the module's plain
// names, as ruff's isort writes it (`combine-as-imports` off):
// MockServer's raw clients import `AsyncHttpResponse`, then
// `HttpResponse as core_http_response_HttpResponse`.
let (aliased, plain): (Vec<&String>, Vec<&String>) = names.iter().partition(|name| {
self.aliases
.contains_key(&((*module).clone(), (*name).clone()))
});
let mut lines = Vec::new();
if !plain.is_empty() {
let joined = plain
.iter()
.map(|name| name.as_str())
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("from {module} import {joined}"));
}
for name in aliased {
let alias = &self.aliases[&(module.clone(), name.clone())];
lines.push(format!("from {module} import {name} as {alias}"));
}
for line in lines {
if Self::is_stdlib(module) {
g1.push(line);
} else {
g2.push(line);
}
}
}
let groups: Vec<String> = [g1, g2]
.into_iter()
.filter(|g| !g.is_empty())
.map(|g| g.join("\n"))
.collect();
groups.join("\n\n")
}
}
/// Compare strings with digit runs treated as integers, matching Fern's import
/// ordering for generated names with numeric suffixes (`item9`, `item10`).
fn natural_cmp(left: &str, right: &str) -> Ordering {
let (mut left_index, mut right_index) = (0, 0);
let (left_bytes, right_bytes) = (left.as_bytes(), right.as_bytes());
while left_index < left_bytes.len() && right_index < right_bytes.len() {
let left_digit = left_bytes[left_index].is_ascii_digit();
let right_digit = right_bytes[right_index].is_ascii_digit();
if left_digit && right_digit {
let left_end = left_index
+ left_bytes[left_index..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
let right_end = right_index
+ right_bytes[right_index..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
let left_digits = &left[left_index..left_end];
let right_digits = &right[right_index..right_end];
let left_value = left_digits.trim_start_matches('0');
let right_value = right_digits.trim_start_matches('0');
let ordering = left_value
.len()
.cmp(&right_value.len())
.then_with(|| left_value.cmp(right_value))
.then_with(|| left_digits.len().cmp(&right_digits.len()));
if ordering != Ordering::Equal {
return ordering;
}
left_index = left_end;
right_index = right_end;
} else {
let ordering = left_bytes[left_index].cmp(&right_bytes[right_index]);
if ordering != Ordering::Equal {
return ordering;
}
left_index += 1;
right_index += 1;
}
}
left_bytes.len().cmp(&right_bytes.len())
}
fn example_import_cmp(left: &str, right: &str) -> Ordering {
let left_prefix = left.split_once('_').map(|(prefix, _)| prefix);
let right_prefix = right.split_once('_').map(|(prefix, _)| prefix);
// Two wrappers of one union (`PartsItem_Text`, `PartsItem_Data`) are
// ordered by isort below, as LangChain's `post_a2a` docstring imports them.
if left_prefix.is_some() && left_prefix == right_prefix {
return left
.to_ascii_lowercase()
.cmp(&right.to_ascii_lowercase())
.then_with(|| left.cmp(right));
}
if left_prefix.is_some_and(|prefix| right.starts_with(prefix)) {
return Ordering::Less;
}
if right_prefix.is_some_and(|prefix| left.starts_with(prefix)) {
return Ordering::Greater;
}
// isort orders the names case-insensitively, as Fern's examples do:
// `OcmGroupsCluster` comes before `OcmGroupUser`.
left.to_ascii_lowercase()
.cmp(&right.to_ascii_lowercase())
.then_with(|| left.cmp(right))
}
/// Render a resolved type to a [`Doc`] expression, registering needed imports.
/// The `Doc` renders flat; `ruff format` handles any line wrapping downstream.
fn render_type(t: &TypeRef, imports: &mut Imports) -> Doc {
match t {
TypeRef::Primitive(p) => match p {
Prim::Str => Doc::atom("str"),
Prim::Bytes => Doc::atom("bytes"),
Prim::Int | Prim::Long => Doc::atom("int"),
Prim::Float => Doc::atom("float"),
Prim::Bool => Doc::atom("bool"),
Prim::LiteralBool(value) => {
imports.add_plain("typing");
Doc::atom(format!(
"typing.Literal[{}]",
if *value { "True" } else { "False" }
))
}
Prim::Any => {
imports.add_plain("typing");
Doc::atom("typing.Any")
}
Prim::Datetime => {
imports.add_plain_as("datetime", "dt");
Doc::atom("dt.datetime")
}
Prim::Date => {
imports.add_plain_as("datetime", "dt");
Doc::atom("dt.date")
}
},
TypeRef::Named(class) => {
let local = imports.add_type(class);
// A forward reference (issue #84) renders as a quoted string so the
// annotation needs no eager import of a not-yet-defined name.
if imports.forward.contains(class) {
Doc::atom(format!("\"{class}\""))
} else {
Doc::atom(local)
}
}
TypeRef::Optional(inner) => {
imports.add_plain("typing");
Doc::group("typing.Optional[", vec![render_type(inner, imports)], "]")
}
TypeRef::Literal(values) => {
imports.add_plain("typing");
let items = values
.iter()
.map(|v| Doc::atom(format!("\"{v}\"")))
.collect();
Doc::group("typing.Literal[", items, "]")
}
TypeRef::List(inner) => {
imports.add_plain("typing");
Doc::group("typing.List[", vec![render_type(inner, imports)], "]")
}
TypeRef::Set(inner) => {
imports.add_plain("typing");
Doc::group("typing.Set[", vec![render_type(inner, imports)], "]")
}
TypeRef::Dict(k, v) => {
imports.add_plain("typing");
Doc::group(
"typing.Dict[",
vec![render_type(k, imports), render_type(v, imports)],
"]",
)
}
TypeRef::Union(variants) => {
imports.add_plain("typing");
let items = variants.iter().map(|v| render_type(v, imports)).collect();
Doc::group("typing.Union[", items, "]")
}
}
}
/// A field prepared for emission: its Python name, resolved annotation, the
/// `= <default>` suffix, and an optional docstring.
struct RenderedField {
py_name: String,
annotation: Doc,
default: String,
docstring: Option<String>,
}
/// The view model handed to `object.py.j2`. Rust computes the semantics (imports,
/// per-field annotation strings, defaults); the template lays out the class body
/// (field order, docstrings, the blank-line rules `ruff` does not impose) and
/// `ruff format` handles the line wrapping. This split keeps the byte-sensitive
/// logic in testable Rust while the file's *shape* lives in the template.
#[derive(Serialize)]
struct ObjectView {
header: &'static str,
imports: String,
class_name: String,
bases: String,
/// The class docstring's raw text (the template applies the `docstring` filter).
docstring: Option<String>,
fields: Vec<FieldView>,
/// The `pydantic.ConfigDict(...)` argument list for the v2 `model_config`
/// (`extra="allow", frozen=True`, or just `frozen=True` for `ignore`).
config_dict_args: String,
/// The v1 `Config`'s `pydantic.Extra.<extra_kind>` member (`allow`/`ignore`/
/// `forbid`).
extra_kind: &'static str,
/// Whether the module opens with `from __future__ import annotations` (an
/// XML model names its own class in its method signatures).
future: bool,
/// Methods rendered after the fields (an XML model's), class-body indented.
members: Option<String>,
/// Whether the v1 `Config` sets `copy_on_model_validation = "none"`.
copy_on_model_validation: bool,
}
/// One field as the object template consumes it: the flat declaration line
/// (`name: annotation = default`, wrapped later by `ruff`) and the raw docstring.
#[derive(Serialize)]
struct FieldView {
decl: String,
docstring: Option<String>,
}
/// Compute a field's annotation + default, registering imports.
fn render_field(field: &Field, imports: &mut Imports) -> RenderedField {
let inner = render_type(&field.type_ref, imports);
let typ = if field.optional && !matches!(field.type_ref, TypeRef::Optional(_)) {
imports.add_plain("typing");
Doc::group("typing.Optional[", vec![inner], "]")
} else {
inner
};
// Fern carries the wire alias in both its serialization metadata and a
// pydantic field marker. The former drives crozier's write-side converter;
// the latter makes direct pydantic construction/deserialization honor it.
let annotation = if field.needs_alias() {
imports.add_plain("pydantic");
imports.add_plain("typing_extensions");
imports.add_core("serialization", "FieldMetadata");
let pydantic_field = field.docstring.as_ref().map_or_else(
|| Doc::atom(format!("pydantic.Field(alias=\"{}\")", field.wire_name)),
|description| {
Doc::group(
"pydantic.Field(",
vec![
Doc::atom(format!("alias=\"{}\"", field.wire_name)),
Doc::atom(format!("description=\"{}\"", escape_py_str(description))),
],
")",
)
},
);
Doc::group(
"typing_extensions.Annotated[",
vec![
typ,
Doc::atom(format!("FieldMetadata(alias=\"{}\")", field.wire_name)),
pydantic_field,
],
"]",
)
} else {
typ
};
RenderedField {
py_name: field.py_name.clone(),
annotation,
default: field_default(field, imports),
docstring: field.docstring.clone(),
}
}
/// The `= <default>` suffix for a field. A documented field carries a
/// `pydantic.Field` (with `default=None` when optional); an undocumented
/// optional field defaults to `None`; a required undocumented field has none.
fn field_default(field: &Field, imports: &mut Imports) -> String {
// An aliased field already carries its pydantic.Field in Annotated. Adding a
// second Field as the default changes pydantic's metadata merge semantics and
// diverges from Fern; optional aliases use the ordinary None default.
if field.needs_alias() {
return if field.optional {
" = None".to_string()
} else {
String::new()
};
}
if field.docstring.is_some() {
imports.add_plain("pydantic");
if field.optional {
" = pydantic.Field(default=None)".to_string()
} else {
" = pydantic.Field()".to_string()
}
} else if field.optional {
" = None".to_string()
} else {
String::new()
}
}
/// Build the minijinja environment with the embedded templates.
fn environment() -> Environment<'static> {
let mut env = Environment::new();
env.set_keep_trailing_newline(true);
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Render a raw docstring into an indented, backslash-escaped triple-quoted
// block; templates apply it with `{{ text | docstring }}`.
env.add_filter("docstring", |text: String| render_docstring(&text, 4));
env.add_template("version.py", include_str!("../templates/version.py.j2"))
.expect("version template compiles");
env.add_template("object.py", include_str!("../templates/object.py.j2"))
.expect("object template compiles");
env.add_template(
"class_body.py",
include_str!("../templates/class_body.py.j2"),
)
.expect("class_body template compiles");
env.add_template("alias.py", include_str!("../templates/alias.py.j2"))
.expect("alias template compiles");
env.add_template(
"raw_client.py",
include_str!("../templates/raw_client.py.j2"),
)
.expect("raw_client template compiles");
env.add_template("error.py", include_str!("../templates/error.py.j2"))
.expect("error template compiles");
env.add_template("lazy_init.py", include_str!("../templates/lazy_init.py.j2"))
.expect("lazy_init template compiles");
env.add_template(
"reference_entry.md",
include_str!("../templates/reference_entry.md.j2"),
)
.expect("reference_entry template compiles");
env.add_template(
"root_client.py",
include_str!("../templates/root_client.py.j2"),
)
.expect("root_client template compiles");
env.add_template(
"client_class.py",
include_str!("../templates/client_class.py.j2"),
)
.expect("client_class template compiles");
env.add_template("file.py", include_str!("../templates/file.py.j2"))
.expect("file template compiles");
env
}
/// Generate every file crozier currently emits for an IR.
/// Collect every named-type reference reachable within a resolved type.
fn collect_named_refs(t: &TypeRef, out: &mut Vec<String>) {
match t {
TypeRef::Named(n) => out.push(n.clone()),
TypeRef::Optional(i) | TypeRef::List(i) | TypeRef::Set(i) => collect_named_refs(i, out),
TypeRef::Dict(k, v) => {
collect_named_refs(k, out);
collect_named_refs(v, out);
}
TypeRef::Union(vs) => vs.iter().for_each(|v| collect_named_refs(v, out)),
TypeRef::Primitive(_) | TypeRef::Literal(_) => {}
}
}
/// The named types a declaration references (base classes plus every field's
/// type), for building the type-reference graph that finds recursive cycles.
pub(crate) fn decl_refs(decl: &TypeDecl) -> Vec<String> {
let mut out = Vec::new();
match decl {
TypeDecl::Object(o) => {
out.extend(o.bases.iter().cloned());
out.extend(o.reach_refs.iter().cloned());
for f in &o.fields {
collect_named_refs(&f.type_ref, &mut out);
}
}
TypeDecl::Alias(a) => {
collect_named_refs(&a.target, &mut out);
for reach in &a.reach_refs {
collect_named_refs(reach, &mut out);
}
}
TypeDecl::DiscriminatedUnion(u) => {
// A union can be any of its mapped variant schemas — that edge is what
// exposes a variant that recurses back through the union (issue #84).
out.extend(u.variant_targets.iter().cloned());
for m in &u.members {
for f in &m.fields {
collect_named_refs(&f.type_ref, &mut out);
}
}
}
TypeDecl::Enum(_) => {}
}
out
}
/// Named types that occur in a declaration's emitted annotations. Unlike the
/// cycle-detection graph, a discriminated union excludes its source variant
/// targets: Fern emits wrapper classes, so those source models are not names
/// `update_forward_refs` must resolve for the union module.
fn decl_annotation_refs(decl: &TypeDecl) -> Vec<String> {
let mut out = Vec::new();
match decl {
TypeDecl::Object(object) => {
out.extend(object.bases.iter().cloned());
out.extend(object.reach_refs.iter().cloned());
for field in &object.fields {
collect_named_refs(&field.type_ref, &mut out);
}
}
TypeDecl::Alias(alias) => collect_named_refs(&alias.target, &mut out),
TypeDecl::DiscriminatedUnion(union) => {
for member in &union.members {
for field in &member.fields {
collect_named_refs(&field.type_ref, &mut out);
}
}
}
TypeDecl::Enum(_) => {}
}
out
}
/// A declaration's references excluding an object's base classes — what the
/// declaration itself annotates.
fn decl_own_refs(decl: &TypeDecl) -> Vec<String> {
match decl {
TypeDecl::Object(object) => {
let mut out = Vec::new();
for field in &object.fields {
collect_named_refs(&field.type_ref, &mut out);
}
out
}
_ => decl_refs(decl),
}
}
/// Whether an alias's target is a union expression, looking through `Optional`.
fn is_union_target(t: &TypeRef) -> bool {
match t {
TypeRef::Union(_) => true,
TypeRef::Optional(inner) => is_union_target(inner),
_ => false,
}
}
/// For every generated type, the subset of its own references that close a cycle
/// back to it — the references crozier must emit as string forward references
/// with a deferred/omitted import so the module graph loads (issue #84).
///
/// A reference `A → B` is a back-edge when `B` can reach `A` again through the
/// type graph (a self-reference is the degenerate `A → A`). Fern renders exactly
/// these as `"B"` and repairs them at class-definition time with
/// `update_forward_refs`.
fn forward_ref_map(
types: &[TypeDecl],
tag_types: &[crate::ir::TagTypeDecl],
) -> std::collections::HashMap<String, std::collections::HashSet<String>> {
use std::collections::{HashMap, HashSet};
// Adjacency: type name → the names it references.
let mut edges: HashMap<String, Vec<String>> = HashMap::new();
for decl in types.iter().chain(tag_types.iter().map(|t| &t.decl)) {
edges
.entry(decl.name().to_string())
.or_default()
.extend(decl_refs(decl));
}
// Whether `target` is reachable from `start` following ≥1 edge.
let reaches = |start: &str, target: &str| -> bool {
let mut stack: Vec<&str> = edges
.get(start)
.into_iter()
.flatten()
.map(String::as_str)
.collect();
let mut seen: HashSet<&str> = HashSet::new();
while let Some(node) = stack.pop() {
if node == target {
return true;
}
if seen.insert(node) {
stack.extend(edges.get(node).into_iter().flatten().map(String::as_str));
}
}
false
};
let mut map: HashMap<String, HashSet<String>> = HashMap::new();
for decl in types.iter().chain(tag_types.iter().map(|t| &t.decl)) {
let name = decl.name();
let defer_recursive_target =
!matches!(decl, TypeDecl::Alias(_) | TypeDecl::DiscriminatedUnion(_));
let mut forward = HashSet::new();
for r in decl_refs(decl) {
// A superclass must already be bound when Python executes the class
// statement; its own module repairs its recursive annotations.
if matches!(decl, TypeDecl::Object(object) if object.bases.contains(&r)) {
continue;
}
// Render `r` as a string forward reference (deferred import) when it
// closes a cycle back to this type, OR when `r` is itself recursive (in a
// cycle of its own). Importing a recursive type eagerly can trip its
// module's own import cycle even from an acyclic referrer, so Fern defers
// every reference *into* a cycle, not only the back-edges of one.
// An alias that merely *wraps* its target imports it eagerly even when
// the target points back at the alias — Fern emits such an alias with
// no forward-reference machinery at all, not even
// `from __future__ import annotations`: Apideck Accounting's
// `ProfitAndLossRecords = Optional[List[ProfitAndLossRecordsItem]]`
// over a discriminated union, and MockServer's
// `SchemaArray = List[Schema]` over the draft-04 `Schema` model. An
// alias whose target is itself a `Union[..]` is a declaration in its
// own right and defers into the cycle like any other member, which is
// how EN 18222's `MultiValuedDataElementValueItem` quotes
// `DataElement`.
let wrapper_alias = match decl {
TypeDecl::Alias(alias) => !is_union_target(&alias.target),
_ => false,
};
if !wrapper_alias && (reaches(&r, name) || (defer_recursive_target && reaches(&r, &r)))
{
forward.insert(r);
}
}
if !forward.is_empty() {
map.insert(name.to_string(), forward);
}
}
map
}
/// For each non-alias declaration that reaches a recursive type, collect every
/// cyclic name in its forward-reference closure. Fern passes that complete set to
/// `update_forward_refs`, including names reached transitively through aliases.
/// An object excludes its own class (already the call's first argument), while a
/// discriminated union retains its alias name because the first argument is a
/// generated variant wrapper.
#[derive(Default)]
struct ForwardRepair {
names: std::collections::HashSet<String>,
/// For a discriminated union, the exact `update_forward_refs` arguments each
/// wrapper class takes, keyed by wrapper class name. Wrappers differ: each
/// resolves what its own fields reach, minus the model it flattened.
member_names: std::collections::HashMap<String, Vec<String>>,
triggers: std::collections::HashSet<String>,
import_order: Vec<String>,
/// For an object, the order Fern writes its trailing deferred imports in:
/// field by field, each named type contributing first the cycle it closes
/// (sorted, minus the model itself) and then every non-union cyclic type it
/// reaches (sorted), each name kept at its first position. Fern registers
/// those as unused ghost imports and its lint pass prunes the ones nothing
/// names, so only the order survives: a model whose `wharf` field closes
/// one cycle and whose later `buoy` field closes another imports `Wharf`
/// ahead of `Buoy`.
deferred_order: Vec<String>,
/// Whether the declaration reaches a cycle through its *own* annotations
/// rather than only through a base class. Eozilla's `QualifiedValue` extends
/// `Format`, whose `schema` field closes the `Schema` map-of-self cycle, and
/// Fern gives the subclass `from __future__ import annotations` but no
/// `update_forward_refs` call — the base's own module already repaired it.
own_reach: bool,
}
/// Each node of the type-reference graph mapped to the id of its strongly
/// connected component (Tarjan's algorithm, iterative so a deep reference chain
/// cannot exhaust the stack). Two names share an id exactly when each reaches
/// the other, so a cycle is one id however many cycles the graph chains.
fn cycle_components(
edges: &std::collections::HashMap<String, Vec<String>>,
) -> std::collections::HashMap<String, usize> {
use std::collections::HashMap;
let mut names: Vec<&str> = edges.keys().map(String::as_str).collect();
names.sort_unstable();
let mut index: HashMap<&str, usize> = HashMap::new();
let mut low: HashMap<&str, usize> = HashMap::new();
let mut on_stack: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut stack: Vec<&str> = Vec::new();
let mut component: HashMap<String, usize> = HashMap::new();
let mut next_index = 0;
let mut next_component = 0;
let neighbours = |node: &str| -> Vec<&str> {
edges
.get(node)
.into_iter()
.flatten()
.map(String::as_str)
.filter(|name| edges.contains_key(*name))
.collect()
};
for root in names {
if index.contains_key(root) {
continue;
}
// Each frame is a node and the neighbours it has still to visit.
let mut frames: Vec<(&str, Vec<&str>)> = Vec::new();
index.insert(root, next_index);
low.insert(root, next_index);
next_index += 1;
stack.push(root);
on_stack.insert(root);
frames.push((root, neighbours(root)));
while let Some((node, pending)) = frames.last_mut() {
let node = *node;
if let Some(next) = pending.pop() {
if !index.contains_key(next) {
index.insert(next, next_index);
low.insert(next, next_index);
next_index += 1;
stack.push(next);
on_stack.insert(next);
frames.push((next, neighbours(next)));
} else if on_stack.contains(next) {
let reached = index[next];
let entry = low.get_mut(node).expect("visited node has a low-link");
*entry = (*entry).min(reached);
}
continue;
}
frames.pop();
let node_low = low[node];
if let Some((parent, _)) = frames.last() {
let entry = low.get_mut(*parent).expect("visited node has a low-link");
*entry = (*entry).min(node_low);
}
if node_low == index[node] {
while let Some(member) = stack.pop() {
on_stack.remove(member);
component.insert(member.to_string(), next_component);
if member == node {
break;
}
}
next_component += 1;
}
}
}
component
}
/// [`ForwardRepair::deferred_order`] for one object: the names its trailing
/// deferred imports follow, field by field.
fn object_deferred_order(
object: &ObjectType,
edges: &std::collections::HashMap<String, Vec<String>>,
cyclic: &std::collections::HashSet<&str>,
unions: &std::collections::HashSet<&str>,
component: &std::collections::HashMap<String, usize>,
) -> Vec<String> {
let mut order: Vec<String> = Vec::new();
let push = |order: &mut Vec<String>, name: &str| {
if !order.iter().any(|known| known == name) {
order.push(name.to_string());
}
};
for base in &object.bases {
push(&mut order, base);
}
let mut annotated = Vec::new();
for field in &object.fields {
let mut refs = Vec::new();
collect_named_refs(&field.type_ref, &mut refs);
for name in &refs {
// The cycle this name closes, without the model being written.
let mut cycle: Vec<&str> = match component.get(name.as_str()) {
Some(id) if cyclic.contains(name.as_str()) => component
.iter()
.filter(|(member, member_id)| {
*member_id == id && member.as_str() != object.name
})
.map(|(member, _)| member.as_str())
.collect(),
_ => Vec::new(),
};
cycle.sort_by(|left, right| natural_cmp(left, right));
// Every non-union cyclic declaration the name reaches.
let mut reached: Vec<&str> = Vec::new();
let mut stack = vec![name.as_str()];
let mut seen = std::collections::HashSet::new();
while let Some(node) = stack.pop() {
if seen.insert(node) {
if cyclic.contains(node) && !unions.contains(node) && node != object.name {
reached.push(node);
}
stack.extend(edges.get(node).into_iter().flatten().map(String::as_str));
}
}
reached.sort_by(|left, right| natural_cmp(left, right));
for member in cycle.into_iter().chain(reached) {
push(&mut order, member);
}
}
annotated.extend(refs);
}
// The field annotations themselves follow the ghost references.
for name in &annotated {
push(&mut order, name);
}
order
}
fn forward_repair_map(
types: &[TypeDecl],
tag_types: &[crate::ir::TagTypeDecl],
) -> std::collections::HashMap<String, ForwardRepair> {
use std::collections::{HashMap, HashSet};
let mut edges: HashMap<String, Vec<String>> = HashMap::new();
let mut repair_edges: HashMap<String, Vec<String>> = HashMap::new();
let mut walk_edges: HashMap<String, Vec<String>> = HashMap::new();
for decl in types.iter().chain(tag_types.iter().map(|t| &t.decl)) {
edges
.entry(decl.name().to_string())
.or_default()
.extend(decl_refs(decl));
repair_edges
.entry(decl.name().to_string())
.or_default()
.extend(decl_annotation_refs(decl));
walk_edges
.entry(decl.name().to_string())
.or_default()
.extend(match decl {
TypeDecl::DiscriminatedUnion(union) => union.variant_targets.clone(),
other => decl_refs(other),
});
}
let reaches = |start: &str, target: &str| -> bool {
let mut stack: Vec<&str> = edges
.get(start)
.into_iter()
.flatten()
.map(String::as_str)
.collect();
let mut seen: HashSet<&str> = HashSet::new();
while let Some(node) = stack.pop() {
if node == target {
return true;
}
if seen.insert(node) {
stack.extend(edges.get(node).into_iter().flatten().map(String::as_str));
}
}
false
};
let cyclic: HashSet<&str> = edges
.keys()
.map(String::as_str)
.filter(|n| reaches(n, n))
.collect();
let aliases: HashSet<&str> = types
.iter()
.chain(tag_types.iter().map(|tag| &tag.decl))
.filter_map(|decl| matches!(decl, TypeDecl::Alias(_)).then_some(decl.name()))
.collect();
let unions: HashSet<&str> = types
.iter()
.chain(tag_types.iter().map(|tag| &tag.decl))
.filter_map(|decl| {
let union = match decl {
TypeDecl::DiscriminatedUnion(_) => true,
TypeDecl::Alias(alias) => is_union_target(&alias.target),
_ => false,
};
union.then_some(decl.name())
})
.collect();
let component = cycle_components(&edges);
let same_cycle = |left: &str, right: &str| {
component
.get(left)
.is_some_and(|id| component.get(right) == Some(id))
};
let classes: HashSet<&str> = types
.iter()
.chain(tag_types.iter().map(|tag| &tag.decl))
.filter_map(|decl| matches!(decl, TypeDecl::Object(_)).then_some(decl.name()))
.collect();
// Whether a declaration needs `from __future__ import annotations` at all.
// Naming a cyclic type directly always does. Reaching one only through other
// declarations does when the cyclic type is a *class*: a class in a cycle is
// still incomplete when its referrer is defined, so the referrer is rebuilt
// too — dnd5eapi's `Monster` names `MonsterActionsItem`, which names the
// cyclic `Choice`. A cyclic *alias* is not: it is a finished object by the
// time the module that names it repairs it, so nothing beyond that module is
// affected — NDW's `Feature` repairs the `FeatureProperties` union and its own
// referrer `FeatureCollection` needs no repair of its own.
let reaches_repairable = |starts: &[String]| -> bool {
if starts.iter().any(|name| cyclic.contains(name.as_str())) {
return true;
}
let mut stack: Vec<&str> = starts.iter().map(String::as_str).collect();
let mut seen: HashSet<&str> = HashSet::new();
while let Some(node) = stack.pop() {
if cyclic.contains(node) && classes.contains(node) {
return true;
}
if seen.insert(node) {
stack.extend(edges.get(node).into_iter().flatten().map(String::as_str));
}
}
false
};
let mut out = HashMap::new();
for decl in types.iter().chain(tag_types.iter().map(|tag| &tag.decl)) {
if matches!(decl, TypeDecl::Alias(_)) {
continue;
}
let reaches_cycle =
reaches_repairable(edges.get(decl.name()).map_or(&[][..], Vec::as_slice));
// The same question asked of this declaration's own annotations only: a
// base class is imported eagerly and repaired in its own module, so
// inheriting a cyclic field does not make the subclass call
// `update_forward_refs`.
let own_reach = reaches_repairable(&decl_own_refs(decl));
// The names one declaration's `update_forward_refs` call must resolve: for
// each cyclic name its own annotations reach, the cycle that name closes,
// walking on **through that cycle's members only**. A non-cyclic node is
// a leaf — whatever it references is repaired in its own module, not
// here — which is why NDW's `FeatureCollection` names nothing (it
// annotates `Feature`, which is not cyclic) while its `Feature` names
// `FeatureProperties` directly. So is a node of a *different* cycle the
// walk could reach: a model naming `Captain`, whose cycle with `Vessel`
// points into a self-recursive `Harbor`, resolves `Captain` and `Vessel`
// and leaves `Harbor` to the modules of the cycle that closes it.
//
// A union is walked by its *variant targets*, not by its wrappers' fields.
// The wrappers are flattened views of those models, so a field they
// flattened is only a name to resolve when the model it came from is
// itself cyclic — the walk reaches it through that model. EN 18222's
// `DataElement` reaches `MultiValuedDataElementValueItem` through the
// cyclic `MultiValuedDataElement`, while NDW's `FeatureProperties` does not
// reach `Reason`, because the only wrapper carrying it flattened the
// acyclic `DestinationFeatureProperties`.
let closure = |starts: Vec<&str>| -> HashSet<String> {
let mut found = HashSet::new();
for start in starts.into_iter().filter(|name| cyclic.contains(name)) {
let mut stack = vec![start];
let mut seen = HashSet::new();
while let Some(node) = stack.pop() {
if seen.insert(node) {
found.insert(node.to_string());
stack.extend(
walk_edges
.get(node)
.into_iter()
.flatten()
.map(String::as_str)
.filter(|name| same_cycle(start, name)),
);
}
}
}
found
};
let mut member_names: HashMap<String, Vec<String>> = HashMap::new();
let mut repair: HashSet<String> = if let TypeDecl::DiscriminatedUnion(union) = decl {
// Each wrapper resolves its own reach, minus the model it flattened —
// a wrapper cannot be asked to resolve its own source.
let mut all = HashSet::new();
for (index, member) in union.members.iter().enumerate() {
let mut refs = Vec::new();
for field in &member.fields {
collect_named_refs(&field.type_ref, &mut refs);
}
let mut names = closure(refs.iter().map(String::as_str).collect());
if let Some(source) = member
.source
.as_ref()
.or_else(|| union.variant_targets.get(index).filter(|_| !member.wrapped))
{
names.remove(source);
}
let mut ordered: Vec<String> = names.iter().cloned().collect();
ordered.sort_by(|left, right| natural_cmp(left, right));
all.extend(names);
member_names.insert(member.class_name.clone(), ordered);
}
all.extend(
union
.variant_targets
.iter()
.filter(|target| cyclic.contains(target.as_str()))
.cloned(),
);
all
} else {
let mut names = closure(
repair_edges
.get(decl.name())
.into_iter()
.flatten()
.map(String::as_str)
.collect(),
);
names.remove(decl.name());
names
};
if matches!(decl, TypeDecl::Object(_)) {
repair.remove(decl.name());
}
if reaches_cycle {
let triggers = edges
.get(decl.name())
.into_iter()
.flatten()
.filter(|name| {
cyclic.contains(name.as_str())
|| cyclic.iter().any(|target| reaches(name.as_str(), target))
})
.cloned()
.collect();
let mut import_order: Vec<String> = repair.iter().cloned().collect();
import_order.sort_by(|left, right| {
aliases
.contains(left.as_str())
.cmp(&aliases.contains(right.as_str()))
.then_with(|| natural_cmp(left, right))
});
let deferred_order = match decl {
TypeDecl::Object(object) => {
object_deferred_order(object, &edges, &cyclic, &unions, &component)
}
_ => Vec::new(),
};
out.insert(
decl.name().to_string(),
ForwardRepair {
names: repair,
member_names,
triggers,
import_order,
deferred_order,
own_reach,
},
);
}
}
out
}
pub fn generate(ir: &Ir) -> Result<Vec<GeneratedFile>> {
let env = environment();
let mut files = Vec::new();
let pkg = &ir.package_name;
// Map each tag-scoped (hoisted) type name to its owning tag directory, so
// references from anywhere resolve to the right relative import path.
let tag_map: BTreeMap<String, String> = ir
.tag_types
.iter()
.map(|t| (t.decl.name().to_string(), t.module.clone()))
.collect();
// Recursive types: per type, the references that must render as string forward
// references (issue #84). Empty for the common acyclic case.
let forward_map = forward_ref_map(&ir.types, &ir.tag_types);
let repair_map = forward_repair_map(&ir.types, &ir.tag_types);
let empty_forward = std::collections::HashSet::new();
// version.py and the py.typed marker are packaging: Fern's flat module tree
// carries neither.
let packaged = ir.layout == Layout::Packaged;
if packaged {
let version = render(
&env,
"version.py",
"version.py",
context! { project_name => ir.project_name },
)?;
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/version.py")),
contents: version,
});
// py.typed (an empty marker file).
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/py.typed")),
contents: String::new(),
});
}
// The client package tree the module names imply. A module is normally one
// directory (`videos`); an `x-crozier-sdk-group-name` list nests it
// (`catalogs/mcp_servers`), which adds intermediate packages that own no
// endpoints and exist only to carry their children.
let children = module_children(&ir.endpoint_modules);
let parent_modules: Vec<String> = children
.keys()
.filter(|module| !module.is_empty() && !ir.endpoint_modules.contains(module))
.cloned()
.collect();
// One file per generated type.
let decl_settings = DeclSettings {
extra: ir.extra_fields,
enum_type: ir.enum_type,
xml_types: xml::XmlTypes::new(&ir.types, &ir.tag_types),
};
for decl in &ir.types {
let forward = forward_map.get(decl.name()).unwrap_or(&empty_forward);
let repair = repair_map.get(decl.name());
let file = render_type_decl(
&env,
decl,
RefLoc::RootTypes,
&tag_map,
decl_settings,
forward,
repair,
)?;
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/types/{}.py", decl.module())),
contents: file,
});
}
// Tag-scoped hoisted types (inline request/response bodies), each in its tag's
// own `types/` package, plus a lazy-loader `__init__.py` per such package.
let mut tag_type_modules: BTreeMap<&str, Vec<&TypeDecl>> = BTreeMap::new();
for tt in &ir.tag_types {
tag_type_modules
.entry(&tt.module)
.or_default()
.push(&tt.decl);
}
for (module, decls) in &tag_type_modules {
for decl in decls {
let forward = forward_map.get(decl.name()).unwrap_or(&empty_forward);
let repair = repair_map.get(decl.name());
let location = if module.is_empty() {
RefLoc::RootTypes
} else {
RefLoc::TagTypes((*module).to_string())
};
let file = render_type_decl(
&env,
decl,
location,
&tag_map,
decl_settings,
forward,
repair,
)?;
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/{module}/types/{}.py", decl.module())),
contents: file,
});
}
// The root `types/` package has one aggregator, written below over the
// component types *and* these hoisted ones; a second one here would land
// on the same path and lose whichever was written first.
if !module.is_empty() {
files.push(tag_types_init_file(&env, pkg, module, decls)?);
}
}
// Fern's static core runtime, emitted verbatim (see assets/README.md), plus
// the auth-shaped `client_wrapper.py`.
files.extend(core_files(
pkg,
ir.endpoints
.iter()
.any(|endpoint| endpoint.pagination.is_some()),
ir.enum_type,
&ir.auth,
));
// An XML model reads and writes itself through `core/xml_utilities.py`, which
// Fern ships (unexported) only with an SDK that has one, together with a
// round-trip test module per model.
if decl_settings.xml_types.any() {
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/core/xml_utilities.py")),
contents: xml::XML_UTILITIES.to_string(),
});
files.extend(xml::test_files(
pkg,
decl_settings.xml_types,
ir.layout == Layout::Packaged || pkg == "fern",
));
}
// Fern's flat tree has no publishing identity, so its wrapper sends no
// `SDK-Name`/`SDK-Version` headers.
files.push(client_wrapper_file(
pkg,
packaged.then_some(ir.project_name.as_str()),
&ir.auth,
&ir.global_headers,
&ir.client_path_parameters,
ir.default_max_retries,
ir.environment.as_ref(),
));
// `environment.py`: the server-environment enum, when the document declares
// `servers`.
if let Some(environment) = &ir.environment {
files.push(environment_file(&env, pkg, environment)?);
}
// One package marker per endpoint client module. Fern's `__init__.py` here is
// a comment-only header (four blank lines once stripped) — unless the tag owns
// hoisted inline types, in which case it is a lazy loader re-exporting them,
// or it has nested sub-packages, in which case it is a lazy loader over those.
for module in ir.endpoint_modules.iter().chain(parent_modules.iter()) {
let child_types = |names: &[String]| -> Vec<(String, Vec<String>)> {
names
.iter()
.filter_map(|child| {
let decls = tag_type_modules.get(child.as_str())?;
let mut exported: Vec<String> = decls
.iter()
.flat_map(|decl| decl.exported_names().into_iter().map(str::to_string))
.collect();
exported.sort();
Some((module_stem(child).to_string(), exported))
})
.collect()
};
if let Some(decls) = tag_type_modules.get(module.as_str()) {
let names = children.get(module.as_str()).cloned().unwrap_or_default();
files.push(tag_pkg_init_file(
&env,
pkg,
module,
decls,
&names,
&child_types(&names),
)?);
} else if let Some(names) = children.get(module.as_str()) {
let child_types = child_types(names);
files.push(module_pkg_init_file(
&env,
pkg,
module,
names,
&child_types,
)?);
} else {
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/{module}/__init__.py")),
contents: "\n\n\n\n".to_string(),
});
}
}
// A package that only holds types (a component placed by its SDK group or
// `x-tags`) has no client, so no module above wrote its `__init__.py`.
let type_only_modules: Vec<String> = tag_type_modules
.keys()
.filter(|module| {
!module.is_empty()
&& !ir.endpoint_modules.iter().any(|m| m == *module)
&& !parent_modules.iter().any(|m| m == *module)
})
.map(|module| (*module).to_string())
.collect();
for module in &type_only_modules {
files.push(tag_pkg_init_file(
&env,
pkg,
module,
&tag_type_modules[module.as_str()],
&[],
&[],
)?);
}
let root_eps: Vec<&Endpoint> = ir
.endpoints
.iter()
.filter(|e| e.module.is_empty() && e.emittable)
.collect();
let root_emittable = !root_eps.is_empty();
if root_emittable {
files.push(raw_client_file(
&env,
pkg,
&ir.client_name,
"",
&root_eps,
&tag_map,
)?);
}
// Per-tag `raw_client.py` and `client.py`, but only for modules whose every
// operation is within the subset crozier emits today (see
// `Endpoint::emittable`); the rest await wider endpoint support. The modules
// that clear this bar (in sorted order) also drive the root client below.
let mut emittable_modules: Vec<&String> = Vec::new();
for module in &ir.endpoint_modules {
let eps: Vec<&Endpoint> = ir
.endpoints
.iter()
.filter(|e| &e.module == module)
.collect();
if !eps.is_empty() && eps.iter().all(|e| e.emittable) {
files.push(raw_client_file(
&env,
pkg,
&ir.client_name,
module,
&eps,
&tag_map,
)?);
let cx = ClientCtx {
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
pkg,
client_name: &ir.client_name,
module,
types: &ir.types,
enum_type: ir.enum_type,
tag_decls: &ir.tag_types,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
tag_types: &tag_map,
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
sdk_first_party: packaged || pkg == "fern",
children: children.get(module.as_str()).map_or(&[][..], Vec::as_slice),
};
files.push(client_file(&env, &cx, &eps)?);
emittable_modules.push(module);
}
}
// A nested `x-crozier-sdk-group-name` creates *intermediate* packages that own
// no operation of their own (`catalogs` over `catalogs/mcp_servers`). Fern
// still emits a client for each: an empty raw client, and a high-level one
// whose whole body is the lazy sub-client properties.
for module in &parent_modules {
files.push(raw_client_file(
&env,
pkg,
&ir.client_name,
module,
&[],
&tag_map,
)?);
let cx = ClientCtx {
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
pkg,
client_name: &ir.client_name,
module,
types: &ir.types,
enum_type: ir.enum_type,
tag_decls: &ir.tag_types,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
tag_types: &tag_map,
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
sdk_first_party: packaged || pkg == "fern",
children: children.get(module.as_str()).map_or(&[][..], Vec::as_slice),
};
files.push(client_file(&env, &cx, &[])?);
}
// The root client aggregates only the *top-level* packages; a nested one is
// reached through its parent's property instead.
let root_modules: Vec<&String> = children.get("").map_or(Vec::new(), |names| {
names
.iter()
.filter(|name| emittable_modules.contains(name) || parent_modules.contains(*name))
.collect()
});
// `emittable_modules` stays in first-appearance (declaration) order — the order
// `ir.endpoint_modules` yields — because that is the order Fern lists sub-clients
// in the root client and `reference.md` (e.g. `widgets` before `gadgets` when
// `/widgets` is declared first, not alphabetically). The root `__init__.py`
// re-sorts its own dynamic-import/`__all__` maps, so its output is unaffected.
// Root client: `FernApi`/`AsyncFernApi` aggregating the tag clients. Fern emits
// it unconditionally — an API whose operations it all discarded still gets a
// client carrying the credential constructor and no methods
// (`cyberark-conjur-api`) — so there is no emptiness guard here.
{
files.push(root_client_file(
&env,
RootClientFileCtx {
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
pkg,
client_name: &ir.client_name,
modules: &root_modules,
root_endpoints: if root_emittable { &root_eps } else { &[] },
types: &ir.types,
enum_type: ir.enum_type,
tag_decls: &ir.tag_types,
tag_map: &tag_map,
auth: &ir.auth,
environment: ir.environment.as_ref(),
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
default_max_retries: ir.default_max_retries,
},
)?);
}
// The `errors/` package: one exception class per distinct declared error,
// plus the lazy-loading `__init__.py` aggregator. Emitted only when there are
// errors to declare.
files.extend(error_files(&env, pkg, &ir.errors, &tag_map)?);
// Package aggregators: `types/__init__.py` over the type layer, and the
// package-root `__init__.py` re-exporting types, errors, the endpoint
// submodules, the root client, and `__version__`.
let root_tag_types: &[&TypeDecl] = tag_type_modules.get("").map_or(&[], Vec::as_slice);
if !ir.types.is_empty() || !root_tag_types.is_empty() {
files.push(types_init_file(&env, pkg, &ir.types, root_tag_types)?);
}
// The root package lists a types-only package beside the client ones.
let mut init_modules = root_modules.clone();
init_modules.extend(
type_only_modules
.iter()
.filter(|module| !module.contains('/')),
);
files.push(root_init_file(&env, pkg, ir, &init_modules)?);
// Generated `README.md` (usage examples from the first endpoint) and the
// per-endpoint `reference.md`.
// Fern's packaged tree has `README.md` whether or not it has a call to
// demonstrate: an API whose every operation it discarded gets the file empty
// (`cyberark-conjur-api`). Its flat module tree has no such file at all.
match readme_file(ir) {
Some(readme) => files.push(readme),
None if ir.layout == Layout::Packaged => files.push(GeneratedFile {
path: PathBuf::from("README.md"),
contents: String::new(),
}),
None => {}
}
files.push(reference_file(&env, ir, &reference_modules(ir), &tag_map)?);
// Project-root scaffolding (pyproject.toml, requirements.txt, metadata).
files.extend(scaffolding_files(pkg, &ir.project_name, ir.layout));
// Final wrapping is delegated to `ruff format` (the tool Fern runs), so the
// emitters above produce content-correct Python without reproducing ruff's
// line layout. `.py` files are formatted in place; non-Python outputs
// (Markdown, TOML, JSON, the `py.typed` marker) are left untouched.
format_python_files(pkg, &mut files)?;
if ir.layout == Layout::Flat {
flatten_package_paths(pkg, &mut files);
}
Ok(files)
}
/// Move the package's modules from `src/<pkg>/` to the output root, which is
/// where Fern's flat tree (a `local-file-system` output) puts them. Everything
/// else — `README.md`, `reference.md`, `.fern/`, `tests/` — is already at the root
/// in both layouts.
fn flatten_package_paths(pkg: &str, files: &mut [GeneratedFile]) {
let package_root = PathBuf::from(format!("src/{pkg}"));
for file in files.iter_mut() {
if let Ok(rest) = file.path.strip_prefix(&package_root) {
file.path = rest.to_path_buf();
}
}
}
/// Generate `environment.py`: an `enum.Enum` of the SDK's server environments.
/// Fern's OpenAPI importer emits a member per named server (see
/// [`ir::Environment`]).
fn environment_file(
env: &Environment<'static>,
pkg: &str,
environment: &crate::ir::Environment,
) -> Result<GeneratedFile> {
let mut body = String::new();
if environment.urls.is_empty() {
body.push_str(&format!(
"import enum\n\n\nclass {}(enum.Enum):\n",
environment.enum_name
));
for (member, url) in environment.members() {
body.push_str(&format!(" {member} = \"{}\"\n", escape_py_str(url)));
}
} else {
// A multi-URL environment: each member an instance whose `base` is its
// server and whose other fields are the operation servers' URLs.
let name = &environment.enum_name;
let fields: Vec<&str> = std::iter::once("base")
.chain(environment.urls.iter().map(|(field, _)| field.as_str()))
.collect();
body.push_str(&format!(
"from __future__ import annotations\n\n\nclass {name}:\n"
));
for (member, _) in environment.members() {
body.push_str(&format!(" {member}: {name}\n"));
}
body.push_str(&format!(
"\n def __init__(self, *, {}):\n",
fields
.iter()
.map(|field| format!("{field}: str"))
.collect::<Vec<_>>()
.join(", ")
));
for field in &fields {
body.push_str(&format!(" self.{field} = {field}\n"));
}
for (member, url) in environment.members() {
let arguments: Vec<String> =
std::iter::once(format!("base=\"{}\"", escape_py_str(url)))
.chain(
environment
.urls
.iter()
.map(|(field, url)| format!("{field}=\"{}\"", escape_py_str(url))),
)
.collect();
body.push_str(&format!(
"\n\n{name}.{member} = {name}({})\n",
arguments.join(", ")
));
}
}
let contents = render(
env,
"file.py",
"environment.py",
context! { header => HEADER, body => body },
)?;
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/environment.py")),
contents,
})
}
/// Generate the `errors/` package: an exception module per class and the
/// lazy-loading `__init__.py` re-exporting them. Empty when there are no errors.
fn error_files(
env: &Environment<'static>,
pkg: &str,
errors: &[ErrorClass],
tag_types: &BTreeMap<String, String>,
) -> Result<Vec<GeneratedFile>> {
if errors.is_empty() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for err in errors {
// Register once to learn whether the body type's name collides with the
// core `ApiError` this module subclasses (Eozilla declares a component
// schema of that name), then render for real with the aliases that forces.
let mut probe = Imports::at(RefLoc::Errors, tag_types);
probe.add_plain("typing");
probe.add_from("..core.api_error", "ApiError");
raw_type_str(&err.body_type, &mut probe);
let mut imports = Imports::at(RefLoc::Errors, tag_types);
imports.aliases = probe.collision_aliases();
imports.add_plain("typing");
let base = imports.add_from_local("..core.api_error", "ApiError");
// Registers the body type's `..types.<module>` import.
let body_type = raw_type_str(&err.body_type, &mut imports);
let module = naming::module_name(&err.class_name);
let contents = render(
env,
"error.py",
&err.class_name,
context! {
header => HEADER,
imports => imports.render(),
class_name => err.class_name,
base => base,
body_type => body_type,
status_code => err.status.code(),
wildcard => err.status.is_range(),
},
)?;
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/errors/{module}.py")),
contents,
});
}
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/errors/__init__.py")),
contents: lazy_loader_init(env, errors)?,
});
Ok(files)
}
/// Fern's package-`__init__` lazy loader for the `errors/` package: a
/// `TYPE_CHECKING` import block, a `_dynamic_imports` map, the static
/// `__getattr__`/`__dir__` machinery, and `__all__`. The error classes arrive
/// pre-sorted, so all three sections are alphabetical.
fn lazy_loader_init(env: &Environment<'static>, errors: &[ErrorClass]) -> Result<String> {
let type_checking: String = errors
.iter()
.map(|e| {
format!(
" from .{} import {}\n",
naming::module_name(&e.class_name),
e.class_name
)
})
.collect();
let pairs: Vec<(String, String)> = errors
.iter()
.map(|e| {
(
e.class_name.clone(),
format!(".{}", naming::module_name(&e.class_name)),
)
})
.collect();
let names: Vec<String> = errors.iter().map(|e| e.class_name.clone()).collect();
render_lazy_loader(env, &type_checking, &pairs, &names)
}
/// Assemble a lazy-loader `__init__.py` from its `TYPE_CHECKING` block, the
/// `_dynamic_imports` pairs `(exported name, ".module")`, and the `__all__`
/// names. The `_dynamic_imports` map and `__all__` are always alphabetical
/// (sorted here); only the `TYPE_CHECKING` block's order is caller-controlled.
/// The header collapses to the four leading blank lines Fern's comment header
/// strips to.
fn render_lazy_loader(
env: &Environment<'static>,
type_checking: &str,
pairs: &[(String, String)],
names: &[String],
) -> Result<String> {
let mut pairs = pairs.to_vec();
pairs.sort();
let mut names = names.to_vec();
names.sort();
// `_dynamic_imports` and `__all__` are emitted flat; `ruff format` wraps them
// (one entry per line with a trailing comma) when they overflow.
let entries: Vec<String> = pairs
.iter()
.map(|(n, m)| format!("\"{n}\": \"{m}\""))
.collect();
let dynamic = format!(
"_dynamic_imports: typing.Dict[str, str] = {{{}}}",
entries.join(", ")
);
let all_entries: Vec<String> = names.iter().map(|n| format!("\"{n}\"")).collect();
let all = format!("__all__ = [{}]", all_entries.join(", "));
render(
env,
"lazy_init.py",
"__init__.py",
context! {
header => HEADER,
type_checking => type_checking,
dynamic => dynamic,
all => all,
},
)
}
/// The `types/__init__.py` lazy loader. `_dynamic_imports`, `__all__`, and the
/// `TYPE_CHECKING` block are all emitted alphabetically.
fn types_init_file(
env: &Environment<'static>,
pkg: &str,
types: &[TypeDecl],
hoisted: &[&TypeDecl],
) -> Result<GeneratedFile> {
// Each declaration may export more than its primary name (a discriminated
// union also exports its per-variant wrappers), all sharing the decl module.
let mut module_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut names: Vec<String> = Vec::new();
for decl in types.iter().chain(hoisted.iter().copied()) {
for n in decl.exported_names() {
names.push(n.to_string());
module_of.insert(n.to_string(), decl.module().to_string());
}
}
// TYPE_CHECKING imports are emitted alphabetically. Their order is never
// executed, so crozier sorts straightforwardly rather than reproducing Fern's
// traversal order: the `init-type-checking-import-order` departure.
let mut ordered = names.clone();
ordered.sort();
// Consecutive names sharing a module collapse into one `from .mod import a, b`
// line (Fern groups a discriminated union's names on a single import).
let module = |n: &str| {
module_of
.get(n)
.cloned()
.unwrap_or_else(|| naming::module_name(n))
};
let mut type_checking = String::new();
let mut i = 0;
while i < ordered.len() {
let m = module(&ordered[i]);
let mut group = vec![ordered[i].clone()];
while i + 1 < ordered.len() && module(&ordered[i + 1]) == m {
i += 1;
group.push(ordered[i].clone());
}
type_checking.push_str(&format!(" from .{m} import {}\n", group.join(", ")));
i += 1;
}
let pairs: Vec<(String, String)> = names
.iter()
.map(|n| (n.clone(), format!(".{}", module(n))))
.collect();
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/types/__init__.py")),
contents: render_lazy_loader(env, &type_checking, &pairs, &names)?,
})
}
/// The `{tag}/types/__init__.py` lazy loader over a tag's hoisted inline types.
/// Each type sits in its own module (`.inlined_search_response`); the block is
/// alphabetical (the `init-type-checking-import-order` departure).
fn tag_types_init_file(
env: &Environment<'static>,
pkg: &str,
module: &str,
decls: &[&TypeDecl],
) -> Result<GeneratedFile> {
let mut named: Vec<(String, String)> = decls
.iter()
.flat_map(|d| {
d.exported_names()
.into_iter()
.map(|n| (n.to_string(), d.module().to_string()))
})
.collect();
named.sort();
let type_checking: String = named
.iter()
.map(|(n, m)| format!(" from .{m} import {n}\n"))
.collect();
let pairs: Vec<(String, String)> = named
.iter()
.map(|(n, m)| (n.clone(), format!(".{m}")))
.collect();
let names: Vec<String> = named.iter().map(|(n, _)| n.clone()).collect();
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/{module}/types/__init__.py")),
contents: render_lazy_loader(env, &type_checking, &pairs, &names)?,
})
}
/// A client package's `__init__.py` when it holds nested sub-packages: the lazy
/// loader over those sub-packages, the way the package-root `__init__.py` lists
/// the top-level ones. `catalogs/` over `catalogs/mcp_servers/` is the shape.
fn module_pkg_init_file(
env: &Environment<'static>,
pkg: &str,
module: &str,
children: &[String],
child_types: &[(String, Vec<String>)],
) -> Result<GeneratedFile> {
let mut names: Vec<String> = children
.iter()
.map(|child| module_stem(child).to_string())
.collect();
let mut type_checking = from_import_block(".", &names, 4);
let mut pairs: Vec<(String, String)> = names
.iter()
.map(|name| (name.clone(), format!(".{name}")))
.collect();
// A sub-package's hoisted types are re-exported here, one level up, and no
// further: CloudPDF's `doc/__init__.py` lists `from .pages import
// DocPagesSetScaleRequestMeasure…`, and the package root does not.
for (child, exported) in child_types {
type_checking.push_str(&from_import_block(&format!(".{child}"), exported, 4));
for name in exported {
pairs.push((name.clone(), format!(".{child}")));
names.push(name.clone());
}
}
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/{module}/__init__.py")),
contents: render_lazy_loader(env, &type_checking, &pairs, &names)?,
})
}
/// The tag package's `__init__.py` when it owns hoisted types: a lazy loader
/// re-exporting them from the tag's `.types` subpackage. A tag with no hoisted
/// types keeps the plain comment-only marker instead.
fn tag_pkg_init_file(
env: &Environment<'static>,
pkg: &str,
module: &str,
decls: &[&TypeDecl],
children: &[String],
child_types: &[(String, Vec<String>)],
) -> Result<GeneratedFile> {
let mut names: Vec<String> = decls
.iter()
.flat_map(|d| d.exported_names().into_iter().map(str::to_string))
.collect();
names.sort();
let mut type_checking = from_import_block(".types", &names, 4);
let mut pairs: Vec<(String, String)> = names
.iter()
.map(|n| (n.clone(), ".types".to_string()))
.collect();
// A group with a child group exports the child too, after its own types
// (Fern's `yard/__init__.py` lists `from . import cranes` beside the
// `.types` it hoisted), and re-exports the child's hoisted types.
let child_names: Vec<String> = children
.iter()
.map(|child| module_stem(child).to_string())
.collect();
type_checking.push_str(&from_import_block(".", &child_names, 4));
for name in &child_names {
pairs.push((name.clone(), format!(".{name}")));
names.push(name.clone());
}
for (child, exported) in child_types {
type_checking.push_str(&from_import_block(&format!(".{child}"), exported, 4));
for name in exported {
pairs.push((name.clone(), format!(".{child}")));
names.push(name.clone());
}
}
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/{module}/__init__.py")),
contents: render_lazy_loader(env, &type_checking, &pairs, &names)?,
})
}
/// The package-root `__init__.py` lazy loader, re-exporting the type layer, the
/// errors, the endpoint submodules, the root client, and `__version__`. Every
/// section is alphabetical; the `TYPE_CHECKING` block groups the imports by
/// source (`.types`, `.errors`, submodules, `.client`, `.version`).
fn root_init_file(
env: &Environment<'static>,
pkg: &str,
ir: &Ir,
modules: &[&String],
) -> Result<GeneratedFile> {
let client_name = &ir.client_name;
let async_name = format!("Async{client_name}");
let mut type_names: Vec<String> = ir
.types
.iter()
.flat_map(|t| t.exported_names())
.map(str::to_string)
.collect();
type_names.sort();
// Hoisted tag types are re-exported at the package root through their tag
// submodule (`from .inlined import InlinedSearchResponse`), grouped per tag.
let mut hoisted_by_tag: BTreeMap<String, Vec<String>> = BTreeMap::new();
for tt in &ir.tag_types {
let names = tt.decl.exported_names().into_iter().map(str::to_string);
if tt.module.is_empty() {
type_names.extend(names);
} else if tt.module.contains('/') {
// A nested sub-package's types are re-exported by its parent
// package instead (see `module_pkg_init_file`).
} else {
hoisted_by_tag
.entry(tt.module.clone())
.or_default()
.extend(names);
}
}
type_names.sort();
let environment = ir.environment.as_ref();
let mut error_names: Vec<String> = ir.errors.iter().map(|e| e.class_name.clone()).collect();
error_names.sort();
let mut module_names: Vec<String> = modules.iter().map(|m| (*m).clone()).collect();
module_names.sort();
// Grouped TYPE_CHECKING block (each group alphabetical, ruff-wrapped at 120).
let mut tc = String::new();
tc.push_str(&from_import_block(".types", &type_names, 4));
tc.push_str(&from_import_block(".errors", &error_names, 4));
tc.push_str(&from_import_block(".", &module_names, 4));
tc.push_str(&from_import_block(
"._default_clients",
&[
"DefaultAioHttpClient".to_string(),
"DefaultAsyncHttpxClient".to_string(),
],
4,
));
tc.push_str(&from_import_block(
".client",
&[async_name.clone(), client_name.to_string()],
4,
));
if let Some(e) = environment {
tc.push_str(&from_import_block(
".environment",
std::slice::from_ref(&e.enum_name),
4,
));
}
for (tag, names) in &hoisted_by_tag {
tc.push_str(&from_import_block(
&format!(".{}", module_path(tag)),
names,
4,
));
}
// `__version__` reads the installed distribution's metadata, so only the
// packaged form exports it; Fern's flat tree has no `version.py`.
let packaged = ir.layout == Layout::Packaged;
if packaged {
tc.push_str(&from_import_block(
".version",
&["__version__".to_string()],
4,
));
}
// `_dynamic_imports` pairs: every symbol → its source module.
let mut pairs: Vec<(String, String)> = Vec::new();
for n in &type_names {
pairs.push((n.clone(), ".types".to_string()));
}
for n in &error_names {
pairs.push((n.clone(), ".errors".to_string()));
}
for m in &module_names {
pairs.push((m.clone(), format!(".{m}")));
}
pairs.push((
"DefaultAioHttpClient".to_string(),
"._default_clients".to_string(),
));
pairs.push((
"DefaultAsyncHttpxClient".to_string(),
"._default_clients".to_string(),
));
pairs.push((client_name.to_string(), ".client".to_string()));
pairs.push((async_name, ".client".to_string()));
if let Some(e) = environment {
pairs.push((e.enum_name.clone(), ".environment".to_string()));
}
for (tag, names) in &hoisted_by_tag {
for n in names {
pairs.push((n.clone(), format!(".{}", module_path(tag))));
}
}
if packaged {
pairs.push(("__version__".to_string(), ".version".to_string()));
}
let names: Vec<String> = pairs.iter().map(|(n, _)| n.clone()).collect();
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/__init__.py")),
contents: render_lazy_loader(env, &tc, &pairs, &names)?,
})
}
/// A lifted base-path parameter's constructor argument line: a required `str`,
/// or an `Optional[str]` defaulting to its declared default on the client and
/// to `None` in the wrapper beneath it.
fn client_path_parameter_param(parameter: &ClientPathParameter, on_client: bool) -> String {
match ¶meter.default {
None => format!(" {}: str,\n", parameter.py_name),
Some(value) if on_client => format!(
" {}: typing.Optional[str] = \"{}\",\n",
parameter.py_name,
escape_py_str(value)
),
Some(_) => format!(
" {}: typing.Optional[str] = None,\n",
parameter.py_name
),
}
}
/// A lifted base-path parameter's example argument: its default by keyword, or
/// the placeholder a required constructor argument takes, as fern-python-sdk
/// 5.41.3's docstrings write it (`FernApi(edition="v2")`).
fn client_path_parameter_example(parameter: &ClientPathParameter) -> String {
match ¶meter.default {
Some(value) => format!("{}=\"{}\"", parameter.py_name, escape_py_str(value)),
None => format!(
"{}=\"YOUR_{}\"",
parameter.py_name,
parameter.py_name.to_uppercase()
),
}
}
/// A promoted header's constructor annotation, before any `Optional[...]`: its
/// scalar or list type, or the one-value `typing.Literal` a subset-promoted
/// string header with a `default` takes.
fn global_header_annotation(header: &crate::ir::GlobalHeader) -> String {
match header.literal() {
Some(value) => format!("typing.Literal[\"{}\"]", escape_py_str(value)),
None => header.py_type().python().to_string(),
}
}
/// A constructor example matching the header's type. Date fields use Fern's
/// measured date-example form instead of an invalid string placeholder.
fn global_header_example(header: &GlobalHeader) -> String {
if header.py_type() == HeaderType::Date {
format!(
"{}=datetime.date.fromisoformat(\"2023-01-15\")",
header.py_name
)
} else {
format!(
"{}=\"YOUR_{}\"",
header.py_name,
header.py_name.to_uppercase()
)
}
}
/// Render an abbreviated call `<prefix>(...)` for the README snippets: empty parens
/// when the body is not complex, else a literal `...` placeholder. Fern does
/// not wrap these advanced README calls,
/// even when a descriptive operation name makes the line unusually long.
fn abbrev_call(indent: usize, prefix: &str, complex: bool) -> String {
let pad = " ".repeat(indent);
if !complex {
return format!("{pad}{prefix}()");
}
format!("{pad}{prefix}(...)")
}
/// Whether an endpoint can anchor the README's representative calls. Fern skips
/// referenced file-form declarations here even though their methods may still have
/// a deliberately argument-free worked example in `reference.md`.
fn readme_endpoint_eligible(ep: &Endpoint, types: &[TypeDecl], tag_decls: &[TagTypeDecl]) -> bool {
endpoint_has_worked_example(ep)
&& !root_binary_download_undocumented(ep)
&& path_object_documented(ep, types, tag_decls)
&& !matches!(ep.request_body, Some(RequestBody::Bytes { .. }))
&& !matches!(&ep.request_body, Some(RequestBody::Form(form))
if ep.body_schema_shape == BodySchemaShape::Ref
&& form.fields.iter().any(|field| field.is_file)
&& !form.fields.iter().any(|field| field.form_content_type.is_some()))
}
/// The endpoint Fern's README and `reference.md` anchor their worked example on:
/// the first eligible `POST`, else the first eligible operation, each tier read
/// in the README's own module order and over the top-level clients before any
/// nested one. Neither a request body nor an argument-free call promotes a
/// non-`POST` operation ahead of that order — corpus row 123 anchors its README
/// on the document's first `GET` even though a later `PUT` carries a flattened
/// JSON body, and an argument-free first `GET` wins over a later argument-free
/// `DELETE`. CloudPDF's first `POST` is `doc.annotations.create`, two levels
/// down, and its README demonstrates `shares.exchange`, the first `POST` of a
/// top-level client; a nested `POST` still wins over a top-level `GET`.
fn select_readme_endpoint<'a>(
endpoints: impl Clone + Iterator<Item = &'a Endpoint>,
types: &[TypeDecl],
tag_decls: &[TagTypeDecl],
) -> Option<&'a Endpoint> {
let tier = |accept: &dyn Fn(&Endpoint) -> bool| {
endpoints
.clone()
.filter(|e| module_depth(&e.module) <= 1)
.find(|e| accept(e))
.or_else(|| endpoints.clone().find(|e| accept(e)))
};
tier(&|e| {
e.emittable && e.http_method == "POST" && readme_endpoint_eligible(e, types, tag_decls)
})
.or_else(|| tier(&|e| e.emittable && readme_endpoint_eligible(e, types, tag_decls)))
}
/// The import block and the call block of an endpoint's worked documentation
/// example — the lines before and after the client constructor. The README's
/// streaming section writes its own constructor, but imports what the call
/// constructs (`from fern import FernApi, Sector`).
fn readme_example_parts(ir: &Ir, ep: &Endpoint, pkg: &str) -> Option<(String, String)> {
let mut ctx = ExampleCtx {
types: &ir.types,
enum_type: ir.enum_type,
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
tag_decls: &ir.tag_types,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: true,
};
let lines = build_documentation_example(
ep,
false,
&ep.module,
pkg,
&ir.client_name,
&mut ctx,
None,
false,
)?;
let start = lines.iter().position(|line| line.starts_with("client."))?;
let constructor = lines
.iter()
.position(|line| line.starts_with("client = "))?;
let imports = lines[..constructor].join("\n");
Some((imports.trim_end().to_string(), lines[start..].join("\n")))
}
/// The client modules in the order the generated package nests them: each
/// package before its own children, siblings in the order
/// [`module_children`] yields. A flat SDK's order is unchanged by this; a nested
/// one (`catalogs/mcp_servers`) is walked through its parent rather than in the
/// document order its paths happened to appear in.
fn client_tree_modules(ir: &Ir) -> Vec<&str> {
let children = module_children(&ir.endpoint_modules);
let mut out: Vec<&str> = Vec::new();
let mut stack: Vec<String> = children
.get("")
.map(|names| names.iter().rev().cloned().collect())
.unwrap_or_default();
while let Some(module) = stack.pop() {
if let Some(name) = ir
.endpoint_modules
.iter()
.find(|known| **known == module)
.map(String::as_str)
{
out.push(name);
}
if let Some(names) = children.get(&module) {
for child in names.iter().rev() {
stack.push(child.clone());
}
}
}
out
}
/// The client modules in the order `reference.md` lists them. A package's own
/// endpoints come at its place among its siblings, but its children are listed
/// where a package that owns no endpoints would be — after every sibling that
/// owns endpoints, among the pure parents sorted by name. TrueForge's `internal`
/// owns endpoints and nests four sub-clients, and its golden lists `Internal`
/// first, then `Agents` … `Skills`, then the `Catalogs`, `Internal` and
/// `Settings` sub-clients in that order.
fn reference_modules(ir: &Ir) -> Vec<&str> {
fn visit<'a>(
parent: &str,
children: &BTreeMap<String, Vec<String>>,
ir: &'a Ir,
out: &mut Vec<&'a str>,
) {
let Some(names) = children.get(parent) else {
return;
};
out.extend(names.iter().filter_map(|name| {
ir.endpoint_modules
.iter()
.find(|known| *known == name)
.map(String::as_str)
}));
let mut folders: Vec<&String> = names
.iter()
.filter(|name| children.contains_key(*name))
.collect();
folders.sort();
for folder in folders {
visit(folder, children, ir, out);
}
}
let children = module_children(&ir.endpoint_modules);
let mut out = Vec::new();
visit("", &children, ir, &mut out);
out
}
/// The order the README walks endpoints in. Fern builds client modules in
/// first-seen module order and reads the README's endpoints from that grouped
/// view. Keep operations within each module in source order, but do not let an
/// operation from a later module leapfrog one from the first module merely
/// because their paths were interleaved. The root client's own operations come
/// after every sub-client's: OpenCodeUI's `Session`-tagged pair offers no `POST`,
/// and its README anchors on the root's first one, `global_dispose`.
fn readme_endpoint_order(ir: &Ir) -> Vec<&Endpoint> {
let grouped: Vec<&Endpoint> = client_tree_modules(ir)
.into_iter()
.chain(std::iter::once(""))
.flat_map(|module| {
ir.endpoints
.iter()
.filter(move |ep| ep.module.as_str() == module)
})
.collect();
if grouped.is_empty() {
ir.endpoints
.iter()
.filter(|e| e.module.is_empty())
.collect()
} else {
grouped
}
}
fn readme_endpoint(ir: &Ir) -> Option<&Endpoint> {
let order = readme_endpoint_order(ir);
select_readme_endpoint(order.into_iter(), &ir.types, &ir.tag_types)
}
/// The endpoint the README's streaming section demonstrates: the first emittable
/// streaming operation anywhere in the SDK. Fern documents streaming whenever the
/// SDK has it, so this is independent of the endpoint the usage example shows.
fn readme_streaming_endpoint(ir: &Ir) -> Option<&Endpoint> {
readme_endpoint_order(ir)
.into_iter()
.find(|e| e.emittable && e.streaming)
}
fn client_call_prefix(ep: &Endpoint) -> String {
let method = ep.method_name.trim_end_matches('_');
if ep.module.is_empty() {
format!("client.{method}")
} else {
format!("client.{}.{method}", module_path(&ep.module))
}
}
fn raw_client_call_prefix(ep: &Endpoint) -> String {
let method = ep.method_name.trim_end_matches('_');
if ep.module.is_empty() {
format!("response = client.with_raw_response.{method}")
} else {
format!(
"response = client.{}.with_raw_response.{method}",
module_path(&ep.module)
)
}
}
/// The generated `README.md`: mostly static prose with the SDK name/package
/// substituted and a worked usage example (sync + async) synthesized from the
/// first endpoint. Compared verbatim (README is not comment-stripped). Emitted
/// only when there is an endpoint to demonstrate; the caller writes the empty file
/// Fern leaves behind when there is not (`cyberark-conjur-api`).
fn readme_file(ir: &Ir) -> Option<GeneratedFile> {
// Fern demonstrates the first emittable endpoint that has a request body (the
// most illustrative call). APIs with no body-bearing operations use the first
// no-argument non-GET operation before falling back to the first endpoint.
let first = readme_endpoint(ir)?;
let pkg = &ir.package_name;
let org = naming::to_pascal_case(pkg);
let async_name = format!("Async{}", ir.client_name);
// The abbreviated calls in the error-handling and advanced sections show `...`
// whenever the endpoint has a path or query argument or a request body; an
// argument-free endpoint (and the `_` placeholder) shows empty parens. The
// error/raw-response calls are ruff-wrapped at the snippet width 88. A golden
// refresh that reintroduces body-shape-sensitive placeholders is a Fern behavior
// change, not a regression here.
// A header is no argument here: measured at Fern 5.20.0 (not re-measured at
// the certified pair), Fern writes `client.probe()` for an endpoint whose only
// parameter is a header, scalar or array, required or not.
let has_arguments = !first.path_params.is_empty()
|| !first.query_params.is_empty()
|| first.request_body.is_some();
let complex = first.method_name != "_" && has_arguments;
let err_call = abbrev_call(4, &client_call_prefix(first), complex);
// A paginated endpoint's raw-response snippet walks its pager, as Fern's
// does; any other reads the raw client's response.
let raw_block = if first.pagination.is_some() {
format!(
"client = @@CLIENT@@(\n ...,\n)\npager = {}\nprint(pager.response) # access the typed response for the first page\nfor item in pager:\n print(item) # access the underlying object(s)\nfor page in pager.iter_pages():\n print(page.response) # access the typed response for each page\n for item in page:\n print(item) # access the underlying object(s)\n",
abbrev_call(0, &client_call_prefix(first), complex)
)
} else {
format!(
"client = @@CLIENT@@(...)\n{}\nprint(response.headers) # access the response headers\nprint(response.status_code) # access the response status code\nprint(response.data) # access the underlying object\n",
abbrev_call(0, &raw_client_call_prefix(first), complex)
)
};
let retry_prefix = client_call_prefix(first);
let retry_call = format!(
"{retry_prefix}({}request_options={{",
if complex { "..., " } else { "" }
);
let sync_example = {
let mut ctx = ExampleCtx {
types: &ir.types,
enum_type: ir.enum_type,
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
tag_decls: &ir.tag_types,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: true,
};
build_documentation_example(
first,
false,
&first.module,
pkg,
&ir.client_name,
&mut ctx,
None,
false,
)?
.join("\n")
};
let async_example = {
let mut ctx = ExampleCtx {
types: &ir.types,
enum_type: ir.enum_type,
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
tag_decls: &ir.tag_types,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: true,
};
build_documentation_example(
first,
true,
&first.module,
pkg,
&ir.client_name,
&mut ctx,
None,
false,
)?
.join("\n")
};
let mut streaming_args =
documentation_client_example_args(&ir.auth, &ir.global_headers, &ir.client_path_parameters)
.into_iter()
.map(|arg| format!(" {arg},\n"))
.collect::<String>();
if ir.environment.is_none() {
streaming_args.push_str(" base_url=\"https://yourhost.com/path/to/api\",\n");
}
// A client taking no argument is constructed on one line: `dot-ai` declares
// a server and no auth, and its streaming section reads `client = FernApi()`.
let client_call = if streaming_args.is_empty() {
format!("{}()", ir.client_name)
} else {
format!("{}(\n{streaming_args})", ir.client_name)
};
let stream_ep = readme_streaming_endpoint(ir);
let streaming = stream_ep.map_or_else(String::new, |ep| {
// The call carries its worked arguments here, exactly as the usage example
// does; an argument-free streaming endpoint renders the same empty parens
// it always did.
let (imports, call) = readme_example_parts(ir, ep, pkg).unwrap_or_else(|| {
(
format!("from {pkg} import {}", ir.client_name),
format!("{}()", client_call_prefix(ep)),
)
});
format!(
"## Streaming\n\nThe SDK supports streaming responses, as well, the response will be a generator that you can loop over.\n\n```python\n{imports}\n\nclient = {client_call}\n\n{call}\n```\n\n",
)
});
// The pager section, on the first paginated endpoint in client order. Fern
// shows the plain call and then the page-by-page form.
let pager_ep = readme_endpoint_order(ir)
.into_iter()
.find(|ep| ep.emittable && ep.pagination.is_some());
let pagination = pager_ep.map_or_else(String::new, |ep| {
let prefix = client_call_prefix(ep);
format!(
"## Pagination\n\nPaginated requests will return a `SyncPager` or `AsyncPager`, which can be used as generators for the underlying object.\n\n```python\nfrom {pkg} import {}\n\nclient = {client_call}\n\n{prefix}()\n```\n\n```python\n# You can also iterate through pages and access the typed response per page\npager = {prefix}(...)\nfor page in pager.iter_pages():\n print(page.response) # access the typed response for each page\n for item in page:\n print(item)\n```\n\n",
ir.client_name,
)
});
let contents = include_str!("../assets/scaffolding/README.md.tmpl")
.replace(
"@@STREAMING_TOC@@",
if stream_ep.is_some() {
"- [Streaming](#streaming)\n"
} else {
""
},
)
.replace("@@STREAMING@@", &streaming)
.replace(
"@@PAGINATION_TOC@@\n",
if pager_ep.is_some() {
"- [Pagination](#pagination)\n"
} else {
""
},
)
.replace("@@PAGINATION@@\n", &pagination)
.replace(
"@@ENVIRONMENTS_TOC@@\n",
if ir.environment.is_some() {
"- [Environments](#environments)\n"
} else {
""
},
)
.replace(
"@@ENVIRONMENTS@@\n\n",
&if ir.environment.is_some() {
// A lifted base-path parameter leads the constructor here too,
// as in every snippet (the `lifted-base-path-docs-examples`
// departure): without a default, Fern's call misses it.
let lifted: String = ir
.client_path_parameters
.iter()
.map(|parameter| format!(" {},\n", client_path_parameter_example(parameter)))
.collect();
format!(
"## Environments\n\nThis SDK allows you to configure different environments for API requests.\n\n```python\nfrom @@PKG@@ import @@CLIENT@@\nfrom @@PKG@@.environment import @@CLIENT@@Environment\n\nclient = @@CLIENT@@(\n{lifted} environment=@@ENV_DEFAULT@@,\n)\n```\n\n"
)
} else {
String::new()
},
)
// Fern's shield credits the organization. Only the flat layout follows
// it: the packaged README keeps the `Fern` it has always carried, so no
// packaged output changes with the layout setting.
.replace(
"@@SHIELD_ORG@@",
match ir.layout {
Layout::Packaged => "Fern",
Layout::Flat => &org,
},
)
.replace("@@ORG@@", &org)
.replace("@@PROJECT@@", &ir.project_name)
.replace("@@PKG@@", pkg)
.replace("@@RAW_BLOCK@@", &raw_block)
.replace("@@CLIENT@@", &ir.client_name)
.replace("@@ASYNC@@", &async_name)
.replace(
"@@ENV_DEFAULT@@",
&ir.environment.as_ref().map_or_else(
|| format!("{}Environment.DEFAULT", ir.client_name),
crate::ir::Environment::default_ref,
),
)
.replace("@@ERR_CALL@@", &err_call)
.replace("@@RETRY_CALL@@", &retry_call)
.replace("@@USAGE@@", &sync_example)
.replace("@@ASYNC_EXAMPLE@@", &async_example);
Some(GeneratedFile {
path: PathBuf::from("README.md"),
contents,
})
}
/// The generated `reference.md`: a per-endpoint reference grouped by tag, each
/// entry a collapsible `<details>` with an optional description, a worked sync
/// usage example, and a parameter table. Endpoints without an example (a raw
/// bytes body) are omitted, exactly as Fern does; an API with no entry at all still
/// gets the bare `# Reference` heading Fern writes (`cyberark-conjur-api`).
/// Compared verbatim.
fn reference_file(
env: &Environment<'static>,
ir: &Ir,
modules: &[&str],
tag_types: &BTreeMap<String, String>,
) -> Result<GeneratedFile> {
let pkg = &ir.package_name;
let mut blocks: Vec<String> = vec!["# Reference".to_string()];
let root_eps: Vec<&Endpoint> = ir
.endpoints
.iter()
.filter(|e| e.module.is_empty() && e.emittable)
.collect();
for ep in root_eps {
blocks.push(reference_entry(env, ir, ep, &ep.module, pkg, tag_types)?);
blocks.push(String::new());
}
for module in modules {
let eps: Vec<&Endpoint> = ir
.endpoints
.iter()
.filter(|e| e.module.as_str() == *module && e.emittable)
.collect();
let title = ir
.endpoint_module_titles
.get(*module)
.cloned()
.unwrap_or_else(|| naming::to_pascal_case(module));
blocks.push(format!("## {title}"));
for ep in eps {
blocks.push(reference_entry(env, ir, ep, module, pkg, tag_types)?);
blocks.push(String::new());
}
}
// The last entry's trailing blank line is kept: Fern ends the file with one.
Ok(GeneratedFile {
path: PathBuf::from("reference.md"),
contents: format!("{}\n", blocks.join("\n")),
})
}
/// One parameter row in the `reference.md` table: name, annotation, and the
/// suffix (a lone space, or ` — <description>`).
#[derive(Serialize)]
struct ParamRow {
name: String,
annot: String,
suffix: String,
}
pub(crate) fn reference_param_suffix(description: Option<&str>) -> String {
match description {
// Importers sometimes retain leading blank lines around a one-line
// description. Fern still renders those through the ordinary em-dash
// form; only multiple substantive lines become a separate paragraph.
Some(d) if d.trim().contains('\n') => {
format!(" \n\n{}", d.trim_matches(['\r', '\n']))
}
Some(d) => format!(" — {d}"),
None => " ".to_string(),
}
}
/// The view model for one `reference.md` endpoint entry (`reference_entry.md.j2`).
#[derive(Serialize)]
struct ReferenceEntryView {
client_prefix: String,
href: String,
pkg: String,
method: String,
dots: &'static str,
return_type: String,
description: Option<String>,
example: String,
example_gap: &'static str,
params: Vec<ParamRow>,
}
/// Render one `reference.md` endpoint entry (a `<details>` block), returning it
/// without a trailing newline so [`reference_file`] can join entries uniformly.
fn reference_entry(
env: &Environment<'static>,
ir: &Ir,
ep: &Endpoint,
module: &str,
pkg: &str,
tag_types: &BTreeMap<String, String>,
) -> Result<String> {
let mut imports = Imports::at(RefLoc::Client(module.to_string()), tag_types);
let mp = method_params(ep, &mut imports);
let has_args = !mp.path.is_empty()
|| !mp.query.is_empty()
|| !mp.header.is_empty()
|| !mp.body.is_empty()
|| !ep.constant_headers.is_empty();
// The example (sync form). Bytes bodies are filtered out before this point.
let mut ctx = ExampleCtx {
types: &ir.types,
enum_type: ir.enum_type,
yaml_unquoted_timestamps: ir.yaml_unquoted_timestamps.as_ref(),
tag_decls: &ir.tag_types,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: &ir.auth,
has_environment: ir.environment.is_some(),
global_headers: &ir.global_headers,
client_path_parameters: &ir.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: true,
};
let example = (!ep.binary_schema_response || !module.is_empty() || ep.openapi_31)
.then(|| {
build_documentation_example(
ep,
false,
module,
pkg,
&ir.client_name,
&mut ctx,
ir.environment.as_ref(),
true,
)
})
.flatten()
.map_or_else(
|| {
if has_args {
format!("{}(...)", client_call_prefix(ep))
} else {
format!("{}()", client_call_prefix(ep))
}
},
|lines| lines.join("\n"),
);
// The parameter rows, in signature order, then `request_options`.
let mut params: Vec<ParamRow> = Vec::new();
for (name, ty, desc) in &mp.path {
// A *declared* description — even the empty string — gets the ` — {desc}`
// separator (Fern emits the em-dash whenever the parameter carries a
// `description` key); only an omitted (`None`) one gets the bare space.
let suffix = reference_param_suffix(desc.as_deref());
params.push(ParamRow {
name: name.trim_end_matches('_').to_string(),
annot: reference_param_annotation(&ty.replace("typing.Sequence[", "typing.List[")),
suffix,
});
}
let reference_body = if !ep.body_schema_dropped {
ep.request_body
.as_ref()
.and(ep.reference_body_type.as_ref())
.map_or_else(
|| mp.body,
|request| {
// An optional `json=request` body is documented optional:
// marimo-plugins' `type: "null"` bodies read
// `typing.Optional[MarimoChatbotCancelPromptOutput]`.
let request = match &ep.request_body {
Some(RequestBody::Single(single)) if !single.required => {
TypeRef::Optional(Box::new(request.clone()))
}
_ => request.clone(),
};
vec![DocParam {
name: "request".to_string(),
annotation: raw_type_str_ctx(&request, &mut imports, true),
default: None,
description: None,
}]
},
)
} else {
mp.body
};
// Fern's reference writer does not document an idempotency argument.
let mut reference_body: Vec<DocParam> = reference_body
.into_iter()
.filter(|param| {
!ep.extensions
.idempotency_headers
.iter()
.any(|header| header.py_name == param.name)
})
.collect();
if let Some(RequestBody::Inline(fields)) = &ep.request_body {
reference_body.sort_by_key(|param| {
fields
.iter()
.find(|field| field.py_name == param.name)
.map_or(usize::MAX, |field| field.reference_order)
});
// The field a stream condition fixes is not a method argument, but the
// reference documents it — as a bare `typing.Literal`, after the required
// fields and ahead of the optional ones: it takes no default, so the
// required-first ordering below places it last of the required.
if let Some((wire, _)) = ep.stream_condition.as_ref() {
if let Some(field) = fields.iter().find(|field| field.wire_name == *wire) {
reference_body.push(DocParam {
name: field.py_name.clone(),
annotation: "typing.Literal".to_string(),
default: None,
description: field.docstring.clone(),
});
}
}
}
// Nor does it document a constant header, which Fern's does (the
// `constant-header-docs-arguments` departure).
let reference_header = mp.header;
for dp in ordered_keyword_params(&mp.query, &reference_header, &reference_body) {
let is_body = reference_body.iter().any(|body| body.name == dp.name);
let query_is_list = ep
.query_params
.iter()
.find(|query| query.py_name == dp.name)
.is_some_and(|query| !query.allow_multiple);
// A header's signature takes `typing.Sequence`; the reference documents it
// as `typing.List`, as it does a body.
let is_header = ep
.header_params
.iter()
.any(|header| header.py_name == dp.name);
// An inline wildcard (`*/*`) binary body is documented as the `bytes` its
// signature takes: apideck-file-storage's `files.upload` and
// `upload_sessions.upload` (measured at fern-python-sdk 5.41.3; 5.20.0's
// reference writer wrote the OpenAPI source type `str`).
let annotation = if is_body || query_is_list || is_header {
reference_list_annotation(&dp.annotation)
} else {
dp.annotation.clone()
};
let description = if is_body {
match &ep.request_body {
Some(RequestBody::Single(_)) => ep.request_body_schema_doc.as_deref(),
Some(RequestBody::Inline(_))
if !ep.body_schema_dropped && ep.reference_body_type.is_some() =>
{
ep.request_body_schema_doc
.as_deref()
.or(dp.description.as_deref())
}
Some(RequestBody::Bytes { .. }) => ep.request_body_doc.as_deref(),
Some(RequestBody::Form(form)) => form
.fields
.iter()
.find(|field| field.py_name == dp.name)
.and_then(|field| field.docstring.as_deref()),
_ => dp.description.as_deref(),
}
} else {
dp.description.as_deref()
};
let reference_name = if is_body {
ep.request_body
.as_ref()
.and_then(|body| match body {
RequestBody::Inline(fields) => fields
.iter()
.find(|field| field.py_name == dp.name)
.filter(|field| {
field
.wire_name
.starts_with(|character: char| character.is_ascii_digit())
})
.map(|field| {
naming::model_field_name(&field.wire_name)
.trim_start_matches("f_")
.to_string()
}),
// A form property whose wire name ends in `[]` is documented
// with the bracket spelled out — DaniWeb's `emails[]` is
// `emails_array` in `reference.md`, while the Python argument
// the same property names stays `emails`.
RequestBody::Form(form) => form
.fields
.iter()
.find(|field| field.py_name == dp.name)
.filter(|field| field.wire_name.ends_with("[]"))
.map(|field| {
naming::field_name(&format!(
"{}_array",
field.wire_name.trim_end_matches("[]")
))
}),
_ => None,
})
.unwrap_or_else(|| dp.name.trim_end_matches('_').to_string())
} else {
// A query parameter's `[]` is spelled out the same way: eNanoMapper's
// `property_uris[]` is `property_uris_array` in `reference.md`.
ep.query_params
.iter()
.find(|query| query.py_name == dp.name)
.filter(|query| query.wire_name.ends_with("[]"))
.map(|query| {
naming::field_name(&format!("{}_array", query.wire_name.trim_end_matches("[]")))
})
.unwrap_or_else(|| dp.name.trim_end_matches('_').to_string())
};
params.push(ParamRow {
name: reference_name,
annot: reference_param_annotation(&annotation),
suffix: reference_param_suffix(description),
});
}
params.push(ParamRow {
name: "request_options".to_string(),
annot: "typing.Optional[RequestOptions]".to_string(),
suffix: " — Request-specific configuration.".to_string(),
});
let view = ReferenceEntryView {
client_prefix: if module.is_empty() {
"client.".to_string()
} else {
format!("client.{}.", module_path(module))
},
href: if module.is_empty() {
format!("src/{pkg}/client.py")
} else {
format!("src/{pkg}/{module}/client.py")
},
pkg: pkg.to_string(),
method: ep.method_name.trim_end_matches('_').to_string(),
// A documented parameter the method does not take — a propertyless model
// documented as `request` — still earns the dots.
dots: if has_args || params.len() > 1 {
"..."
} else {
""
},
return_type: reference_return_type(
ep,
&mp.inner,
&sse_chunk(
ep,
&mut Imports::at(RefLoc::Client(module.to_string()), tag_types),
),
),
description: ep
.docstring
.as_ref()
.map(|description| format!("{description}{}", ep.reference_description_suffix)),
example,
example_gap: if !endpoint_has_worked_example(ep)
|| !path_object_documented(ep, &ir.types, &ir.tag_types)
|| matches!(ep.request_body, Some(RequestBody::Bytes { .. }))
|| ep.binary_schema_response && module.is_empty() && !ep.openapi_31
{
""
} else {
"\n"
},
params,
};
let rendered = render(
env,
"reference_entry.md",
"reference.md",
minijinja::Value::from_serialize(&view),
)?;
Ok(rendered.trim_end_matches('\n').to_string())
}
fn reference_list_annotation(annotation: &str) -> String {
annotation.replace("typing.Sequence[", "typing.List[")
}
pub(crate) fn reference_param_annotation(annotation: &str) -> String {
let mut annotation = annotation.replace("dt.", "datetime.");
if let Some(inner) = annotation
.strip_prefix("typing.Optional[typing.Optional[")
.and_then(|value| value.strip_suffix("]]"))
{
annotation = format!("typing.Optional[{inner}]");
}
annotation
}
/// The ` -> <type>` suffix on a `reference.md` entry's `<summary>` line. Fern's
/// markdown writer spells the datetime types out in full (`datetime.datetime`)
/// rather than with the `dt.` alias the generated Python imports them under —
/// the same prose spelling [`reference_param_annotation`] applies to the
/// parameter rows, so no golden's `reference.md` ever carries a `dt.` type.
///
/// A stream is headed with the iterator its method returns, `chunk` typed —
/// where Fern heads every stream `typing.Iterator[bytes]` (the
/// `stream-reference-return-type` departure); a binary download does return
/// `typing.Iterator[bytes]`.
fn reference_return_type(ep: &Endpoint, response: &str, chunk: &str) -> String {
let streamed = format!("typing.Iterator[{chunk}]");
let rendered = if ep.streaming {
streamed.as_str()
} else if ep.binary_response {
"typing.Iterator[bytes]"
} else {
response
};
if rendered == "None" {
String::new()
} else {
format!(" -> {}", rendered.replace("dt.", "datetime."))
}
}
/// Render `from <module> import <names>` at `indent` spaces, sorted, on one line.
/// `ruff format` wraps it into a parenthesized per-name block if it overflows.
/// Returns the statement with its trailing newline.
fn from_import_block(module: &str, names: &[String], indent: usize) -> String {
// An empty group emits nothing — a `from <module> import` with no names is a
// syntax error, and the source module (e.g. `errors/`) is not emitted when
// there is nothing to import from it. The corpus always has errors, so this
// path is only reached for specs that declare none.
if names.is_empty() {
return String::new();
}
let pad = " ".repeat(indent);
let mut names = names.to_vec();
names.sort();
format!("{pad}from {module} import {}\n", names.join(", "))
}
/// Fern's SDK runtime, vendored under `assets/core/` and emitted into every SDK.
/// Each entry is (path under `core/`, verbatim contents). `client_wrapper.py` is
/// *not* here — Fern shapes it from the auth model, so it is generated by
/// [`client_wrapper_file`]. See `assets/README.md` and `NOTICE` for provenance.
const CORE_ASSETS: &[(&str, &str)] = &[
("__init__.py", include_str!("../assets/core/__init__.py")),
("api_error.py", include_str!("../assets/core/api_error.py")),
(
"datetime_utils.py",
include_str!("../assets/core/datetime_utils.py"),
),
("enum.py", include_str!("../assets/core/enum.py")),
("file.py", include_str!("../assets/core/file.py")),
(
"force_multipart.py",
include_str!("../assets/core/force_multipart.py"),
),
(
"http_client.py",
include_str!("../assets/core/http_client.py"),
),
(
"http_response.py",
include_str!("../assets/core/http_response.py"),
),
(
"http_sse/__init__.py",
include_str!("../assets/core/http_sse/__init__.py"),
),
(
"http_sse/_api.py",
include_str!("../assets/core/http_sse/_api.py"),
),
(
"http_sse/_decoders.py",
include_str!("../assets/core/http_sse/_decoders.py"),
),
(
"http_sse/_exceptions.py",
include_str!("../assets/core/http_sse/_exceptions.py"),
),
(
"http_sse/_models.py",
include_str!("../assets/core/http_sse/_models.py"),
),
(
"jsonable_encoder.py",
include_str!("../assets/core/jsonable_encoder.py"),
),
("logging.py", include_str!("../assets/core/logging.py")),
(
"parse_error.py",
include_str!("../assets/core/parse_error.py"),
),
(
"pydantic_utilities.py",
include_str!("../assets/core/pydantic_utilities.py"),
),
(
"query_encoder.py",
include_str!("../assets/core/query_encoder.py"),
),
(
"remove_none_from_dict.py",
include_str!("../assets/core/remove_none_from_dict.py"),
),
(
"request_options.py",
include_str!("../assets/core/request_options.py"),
),
(
"serialization.py",
include_str!("../assets/core/serialization.py"),
),
];
/// Placeholders substituted in `client_wrapper.py` and the scaffolding files.
const SDK_NAME_PLACEHOLDER: &str = "@@CROZIER_SDK_NAME@@";
const SDK_VERSION_PLACEHOLDER: &str = "@@CROZIER_SDK_VERSION@@";
/// The import package name (directory under `src/`) placeholder in `pyproject.toml`.
const PACKAGE_PLACEHOLDER: &str = "@@CROZIER_PACKAGE@@";
/// Default SDK version stamped into the runtime (Fern uses `0.0.0` when none is
/// configured). Not yet exposed as a flag. The `sdk-name-version-headers`
/// departure recognises exactly this value.
pub(crate) const DEFAULT_SDK_VERSION: &str = "0.0.0";
/// The one `.fern/metadata.json` crozier writes, whatever it is configured with;
/// the `fern-metadata-generator-config` departure recognises exactly this record.
pub(crate) const FERN_METADATA_RECORD: &str = include_str!("../assets/scaffolding/metadata.json");
/// The Fern pair crozier is certified against: a Fern CLI version and the
/// `fernapi/fern-python-sdk` generator version it drives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FernPair {
/// The Fern CLI version (`cliVersion`).
pub cli: &'static str,
/// The `fernapi/fern-python-sdk` version (`generatorVersion`).
pub python_sdk: &'static str,
}
/// The certified Fern pair, read from `assets/scaffolding/metadata.json` — the
/// one declaration of it. Tests and tools derive every spelling of the pair from
/// that file rather than restating it.
pub fn certified_fern_pair() -> FernPair {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Record {
cli_version: &'static str,
generator_version: &'static str,
}
// The record is compiled in; a malformed one fails every test that reads it.
let record: Record = serde_json::from_str(FERN_METADATA_RECORD)
.expect("assets/scaffolding/metadata.json names cliVersion and generatorVersion");
FernPair {
cli: record.cli_version,
python_sdk: record.generator_version,
}
}
/// Project scaffolding Fern emits verbatim apart from project/package names and
/// the SDK version. The Python test/default-client templates carry the same
/// substitutions so non-`fern` package names remain importable. Vendored under
/// `assets/scaffolding/` (Apache-2.0; see `NOTICE`).
///
/// The flat layout drops the packaging (`pyproject.toml`, `requirements.txt`), and
/// its aiohttp install hint names the package (import) name where the packaged
/// form names the distribution — Fern's flat tree has no distribution to name.
fn scaffolding_files(pkg: &str, project_name: &str, layout: Layout) -> Vec<GeneratedFile> {
let pyproject = include_str!("../assets/scaffolding/pyproject.toml")
.replace(SDK_NAME_PLACEHOLDER, project_name)
.replace(PACKAGE_PLACEHOLDER, pkg)
.replace(SDK_VERSION_PLACEHOLDER, DEFAULT_SDK_VERSION);
let install_name = match layout {
Layout::Packaged => project_name,
Layout::Flat => pkg,
};
let substitute_names = |contents: &str| {
contents
.replace(SDK_NAME_PLACEHOLDER, install_name)
.replace(PACKAGE_PLACEHOLDER, pkg)
};
let mut default_clients = substitute_names(include_str!(
"../assets/scaffolding/default_clients.py.tmpl"
));
// The vendored file carries the hint as ruff lays out a mid-length name: the
// call split over three lines. Ruff joins it back onto one line when that fits
// in 120 columns (a short name, e.g. a flat tree's `fern`), and splits the
// string itself when even the split call overflows. Only the flat layout
// joins it, so no packaged output changes with the layout setting.
let hint = format!(
"\"To use the aiohttp client, install the aiohttp extra: pip install {install_name}[aiohttp]\""
);
let one_line = format!(" raise RuntimeError({hint})");
if layout == Layout::Flat && one_line.chars().count() <= crate::pyfmt::LINE_LENGTH {
default_clients = default_clients.replace(
&format!(" raise RuntimeError(\n {hint}\n )"),
&one_line,
);
} else if default_clients.lines().any(|line| line.len() > 120) {
default_clients = default_clients.replace(
&hint,
&format!(
"\"To use the aiohttp client, install the aiohttp extra: \"\n \"pip install {install_name}[aiohttp]\""
),
);
}
let mut aiohttp_test = substitute_names(include_str!(
"../assets/scaffolding/test_aiohttp_autodetect.py.tmpl"
));
// Fern's isort pass files the SDK's own import in a first-party section of
// its own when ruff can find the package: always in the packaged tree
// (`src/<pkg>/`), and in the flat tree only for a package named `fern`, which
// ruff resolves inside Fern's `/fern` container. Any other flat package is
// third-party to it and joins `import httpx_aiohttp` with no blank line.
if layout == Layout::Flat && pkg != "fern" {
aiohttp_test = aiohttp_test.replace(
&format!(" import httpx_aiohttp # type: ignore[import-not-found]\n\n from {pkg}."),
&format!(" import httpx_aiohttp # type: ignore[import-not-found]\n from {pkg}."),
);
}
let mut files = Vec::new();
if layout == Layout::Packaged {
files.push(GeneratedFile {
path: PathBuf::from("pyproject.toml"),
contents: pyproject,
});
files.push(GeneratedFile {
path: PathBuf::from("requirements.txt"),
contents: include_str!("../assets/scaffolding/requirements.txt").to_string(),
});
}
files.extend([
GeneratedFile {
path: PathBuf::from(".fern/metadata.json"),
contents: FERN_METADATA_RECORD.to_string(),
},
GeneratedFile {
path: PathBuf::from("CONTRIBUTING.md"),
contents: include_str!("../assets/scaffolding/CONTRIBUTING.md").to_string(),
},
GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/_default_clients.py")),
contents: default_clients,
},
GeneratedFile {
path: PathBuf::from("tests/conftest.py"),
contents: include_str!("../assets/scaffolding/conftest.py").to_string(),
},
GeneratedFile {
path: PathBuf::from("tests/test_aiohttp_autodetect.py"),
contents: aiohttp_test,
},
]);
files
}
/// Emit the vendored core runtime for a package. The runtime assets are emitted
/// verbatim; `client_wrapper.py` is generated separately (see
/// [`client_wrapper_file`]) because Fern shapes it from the auth model. Fern
/// ships `core/enum.py` (the `StrEnum` base) only when enums render as classes,
/// so a literals SDK omits it.
fn core_files(pkg: &str, paginated: bool, enum_type: EnumType, auth: &Auth) -> Vec<GeneratedFile> {
let mut files: Vec<GeneratedFile> = CORE_ASSETS
.iter()
.filter(|(rel, _)| *rel != "enum.py" || enum_type == EnumType::PythonEnums)
.map(|(rel, content)| GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/core/{rel}")),
contents: if paginated && *rel == "__init__.py" {
core_init_with_pagination(content)
} else if *rel == "http_client.py" {
http_client_with_auth_headers(content, auth)
} else {
(*content).to_string()
},
})
.collect();
// The pager runtime ships only with an SDK that has a paginated endpoint —
// Fern emits `core/pagination.py` and exports its two classes from `core`
// exactly then, and leaves both out otherwise.
if paginated {
files.push(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/core/pagination.py")),
contents: include_str!("../assets/core/pagination.py").to_string(),
});
}
files
}
/// The empty `_AUTH_HEADERS` set the vendored `core/http_client.py` declares.
const EMPTY_AUTH_HEADERS: &str = "_AUTH_HEADERS: typing.FrozenSet[str] = frozenset()\n";
/// `core/http_client.py` with `_AUTH_HEADERS` naming the header an `apiKey`
/// credential travels in, lower-cased, so its logged requests redact it. Fern
/// names only header schemes there: a bearer or basic credential's
/// `Authorization` is already in the runtime's own sensitive set, and the set
/// stays empty for it as for an unauthenticated API.
fn http_client_with_auth_headers(asset: &str, auth: &Auth) -> String {
let header = match auth {
Auth::ApiKey { header, .. } => Some(header.to_ascii_lowercase()),
Auth::Bearer { .. } | Auth::Basic { .. } | Auth::None => None,
};
debug_assert!(
asset.contains(EMPTY_AUTH_HEADERS),
"core/http_client.py asset no longer carries {EMPTY_AUTH_HEADERS:?}"
);
match header {
Some(header) => asset.replacen(
EMPTY_AUTH_HEADERS,
&format!(
"_AUTH_HEADERS: typing.FrozenSet[str] = frozenset({{{}}})\n",
literal_enum_value(&header)
),
1,
),
None => asset.to_owned(),
}
}
/// `core/__init__.py` with the two pager names spliced into its three sorted
/// blocks. The asset is verbatim Fern output, so the splices are anchored on the
/// neighbours the sort puts them beside rather than re-rendering the file.
fn core_init_with_pagination(asset: &str) -> String {
let mut out = asset.to_string();
for (anchor, inserted) in [
(
" from .parse_error import ParsingError\n",
" from .pagination import AsyncPager, SyncPager\n",
),
(
" \"BaseClientWrapper\": \".client_wrapper\",\n",
" \"AsyncPager\": \".pagination\",\n",
),
(
" \"UniversalBaseModel\": \".pydantic_utilities\",\n",
" \"SyncPager\": \".pagination\",\n",
),
(" \"BaseClientWrapper\",\n", " \"AsyncPager\",\n"),
(" \"UniversalBaseModel\",\n", " \"SyncPager\",\n"),
] {
debug_assert!(
out.contains(anchor),
"core/__init__.py asset no longer carries {anchor:?}"
);
out = out.replacen(anchor, &format!("{inserted}{anchor}"), 1);
}
out
}
/// The auth-varying fragments of `client_wrapper.py`: the credential constructor
/// parameter, its assignment in `BaseClientWrapper.__init__`, the `get_headers`
/// lines that apply it, the optional `_get_token` helper, and the `super().__init__`
/// argument. Each fragment carries its own trailing newline where the template
/// expects one.
struct AuthWrapper {
/// Each credential's `(name, constructor parameter line)`; a credential a
/// promoted header of the same name already declares is not declared again.
params: Vec<(String, String)>,
assign: String,
header_block: String,
token_method: String,
/// Whether the credential is a bearer token sent in a header — Fern's
/// `_has_flat_token_header`. Such a wrapper's `get_headers` takes an
/// `include_token` flag, and `async_get_headers` turns it off when an async
/// token supplier is set, so a blocking token callable never runs on the event
/// loop (`airbyte.local-config`, `squareup.com`, `xero.com-xero-payroll-au`;
/// measured at fern-python-sdk 5.41.3).
flat_token: bool,
}
impl AuthWrapper {
/// The constructor parameters no name in `taken` already declares.
fn param(&self, taken: &std::collections::HashSet<&str>) -> String {
self.params
.iter()
.filter(|(name, _)| !taken.contains(name.as_str()))
.map(|(_, line)| line.as_str())
.collect()
}
/// The `super().__init__` arguments no name in `taken` already passes.
fn super_arg(&self, taken: &std::collections::HashSet<&str>) -> String {
self.params
.iter()
.filter(|(name, _)| !taken.contains(name.as_str()))
.map(|(name, _)| format!("{name}={name}, "))
.collect()
}
}
fn auth_wrapper_parts(auth: &Auth) -> AuthWrapper {
// The callable-or-string getter Fern writes for a bearer or basic credential.
let getter = |param: &str, required: bool| {
if required {
format!(" def _get_{param}(self) -> str:\n if isinstance(self._{param}, str):\n return self._{param}\n else:\n return self._{param}()\n\n")
} else {
format!(" def _get_{param}(self) -> typing.Optional[str]:\n if isinstance(self._{param}, str) or self._{param} is None:\n return self._{param}\n else:\n return self._{param}()\n\n")
}
};
let callable = |required: bool| {
if required {
"typing.Union[str, typing.Callable[[], str]]".to_string()
} else {
"typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None".to_string()
}
};
match auth {
Auth::ApiKey {
header,
required,
credential,
prefix,
} => {
let p = &credential.param;
let value = prefix.as_ref().map_or_else(
|| format!("self.{p}"),
|prefix| format!("f\"{} {{self.{p}}}\"", escape_py_str(prefix)),
);
let (ty, header_block) = if *required {
(
"str".to_string(),
format!(" headers[\"{header}\"] = {value}\n"),
)
} else {
(
"typing.Optional[str] = None".to_string(),
format!(" if self.{p} is not None:\n headers[\"{header}\"] = {value}\n"),
)
};
AuthWrapper {
params: vec![(p.clone(), format!(" {p}: {ty},\n"))],
assign: format!(" self.{p} = {p}\n"),
header_block,
token_method: String::new(),
flat_token: false,
}
}
Auth::Bearer {
required,
credential,
} => {
let p = &credential.param;
// The token is written under the `include_token` flag (see
// [`AuthWrapper::flat_token`]).
let header_block = if *required {
format!(" if include_token:\n headers[\"Authorization\"] = f\"Bearer {{self._get_{p}()}}\"\n")
} else {
format!(" if include_token:\n {p} = self._get_{p}()\n if {p} is not None:\n headers[\"Authorization\"] = f\"Bearer {{{p}}}\"\n")
};
AuthWrapper {
params: vec![(
p.clone(),
format!(" {p}: {},\n", callable(*required)),
)],
assign: format!(" self._{p} = {p}\n"),
header_block,
token_method: getter(p, *required),
flat_token: true,
}
}
Auth::Basic {
required,
username,
password,
} => {
let (u, w) = (&username.param, &password.param);
let header_block = if *required {
format!(" headers[\"Authorization\"] = httpx.BasicAuth(self._get_{u}(), self._get_{w}())._auth_header\n")
} else {
format!(" {u} = self._get_{u}()\n {w} = self._get_{w}()\n if {u} is not None and {w} is not None:\n headers[\"Authorization\"] = httpx.BasicAuth({u}, {w})._auth_header\n")
};
AuthWrapper {
params: vec![
(
u.clone(),
format!(" {u}: {},\n", callable(*required)),
),
(
w.clone(),
format!(" {w}: {},\n", callable(*required)),
),
],
assign: format!(" self._{u} = {u}\n self._{w} = {w}\n"),
header_block,
token_method: getter(u, *required) + &getter(w, *required),
flat_token: false,
}
}
// No auth: no credential parameter, assignment, header, or token helper.
Auth::None => AuthWrapper {
params: Vec::new(),
assign: String::new(),
header_block: String::new(),
token_method: String::new(),
flat_token: false,
},
}
}
/// The auth-varying fragments of the root client: the constructor parameter, the
/// credential name passed to the client wrapper, the docstring `Parameters` line,
/// and the `Examples` instantiation argument.
struct AuthClient {
ctor_param: String,
wrapper_arg: String,
doc_param: String,
example_line: String,
/// The checks a required credential read from the environment gets once the
/// client is built.
check: String,
}
fn auth_client_parts(auth: &Auth, taken: &std::collections::HashSet<&str>) -> AuthClient {
// Each credential: its parameter, its type, and whether the client requires
// it; basic auth carries two (`username`/`password`), every other scheme one.
let callable = "typing.Union[str, typing.Callable[[], str]]";
let creds: Vec<(&Credential, &str, bool)> = match auth {
Auth::ApiKey {
required,
credential,
..
} => vec![(credential, "str", *required)],
Auth::Bearer {
required,
credential,
} => vec![(credential, callable, *required)],
Auth::Basic {
required,
username,
password,
} => vec![
(username, callable, *required),
(password, callable, *required),
],
Auth::None => Vec::new(),
};
// A credential read from the environment is optional in the signature,
// defaulting to `None`: the variable is read when the client is built (see
// [`env_fallback`]), not when the module is imported (`x-fern-bearer: {env:
// …}`).
let ctor_ty = |credential: &Credential, ty: &str, required: bool| match &credential.env {
Some(_) => format!("typing.Optional[{ty}] = None"),
None if required => ty.to_string(),
None => format!("typing.Optional[{ty}] = None"),
};
let doc_ty = |credential: &Credential, ty: &str, required: bool| {
if required && credential.env.is_none() {
ty.to_string()
} else {
format!("typing.Optional[{ty}]")
}
};
let declared = |credential: &&Credential| !taken.contains(credential.param.as_str());
let ctor_param: String = creds
.iter()
.filter(|(credential, _, _)| declared(credential))
.map(|(credential, ty, required)| {
format!(
" {}: {},\n",
credential.param,
ctor_ty(credential, ty, *required)
)
})
.collect();
let doc_param: String = creds
.iter()
.map(|(credential, ty, required)| {
format!(
" {} : {}\n",
credential.param,
doc_ty(credential, ty, *required)
)
})
.collect();
// The wrapper call passes each credential through; the template supplies the
// final newline, so the joined block carries none.
let wrapper_arg = creds
.iter()
.filter(|(credential, _, _)| declared(credential))
.map(|(credential, _, _)| format!(" {0}={0},\n", credential.param))
.collect::<String>()
.trim_end_matches('\n')
.to_string();
// A credential sharing a promoted header's parameter is passed once (the
// `repeated-credential-example-keyword` departure).
let example_line: String = auth_example_args(auth)
.iter()
.filter(|arg| !taken.contains(keyword_name(arg)))
.map(|arg| format!(" {arg},\n"))
.collect();
let check: String = creds
.iter()
.filter(|(credential, _, _)| declared(credential))
.filter_map(|(credential, _, required)| {
Some(env_fallback(
&credential.param,
credential.env.as_ref()?,
*required,
))
})
.collect();
AuthClient {
ctor_param,
wrapper_arg,
doc_param,
example_line,
check,
}
}
/// The root client's read of the environment variable `env` for a constructor
/// field the caller left `None`, and, for a `required` one, the check that it
/// was given one way or the other. Fern reads the variable when the client is
/// built, not as the signature default (`lock-keeper-vault`, `ski-lift-gates`,
/// `relay-station-header-names`; measured at fern-python-sdk 5.41.3).
fn env_fallback(param: &str, env: &str, required: bool) -> String {
let env = escape_py_str(env);
let fallback =
format!(" {param} = {param} if {param} is not None else os.getenv(\"{env}\")\n");
if required {
format!(
"{fallback} if {param} is None:\n raise ApiError(body=\"The client must be instantiated be either passing in {param} or setting {env}\")\n"
)
} else {
fallback
}
}
/// The environment variable a promoted header's constructor field defaults to:
/// an additional header `apiKey` scheme's declared `env`.
fn header_env(header: &GlobalHeader) -> Option<&str> {
header.credential.as_ref()?.env.as_deref()
}
/// Every credential of `auth`, in constructor order.
fn auth_credentials(auth: &Auth) -> Vec<&Credential> {
auth.credentials()
}
/// The credential arguments in a worked `Examples` client instantiation, e.g.
/// `["token=\"YOUR_TOKEN\""]` or, for basic auth, both `username`/`password` lines
/// (each with no indent or trailing comma — the caller adds those).
fn auth_example_args(auth: &Auth) -> Vec<String> {
auth_credentials(auth)
.into_iter()
.map(|credential| {
// A keyword's escape (`class_`) is not in the placeholder: Fern
// writes `class_="YOUR_CLASS"`.
let stem = credential
.param
.strip_suffix('_')
.filter(|stem| naming::is_python_keyword(stem))
.unwrap_or(&credential.param);
format!(
"{}=\"YOUR_{}\"",
credential.param,
stem.to_ascii_uppercase()
)
})
.collect()
}
/// A header parameter's synthesized example. Fern fills a header with its own
/// name, and an array header with two such elements
/// (`probe_param=["probeParam", "probeParam"]`) where a list anywhere else holds
/// one.
fn header_example(value: Example) -> Example {
match value {
Example::List(items) if items.len() == 1 => {
Example::List(vec![items[0].clone(), items[0].clone()])
}
Example::ReferenceList(items) if items.len() == 1 => {
Example::ReferenceList(vec![items[0].clone(), items[0].clone()])
}
other => other,
}
}
/// The subsequence of promoted global headers that carries a distinct Python
/// parameter name, keeping the first of each. Two header apiKey schemes can
/// normalize alike — EN 18222 declares both `X-API-KEY` and `API-KEY`, and both
/// give `api_key` — and Fern splits them: each keeps its own assignment, header
/// write, docstring line, and example argument, but the emitted *signatures* and
/// call sites carry one `api_key`, because a repeated keyword there is a Python
/// syntax error rather than a redundancy.
fn distinct_global_header_params(global_headers: &[GlobalHeader]) -> Vec<&GlobalHeader> {
let mut seen = std::collections::HashSet::new();
global_headers
.iter()
.filter(|h| seen.insert(h.py_name.as_str()))
.collect()
}
/// Generate `core/client_wrapper.py`, shaped by the SDK's [`Auth`] model. The
/// bearer-optional form is byte-identical to Fern's default wrapper; api-key and
/// required-credential forms swap the constructor parameter, the header wiring,
/// and the token helper. Every wrapper's `max_retries` parameter defaults to
/// `default_max_retries`, as Fern's does to its `default_max_retries`.
/// Assembled from literal blocks (no source-line continuations, which would eat
/// the Python indentation).
fn client_wrapper_file(
pkg: &str,
sdk_name: Option<&str>,
auth: &Auth,
global_headers: &[GlobalHeader],
client_path_parameters: &[ClientPathParameter],
default_max_retries: u32,
environment: Option<&crate::ir::Environment>,
) -> GeneratedFile {
let a = auth_wrapper_parts(auth);
// A defaulted header trails every built-in field instead (see
// [`GlobalHeader::default`]), so it is split off the leading ones. The
// `353-string-default-*` authored probes byte-match this file end to end.
let (trailing, leading): (Vec<GlobalHeader>, Vec<GlobalHeader>) = global_headers
.iter()
.cloned()
.partition(|h| h.default().is_some());
// A credential a promoted header of the same name already declares is not
// declared or passed again: Fern's two `X-Api-Key` schemes, or a bearer named
// `api_key` beside one, share the header's `api_key` parameter.
let taken: std::collections::HashSet<&str> =
leading.iter().map(|h| h.py_name.as_str()).collect();
let tr_param: String = trailing
.iter()
.map(|h| format!(" {}: typing.Optional[str] = None,\n", h.py_name))
.collect();
let tr_assign: String = trailing
.iter()
.map(|h| format!(" self._{0} = {0}\n", h.py_name))
.collect();
let tr_super: String = trailing
.iter()
.map(|h| format!(" {0}={0},\n", h.py_name))
.collect();
// Promoted global headers: a constructor parameter, an assignment, a
// `get_headers` block, and a `super().__init__` argument — all emitted before
// the auth credential's, matching Fern's ordering. A required header is a
// mandatory `str` set unconditionally; an optional one is `Optional[str] = None`
// set only when provided.
// A lifted base-path parameter leads them: a field the routes read, never a
// header.
let path_param: String = client_path_parameters
.iter()
.map(|parameter| client_path_parameter_param(parameter, false))
.collect();
let client_param: String = path_param
+ &distinct_global_header_params(&leading)
.into_iter()
.map(|h| {
if h.required() {
format!(" {}: {},\n", h.py_name, global_header_annotation(h))
} else {
format!(
" {}: typing.Optional[{}] = None,\n",
h.py_name,
global_header_annotation(h)
)
}
})
.collect::<String>();
let client_assign: String = client_path_parameters
.iter()
.map(|parameter| ¶meter.py_name)
.chain(leading.iter().map(|h| &h.py_name))
.map(|name| format!(" self._{name} = {name}\n"))
.collect();
let header_line = |h: &GlobalHeader| {
// A non-string header is written as its `str()`, as Fern's is.
let value = if h.py_type() == HeaderType::Str {
format!("self._{}", h.py_name)
} else {
format!("str(self._{})", h.py_name)
};
if let Some(default) = h.default() {
format!(
" headers[\"{}\"] = {value} if {value} is not None else \"{}\"\n",
escape_py_str(&h.wire_name),
escape_py_str(default)
)
} else if h.required() {
format!(
" headers[\"{}\"] = {value}\n",
escape_py_str(&h.wire_name)
)
} else {
format!(
" if self._{} is not None:\n headers[\"{}\"] = {value}\n",
h.py_name,
escape_py_str(&h.wire_name)
)
}
};
// A defaulted header is written after the credential, as its field follows
// `logging`: Fern's `Accept`, defaulted on every operation, comes after the
// api-key header it sits beside.
let gh_header: String = global_headers
.iter()
.filter(|h| h.default().is_none())
.map(header_line)
.collect();
let tr_header: String = global_headers
.iter()
.filter(|h| h.default().is_some())
.map(header_line)
.collect();
let client_super: String = client_path_parameters
.iter()
.map(|parameter| ¶meter.py_name)
.chain(
distinct_global_header_params(&leading)
.into_iter()
.map(|h| &h.py_name),
)
.map(|name| format!("{name}={name}, "))
.collect();
// crozier brands its own SDK-identity headers rather than impersonating Fern:
// the `sdk-identity-header-prefix` departure (docs/departures/README.md),
// which the comparison recognises line by line.
// The SDK-identity pair names the published distribution, so only the
// packaged form (`sdk_name` present) sends it.
let sdk_identity = sdk_name.map_or_else(String::new, |name| {
format!(
" \"X-Crozier-SDK-Name\": \"{name}\",\n \"X-Crozier-SDK-Version\": \"{DEFAULT_SDK_VERSION}\",\n"
)
});
let include_token_param = if a.flat_token {
", *, include_token: bool = True"
} else {
""
};
let get_headers_head = format!(
" self._headers = headers\n self._base_url = base_url\n self._timeout = timeout\n self._max_retries = max_retries\n self._stream_reconnection_enabled = stream_reconnection_enabled\n self._max_stream_reconnection_attempts = max_stream_reconnection_attempts\n self._logging = logging\n{tr_assign}\n def get_headers(self{include_token_param}) -> typing.Dict[str, str]:\n import platform\n\n headers: typing.Dict[str, str] = {{\n \"X-Crozier-Language\": \"Python\",\n \"X-Crozier-Runtime\": f\"python/{{platform.python_version()}}\",\n \"X-Crozier-Platform\": f\"{{platform.system().lower()}}/{{platform.release()}}\",\n{sdk_identity} **(self.get_custom_headers() or {{}}),\n }}\n"
);
let mut c = String::new();
// Lead with the generated-file header (like every other emitted module): it
// survives `ruff format` and the e2e comment-strip folds it to the blank lines
// Fern's own stripped header leaves, so the leading layout matches.
c.push_str(HEADER);
if global_headers
.iter()
.any(|header| matches!(header.py_type(), HeaderType::Date))
{
c.push_str("\n\nimport datetime as dt");
} else {
c.push('\n');
}
c.push_str("\nimport typing\n\nimport httpx\nfrom .http_client import AsyncHttpClient, HttpClient\nfrom .logging import LogConfig, Logger\n\n\nclass BaseClientWrapper:\n def __init__(\n self,\n *,\n");
c.push_str(&client_param);
c.push_str(&a.param(&taken));
c.push_str(&format!(" headers: typing.Optional[typing.Dict[str, str]] = None,\n base_url: str,\n timeout: typing.Optional[float] = None,\n max_retries: int = {default_max_retries},\n stream_reconnection_enabled: typing.Optional[bool] = None,\n max_stream_reconnection_attempts: typing.Optional[int] = None,\n logging: typing.Optional[typing.Union[LogConfig, Logger]] = None,\n{tr_param} ):\n"));
c.push_str(&client_assign);
c.push_str(&a.assign);
c.push_str(&get_headers_head);
// Fern applies the credential after the global headers for every scheme but
// basic, which it applies before them: `squareup.com`, `exa-gate` and
// `openepcis-dpp-ready` each write their api-key global headers ahead of a
// bearer block declared before those schemes, while `cyberark-conjur-api`
// writes its basic block ahead of the `Authorization` api-key header. No
// golden pairs basic auth with an *ordinary* promoted header, so this places
// it ahead of both kinds — the only ordering the corpus measures.
let basic_auth = matches!(auth, Auth::Basic { .. });
if basic_auth {
c.push_str(&a.header_block);
}
c.push_str(&gh_header);
if !basic_auth {
c.push_str(&a.header_block);
}
c.push_str(&tr_header);
c.push_str(" return headers\n\n");
c.push_str(&a.token_method);
c.push_str(" def get_custom_headers(self) -> typing.Optional[typing.Dict[str, str]]:\n return self._headers\n\n def get_base_url(self) -> str:\n return self._base_url\n\n def get_timeout(self) -> typing.Optional[float]:\n return self._timeout\n\n def get_max_retries(self) -> int:\n return self._max_retries\n\n def get_stream_reconnection_enabled(self) -> bool:\n return self._stream_reconnection_enabled if self._stream_reconnection_enabled is not None else True\n\n def get_max_stream_reconnection_attempts(self) -> typing.Optional[int]:\n return self._max_stream_reconnection_attempts\n\n\nclass SyncClientWrapper(BaseClientWrapper):\n def __init__(\n self,\n *,\n");
c.push_str(&client_param);
c.push_str(&a.param(&taken));
c.push_str(&format!(" headers: typing.Optional[typing.Dict[str, str]] = None,\n base_url: str,\n timeout: typing.Optional[float] = None,\n max_retries: int = {default_max_retries},\n stream_reconnection_enabled: typing.Optional[bool] = None,\n max_stream_reconnection_attempts: typing.Optional[int] = None,\n logging: typing.Optional[typing.Union[LogConfig, Logger]] = None,\n{tr_param} httpx_client: httpx.Client,\n ):\n super().__init__(\n "));
c.push_str(&client_super);
c.push_str(&a.super_arg(&taken));
c.push_str("headers=headers,\n base_url=base_url,\n timeout=timeout,\n max_retries=max_retries,\n stream_reconnection_enabled=stream_reconnection_enabled,\n max_stream_reconnection_attempts=max_stream_reconnection_attempts,\n logging=logging,\n");
c.push_str(&tr_super);
c.push_str(" )\n self.httpx_client = HttpClient(\n httpx_client=httpx_client,\n base_headers=self.get_headers,\n base_timeout=self.get_timeout,\n base_url=self.get_base_url,\n base_max_retries=self.get_max_retries(),\n logging_config=self._logging,\n )\n\n\nclass AsyncClientWrapper(BaseClientWrapper):\n def __init__(\n self,\n *,\n");
c.push_str(&client_param);
c.push_str(&a.param(&taken));
c.push_str(&format!(" headers: typing.Optional[typing.Dict[str, str]] = None,\n base_url: str,\n timeout: typing.Optional[float] = None,\n max_retries: int = {default_max_retries},\n stream_reconnection_enabled: typing.Optional[bool] = None,\n max_stream_reconnection_attempts: typing.Optional[int] = None,\n logging: typing.Optional[typing.Union[LogConfig, Logger]] = None,\n{tr_param} async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None,\n httpx_client: httpx.AsyncClient,\n ):\n super().__init__(\n "));
c.push_str(&client_super);
c.push_str(&a.super_arg(&taken));
c.push_str("headers=headers,\n base_url=base_url,\n timeout=timeout,\n max_retries=max_retries,\n stream_reconnection_enabled=stream_reconnection_enabled,\n max_stream_reconnection_attempts=max_stream_reconnection_attempts,\n logging=logging,\n");
c.push_str(&tr_super);
c.push_str(" )\n self._async_token = async_token\n self.httpx_client = AsyncHttpClient(\n httpx_client=httpx_client,\n base_headers=self.get_headers,\n base_timeout=self.get_timeout,\n base_url=self.get_base_url,\n base_max_retries=self.get_max_retries(),\n async_base_headers=self.async_get_headers,\n logging_config=self._logging,\n )\n\n async def async_get_headers(self) -> typing.Dict[str, str]:\n headers = self.get_headers(");
if a.flat_token {
c.push_str("include_token=self._async_token is None");
}
c.push_str(")\n if self._async_token is not None:\n token = await self._async_token()\n headers[\"Authorization\"] = f\"Bearer {token}\"\n return headers\n");
// A multi-URL environment replaces the single `base_url` throughout: the
// wrapper is built from, keeps and serves the environment, each request
// naming its field's URL.
let c = match environment.filter(|environment| !environment.urls.is_empty()) {
None => c,
Some(environment) => {
let name = &environment.enum_name;
c.replacen(
"from .http_client import AsyncHttpClient, HttpClient\n",
&format!("from ..environment import {name}\nfrom .http_client import AsyncHttpClient, HttpClient\n"),
1,
)
.replace(" base_url: str,\n", &format!(" environment: {name},\n"))
.replacen(" self._base_url = base_url\n", " self._environment = environment\n", 1)
.replacen(
" def get_base_url(self) -> str:\n return self._base_url\n",
&format!(" def get_environment(self) -> {name}:\n return self._environment\n"),
1,
)
.replace(" base_url=base_url,\n", " environment=environment,\n")
.replace(" base_url=self.get_base_url,\n", "")
}
};
GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/core/client_wrapper.py")),
contents: c,
}
}
/// Render a docstring as an indented triple-quoted block, escaping backslashes
/// the way Fern's class and field writers do so the value round-trips. Every line
/// (including blank ones) carries the block indent; the block has no trailing
/// newline.
fn render_docstring(doc: &str, indent: usize) -> String {
render_doc_block(doc, indent, true)
}
/// The same block for an *alias* module docstring, which Fern writes verbatim.
/// Only the class and field writers escape: `dnd5eapi.co`'s `DC.success_type`
/// field carries `\\"none\\"` for a source `\"none\"`, while Listen Notes'
/// `TranscriptField` alias describes "the newline character \n" and its golden
/// keeps that one backslash.
fn render_alias_docstring(doc: &str, indent: usize) -> String {
render_doc_block(doc, indent, false)
}
fn render_doc_block(doc: &str, indent: usize, escape: bool) -> String {
let pad = " ".repeat(indent);
let mut out = format!("{pad}\"\"\"\n");
for line in doc.split('\n') {
out.push_str(&pad);
if escape {
out.push_str(&line.replace('\\', "\\\\"));
} else {
out.push_str(line);
}
out.push('\n');
}
out.push_str(&pad);
out.push_str("\"\"\"");
out
}
/// The class-body context for `class_body.py.j2`: the optional class docstring
/// and the fields. Shared by the object template (via `{% include %}`) and the
/// discriminated-union path, so one template lays out every pydantic model body.
#[derive(Serialize)]
struct ClassBodyView {
docstring: Option<String>,
fields: Vec<FieldView>,
/// The `pydantic.ConfigDict(...)` argument list for the v2 `model_config`.
config_dict_args: String,
/// The v1 `Config`'s `pydantic.Extra.<extra_kind>` member, or `None` for a
/// class whose config declares no extra-fields behaviour at all.
extra_kind: Option<&'static str>,
/// Methods rendered after the fields (an XML model's), class-body indented.
members: Option<String>,
/// Whether the v1 `Config` sets `copy_on_model_validation = "none"`.
copy_on_model_validation: bool,
}
/// The two `extra`-fields fragments a pydantic model body needs: the v2
/// `pydantic.ConfigDict(...)` argument list, and the v1 `pydantic.Extra.<name>`
/// member. Fern **omits** the `extra=` kwarg under pydantic v2 for `ignore` (v2's
/// own default, so `pydantic.ConfigDict(frozen=True)`), while the v1 `Config`
/// always spells the member out.
fn extra_config(extra: ExtraFields) -> (String, &'static str) {
let args = match extra {
ExtraFields::Ignore => "frozen=True".to_string(),
ExtraFields::Allow | ExtraFields::Forbid => {
format!("extra=\"{}\", frozen=True", extra.as_str())
}
};
(args, extra.as_str())
}
/// The flat field declaration line, `name: annotation = default`. `ruff format`
/// wraps a long annotation downstream.
fn field_decl(f: &RenderedField) -> String {
format!("{}: {}{}", f.py_name, f.annotation.flat(), f.default)
}
/// Render a class body (docstring + fields + config block) with `class_body.py`.
/// Used directly for the discriminated-union variant classes; the object template
/// renders the same partial through `{% include %}`.
fn render_class_body(
env: &Environment<'static>,
docstring: Option<String>,
fields: Vec<FieldView>,
extra: Option<ExtraFields>,
) -> Result<String> {
render_class_body_with(env, docstring, fields, extra, xml::XmlMembers::default())
}
/// [`render_class_body`] for a model that also carries methods: an XML model's
/// (see [`xml::XmlMembers`]).
fn render_class_body_with(
env: &Environment<'static>,
docstring: Option<String>,
fields: Vec<FieldView>,
extra: Option<ExtraFields>,
members: xml::XmlMembers,
) -> Result<String> {
// `None` is the value-wrapper form: `frozen=True` alone under v2, and a v1
// `Config` that stops after `smart_union`.
let (config_dict_args, extra_kind) = match extra {
Some(extra) => {
let (args, kind) = extra_config(extra);
(args, Some(kind))
}
None => ("frozen=True".to_string(), None),
};
let view = ClassBodyView {
docstring,
fields,
config_dict_args,
extra_kind,
members: members.methods,
copy_on_model_validation: members.preserve_child_identity,
};
render(
env,
"class_body.py",
"class_body",
minijinja::Value::from_serialize(&view),
)
}
fn update_forward_refs_call(target: &str, repair: &ForwardRepair) -> String {
let mut names: Vec<&str> = repair.names.iter().map(String::as_str).collect();
names.sort_by(|left, right| natural_cmp(left, right));
let mut arguments = vec![target.to_string()];
arguments.extend(names.into_iter().map(|name| format!("{name}={name}")));
format!("update_forward_refs({})", arguments.join(", "))
}
/// The generator settings a declaration's rendered shape reads, bundled to keep
/// [`render_type_decl`] within the argument limit.
#[derive(Debug, Clone, Copy, Default)]
struct DeclSettings<'a> {
/// Fern's `pydantic_config.extra_fields`: every model's `extra` config.
extra: ExtraFields,
/// Fern's `pydantic_config.enum_type`: an enum's class or literal form.
enum_type: EnumType,
/// Every declaration in the SDK, which an XML model reads to type its child
/// elements (see [`xml::XmlTypes`]).
xml_types: xml::XmlTypes<'a>,
}
/// Render one type declaration to a file body. `loc` is the file's location
/// (package-root `types/` or a tag's `types/`), which sets the `core`/type import
/// depth; `tag_types` maps hoisted type names to their tags for those references.
fn render_type_decl(
env: &Environment<'static>,
decl: &TypeDecl,
loc: RefLoc,
tag_types: &BTreeMap<String, String>,
settings: DeclSettings<'_>,
forward: &std::collections::HashSet<String>,
repair: Option<&ForwardRepair>,
) -> Result<String> {
// Reaching a cycle requires `from __future__` + `update_forward_refs`, even
// when `forward` (this declaration's own quoted field refs) is empty.
let needs_forward = repair.is_some();
let empty_repair = ForwardRepair::default();
let repair = repair.unwrap_or(&empty_repair);
let DeclSettings {
extra,
enum_type,
xml_types,
} = settings;
match decl {
TypeDecl::Object(obj) => {
let mut imports = Imports::at(loc, tag_types);
// A recursive model's cyclic field references render as string forward
// references and skip/defer their imports (issue #84).
imports.forward = forward.clone();
imports.forward.extend(repair.names.iter().cloned());
imports.forward.retain(|name| !obj.bases.contains(name));
imports.cur_module = obj.module.clone();
// A model's own pydantic `class Config` holds the name `Config`, so a
// component of that name is imported under its package path: Hasura's
// `DataConnectorConnSourceConfig.value` is `types_config_Config`.
let config_module = imports.type_import_path("Config");
let config_alias = format!(
"{}_Config",
imports.package_module_path("Config").replace('.', "_")
);
imports
.aliases
.insert((config_module, "Config".to_string()), config_alias);
imports.add_plain("typing");
imports.add_plain("pydantic");
imports.add_core("pydantic_utilities", "IS_PYDANTIC_V2");
// Bases come from an `allOf`'s `$ref` members; with none, the model
// extends Fern's `UniversalBaseModel`.
let bases = if obj.bases.is_empty() {
imports.add_core("pydantic_utilities", "UniversalBaseModel");
"UniversalBaseModel".to_string()
} else {
for base in &obj.bases {
imports.add_type(base);
}
obj.bases.join(", ")
};
let xml_model = obj
.xml
.as_ref()
.map(|encoding| xml::XmlModel::new(obj, encoding, xml_types));
let own_fields = xml_model
.as_ref()
.map_or_else(|| obj.fields.clone(), xml::XmlModel::declared_fields);
let mut fields: Vec<FieldView> = own_fields
.iter()
.map(|f| {
let rf = render_field(f, &mut imports);
FieldView {
decl: field_decl(&rf),
docstring: rf.docstring,
}
})
.collect();
let members = match &xml_model {
Some(model) => model.members(&mut imports, &mut fields),
None => xml::XmlMembers::default(),
};
for name in &repair.import_order {
imports.add_type(name);
}
if !forward.is_empty() || needs_forward {
// A recursive model: `from __future__ import annotations` lets the
// string forward refs resolve lazily, and `update_forward_refs`
// rebuilds the model once the referenced names exist. A model that
// only *inherits* its way into a cycle takes the future import and
// nothing else.
let repairs = !forward.is_empty() || repair.own_reach;
if repairs {
imports.add_core("pydantic_utilities", "update_forward_refs");
}
let body = render_class_body_with(
env,
obj.docstring.clone(),
fields,
Some(extra),
members,
)?;
let mut file = format!(
"{HEADER}\n\nfrom __future__ import annotations\n\n{}\n\n\nclass {}({}):\n{}",
imports.render(),
obj.name,
bases,
body.trim_end(),
);
if !repairs {
file.push('\n');
return Ok(file);
}
// Two blank lines, then any deferred cross-module forward-ref
// imports (a blank line after them), then the forward-ref repair.
file.push_str("\n\n\n");
if !imports.deferred.is_empty() {
// Fern's per-field order (`ForwardRepair::deferred_order`); a
// line naming nothing in it keeps the sorted tail.
let mut deferred: Vec<&String> = imports.deferred.iter().collect();
deferred.sort_by_key(|line| {
let name = line.rsplit(' ').next().unwrap_or_default();
repair
.deferred_order
.iter()
.position(|known| known == name)
.unwrap_or(usize::MAX)
});
for line in deferred {
file.push_str(line);
file.push('\n');
}
file.push('\n');
}
file.push_str(&update_forward_refs_call(&obj.name, repair));
file.push('\n');
return Ok(file);
}
let (config_dict_args, extra_kind) = extra_config(extra);
let view = ObjectView {
header: HEADER,
imports: imports.render(),
class_name: obj.name.clone(),
bases,
docstring: obj.docstring.clone(),
fields,
config_dict_args,
extra_kind,
future: xml_model.is_some(),
copy_on_model_validation: members.preserve_child_identity,
members: members.methods,
};
render(
env,
"object.py",
&obj.name,
minijinja::Value::from_serialize(&view),
)
}
TypeDecl::Alias(alias) => {
let mut imports = Imports::at(loc, tag_types);
imports.forward = forward.clone();
imports.cur_module = alias.module.clone();
let target = render_type(&alias.target, &mut imports);
let assignment = format!("{} = {}", alias.name, target.flat());
// Fern drops descriptions from union aliases while preserving
// them for scalar and collection aliases.
let docstring = (!matches!(alias.target, TypeRef::Union(_)))
.then_some(alias.docstring.as_deref())
.flatten()
.map(|doc| {
if doc.is_empty() {
"\"\"\"\n\"\"\"\n".to_string()
} else {
format!("{}\n", render_alias_docstring(doc, 0))
}
})
.unwrap_or_default();
if !forward.is_empty() {
let mut contents = format!(
"{HEADER}\n\nfrom __future__ import annotations\n\n{}\n",
imports.render()
);
if !imports.deferred.is_empty() {
contents.push_str("\nif typing.TYPE_CHECKING:\n");
for line in &imports.deferred {
contents.push_str(&format!(" {line}\n"));
}
}
contents.push_str(&format!("{assignment}\n{docstring}"));
return Ok(contents);
}
render(
env,
"alias.py",
&alias.name,
context! {
header => HEADER,
imports => imports.render(),
assignment => assignment,
docstring => docstring,
},
)
}
TypeDecl::Enum(e) if enum_type == EnumType::Literals => render_literal_enum(env, e),
TypeDecl::Enum(e) => render_enum(env, e, &loc),
TypeDecl::DiscriminatedUnion(union) => {
render_discriminated_union(env, union, extra, loc, tag_types, forward, repair)
}
}
}
/// Render a string enum to its module: a `class {Name}(enum.StrEnum)` with a
/// `SCREAMING_SNAKE = "value"` member per value and a `visit` dispatch method
/// (Fern's `enum_type: python_enums` shape). Fern's compatibility enum is under
/// the package-root `core`, so tag-hoisted types need one more relative dot.
fn render_enum(
env: &Environment<'static>,
e: &crate::ir::EnumType,
loc: &RefLoc,
) -> Result<String> {
// A nested client module (`doc/forms`) sits one level deeper per segment:
// CloudPDF's `ExportDataFormsRequestFormat` imports `from ....core`.
let core_parent = match loc {
RefLoc::TagTypes(module) => format!("{}core", ".".repeat(module_depth(module) + 2)),
RefLoc::RootTypes => "..core".to_string(),
RefLoc::Client(_) | RefLoc::Errors | RefLoc::PackageRoot => ".core".to_string(),
};
let mut body = format!(
"import typing\n\nfrom {core_parent} import enum\n\nT_Result = typing.TypeVar(\"T_Result\")\n\n\nclass "
);
body.push_str(&e.name);
body.push_str("(enum.StrEnum):\n");
if let Some(doc) = &e.docstring {
// Fern renders the schema description as a class docstring, then a blank
// line before the first member.
body.push_str(&render_docstring(doc, 4));
body.push_str("\n\n");
}
for m in &e.members {
body.push_str(&format!(
" {} = \"{}\"\n",
m.name,
escape_py_str(&m.value)
));
if let Some(doc) = &m.docstring {
body.push_str(&render_docstring(doc, 4));
body.push_str("\n\n");
}
}
body.push('\n');
// Two members Fern names alike share one callback, listed once.
let mut listed = std::collections::HashSet::new();
let params: Vec<String> = e
.members
.iter()
.filter(|m| listed.insert(m.visit_param.as_str()))
.map(|m| format!("{}: typing.Callable[[], T_Result]", m.visit_param))
.collect();
body.push_str(&format!(
" def visit(self, {}) -> T_Result:\n",
params.join(", ")
));
for m in &e.members {
body.push_str(&format!(
" if self is {}.{}:\n return {}()\n",
e.name, m.name, m.visit_param
));
}
render(
env,
"file.py",
&e.name,
context! { header => HEADER, body => body },
)
}
/// A literal enum value as Fern's snippet writer spells it: the wire value in
/// double quotes, with backslashes and double quotes escaped.
fn literal_enum_value(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Render a string enum as Fern does with `enum_type` unset (its `literals`
/// default): an open alias, `{Name} = typing.Union[typing.Literal["a", "b"],
/// typing.Any]`, that takes any value besides the listed ones, so a response
/// carrying a value the spec does not list still parses. Fern writes the alias
/// without the enum's description and imports nothing from `core`; ruff wraps a
/// long value list.
fn render_literal_enum(env: &Environment<'static>, e: &crate::ir::EnumType) -> Result<String> {
let values: Vec<String> = e
.members
.iter()
.map(|m| format!("\"{}\"", escape_py_str(&m.value)))
.collect();
let body = format!(
"import typing\n\n{} = typing.Union[typing.Literal[{}], typing.Any]",
e.name,
values.join(", ")
);
render(
env,
"file.py",
&e.name,
context! { header => HEADER, body => body },
)
}
/// Render a discriminated union to its module: a `{Union}_{Variant}` pydantic
/// model per variant (each with the discriminant as a `Literal` field), then the
/// `{Union} = typing.Union[..]` alias over them. Built directly (not via the
/// object template) because it carries a `from __future__ import annotations` and
/// several classes in one file. Each variant's body is rendered with the shared
/// `class_body.py` partial (same as a plain object).
fn render_discriminated_union(
env: &Environment<'static>,
union: &crate::ir::DiscriminatedUnion,
extra: ExtraFields,
loc: RefLoc,
tag_types: &BTreeMap<String, String>,
forward: &std::collections::HashSet<String>,
repair: &ForwardRepair,
) -> Result<String> {
let mut imports = Imports::at(loc, tag_types);
// A recursive variant references the union by string forward reference
// (issue #84); a same-module ref (the common case — the union alias lives in
// this file) needs no import.
imports.forward = forward.clone();
imports.forward.extend(repair.names.iter().cloned());
imports.cur_module = union.module.clone();
imports.add_plain("typing");
imports.add_plain("pydantic");
if union.members.len() > 1 {
imports.add_plain("typing_extensions");
}
imports.add_core("pydantic_utilities", "IS_PYDANTIC_V2");
imports.add_core("pydantic_utilities", "UniversalBaseModel");
// The `update_forward_refs` calls, resolved before the imports they need. Each
// wrapper takes the module's cyclic names *minus the model it flattened* — a
// wrapper cannot be asked to resolve its own source — so the calls differ from
// one another, and the deferred imports follow the order the calls first name
// them rather than a sort of the whole set.
let repair_calls: Vec<(&str, Vec<String>)> = if forward.is_empty() && repair.triggers.is_empty()
{
Vec::new()
} else {
union
.members
.iter()
.filter(|member| {
member.fields.iter().any(|f| {
type_uses_forward(&f.type_ref, forward)
|| type_uses_forward(&f.type_ref, &repair.triggers)
})
})
.map(|member| {
let names = repair
.member_names
.get(&member.class_name)
.cloned()
.unwrap_or_default();
(member.class_name.as_str(), names)
})
.collect()
};
if repair_calls.is_empty() {
for name in &repair.import_order {
imports.add_type(name);
}
} else {
let mut ordered: Vec<&str> = Vec::new();
for name in repair_calls.iter().flat_map(|(_, names)| names) {
if !ordered.contains(&name.as_str()) {
ordered.push(name.as_str());
}
}
for name in ordered {
imports.add_type(name);
}
}
let mut classes: Vec<String> = Vec::new();
// The shared superclass, when the union came from a base object schema that
// declares properties of its own. Fern spells it `Base` in every module that
// needs one, and every wrapper below extends it instead of `UniversalBaseModel`.
let base_class = if union.base_fields.is_empty() {
"UniversalBaseModel"
} else {
let fields: Vec<FieldView> = union
.base_fields
.iter()
.map(|f| render_field(f, &mut imports))
.map(|rf| FieldView {
decl: field_decl(&rf),
docstring: rf.docstring,
})
.collect();
let body = render_class_body(env, None, fields, Some(extra))?;
classes.push(format!(
"class Base(UniversalBaseModel):\n{}",
body.trim_end()
));
"Base"
};
for member in &union.members {
// The discriminant field: `type: typing.Literal["circle"] = "circle"`. It is
// an ordinary field bar its default, so a wire name that does not survive
// Python naming carries the same alias metadata every other field would —
// EN 18222's `objectType` tag renders as `object_type: Annotated[...,
// FieldMetadata(alias="objectType"), pydantic.Field(alias="objectType")]`.
let mut discriminant = render_field(
&Field {
wire_name: union.discriminant_property.clone(),
py_name: discriminant_field_name(union),
type_ref: TypeRef::Literal(vec![member.discriminant.clone()]),
optional: false,
nullable: false,
spec_required: true,
docstring: None,
example: None,
declared_name: None,
deprecated: false,
xml: None,
admits_only_empty_object: false,
inherited_from_flattened_parent: false,
min_length: None,
max_length: None,
},
&mut imports,
);
discriminant.default = format!(" = \"{}\"", member.discriminant);
// A `value`-wrapped variant holds a payload rather than a flattened
// object, so its `value` field is written ahead of the tag and its config
// carries no `extra` member — the wrapper owns no open field set of its own.
let mut rendered: Vec<RenderedField> = member
.fields
.iter()
.map(|f| render_field(f, &mut imports))
.collect();
let tag_at = if member.wrapped { rendered.len() } else { 0 };
rendered.insert(tag_at, discriminant);
let fields: Vec<FieldView> = rendered
.into_iter()
.map(|rf| FieldView {
decl: field_decl(&rf),
docstring: rf.docstring,
})
.collect();
let member_extra = (!member.wrapped).then_some(extra);
let body = render_class_body(env, member.docstring.clone(), fields, member_extra)?;
classes.push(format!(
"class {}({base_class}):\n{}",
member.class_name,
body.trim_end()
));
}
let variants: Vec<Doc> = union
.members
.iter()
.map(|m| Doc::atom(m.class_name.clone()))
.collect();
// Fern wraps a discriminated union's alias in
// `typing_extensions.Annotated[Union[...], pydantic.Field(discriminator="…")]`
// (issue #50 part 2) so pydantic can dispatch on the tag — a plain
// `Union[...]` cannot. `ruff` wraps the line if it exceeds the width.
let alias = if let [member] = union.members.as_slice() {
format!("{} = {}", union.name, member.class_name)
} else {
let union_expr = Doc::group("typing.Union[", variants, "]").flat();
format!(
"{} = typing_extensions.Annotated[{union_expr}, pydantic.Field(discriminator=\"{}\")]",
union.name,
discriminant_field_name(union)
)
};
// A recursive variant is repaired at load time: import `update_forward_refs`
// and, right after the alias, call it on each wrapper that carries a forward
// reference (issue #84). Non-recursive unions emit no such trailer.
let mut trailer = String::new();
if !forward.is_empty() || !repair.triggers.is_empty() {
imports.add_core("pydantic_utilities", "update_forward_refs");
for (class_name, names) in &repair_calls {
let mut arguments = vec![(*class_name).to_string()];
arguments.extend(names.iter().map(|name| format!("{name}={name}")));
trailer.push('\n');
trailer.push_str(&format!("update_forward_refs({})", arguments.join(", ")));
}
}
// Registration order, not sorted: the union registers each name as its repair
// calls first mention it, and Fern's block follows the calls. EN 18222's
// `data_element.py` imports `MultiValuedDataElementValueItem` (named by the
// first call) ahead of `MultiValuedDataElement` (named only by the second),
// which no sort of the three puts in that order.
let deferred = if imports.deferred_order.is_empty() {
String::new()
} else {
format!("\n{}\n", imports.deferred_order.join("\n"))
};
Ok(format!(
"{HEADER}\n\nfrom __future__ import annotations\n\n{}\n\n\n{}\n\n\n{alias}{deferred}{trailer}\n",
imports.render(),
classes.join("\n\n\n")
))
}
/// The Python name of a discriminated union's tag field: the one its
/// discriminator declares, else its wire key's. The hand-written
/// `telescope-renamed-discriminant` fixture renames `$class` to `targetClass`,
/// and Fern's wrappers declare `target_class` aliased to `$class`.
fn discriminant_field_name(union: &crate::ir::DiscriminatedUnion) -> String {
naming::model_field_name(
union
.discriminant_name
.as_deref()
.unwrap_or(&union.discriminant_property),
)
}
/// Whether a resolved type references any name that must render as a forward
/// reference — i.e. the enclosing model needs an `update_forward_refs` repair.
fn type_uses_forward(t: &TypeRef, forward: &std::collections::HashSet<String>) -> bool {
let mut names = Vec::new();
collect_named_refs(t, &mut names);
names.iter().any(|n| forward.contains(n))
}
/// Render a resolved type to its flat Python expression for the raw client,
/// registering imports relative to the package root (named types live under
/// `..types`). Only the shapes reachable in an emittable endpoint are exercised;
/// the rest are rendered structurally for completeness.
fn raw_type_str(t: &TypeRef, imports: &mut Imports) -> String {
raw_type_str_ctx(t, imports, false)
}
/// Like [`raw_type_str`], but in *request context* (`seq = true`) Fern renders
/// list/set collections as `typing.Sequence` rather than `typing.List`/`Set` —
/// request inputs accept any sequence. Response types use `seq = false`.
fn raw_type_str_ctx(t: &TypeRef, imports: &mut Imports, seq: bool) -> String {
match t {
TypeRef::Primitive(Prim::Str) => "str".to_string(),
TypeRef::Primitive(Prim::Bytes) => "bytes".to_string(),
TypeRef::Primitive(Prim::Int | Prim::Long) => "int".to_string(),
TypeRef::Primitive(Prim::Float) => "float".to_string(),
TypeRef::Primitive(Prim::Bool) => "bool".to_string(),
TypeRef::Primitive(Prim::LiteralBool(value)) => {
imports.add_plain("typing");
format!("typing.Literal[{}]", if *value { "True" } else { "False" })
}
TypeRef::Primitive(Prim::Any) => {
imports.add_plain("typing");
"typing.Any".to_string()
}
TypeRef::Primitive(Prim::Datetime) => {
imports.add_plain_as("datetime", "dt");
"dt.datetime".to_string()
}
TypeRef::Primitive(Prim::Date) => {
imports.add_plain_as("datetime", "dt");
"dt.date".to_string()
}
TypeRef::Named(class) => imports.add_type(class),
TypeRef::Optional(inner) => {
imports.add_plain("typing");
format!("typing.Optional[{}]", raw_type_str_ctx(inner, imports, seq))
}
TypeRef::List(inner) => {
imports.add_plain("typing");
let kw = if seq { "Sequence" } else { "List" };
format!("typing.{kw}[{}]", raw_type_str_ctx(inner, imports, seq))
}
TypeRef::Set(inner) => {
imports.add_plain("typing");
let kw = if seq { "Sequence" } else { "Set" };
format!("typing.{kw}[{}]", raw_type_str_ctx(inner, imports, seq))
}
TypeRef::Dict(k, v) => {
imports.add_plain("typing");
format!(
"typing.Dict[{}, {}]",
raw_type_str_ctx(k, imports, seq),
raw_type_str_ctx(v, imports, seq)
)
}
TypeRef::Union(variants) => {
imports.add_plain("typing");
let inner = variants
.iter()
.map(|v| raw_type_str_ctx(v, imports, seq))
.collect::<Vec<_>>()
.join(", ");
format!("typing.Union[{inner}]")
}
TypeRef::Literal(values) => {
imports.add_plain("typing");
let inner = values
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
format!("typing.Literal[{inner}]")
}
}
}
/// The request's URL argument: the document path with its leading slash stripped,
/// rendered as an f-string interpolating `encode_path_param(param)` for each path
/// placeholder, or a plain string literal when there are none.
///
/// An object-typed path parameter is interpolated through the same
/// `convert_and_respect_annotation_metadata` wrapper Fern gives an object-typed
/// *query* parameter, so its field aliases are respected on the way into the URL
/// segment. No registered source declares that shape; it is measured on
/// `docs/openapi-surface/probes/parameter-style-simple-path-object.yml`, whose
/// Fern tree interpolates
/// `encode_path_param(convert_and_respect_annotation_metadata(object_=probe_param,
/// annotation=ProbeParam, direction='write'))`. The quoting is single inside the
/// f-string, which is Fern's own rendering there.
fn url_arg(ep: &Endpoint, imports: &mut Imports) -> String {
let stripped = ep.path.strip_prefix('/').unwrap_or(&ep.path);
if stripped.contains('{') {
let mut rendered = stripped.to_string();
// A placeholder the document's base path lifts to the client reads the
// value the client was constructed with.
for lifted in &ep.client_path_params {
rendered = rendered.replace(
&format!("{{{}}}", lifted.wire_name),
&format!(
"{{encode_path_param(self._client_wrapper._{})}}",
lifted.py_name
),
);
}
for pp in &ep.path_params {
let value = if pp.convert {
imports.add_core("serialization", "convert_and_respect_annotation_metadata");
let annotation = raw_type_str_ctx(&pp.type_ref, imports, true);
format!(
"convert_and_respect_annotation_metadata(object_={}, annotation={annotation}, direction='write')",
pp.py_name
)
} else {
pp.py_name.clone()
};
rendered = rendered.replace(
&format!("{{{}}}", pp.wire_name),
&format!("{{encode_path_param({value})}}"),
);
}
format!("f\"{rendered}\"")
} else {
format!("\"{stripped}\"")
}
}
/// The method's resolved parameters and response type, shared by the raw client
/// and the higher-level client so their signatures stay identical.
struct MethodParams {
/// The response Python type (`None` when the endpoint returns no content).
inner: String,
/// Positional path parameters: `(py_name, annotation, description)`.
path: Vec<(String, String, Option<String>)>,
/// Keyword-only query parameters.
query: Vec<DocParam>,
/// Keyword-only header parameters.
header: Vec<DocParam>,
/// The request body, as one `request` argument or the hoisted inline fields.
body: Vec<DocParam>,
}
/// Compute an endpoint's resolved parameters and response type, registering the
/// imports the annotations need. Independent of sync/async.
fn method_params(ep: &Endpoint, imports: &mut Imports) -> MethodParams {
let inner = if ep.binary_response {
"None".to_string()
} else {
ep.response
.as_ref()
.map_or_else(|| "None".to_string(), |t| raw_type_str(t, imports))
};
let path: Vec<(String, String, Option<String>)> = ep
.path_params
.iter()
.map(|pp| {
(
pp.py_name.clone(),
raw_type_str_ctx(&pp.type_ref, imports, true),
pp.docstring.clone(),
)
})
.collect();
// Query and header parameters become keyword-only arguments; each carries an
// annotation (`Optional[..]` unless required, `= None` when optional) and its
// description.
let optional_arg = |base: String, required: bool, description: Option<String>, name: String| {
// A required parameter whose *type* is optional still defaults to `None`:
// Fern's `optional<..>` decides the default, and the Parameter Object's
// `required` only decides whether a worked call passes the argument.
// oSPARC's `file_size` is `required: true` over an `anyOf` carrying a
// `type: null` alternative, and its golden writes
// `file_size: typing.Optional[UploadFileRequestFileSize] = None` and
// still passes `file_size="file_size"` in the example.
if required && !base.starts_with("typing.Optional[") {
DocParam {
name,
annotation: base,
default: None,
description,
}
} else {
// An argument whose own type is already optional — a `$ref` to a
// nullable schema — is not wrapped twice.
let annotation = if base.starts_with("typing.Optional[") {
base
} else {
format!("typing.Optional[{base}]")
};
DocParam {
name,
annotation,
default: Some("None".to_string()),
description,
}
}
};
let query: Vec<DocParam> = ep
.query_params
.iter()
.map(|qp| {
// An array query parameter allows multiple values: Fern types it
// `Optional[Union[T, Sequence[T]]]` and always defaults it to `None`.
// A *nullable* array (`anyOf: [array, null]`, letta's `tags`) renders
// the same way — the null alternative only supplies the `Optional`
// this arm already writes.
let array = match &qp.type_ref {
TypeRef::List(inner) => Some((inner, qp.argument_required())),
TypeRef::Optional(inner) => match inner.as_ref() {
TypeRef::List(item) => Some((item, false)),
_ => None,
},
_ => None,
};
if let Some((inner, required)) = array {
if qp.allow_multiple {
// Nullable items lose their `Optional` here, as in Fern's
// signature; its `reference.md` keeps it (the
// `nullable-items-docs` departure).
let inner = match inner.as_ref() {
TypeRef::Optional(item) => item,
_ => inner,
};
let item = raw_type_str(inner, imports);
return DocParam {
name: qp.py_name.clone(),
annotation: format!(
"typing.Optional[typing.Union[{item}, typing.Sequence[{item}]]]"
),
default: Some("None".to_string()),
description: qp.docstring.clone(),
};
}
return optional_arg(
format!("typing.Sequence[{}]", raw_type_str(inner, imports)),
required,
qp.docstring.clone(),
qp.py_name.clone(),
);
}
let mut parameter = optional_arg(
raw_type_str(&qp.type_ref, imports),
qp.argument_required(),
qp.docstring.clone(),
qp.py_name.clone(),
);
if let Some(default) = &qp.default {
parameter.default = Some(default.clone());
}
parameter
})
.collect();
let header: Vec<DocParam> = ep
.header_params
.iter()
.map(|header_param| {
optional_arg(
raw_type_str_ctx(&header_param.type_ref, imports, true),
header_param.required,
header_param.docstring.clone(),
header_param.py_name.clone(),
)
})
.collect();
// The request body renders either as a single keyword-only `request` argument
// or, for an inlined object, as one keyword-only argument per hoisted field
// (`Optional[..] = OMIT` when optional). Collection types render in request
// context (`typing.Sequence`).
let body: Vec<DocParam> = match &ep.request_body {
None => Vec::new(),
Some(RequestBody::Single(s)) => {
let base = raw_type_str_ctx(&s.type_ref, imports, true);
vec![optional_arg(base, s.required, None, "request".to_string())]
}
Some(RequestBody::Inline(fields)) => {
let mut params: Vec<DocParam> = fields
.iter()
// The field a stream condition fixes is not an argument: this
// method's whole identity is the value it sends there.
.filter(|f| {
ep.stream_condition
.as_ref()
.is_none_or(|(wire, _)| *wire != f.wire_name)
})
.map(|f| {
let base = raw_type_str_ctx(&f.type_ref, imports, true);
if f.optional {
DocParam {
name: f.py_name.clone(),
annotation: format!("typing.Optional[{base}]"),
default: Some("OMIT".to_string()),
description: f.docstring.clone(),
}
} else {
DocParam {
name: f.py_name.clone(),
annotation: base,
default: None,
description: f.docstring.clone(),
}
}
})
.collect();
// Fern orders the inlined body's *signature* and docstring required-first
// (optional `= OMIT` args last), a stable partition that preserves schema
// order within each group. The `json={...}` dict keeps pure schema order
// (built separately below), so only this view is reordered — matching how
// a `readOnly`/`writeOnly` field lands after the required ones.
params.sort_by_key(|p| p.default.is_some());
params
}
Some(RequestBody::Bytes { .. }) => vec![DocParam {
name: "request".to_string(),
annotation: "typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]"
.to_string(),
default: None,
description: None,
}],
Some(RequestBody::Form(form)) => form
.fields
.iter()
.map(|f| {
// A file field is typed `core.File` (importing the `core` package)
// and documented with Fern's fixed pointer to `core.File`.
let base = if f.is_file {
imports.add_core_package();
if matches!(f.type_ref, TypeRef::List(_) | TypeRef::Set(_)) {
imports.add_plain("typing");
let container = if form.multipart { "List" } else { "Sequence" };
format!("typing.{container}[core.File]")
} else {
"core.File".to_string()
}
} else {
raw_type_str_ctx(&f.type_ref, imports, !form.multipart)
};
let description = if f.is_file {
Some("See core.File for more documentation".to_string())
} else {
f.docstring.clone()
};
if f.optional {
DocParam {
name: f.py_name.clone(),
annotation: format!("typing.Optional[{base}]"),
default: Some("OMIT".to_string()),
description,
}
} else {
DocParam {
name: f.py_name.clone(),
annotation: base,
default: None,
description,
}
}
})
.collect(),
};
// An idempotent operation's idempotency headers follow its body, optional
// `str` arguments with no description (Fern's `dedupe_token`).
let mut body = body;
body.extend(ep.extensions.idempotency_headers.iter().map(|idempotency| {
optional_arg(
raw_type_str_ctx(&idempotency.type_ref, imports, true),
idempotency.required,
idempotency.docstring.clone(),
idempotency.py_name.clone(),
)
}));
MethodParams {
inner,
path,
query,
header,
body,
}
}
/// A keyword-only argument atom for a method signature: `name: annotation`, plus
/// ` = <default>` when the argument has a default.
fn doc_arg(dp: &DocParam) -> Doc {
Doc::atom(match &dp.default {
Some(d) => format!("{}: {} = {}", dp.name, dp.annotation, d),
None => format!("{}: {}", dp.name, dp.annotation),
})
}
/// The `def`/`async def` signature line (indent 4), laid out with ruff's
/// right-hand-split: `self`, positional path params, `*`, keyword-only query,
/// header, and body arguments, then `request_options`, closing with the return
/// type.
fn signature(ep: &Endpoint, mp: &MethodParams, return_type: &str, is_async: bool) -> String {
let mut args: Vec<Doc> = vec![Doc::atom("self")];
for (name, ty, _) in &mp.path {
args.push(Doc::atom(format!("{name}: {ty}")));
}
args.push(Doc::atom("*"));
args.extend(
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.map(doc_arg),
);
args.push(Doc::atom(
"request_options: typing.Optional[RequestOptions] = None",
));
let sig_open = format!(
" {}def {}(",
if is_async { "async " } else { "" },
ep.method_name
);
// Emit the signature flat; `ruff format` splits it across lines if it
// overflows (its right-hand-split matches Fern's output).
format!(
"{} -> {return_type}:",
Doc::group(sig_open, args, ")").flat()
)
}
/// Build one raw-client method (sync or async), registering its imports.
fn raw_method(ep: &Endpoint, is_async: bool, imports: &mut Imports) -> String {
imports.add_plain("typing");
imports.add_core("request_options", "RequestOptions");
if ep.streaming {
return raw_stream_method(ep, is_async, imports);
}
if ep.binary_response {
return raw_binary_stream_method(ep, is_async, imports);
}
let wrapper = if is_async {
imports.core_local("http_response", "AsyncHttpResponse")
} else {
imports.core_local("http_response", "HttpResponse")
};
let mp = method_params(ep, imports);
let return_type = pager_return_type(ep, &mp.inner, is_async, imports)
.unwrap_or_else(|| format!("{wrapper}[{}]", mp.inner));
let signature = signature(ep, &mp, &return_type, is_async);
let docstring = raw_docstring(ep, &mp.path, &mp.query, &mp.header, &mp.body, &return_type);
let body = raw_body(ep, is_async, &mp.inner, imports);
format!("{signature}\n{docstring}\n{body}")
}
/// The success branch of a paginated raw method: parse the page, read its items
/// and next cursor, and return a pager whose `get_next` calls this same method
/// with the cursor advanced. The sync form binds a `lambda`; the async form needs
/// a nested `async def`, because a coroutine cannot be produced by a lambda.
fn pager_success_branch(
ep: &Endpoint,
pagination: &EndpointPagination,
inner: &str,
is_async: bool,
) -> Vec<String> {
let pager = if is_async { "AsyncPager" } else { "SyncPager" };
let mut lines = vec![
" _parsed_response = typing.cast(".to_string(),
format!(" {inner},"),
" parse_obj_as(".to_string(),
format!(" type_={inner},"),
" object_=_response.json(),".to_string(),
" ),".to_string(),
" )".to_string(),
format!(
" _items = _parsed_response.{}",
pagination.results
),
];
let (indent, next_value) = match &pagination.advance {
PageAdvance::Cursor { container, leaf } => {
// The cursor's container is optional on the response model, so the
// whole advance is guarded on it; a cursor declared at the top level
// needs no guard.
// A guarded advance starts from no next page, which the guard then
// overwrites; an unguarded one assigns both outright.
let (indent, holder) = if container.is_empty() {
(" ", "_parsed_response".to_string())
} else {
let holder = format!("_parsed_response.{}", container.join("."));
lines.push(" _has_next = False".to_string());
lines.push(" _get_next = None".to_string());
lines.push(format!(" if {holder} is not None:"));
(" ", holder)
};
lines.push(format!("{indent}_parsed_next = {holder}.{leaf}"));
lines.push(format!(
"{indent}_has_next = _parsed_next is not None and _parsed_next != \"\""
));
(indent, "_parsed_next".to_string())
}
PageAdvance::Offset => {
// The offset form reads no cursor: a page with items has a next one,
// one offset on (`x-fern-pagination: {offset: …}`).
lines.push(" _has_next = len(_items or []) > 0".to_string());
(
" ",
format!("{} + 1", pagination.cursor_param),
)
}
};
// The recursive call: every argument the method took, with the cursor replaced
// by the one just parsed, or the offset by the next one.
let mut args: Vec<String> = Vec::new();
for param in &ep.path_params {
args.push(param.py_name.clone());
}
for param in &ep.query_params {
let value = if param.py_name == pagination.cursor_param {
next_value.as_str()
} else {
param.py_name.as_str()
};
args.push(format!("{}={value}", param.py_name));
}
for param in &ep.header_params {
args.push(format!("{}={}", param.py_name, param.py_name));
}
args.push("request_options=request_options".to_string());
if is_async {
lines.push(String::new());
lines.push(format!("{indent}async def _get_next():"));
lines.push(format!("{indent} return await self.{}(", ep.method_name));
for arg in &args {
lines.push(format!("{indent} {arg},"));
}
lines.push(format!("{indent} )"));
lines.push(String::new());
} else {
lines.push(format!(
"{indent}_get_next = lambda: self.{}(",
ep.method_name
));
for arg in &args {
lines.push(format!("{indent} {arg},"));
}
lines.push(format!("{indent})"));
}
lines.push(format!(
" return {pager}(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response)"
));
lines
}
/// The pager type as written in a docstring, where no import is registered — the
/// signature that names it has already registered one.
fn pager_doc_type(pagination: &EndpointPagination, inner: &str, is_async: bool) -> String {
let pager = if is_async { "AsyncPager" } else { "SyncPager" };
let item = match &pagination.item_type {
TypeRef::Named(item) => item.clone(),
// A scalar names no import, so a scratch registry renders it.
primitive @ TypeRef::Primitive(_) => raw_type_str(primitive, &mut Imports::default()),
_ => return inner.to_string(),
};
format!("{pager}[{item}, {inner}]")
}
/// The `SyncPager[Item, Response]` / `AsyncPager[Item, Response]` a paginated
/// operation returns in place of the buffered `HttpResponse[Response]`, or `None`
/// when the operation declares no pagination contract.
fn pager_return_type(
ep: &Endpoint,
inner: &str,
is_async: bool,
imports: &mut Imports,
) -> Option<String> {
let pagination = ep.pagination.as_ref()?;
let pager = if is_async { "AsyncPager" } else { "SyncPager" };
imports.add_core("pagination", pager);
let item = raw_type_str(&pagination.item_type, imports);
Some(format!("{pager}[{item}, {inner}]"))
}
/// A parameter as it appears in a method docstring / signature: its Python name,
/// the annotation (the type shown in the signature and docstring), an optional
/// `= <default>` RHS, and an optional description.
struct DocParam {
name: String,
annotation: String,
default: Option<String>,
description: Option<String>,
}
/// The endpoint's keyword parameters in Fern's declaration order: every *required*
/// argument (no default) first, then the optional ones, each group preserving the
/// query → header → body grouping. Fern lists a required argument (e.g. a mandatory
/// body field) ahead of an optional query parameter, rather than grouping strictly
/// by kind.
fn ordered_keyword_params<'a>(
query: &'a [DocParam],
header: &'a [DocParam],
body: &'a [DocParam],
) -> Vec<&'a DocParam> {
let (required, optional): (Vec<&DocParam>, Vec<&DocParam>) = query
.iter()
.chain(header)
.chain(body)
.partition(|dp| dp.default.is_none());
required.into_iter().chain(optional).collect()
}
/// The method docstring (indent 8): an optional summary line, a `Parameters`
/// section (path params, query params, the request body, then `request_options`),
/// and a `Returns` section.
fn raw_docstring(
ep: &Endpoint,
param_types: &[(String, String, Option<String>)],
query_params: &[DocParam],
header_params: &[DocParam],
body_params: &[DocParam],
return_type: &str,
) -> String {
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
// A multi-line summary carries the 8-space docstring indent on every line.
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in param_types {
push_path_param(&mut lines, name, ty, desc);
}
// Query params, header params, then the request body/fields, each: a
// `name : annotation` line, an optional indented description, and a trailing
// blank line.
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
for dp in ordered_keyword_params(query_params, header_params, body_params) {
push_param(dp);
}
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(" Request-specific configuration.".to_string());
lines.push(String::new());
lines.push(" Returns".to_string());
lines.push(" -------".to_string());
lines.push(format!(" {return_type}"));
// A concrete response type is followed by the response description (indented,
// blank when the spec gives none) before the closing quotes; a `None` return
// closes immediately.
if ep.response.is_some() {
push_return_doc(&mut lines, ep.response_doc.as_deref());
}
lines.push(" \"\"\"".to_string());
if ep.module.is_empty() && ep.openapi_31 {
lines
.iter_mut()
.filter(|line| line.is_empty())
.for_each(|line| {
*line = " ".to_string();
});
}
lines.join("\n")
}
/// Render one path parameter's docstring block: its `name : ty` line, then the
/// description slot, then a trailing blank line. Fern fills the slot when the spec
/// gives a non-empty description, emits a *blank* slot when it declares an empty one
/// (`Some("")`, e.g. bunq's path params — two blank lines total), and omits the slot
/// entirely when it declares none (`None` — one blank line).
/// See [`crate::ir`]'s `path_param_doc`.
fn push_path_param(lines: &mut Vec<String>, name: &str, ty: &str, desc: &Option<String>) {
lines.push(format!(" {name} : {ty}"));
match desc {
Some(d) if !d.is_empty() => push_param_doc(lines, d),
Some(_) => lines.push(String::new()),
None => {}
}
lines.push(String::new());
}
fn push_param_doc(lines: &mut Vec<String>, description: &str) {
lines.extend(description.split('\n').map(|line| {
format!(
" {}",
python_doc_line(&line.replace('\t', " "))
)
}));
}
/// One line of Python docstring prose. A backslash is escaped for the source
/// literal, and a tab is expanded to four spaces: `ruff format` normalizes
/// docstring indentation, so SteamInputDB's tab-indented second description line
/// reaches its golden's `client.py` as spaces while `reference.md` — which no
/// formatter touches — keeps the tabs the document wrote.
fn python_doc_line(line: &str) -> String {
line.replace('\\', "\\\\").replace('\t', " ")
}
/// Push the `Returns` description under the return type: each line of the
/// response description indented 12 spaces, a single blank line when the spec
/// declares a blank one, and nothing when the response has no `description` key
/// at all — matching Fern.
fn push_return_doc(lines: &mut Vec<String>, response_doc: Option<&str>) {
match response_doc {
Some("") => lines.push(String::new()),
Some(doc) => lines.extend(
doc.split('\n')
.map(|line| format!(" {}", python_doc_line(line))),
),
None => {}
}
}
/// The method body (indent 8): the `httpx_client.request(...)` call and the
/// response-handling block.
fn raw_body(ep: &Endpoint, is_async: bool, inner: &str, imports: &mut Imports) -> String {
imports.add_plain("typing");
imports.add_from("json.decoder", "JSONDecodeError");
let api_error = imports.add_core_local("api_error", "ApiError");
if !ep.path_params.is_empty() || !ep.client_path_params.is_empty() {
imports.add_core("jsonable_encoder", "encode_path_param");
}
imports.add_core("parse_error", "ParsingError");
imports.add_from("pydantic", "ValidationError");
let await_ = if is_async { "await " } else { "" };
let wrapper = if is_async {
imports.core_local("http_response", "AsyncHttpResponse")
} else {
imports.core_local("http_response", "HttpResponse")
};
let mut lines: Vec<String> = offset_default_preamble(ep);
lines.extend(retries_disabled_preamble(ep));
lines.push(format!(
" _response = {await_}self._client_wrapper.httpx_client.request("
));
if ep.path != "/" {
lines.push(format!(" {},", url_arg(ep, imports)));
}
lines.extend(environment_base_url(ep));
lines.push(format!(" method=\"{}\",", ep.http_method));
append_request_call_args(&mut lines, ep, imports);
lines.extend([" )".to_string(), " try:".to_string()]);
if matches!(ep.response, Some(TypeRef::Optional(_)))
|| ep.response_may_be_empty
&& (matches!(ep.response, Some(TypeRef::Primitive(Prim::Any)))
|| ep.response_names_unknown)
{
lines.extend([
" if _response is None or not _response.text.strip():".to_string(),
format!(" return {wrapper}(response=_response, data=None)"),
]);
}
lines.push(" if 200 <= _response.status_code < 300:".to_string());
if ep.text_response {
lines.push(format!(
" return {wrapper}(response=_response, data=_response.text)"
));
} else if let Some(pagination) = ep.pagination.as_ref() {
imports.add_core("pydantic_utilities", "parse_obj_as");
lines.extend(pager_success_branch(ep, pagination, inner, is_async));
} else if ep.response.is_some() {
imports.add_core("pydantic_utilities", "parse_obj_as");
lines.extend([
" _data = typing.cast(".to_string(),
format!(" {inner},"),
" parse_obj_as(".to_string(),
format!(" type_={inner},"),
" object_=_response.json(),".to_string(),
" ),".to_string(),
" )".to_string(),
format!(" return {wrapper}(response=_response, data=_data)"),
]);
} else {
lines.push(format!(
" return {wrapper}(response=_response, data=None)"
));
}
// One `raise <Error>` branch per declared error response, keyed on its status
// code, parsing the error body into the generated exception.
for err in &ep.errors {
imports.add_core("pydantic_utilities", "parse_obj_as");
let module = naming::module_name(&err.class_name);
imports.add_from(&imports.error_import_path(&module), &err.class_name);
let body = raw_type_str(&err.body_type, imports);
lines.extend(error_raise_branch(err, &body, " "));
}
lines.extend([
" _response_json = _response.json()".to_string(),
" except JSONDecodeError:".to_string(),
format!(" raise {api_error}(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text)"),
]);
lines.extend([
" except ValidationError as e:".to_string(),
" raise ParsingError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e)".to_string(),
]);
lines.push(format!(" raise {api_error}(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json)"));
lines.join("\n")
}
/// A request's `base_url` argument in a multi-URL environment: the URL of the
/// environment field the operation reads (`get_environment().archive`).
fn environment_base_url(ep: &Endpoint) -> Option<String> {
ep.extensions.environment_field.as_ref().map(|field| {
format!(" base_url=self._client_wrapper.get_environment().{field},")
})
}
/// An offset-paginated operation's first statement: its offset argument
/// defaulted to the first page (`page = page if page is not None else 1`), as
/// Fern writes it, then a blank line.
fn offset_default_preamble(ep: &Endpoint) -> Vec<String> {
match &ep.pagination {
Some(pagination) if matches!(pagination.advance, PageAdvance::Offset) => vec![
format!(
" {0} = {0} if {0} is not None else 1",
pagination.cursor_param
),
String::new(),
],
_ => Vec::new(),
}
}
/// The request options an operation with `x-fern-retries: {disabled: true}`
/// sends in place of the caller's: theirs with `max_retries` set to 0, written
/// ahead of the request call; nothing for any other operation.
fn retries_disabled_preamble(ep: &Endpoint) -> Vec<String> {
if !ep.extensions.retries_disabled {
return Vec::new();
}
vec![
" _request_options_with_retries_disabled: typing.Optional[RequestOptions] = (".to_string(),
" {**request_options, \"max_retries\": 0} if request_options is not None else {\"max_retries\": 0}".to_string(),
" )".to_string(),
]
}
/// Append an endpoint's httpx call arguments (indent 12) to `lines` — the query
/// `params` dict, the serialized request body, the `headers` dict,
/// `request_options`, and the `omit`/`force_multipart` sentinels. Shared by the
/// buffered `.request(...)` path ([`raw_body`]) and the streaming `.stream(...)`
/// path ([`raw_stream_body`]) so both serialize a body identically.
fn append_request_call_args(lines: &mut Vec<String>, ep: &Endpoint, imports: &mut Imports) {
// Query parameters map wire name to the Python argument in a `params` dict. An
// object/union-typed parameter serializes through the convert wrapper (Fern
// respects its field aliases), exactly as an inlined body field does.
if !ep.query_params.is_empty() {
lines.push(" params={".to_string());
for qp in &ep.query_params {
if qp.comma_separated {
lines.push(format!(
" \"{}\": \",\".join(map(str, {})) if isinstance({}, (list, tuple, set)) else {},",
qp.wire_name, qp.py_name, qp.py_name, qp.py_name
));
} else if qp.convert {
imports.add_core("serialization", "convert_and_respect_annotation_metadata");
// Fern converts collections per element: a query
// `Sequence[Model]` passes `annotation=Model`, while a direct
// object parameter passes its model unchanged.
let annotation_type = match &qp.type_ref {
TypeRef::List(inner) | TypeRef::Set(inner) => inner.as_ref(),
other => other,
};
let annotation = raw_type_str_ctx(annotation_type, imports, true);
let call = Doc::group(
format!(
" \"{}\": convert_and_respect_annotation_metadata(",
qp.wire_name
),
vec![
Doc::atom(format!("object_={}", qp.py_name)),
Doc::atom(format!("annotation={annotation}")),
Doc::atom("direction=\"write\""),
],
")",
);
lines.push(format!("{},", call.flat()));
} else if type_serializes_as(&qp.type_ref, Prim::Date)
|| qp.aliased_datetime == Some(Prim::Date)
{
let value = if qp.argument_required() {
format!("str({})", qp.py_name)
} else {
format!(
"str({}) if {} is not None else None",
qp.py_name, qp.py_name
)
};
lines.push(format!(" \"{}\": {value},", qp.wire_name));
} else if type_serializes_as(&qp.type_ref, Prim::Datetime)
|| qp.aliased_datetime == Some(Prim::Datetime)
{
imports.add_core("datetime_utils", "serialize_datetime");
let value = if qp.argument_required() {
format!("serialize_datetime({})", qp.py_name)
} else {
format!(
"serialize_datetime({}) if {} is not None else None",
qp.py_name, qp.py_name
)
};
lines.push(format!(" \"{}\": {value},", qp.wire_name));
} else {
lines.push(format!(
" \"{}\": {},",
qp.wire_name, qp.py_name
));
}
}
lines.push(" },".to_string());
}
// The request body serializes as `json=request`, through the convert wrapper
// for types carrying field aliases, or — for an inlined object — as a
// `json={...}` dict mapping each field's wire name to its argument (each value
// itself convert-wrapped when the field carries aliases).
match &ep.request_body {
None => {}
Some(RequestBody::Single(s)) if s.convert => {
imports.add_core("serialization", "convert_and_respect_annotation_metadata");
let annotation_type = if s.required {
s.type_ref.clone()
} else {
TypeRef::Optional(Box::new(s.type_ref.clone()))
};
let annotation = raw_type_str_ctx(&annotation_type, imports, true);
// A urlencoded single body is sent as form data.
let key = if s.content_type_override.as_deref()
== Some("application/x-www-form-urlencoded")
{
"data"
} else {
"json"
};
let call = Doc::group(
format!(" {key}=convert_and_respect_annotation_metadata("),
vec![
Doc::atom("object_=request"),
Doc::atom(format!("annotation={annotation}")),
Doc::atom("direction=\"write\""),
],
")",
);
lines.push(format!("{},", call.flat()));
}
Some(RequestBody::Single(_)) => lines.push(" json=request,".to_string()),
Some(RequestBody::Inline(fields)) => {
lines.push(" json={".to_string());
for f in fields {
// The field a stream condition fixes is written last, whatever
// position the schema declares it in.
if ep
.stream_condition
.as_ref()
.is_some_and(|(wire, _)| *wire == f.wire_name)
{
continue;
}
let value_name = body_field_value_name(f, &ep.query_params);
if f.convert {
imports.add_core("serialization", "convert_and_respect_annotation_metadata");
let annotation_type = if f.nullable {
TypeRef::Optional(Box::new(f.type_ref.clone()))
} else {
f.type_ref.clone()
};
let annotation = raw_type_str_ctx(&annotation_type, imports, true);
let call = Doc::group(
format!(
" \"{}\": convert_and_respect_annotation_metadata(",
f.wire_name
),
vec![
Doc::atom(format!("object_={value_name}")),
Doc::atom(format!("annotation={annotation}")),
Doc::atom("direction=\"write\""),
],
")",
);
lines.push(format!("{},", call.flat()));
} else {
lines.push(format!(
" \"{}\": {value_name},",
f.wire_name
));
}
}
if let Some((wire, value)) = ep.stream_condition.as_ref() {
let literal = if *value { "True" } else { "False" };
lines.push(format!(" \"{wire}\": {literal},"));
}
lines.push(" },".to_string());
}
// A raw bytes body is streamed via `content=` rather than serialized.
Some(RequestBody::Bytes { .. }) => lines.push(" content=request,".to_string()),
// A form body: non-file fields into `data={...}`, file fields into
// `files={...}` (in document order within each group).
Some(RequestBody::Form(form)) => {
let mut data = String::new();
let mut files = String::new();
let json_shadowed = form.fields.iter().any(|field| field.py_name == "json")
|| ep.path_params.iter().any(|param| param.py_name == "json")
|| ep.query_params.iter().any(|param| param.py_name == "json")
|| ep.header_params.iter().any(|param| param.py_name == "json");
for f in &form.fields {
if let Some(content_type) = &f.form_content_type {
if f.form_json {
imports.add_core("jsonable_encoder", "jsonable_encoder");
let array_part = matches!(&f.type_ref, TypeRef::List(_))
|| matches!(&f.type_ref, TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::List(_)));
let encoded = if array_part && !is_json_like_media_type(content_type) {
format!("jsonable_encoder({})", f.py_name)
} else {
let module = imports.json_module(json_shadowed);
format!("{module}.dumps(jsonable_encoder({}))", f.py_name)
};
let tuple = format!("(None, {encoded}, \"{content_type}\")");
if f.optional {
files.push_str(&format!(
" **(\n {{\"{}\": {tuple}}}\n if {} is not OMIT\n else {{}}\n ),\n",
f.wire_name, f.py_name
));
} else {
files.push_str(&format!(
" \"{}\": {tuple},\n",
f.wire_name
));
}
} else {
let value = format!(
"core.with_content_type(file={}, default_content_type=\"{content_type}\")",
f.py_name
);
if f.optional {
files.push_str(&format!(
" **(\n {{\n \"{}\": {value}\n }}\n if {} is not None\n else {{}}\n ),\n",
f.wire_name, f.py_name
));
} else {
files.push_str(&format!(
" \"{}\": {value},\n",
f.wire_name
));
}
}
continue;
}
let repeated =
form.multipart && !f.is_file && !f.form_json && is_list_type(&f.type_ref);
let value = if f.form_json {
let module = imports.json_module(json_shadowed);
imports.add_core("jsonable_encoder", "jsonable_encoder");
let encoded = format!("{module}.dumps(jsonable_encoder({}))", f.py_name);
if f.optional {
format!("{encoded} if {} is not OMIT else OMIT", f.py_name)
} else {
encoded
}
} else if repeated {
// A list of form primitives (the IR leaves it out of
// `form_json`) is passed through `jsonable_encoder` alone, so
// httpx repeats the field per item.
imports.add_core("jsonable_encoder", "jsonable_encoder");
let encoded = format!("jsonable_encoder({})", f.py_name);
if f.optional {
format!("{encoded} if {} is not OMIT else OMIT", f.py_name)
} else {
encoded
}
} else {
f.py_name.clone()
};
// A form field whose type is a named model serializes through the
// annotation converter, exactly as the same field would in a JSON
// body (`mosip-esignet`'s urlencoded `claims`, a `$ref` to `Claim`).
let entry = if f.convert && !f.is_file && !f.form_json && !repeated {
imports.add_core("serialization", "convert_and_respect_annotation_metadata");
let annotation_type = if f.nullable {
TypeRef::Optional(Box::new(f.type_ref.clone()))
} else {
f.type_ref.clone()
};
let annotation = raw_type_str_ctx(&annotation_type, imports, true);
let call = Doc::group(
format!(
" \"{}\": convert_and_respect_annotation_metadata(",
f.wire_name
),
vec![
Doc::atom(format!("object_={value}")),
Doc::atom(format!("annotation={annotation}")),
Doc::atom("direction=\"write\""),
],
")",
);
format!("{},\n", call.flat())
} else if f.is_file && f.optional {
// An optional file field is spread in conditionally rather
// than passed as a `None` part: DaniWeb's `csv` is the
// corpus's first multipart file property outside `required`.
format!(
" **({{\"{}\": {value}}} if {value} is not None else {{}}),\n",
f.wire_name
)
} else {
format!(" \"{}\": {value},\n", f.wire_name)
};
if f.is_file {
files.push_str(&entry);
} else {
data.push_str(&entry);
}
}
if !data.is_empty() {
lines.push(format!(" data={{\n{data} }},"));
} else if !files.is_empty() {
lines.push(" data={},".to_string());
}
if !files.is_empty() {
lines.push(format!(" files={{\n{files} }},"));
} else if form.multipart {
lines.push(" files={},".to_string());
}
}
}
fn is_list_type(t: &TypeRef) -> bool {
match t {
TypeRef::List(_) | TypeRef::Set(_) => true,
TypeRef::Optional(inner) => is_list_type(inner),
_ => false,
}
}
fn type_serializes_as(t: &TypeRef, primitive: Prim) -> bool {
match t {
TypeRef::Primitive(value) => *value == primitive,
TypeRef::Optional(inner) => type_serializes_as(inner, primitive),
_ => false,
}
}
// The `headers` dict carries a `content-type`: `application/octet-stream` for a
// raw bytes body, else `application/json` when the body intrinsically needs it
// or is accompanied by path/header parameters. Header parameters follow,
// rendered as `str(x) if x is not None else None`.
// A *resource envelope*: a documented body schema that is mostly `readOnly`
// and declares exactly one required member — the k8s Container Service
// Provider's `Container`, six of whose seven properties are `readOnly` around
// the single required `spec`. Fern keeps the explicit `content-type` for that
// shape, so it escapes both drops an ordinary echo body takes: the
// response-heavy one below, which applies only to an *undocumented* schema
// (apideck's `add`, netbox's `groups_create`), and the request/response
// same-`$ref` one, which drops the header for Airflow's `post_pool` (no
// required member) and the Petstore's `place_order` (not response-heavy).
let resource_envelope = ep.body_schema_is_response_heavy
&& ep.body_schema_documented
&& matches!(&ep.request_body, Some(RequestBody::Inline(fields))
if fields.iter().filter(|field| field.spec_required).count() == 1);
let content_type: Option<String> = match &ep.request_body {
Some(_) if ep.body_content_type_override.is_some() => ep.body_content_type_override.clone(),
Some(RequestBody::Bytes { content_type }) => Some(content_type.clone()),
// A multipart form uses `force_multipart=True` (no content-type header);
// a urlencoded form carries its own content-type.
Some(RequestBody::Form(form)) => {
(!form.multipart).then_some("application/x-www-form-urlencoded".to_string())
}
Some(RequestBody::Single(body))
if body.content_type_override.is_some()
&& body.content_type_override.as_deref() != Some("*/*") =>
{
body.content_type_override.clone()
}
// A JSON body carries the `content-type` header when it accompanies a
// path/header param, is *undocumented*, or is *always sent whole* (every field
// required, so no `OMIT`). Fern drops the explicit header only for a
// documented body with at least one optional field — which may serialize to
// nothing — or a legacy codegen-named body that rides no header-forcing
// param, leaving the content-type to httpx. An all-required body
// (`CREATE_SessionServer`) keeps it. A `components.requestBodies` body drops
// it only while its schema survives in the public type layer; one whose
// schema exists solely to be flattened here (exa-gate's `KeyBatch`) keeps it.
// The security scheme does not enter into it: measured at fern-python-sdk
// 5.41.3, a Basic-auth operation's titled `$ref` body keeps the header (mi_music's
// `DidVolume` and fourteen more), and otoroshi's untitled, surviving
// `Group` loses it to the surviving-schema drop below, not to its scheme.
// Nor does an `allOf` or an empty `requestBody.description`: a single-use
// `allOf` body is a dropped schema like any other, and an empty description
// is a description. A documented, response-heavy body with one required member — a resource
// envelope — keeps the header through both of those drops; see
// `resource_envelope` above.
Some(body)
if !body.is_wildcard_media()
&& !ep.body_collapses_to_type_reference
&& (ep.body_media_has_example
&& ep.body_schema_dropped
&& ep.body_schema_shape == BodySchemaShape::Ref
|| ep.reference_body_example.is_some()
// Inline named examples keep their header. A surviving
// referenced schema still follows the schema drop below.
&& (ep.body_schema_shape != BodySchemaShape::Ref || ep.body_schema_dropped)
&& !ep.body_schema_is_success_response
&& matches!(body, RequestBody::Inline(_))
|| !ep.header_params.is_empty()
|| !ep.path_params.is_empty()
|| !ep.query_params.is_empty()
|| (body.content_type_header()
// A body schema more than one operation posts, which is
// neither `required: true` nor sent whole, drops the
// header two ways. It drops it when the body says nothing
// about itself anywhere — no `description` on the
// `requestBody` and none on the schema: the Open
// Integration Hub's `MutableSecret` and `MutableAuthClient`
// are each the body of a create and an update. And it drops
// it when the `requestBody` *is* described, the operation
// sends nothing but that body, and the body declares
// required members — some of them, since all of them is the
// sent-whole case above: braintrust's `CreateView`,
// `AclItem` and twelve more are each the body of two
// argument-free operations under a described `requestBody`,
// and a description on the *schema* does not save
// `AclItem`. Both halves of that second drop are load
// bearing across the corpus. A query parameter beside the
// body keeps the header — Palo Alto's `IkeCryptoProfiles`
// and `IpsecCryptoProfiles` are shared, described and
// partly required, and each rides a `SubTenantName` — and
// so does a body with no required member at all, which is
// what leaves the echo `Message` of the same-`$ref` case
// (one optional field) its header. Every other way to be
// shared keeps it:
// bodies declaring `required: true` or sent whole retain
// the header. Adyen's `GrantInfo` is posted by one operation.
&& !(ep.body_schema_shared
&& !ep.body_declared_required
&& !body.all_fields_required()
&& !ep.body_schema_dropped
&& (ep.body_description_missing && !ep.body_schema_documented
|| !ep.body_description_missing
&& ep.query_params.is_empty()
&& matches!(body, RequestBody::Inline(fields)
if fields.iter().any(|field| field.spec_required))))
&& !ep.body_codegen_named
// An inline union body that neither names itself nor
// declares a discriminator drops the header, however it is
// documented and whatever its auth: the Vonage Messages
// API's described, required `sendMessage` `oneOf`,
// Flowdapt's `create_config` and Otoroshi's
// `create_global_auth_module` all leave the content type to
// httpx, where Letta's titled `anyOf` (`add_mcp_server`)
// and its discriminated `createTemplateNoProject` keep it.
&& ep.body_schema_shape != BodySchemaShape::InlinePlainUnion
// …and so does one whose schema the `requestBodies` entry
// writes inline, which names no type at all: the Vonage
// Conversation API's `Conversation` body.
&& (!ep.body_component_ref
|| ep.body_schema_dropped
|| ep.body_schema_shape != BodySchemaShape::Ref)
// A referenced request schema that SURVIVES in the public
// type layer — Fern kept the model because something else
// (a response, another body) needs it — is a documented
// type the request merely flattens, and Fern leaves its
// content type to httpx. AGCO posts 28 such bodies
// (`UpdateSystem.Models.Bundle`, `BuildSystem.Shared.DTO.Job`,
// …), every one of them without the header, against the
// seven whose schema Fern dropped (`API.Models.Credentials`,
// `DealerDB.Models.LicenseActivationCreate`, …), every one
// of them with it. A query parameter beside the body keeps
// it either way — AGCO's `POST /api/v2/GlobalImages` rides
// an `overridePublisherOrDate` and is the document's one
// surviving-schema body that carries the header — which is
// the same query-parameter exception the shared-body drop
// above already records for Palo Alto's crypto profiles.
// A schema declaring its own `title` escapes the drop.
// The `title` is what Fern's importer names the request
// model from, and every measured surviving-schema body
// splits on it: SFTPGo's `Admin`, `APIKey`, `Role`,
// `Share`, `AdminProfile` and `UserProfile`,
// Audiobookshelf's `EmailSettings` and `Podcast`, LORIS's
// `QueryObject` and all 28 of AGCO's are untitled and lose
// the header, while letta's titled `CreateBlock`/`BlockUpdate`
// carry one and keep it. A
// body offering SEVERAL media types drops it either way,
// which is what AGCO measured first and what no titled
// source has since contradicted. A `stream-condition`
// request escapes the drop too: Fern augments that body
// with the condition property itself (`"stream": True`),
// so it is no longer just the referenced schema.
// A documented resource envelope escapes this drop too,
// exactly as it escapes the same-`$ref` one below:
// corpus row 113's `Container` is untitled, and while its
// own `createContainer` also rides an `id` query parameter,
// the shape that measurement pinned is the envelope.
// Every measured witness is a FLATTENED object body, so
// the drop is scoped to one: a `$ref` to a scalar alias
// (an integer enum, a `format: uri` string) rides as a
// single `request` argument and keeps its header.
&& !(matches!(body, RequestBody::Inline(_))
&& ep.body_schema_shape == BodySchemaShape::Ref
&& !ep.body_schema_dropped
&& ep.query_params.is_empty()
&& ep.stream_condition.is_none()
&& !resource_envelope
&& (ep.body_media_alternatives || !ep.body_schema_titled))
// A query parameter beside the body keeps the header
// through both drops below, the same exception the shared
// and surviving-schema drops above already record: SFTPGo's
// `loaddata_from_request_body` posts an open, unnamed body
// beside two, and Fern keeps its content type. A titled
// schema escapes the same-`$ref` drop as it escapes the
// surviving-schema one above: measured at Fern 5.20.0
// (not re-measured at the certified pair), a
// titled `Message` echoed back by an unauthenticated
// operation keeps the header that Airflow's untitled
// `Pool` loses.
&& !(ep.query_params.is_empty()
&& matches!(body, RequestBody::Inline(_))
&& ep.body_response_same_ref
&& !ep.body_schema_titled
&& !resource_envelope)
// A `stream-condition` body escapes this drop as it
// escapes the surviving-schema one above, and for the
// same reason: Fern sends the condition beside it.
&& !(ep.query_params.is_empty()
&& ep.stream_condition.is_none()
&& matches!(body, RequestBody::Inline(fields)
if ep.body_schema_shape == BodySchemaShape::Ref
&& (!ep.body_schema_dropped
&& ep.body_schema_metadata_missing
&& ep.body_schema_is_open
&& !ep.body_schema_is_success_response
|| ep.body_schema_is_response_heavy
&& ep.body_schema_is_open
&& ep.body_description_missing
&& !ep.body_schema_documented
&& fields.iter().filter(|field| field.spec_required).count() == 1)))
&& (!(ep.body_schema_has_example
&& ep.body_schema_documented
&& !ep.body_schema_example_wrapped
|| ep.body_schema_example_wrapped
&& matches!(body, RequestBody::Inline(fields)
if !fields.is_empty()
&& fields.len() > 1
&& fields.iter().all(|field| field.optional)
&& ep.body_schema_example_covers_all_fields
&& fields.iter().any(|field| field.convert)))
|| body.all_fields_required()))) =>
{
Some(
ep.body_json_media_type
.clone()
.unwrap_or_else(|| "application/json".to_string()),
)
}
_ => None,
};
if content_type.is_some()
|| !ep.header_params.is_empty()
|| !ep.constant_headers.is_empty()
|| !ep.extensions.idempotency_headers.is_empty()
{
lines.push(" headers={".to_string());
if let Some(value) = content_type {
lines.push(format!(" \"content-type\": \"{value}\","));
}
// Constants and arguments together, in the order the operation declares
// them; a header the order does not name (an endpoint built by hand)
// follows, constants first.
let mut headers: Vec<(&str, String)> = ep
.constant_headers
.iter()
.map(|(name, value)| {
(
name.as_str(),
format!(" \"{name}\": {value:?},"),
)
})
.collect();
for header_param in &ep.header_params {
let value = if header_param.enum_value {
format!("{}.value", header_param.py_name)
} else {
format!("str({})", header_param.py_name)
};
headers.push((
header_param.wire_name.as_str(),
format!(
" \"{}\": {value} if {} is not None else None,",
header_param.wire_name, header_param.py_name
),
));
}
headers.sort_by_key(|(wire, _)| {
ep.header_order
.iter()
.position(|declared| declared == wire)
.unwrap_or(usize::MAX)
});
lines.extend(headers.into_iter().map(|(_, line)| line));
// The idempotency headers follow the operation's own, in the order the
// document lists them.
lines.extend(
ep.extensions
.idempotency_headers
.iter()
.map(|header_param| {
format!(
" \"{}\": str({1}) if {1} is not None else None,",
header_param.wire_name, header_param.py_name
)
}),
);
lines.push(" },".to_string());
}
lines.push(if ep.extensions.retries_disabled {
" request_options=_request_options_with_retries_disabled,".to_string()
} else {
" request_options=request_options,".to_string()
});
// A request body passes the `OMIT` sentinel so unset optionals drop out.
if ep.request_body.is_some() {
lines.push(" omit=OMIT,".to_string());
}
// A multipart form forces the request to serialize as `multipart/form-data`.
if matches!(&ep.request_body, Some(RequestBody::Form(f)) if f.multipart) {
lines.push(" force_multipart=True,".to_string());
}
}
fn body_field_value_name<'a>(field: &'a BodyField, query: &[QueryParam]) -> &'a str {
let original = field
.collision_prefix
.as_ref()
.and_then(|prefix| field.py_name.strip_prefix(&format!("{prefix}_")))
.unwrap_or(&field.py_name);
// Keep the renamed signature, but serialize its body argument when a query
// parameter shares the body's wire key (body-query-parameter-value).
if query
.iter()
.any(|parameter| parameter.py_name == original && parameter.wire_name == field.wire_name)
{
&field.py_name
} else {
original
}
}
/// The chunk type Fern yields from an OpenAPI-sourced SSE stream: the
/// `text/event-stream` media type's own schema, or `typing.Any` when it declares
/// none — Fern's importer does not resolve `x-fern-streaming`'s
/// `chunk-schema-ref`, so no extension can supply one.
fn sse_chunk(ep: &Endpoint, imports: &mut Imports) -> String {
ep.stream_chunk
.as_ref()
.map_or_else(|| "typing.Any".to_string(), |ty| raw_type_str(ty, imports))
}
/// The `_sse.data` value that ends a Server-Sent-Events stream: the declared
/// terminator, or `None` — an event carrying no data — when none is declared.
fn sse_end(terminator: Option<&str>) -> String {
terminator.map_or_else(
|| "None".to_string(),
|end| format!("\"{}\"", escape_py_str(end)),
)
}
/// Build one streaming raw-client method (sync or async): a context-managed
/// `httpx_client.stream(...)` that decodes Server-Sent Events into an iterator of
/// chunks over the `core.http_sse` runtime, matching Fern's shape (issue #43).
fn raw_stream_method(ep: &Endpoint, is_async: bool, imports: &mut Imports) -> String {
imports.add_plain("contextlib");
imports.add_plain("typing");
imports.add_from("json.decoder", "JSONDecodeError");
imports.add_core("api_error", "ApiError");
imports.add_core("parse_error", "ParsingError");
imports.add_from("pydantic", "ValidationError");
if !ep.path_params.is_empty() || !ep.client_path_params.is_empty() {
imports.add_core("jsonable_encoder", "encode_path_param");
}
let (wrapper, iter_t, aiter, decorator, async_kw, async_with, for_kw, iter_sse, read) =
if is_async {
(
"AsyncHttpResponse",
"typing.AsyncIterator",
"typing.AsyncIterator",
"@contextlib.asynccontextmanager",
"async ",
"async with",
"async for",
"aiter_sse",
"await _response.aread()",
)
} else {
(
"HttpResponse",
"typing.Iterator",
"typing.Iterator",
"@contextlib.contextmanager",
"",
"with",
"for",
"iter_sse",
"_response.read()",
)
};
let mp = method_params(ep, imports);
let chunk = sse_chunk(ep, imports);
// A streaming operation still declares error statuses, and Fern raises them
// from inside the stream body once the response has been read, exactly as the
// buffered path does.
let error_branches = raw_error_branches(ep, imports);
let error_branches = if error_branches.is_empty() {
String::new()
} else {
format!("\n{error_branches}")
};
let data_type = format!("{aiter}[{chunk}]");
let return_type = format!("{iter_t}[{wrapper}[{data_type}]]");
let sig = signature(ep, &mp, &return_type, is_async);
let docstring = raw_stream_docstring(ep, &mp, &return_type);
let mut call: Vec<String> = retries_disabled_preamble(ep);
call.push(format!(
" {async_with} self._client_wrapper.httpx_client.stream("
));
if ep.path != "/" {
call.push(format!(" {},", url_arg(ep, imports)));
}
call.extend(environment_base_url(ep));
call.push(format!(" method=\"{}\",", ep.http_method));
append_request_call_args(&mut call, ep, imports);
call.push(" ) as _response:".to_string());
let inner_return = format!("{wrapper}(response=_response, data=_iter())");
let iter_body = match ep.stream_protocol.as_ref().unwrap_or(&StreamProtocol::Sse {
terminator: None,
events: Vec::new(),
}) {
StreamProtocol::Sse { terminator, events } if !events.is_empty() => {
imports.add_plain("json");
imports.add_from("logging", "warning");
imports.add_core("http_sse._api", "EventSource");
imports.add_core("pydantic_utilities", "parse_obj_as");
// Each event's `event` field picks the model its JSON `data` parses
// as; an event no branch names is skipped. Like the single-model
// arm below, an event with empty `data` (a comment- or retry-only
// frame) is skipped before any parse — measured at
// fern-python-sdk 5.41.3 (exa-gate, torrentarr, truefoundry-trueforge).
// Every dispatched model is `{event, data}` (see
// `sse_event_dispatch`), which 5.41.3 reads as the SSE envelope
// itself — `data` plus at most `id` and `retry` beside the
// discriminant — so it parses `{"data": …}` rather than the bare
// payload (the hand-written `event-stream-event-dispatch` fixture).
let branches: Vec<String> = events
.iter()
.enumerate()
.map(|(index, dispatched)| {
let model = raw_type_str(&TypeRef::Named(dispatched.model.clone()), imports);
let keyword = if index == 0 { "if" } else { "elif" };
let event = escape_py_str(&dispatched.event);
format!(
" {keyword} _sse.event == \"{event}\":
try:
yield typing.cast(
{model},
parse_obj_as(
type_={model},
object_={{\"data\": json.loads(_sse.data)}},
),
)
except Exception as e:
warning(f\"Failed to parse SSE event '{event}': {{e}}, sse: {{_sse!r}}\")"
)
})
.collect();
format!(
" _event_source = EventSource(_response)
{for_kw} _sse in _event_source.{iter_sse}():
if _sse.data == {end}:
return
if len(_sse.data) == 0:
continue
{branches}",
end = sse_end(terminator.as_deref()),
branches = branches.join("\n"),
)
}
StreamProtocol::Sse { terminator, .. } => {
imports.add_from("logging", "error");
imports.add_from("logging", "warning");
imports.add_core("http_sse._api", "EventSource");
imports.add_core("pydantic_utilities", "parse_sse_obj");
let end = sse_end(terminator.as_deref());
format!(
" _event_source = EventSource(_response)
{for_kw} _sse in _event_source.{iter_sse}():
if _sse.data == {end}:
return
if len(_sse.data) == 0:
continue
try:
yield typing.cast(
{chunk},
parse_sse_obj(
sse=_sse,
type_={chunk},
),
)
except JSONDecodeError as e:
warning(f\"Skipping SSE event with invalid JSON: {{e}}, sse: {{_sse!r}}\")
except (TypeError, ValueError, KeyError, AttributeError) as e:
warning(
f\"Skipping SSE event due to model construction error: {{type(e).__name__}}: {{e}}, sse: {{_sse!r}}\"
)
except Exception as e:
error(
f\"Unexpected error processing SSE event: {{type(e).__name__}}: {{e}}, sse: {{_sse!r}}\"
)"
)
}
StreamProtocol::JsonLines => {
imports.add_plain("json");
imports.add_core("pydantic_utilities", "parse_obj_as");
// One chunk per non-empty line; Fern skips a line that does not parse.
let iter_lines = if is_async {
"aiter_lines"
} else {
"iter_lines"
};
format!(
" {for_kw} _text in _response.{iter_lines}():
try:
if len(_text) == 0:
continue
yield typing.cast(
{chunk},
parse_obj_as(
type_={chunk},
object_=json.loads(_text),
),
)
except Exception:
pass"
)
}
};
let body = format!(
"\
{call}
{async_kw}def _stream() -> {wrapper}[{data_type}]:
try:
if 200 <= _response.status_code < 300:
{async_kw}def _iter():
{iter_body}
return
return {inner_return}
{read}{error_branches}
_response_json = _response.json()
except JSONDecodeError:
raise ApiError(
status_code=_response.status_code, headers=dict(_response.headers), body=_response.text
)
except ValidationError as e:
raise ParsingError(
status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e
)
raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json)
yield {yield_expr}",
call = call.join("\n"),
yield_expr = if is_async {
"await _stream()"
} else {
"_stream()"
},
);
format!(" {decorator}\n{sig}\n{docstring}\n{body}")
}
/// The streaming raw method docstring (indent 8): `Parameters` then a `Yields`
/// section (Fern uses `Yields`, not `Returns`, for a streaming method) carrying the
/// return type and the response description.
fn raw_stream_docstring(ep: &Endpoint, mp: &MethodParams, return_type: &str) -> String {
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
// A multi-line summary carries the 8-space docstring indent on every line,
// as in the buffered raw method.
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in &mp.path {
push_path_param(&mut lines, name, ty, desc);
}
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.for_each(&mut push_param);
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(" Request-specific configuration.".to_string());
lines.push(String::new());
lines.push(" Yields".to_string());
lines.push(" ------".to_string());
lines.push(format!(" {return_type}"));
push_return_doc(&mut lines, ep.response_doc.as_deref());
lines.push(" \"\"\"".to_string());
lines.join("\n")
}
/// Build one binary-download raw-client method (sync or async): a
/// context-managed `httpx_client.stream(...)` that yields response bytes.
fn raw_binary_stream_method(ep: &Endpoint, is_async: bool, imports: &mut Imports) -> String {
imports.add_plain("contextlib");
imports.add_plain("typing");
imports.add_from("json.decoder", "JSONDecodeError");
imports.add_core("api_error", "ApiError");
if !ep.errors.is_empty() {
imports.add_core("pydantic_utilities", "parse_obj_as");
}
imports.add_core("parse_error", "ParsingError");
imports.add_from("pydantic", "ValidationError");
if !ep.path_params.is_empty() || !ep.client_path_params.is_empty() {
imports.add_core("jsonable_encoder", "encode_path_param");
}
let (wrapper, iter_t, data_iter, decorator, async_kw, async_with, read, iter_bytes, yield_expr) =
if is_async {
(
"AsyncHttpResponse",
"typing.AsyncIterator",
"typing.AsyncIterator",
"@contextlib.asynccontextmanager",
"async ",
"async with",
"await _response.aread()",
"aiter_bytes",
"await _stream()",
)
} else {
(
"HttpResponse",
"typing.Iterator",
"typing.Iterator",
"@contextlib.contextmanager",
"",
"with",
"_response.read()",
"iter_bytes",
"_stream()",
)
};
let mp = method_params(ep, imports);
let data_type = format!("{data_iter}[bytes]");
let return_type = format!("{iter_t}[{wrapper}[{data_type}]]");
let sig = signature(ep, &mp, &return_type, is_async);
let docstring = raw_binary_stream_docstring(ep, &mp, &return_type);
let mut call: Vec<String> = retries_disabled_preamble(ep);
call.push(format!(
" {async_with} self._client_wrapper.httpx_client.stream("
));
if ep.path != "/" {
call.push(format!(" {},", url_arg(ep, imports)));
}
call.extend(environment_base_url(ep));
call.push(format!(" method=\"{}\",", ep.http_method));
append_request_call_args(&mut call, ep, imports);
call.push(" ) as _response:".to_string());
let data_expr = if is_async {
format!("(_chunk async for _chunk in _response.{iter_bytes}(chunk_size=_chunk_size))")
} else {
format!("(_chunk for _chunk in _response.{iter_bytes}(chunk_size=_chunk_size))")
};
let success_return = if is_async {
format!("return {wrapper}(\n response=_response,\n data={data_expr},\n )")
} else {
format!("return {wrapper}(\n response=_response, data={data_expr}\n )")
};
let error_branches = raw_error_branches(ep, imports);
let error_branches = if error_branches.is_empty() {
String::new()
} else {
format!("\n{error_branches}")
};
let validation_error = "\n except ValidationError as e:\n raise ParsingError(\n status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e\n )";
let body = format!(
"\
{call}
{async_kw}def _stream() -> {wrapper}[{data_type}]:
try:
if 200 <= _response.status_code < 300:
_chunk_size = request_options.get(\"chunk_size\", None) if request_options is not None else None
{success_return}
{read}{error_branches}
_response_json = _response.json()
except JSONDecodeError:
raise ApiError(
status_code=_response.status_code, headers=dict(_response.headers), body=_response.text
){validation_error}
raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json)
yield {yield_expr}",
call = call.join("\n"),
);
format!(" {decorator}\n{sig}\n{docstring}\n{body}")
}
fn raw_binary_stream_docstring(ep: &Endpoint, mp: &MethodParams, return_type: &str) -> String {
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in &mp.path {
push_path_param(&mut lines, name, ty, desc);
}
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.for_each(&mut push_param);
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(
" Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response.".to_string(),
);
lines.push(String::new());
lines.push(" Returns".to_string());
lines.push(" -------".to_string());
lines.push(format!(" {return_type}"));
push_return_doc(&mut lines, ep.response_doc.as_deref());
lines.push(" \"\"\"".to_string());
lines.join("\n")
}
fn raw_error_branches(ep: &Endpoint, imports: &mut Imports) -> String {
let mut lines = Vec::new();
for err in &ep.errors {
imports.add_core("pydantic_utilities", "parse_obj_as");
let module = naming::module_name(&err.class_name);
imports.add_from(&imports.error_import_path(&module), &err.class_name);
let body = raw_type_str(&err.body_type, imports);
lines.extend(error_raise_branch(err, &body, " "));
}
lines.join("\n")
}
/// One declared error's `raise` branch at `indent`: the status test, then the
/// exception built from the parsed body. A `4XX`/`5XX` range tests the whole
/// range and hands the exception the actual status, as fern-python-sdk 5.41.3
/// writes it.
fn error_raise_branch(err: &crate::ir::ErrorResponse, body: &str, indent: &str) -> Vec<String> {
let code = err.status.code();
let test = if err.status.is_range() {
format!("if {code} <= _response.status_code < {}:", code + 100)
} else {
format!("if _response.status_code == {code}:")
};
let mut lines = vec![
format!("{indent}{test}"),
format!("{indent} raise {}(", err.class_name),
format!("{indent} headers=dict(_response.headers),"),
format!("{indent} body=typing.cast("),
format!("{indent} {body},"),
format!("{indent} parse_obj_as("),
format!("{indent} type_={body},"),
format!("{indent} object_=_response.json(),"),
format!("{indent} ),"),
format!("{indent} ),"),
];
if err.status.is_range() {
lines.push(format!(
"{indent} status_code=_response.status_code,"
));
}
lines.push(format!("{indent} )"));
lines
}
/// Assemble the root `client.py`: the `FernApi`/`AsyncFernApi` classes that
/// aggregate every tag client. Bearer-auth-coupled today (a `token` argument);
/// generalize when more auth schemes are modeled.
struct RootClientFileCtx<'a> {
/// The document's unquoted YAML timestamps; see [`ExampleCtx::yaml_unquoted_timestamps`].
yaml_unquoted_timestamps: Option<&'a std::collections::BTreeSet<String>>,
pkg: &'a str,
client_name: &'a str,
modules: &'a [&'a String],
root_endpoints: &'a [&'a Endpoint],
types: &'a [TypeDecl],
enum_type: EnumType,
tag_decls: &'a [TagTypeDecl],
tag_map: &'a BTreeMap<String, String>,
auth: &'a Auth,
environment: Option<&'a crate::ir::Environment>,
global_headers: &'a [GlobalHeader],
client_path_parameters: &'a [ClientPathParameter],
/// The fallback for an unset `max_retries` (Fern's `default_max_retries`).
default_max_retries: u32,
}
fn root_client_file(
env: &Environment<'static>,
cx: RootClientFileCtx<'_>,
) -> Result<GeneratedFile> {
let RootClientFileCtx {
yaml_unquoted_timestamps,
pkg,
client_name,
modules,
root_endpoints,
types,
enum_type,
tag_decls,
tag_map,
auth,
environment,
global_headers,
client_path_parameters,
default_max_retries,
} = cx;
let mut imports = Imports::at(RefLoc::PackageRoot, tag_map);
if global_headers
.iter()
.any(|header| matches!(header.py_type(), HeaderType::Date))
{
imports.add_plain_as("datetime", "dt");
}
imports.add_plain("typing");
imports.add_plain("httpx");
// A credential read from the environment defaults to `os.getenv`, and a
// required one is reported missing with an `ApiError`.
if auth_credentials(auth)
.iter()
.any(|credential| credential.env.is_some())
|| global_headers
.iter()
.any(|header| header_env(header).is_some())
{
imports.add_plain("os");
imports.add_core("api_error", "ApiError");
}
imports.add_core("client_wrapper", "AsyncClientWrapper");
imports.add_core("client_wrapper", "SyncClientWrapper");
imports.add_core("logging", "LogConfig");
imports.add_core("logging", "Logger");
if !root_endpoints.is_empty() {
imports.add_core("request_options", "RequestOptions");
imports.add_from(".raw_client", &format!("Raw{client_name}"));
imports.add_from(".raw_client", &format!("AsyncRaw{client_name}"));
}
if let Some(e) = environment {
imports.add_from(".environment", &e.enum_name);
}
let mut type_checking = if modules.is_empty() {
String::new()
} else {
"if typing.TYPE_CHECKING:\n".to_string()
};
// Fern emits the sub-client imports alphabetically by module (`gadgets` before
// `widgets`), even though the properties below stay in declaration order. This
// block is never executed, but — unlike the `__init__.py` aggregators — client.py
// is not isort-canonicalized by the e2e, so the order must match Fern exactly.
let mut import_modules: Vec<&&String> = modules.iter().collect();
import_modules.sort();
for m in import_modules {
let pascal = naming::to_pascal_case(m);
// The two imported names are ordered the way ruff's isort orders
// members — case-insensitively — so `Async{X}Client` usually comes
// first, but not when the tag sorts `{X}Client` ahead
// (`ApikeyauthClient`, and MockServer's `AsyncapiClient`).
let mut names = [format!("Async{pascal}Client"), format!("{pascal}Client")];
names.sort_by_key(|name| name.to_lowercase());
if names
.iter()
.any(|name| root_sub_client_alias(m, name, client_name).is_some())
{
// An aliased import is never combined with its sibling: Fern writes
// one `from … import X as alias` statement per name.
for name in &names {
type_checking.push_str(&format!(
" from .{m}.client import {}\n",
root_sub_client_import(m, name, client_name)
));
}
} else {
type_checking.push_str(&format!(
" from .{m}.client import {}, {}\n",
names[0], names[1]
));
}
}
let cfg = RootClientCfg {
auth,
environment,
global_headers,
client_path_parameters,
default_max_retries,
};
let root_methods = root_client_methods(
env,
yaml_unquoted_timestamps,
pkg,
client_name,
root_endpoints,
types,
enum_type,
tag_decls,
tag_map,
auth,
global_headers,
client_path_parameters,
environment.is_some(),
false,
&mut imports,
)?;
let async_root_methods = root_client_methods(
env,
yaml_unquoted_timestamps,
pkg,
client_name,
root_endpoints,
types,
enum_type,
tag_decls,
tag_map,
auth,
global_headers,
client_path_parameters,
environment.is_some(),
true,
&mut imports,
)?;
let mut body = String::new();
// Fern uses postponed annotations for sub-client properties. A root-only SDK
// has no forward-referenced sub-client types and omits the future import.
if !modules.is_empty() {
body.push_str("from __future__ import annotations\n\n");
}
body.push_str(&imports.render());
body.push_str("\n\n");
body.push_str(&type_checking);
if root_endpoints
.iter()
.any(|endpoint| endpoint.request_body.is_some())
{
body.push_str("\nOMIT = typing.cast(typing.Any, ...)\n");
}
body.push_str("\n\n");
body.push_str(&root_client_class(
env,
client_name,
pkg,
false,
modules,
&root_methods,
&cfg,
)?);
body.push_str(
"\n\n\ndef _make_default_async_client(\n timeout: typing.Optional[float],\n follow_redirects: typing.Optional[bool],\n) -> httpx.AsyncClient:\n try:\n import httpx_aiohttp\n except ImportError:\n pass\n else:\n if follow_redirects is not None:\n return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout, follow_redirects=follow_redirects)\n return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout)\n\n if follow_redirects is not None:\n return httpx.AsyncClient(timeout=timeout, follow_redirects=follow_redirects)\n return httpx.AsyncClient(timeout=timeout)",
);
body.push_str("\n\n\n");
body.push_str(&root_client_class(
env,
client_name,
pkg,
true,
modules,
&async_root_methods,
&cfg,
)?);
// When environments are in play, a module-level `_get_base_url` resolves the
// explicit `base_url` or falls back to the selected environment's URL.
if let Some(e) = environment.filter(|e| e.urls.is_empty()) {
body.push_str(&format!(
"\n\n\ndef _get_base_url(*, base_url: typing.Optional[str] = None, environment: {enum}) -> str:\n if base_url is not None:\n return base_url\n elif environment is not None:\n return environment.value\n else:\n raise Exception(\"Please pass in either base_url or environment to construct the client\")",
enum = e.enum_name,
));
}
let contents = render(
env,
"file.py",
"client.py",
context! { header => HEADER, body => body },
)?;
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{pkg}/client.py")),
contents,
})
}
/// The credential/environment/global-header configuration shared by both root
/// client classes, bundled to keep [`root_client_class`] within the argument limit.
struct RootClientCfg<'a> {
auth: &'a Auth,
environment: Option<&'a crate::ir::Environment>,
global_headers: &'a [GlobalHeader],
client_path_parameters: &'a [ClientPathParameter],
default_max_retries: u32,
}
#[allow(
clippy::too_many_arguments,
reason = "root method emission mirrors ClientCtx plus root imports"
)]
fn root_client_methods(
_env: &Environment<'static>,
yaml_unquoted_timestamps: Option<&std::collections::BTreeSet<String>>,
pkg: &str,
client_name: &str,
endpoints: &[&Endpoint],
types: &[TypeDecl],
enum_type: EnumType,
tag_decls: &[TagTypeDecl],
tag_map: &BTreeMap<String, String>,
auth: &Auth,
global_headers: &[GlobalHeader],
client_path_parameters: &[ClientPathParameter],
has_environment: bool,
is_async: bool,
imports: &mut Imports,
) -> Result<Vec<String>> {
if endpoints.is_empty() {
return Ok(Vec::new());
}
let cx = ClientCtx {
yaml_unquoted_timestamps,
pkg,
client_name,
module: "",
types,
enum_type,
tag_decls,
auth,
has_environment,
tag_types: tag_map,
global_headers,
client_path_parameters,
sdk_first_party: true,
children: &[],
};
let mut method_imports = Imports::at(RefLoc::PackageRoot, tag_map);
let methods = endpoints
.iter()
.map(|ep| client_method(&cx, ep, is_async, &mut method_imports))
.collect();
for (module, names) in method_imports.from {
for name in names {
imports.add_from(&module, &name);
}
}
for (module, alias) in method_imports.plain {
match alias {
Some(alias) => imports.add_plain_as(&module, &alias),
None => imports.add_plain(&module),
}
}
imports.json_module_alias |= method_imports.json_module_alias;
Ok(methods)
}
/// One root client class: the class docstring, the `__init__` wiring up the
/// client wrapper and the per-tag lazy slots, then one cached `@property` per tag.
fn root_client_class(
env: &Environment<'static>,
client_name: &str,
pkg: &str,
is_async: bool,
modules: &[&String],
root_methods: &[String],
cfg: &RootClientCfg,
) -> Result<String> {
let auth = cfg.auth;
let environment = cfg.environment;
let global_headers = cfg.global_headers;
let (class_name, wrapper, httpx_type) = if is_async {
(
format!("Async{client_name}"),
"AsyncClientWrapper",
"httpx.AsyncClient",
)
} else {
(client_name.to_string(), "SyncClientWrapper", "httpx.Client")
};
let leading_names: std::collections::HashSet<&str> = cfg
.global_headers
.iter()
.filter(|h| h.default().is_none())
.map(|h| h.py_name.as_str())
.collect();
let a = auth_client_parts(auth, &leading_names);
// With an environment, `base_url` becomes optional, an `environment` parameter
// (defaulting to the first server) is added, and the wrapper's `base_url` is
// resolved through `_get_base_url`.
let e = environment.map(EnvClientParts::new);
// A multi-URL environment (operation-level servers) has no single base URL
// to override: the client takes only the environment, and the wrapper
// carries it whole.
let multi_url = environment.is_some_and(|environment| !environment.urls.is_empty());
let base_url_doc = if multi_url {
String::new()
} else {
format!(
" base_url : {}\n The base url to use for requests from the client.\n\n",
if e.is_some() {
"typing.Optional[str]"
} else {
"str"
}
)
};
let env_doc = e.as_ref().map_or_else(String::new, |e| e.doc.clone());
let server_variable_doc = e
.as_ref()
.map_or_else(String::new, |e| e.server_variable_doc.clone());
let base_url_ctor = e.as_ref().map_or_else(
|| " base_url: str,\n".to_string(),
|e| e.ctor.clone(),
);
let server_variable_init = e
.as_ref()
.map_or_else(String::new, |e| e.server_variable_init.clone());
let wrapper_location = if multi_url {
"environment=environment".to_string()
} else {
e.as_ref().map_or_else(
|| "base_url=base_url".to_string(),
|_| "base_url=_get_base_url(base_url=base_url, environment=environment)".to_string(),
)
};
// A defaulted header trails `logging` instead and is in no example (see
// [`GlobalHeader::default`]); the `353-string-default-*` authored probes
// byte-match the resulting `client.py` end to end.
let (trailing, global_headers): (Vec<GlobalHeader>, Vec<GlobalHeader>) = global_headers
.iter()
.cloned()
.partition(|h| h.default().is_some());
let global_headers = global_headers.as_slice();
let tr_doc: String = trailing
.iter()
.map(|h| format!(" {} : typing.Optional[str]\n", h.py_name))
.collect();
let tr_ctor: String = trailing
.iter()
.map(|h| format!(" {}: typing.Optional[str] = None,\n", h.py_name))
.collect();
let tr_wrapper: String = trailing
.iter()
.map(|h| format!(" {0}={0},\n", h.py_name))
.collect();
// Promoted global headers: a docstring/ctor/example/wrapper line each, emitted
// right after `base_url` and before the auth credential. A required header is a
// mandatory `str`; an optional one is `Optional[str] = None` — matching the
// client wrapper.
// A lifted base-path parameter leads the promoted headers on every line.
let client_path_parameters = cfg.client_path_parameters;
let client_param_doc: String = client_path_parameters
.iter()
.map(|parameter| {
let ty = if parameter.default.is_some() {
"typing.Optional[str]"
} else {
"str"
};
format!(" {} : {ty}\n", parameter.py_name)
})
.chain(global_headers.iter().map(|h| {
let ty = if h.required() && header_env(h).is_none() {
global_header_annotation(h)
} else {
format!("typing.Optional[{}]", global_header_annotation(h))
};
format!(" {} : {ty}\n", h.py_name)
}))
.collect();
let client_param_ctor: String = client_path_parameters
.iter()
.map(|parameter| client_path_parameter_param(parameter, true))
.chain(
distinct_global_header_params(global_headers)
.into_iter()
.map(|h| {
// A header read from the environment defaults to `None`, as a
// credential does ([`env_fallback`]).
if h.required() && header_env(h).is_none() {
format!(" {}: {},\n", h.py_name, global_header_annotation(h))
} else {
format!(
" {}: typing.Optional[{}] = None,\n",
h.py_name,
global_header_annotation(h)
)
}
}),
)
.collect();
// Two promoted headers sharing a parameter pass it once (the
// `repeated-credential-example-keyword` departure): Fern repeats it.
let client_param_example: String = client_path_parameters
.iter()
.map(|parameter| format!(" {},\n", client_path_parameter_example(parameter)))
.chain(
distinct_global_header_params(global_headers)
.into_iter()
.map(|header| format!(" {},\n", global_header_example(header))),
)
.collect();
let client_param_wrapper: String = client_path_parameters
.iter()
.map(|parameter| ¶meter.py_name)
.chain(
distinct_global_header_params(global_headers)
.into_iter()
.map(|h| &h.py_name),
)
.map(|name| format!(" {name}={name},\n"))
.collect();
let module_views: Vec<RootModuleView> = modules
.iter()
.map(|m| {
let class = tag_client_name(m, is_async);
let import = root_sub_client_import(m, &class, client_name);
let cls = root_sub_client_alias(m, &class, client_name).unwrap_or(class);
// The lazy `from .{attr}.client import {import}` sits at 12 spaces of indent.
// Fern's printer wraps this single-name import into the parenthesized,
// trailing-comma form once the flat line reaches 107 columns; ruff (run at
// `line-length = 120`) then preserves that shape via its magic trailing
// comma, and leaves the shorter flat lines untouched. crozier must make the
// same call itself — ruff will not split a single-name import for it. The
// boundary is exact: corpus row 123 wraps at 107 and the corpus's widest
// flat import is 106.
let wrap =
12 + "from .".len() + m.len() + ".client import ".len() + import.len() >= 107;
RootModuleView {
attr: (*m).clone(),
cls,
import,
wrap,
}
})
.collect();
let has_async_token = is_async && matches!(auth, Auth::Bearer { .. });
let view = RootClientView {
class_name: class_name.to_string(),
pkg: pkg.to_string(),
httpx_type: httpx_type.to_string(),
wrapper: wrapper.to_string(),
ctor_param: a.ctor_param,
wrapper_arg: a.wrapper_arg,
doc_param: a.doc_param,
example_line: a.example_line,
auth_check: distinct_global_header_params(global_headers)
.into_iter()
.filter_map(|h| Some(env_fallback(&h.py_name, header_env(h)?, h.required())))
.collect::<String>()
+ &a.check,
base_url_doc,
env_doc,
server_variable_doc,
base_url_ctor,
server_variable_init,
wrapper_location,
example_base_url: if e.is_some() {
String::new()
} else {
" base_url=\"https://yourhost.com/path/to/api\",\n".to_string()
},
client_param_doc,
client_param_ctor,
client_param_example,
client_example_imports: if global_headers.iter().any(|header| header.py_type() == HeaderType::Date) { " import datetime\n\n" } else { "" }.to_string(),
client_param_wrapper,
tr_doc,
tr_ctor,
tr_wrapper,
raw_client_cls: if root_methods.is_empty() {
String::new()
} else if is_async {
format!("AsyncRaw{client_name}")
} else {
format!("Raw{client_name}")
},
root_methods: root_methods.to_vec(),
modules: module_views,
is_async,
async_token_doc: if has_async_token {
" async_token : typing.Optional[typing.Callable[[], typing.Awaitable[str]]]\n An async callable that returns a bearer token. Use this when token acquisition involves async I/O (e.g., refreshing tokens via an async HTTP client). When provided, this is used instead of the synchronous token for async requests.\n\n"
} else {
""
}
.to_string(),
async_token_ctor: if has_async_token {
" async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None,\n"
} else {
""
}
.to_string(),
async_token_wrapper: if has_async_token {
" async_token=async_token,\n"
} else {
""
}
.to_string(),
default_max_retries: cfg.default_max_retries,
};
// The caller controls the separation between the sync/async classes and the
// file's final newline, so drop the template's trailing newline.
let rendered = render(
env,
"root_client.py",
"client.py",
minijinja::Value::from_serialize(&view),
)?;
Ok(rendered.trim_end_matches('\n').to_string())
}
/// One root client's per-tag slot: the attribute name (`dispatch_bays`) and its
/// client class (`DispatchBaysClient`/`AsyncDispatchBaysClient`).
#[derive(Serialize)]
struct RootModuleView {
attr: String,
/// The name the sub-client class is bound to in the root `client.py`: its own
/// name, or the alias [`root_sub_client_alias`] gives it.
cls: String,
/// What the lazy import names: the class, with ` as {alias}` when aliased.
import: String,
/// Whether the lazy import line overflows Fern's 107-column threshold and must be
/// emitted in the parenthesized multi-line form (a single-name import ruff won't
/// split, so its magic trailing comma just preserves whichever shape we emit).
wrap: bool,
}
/// The view model for `root_client.py.j2` (the `FernApi`/`AsyncFernApi` class).
#[derive(Serialize)]
struct RootClientView {
class_name: String,
pkg: String,
httpx_type: String,
wrapper: String,
ctor_param: String,
wrapper_arg: String,
doc_param: String,
example_line: String,
/// The checks a required credential read from the environment gets.
auth_check: String,
/// The `base_url` docstring block (`str`, or `typing.Optional[str]` with an
/// environment; empty with a multi-URL environment, which takes none).
base_url_doc: String,
/// The `environment` docstring `Parameters` block (empty without environments).
env_doc: String,
/// Root-client parameter documentation for variables in the first server URL.
server_variable_doc: String,
/// The `base_url` (and `environment`) constructor parameter line(s).
base_url_ctor: String,
/// Constructor statements that apply explicitly supplied server URL variables.
server_variable_init: String,
/// What the client wrapper is constructed from: `base_url=…` (`base_url`, or a
/// `_get_base_url(...)` call with environments), or `environment=environment`
/// for a multi-URL environment.
wrapper_location: String,
/// The `Examples` client instantiation's `base_url` line (empty with
/// environments, which drop it).
example_base_url: String,
/// Lifted base-path and promoted global-header lines: the docstring `Parameters`
/// entries, the constructor parameters, the `Examples` arguments, and the
/// client-wrapper call arguments — all placed after `base_url`.
client_param_doc: String,
client_param_ctor: String,
client_param_example: String,
/// Imports needed by the constructor's worked example.
client_example_imports: String,
client_param_wrapper: String,
/// Defaulted global-header lines (empty without any): the docstring
/// `Parameters` entries, the constructor parameters and the client-wrapper call
/// arguments, all placed after `logging`.
tr_doc: String,
tr_ctor: String,
tr_wrapper: String,
raw_client_cls: String,
root_methods: Vec<String>,
modules: Vec<RootModuleView>,
/// Selects the aiohttp-autodetecting default for the async root client.
is_async: bool,
/// Bearer-only asynchronous token provider fragments, present on the async client.
async_token_doc: String,
async_token_ctor: String,
async_token_wrapper: String,
/// The `max_retries` fallback and its documented default (Fern's
/// `default_max_retries`).
default_max_retries: u32,
}
/// The environment-varying fragments of the root client: the docstring block, the
/// constructor parameter lines. Assembled once and reused across the sync/async
/// classes.
struct EnvClientParts {
doc: String,
ctor: String,
server_variable_doc: String,
server_variable_init: String,
}
impl EnvClientParts {
fn new(e: &crate::ir::Environment) -> Self {
let enum_name = &e.enum_name;
let default = e.default_ref();
let server_variable_doc = e
.variables
.iter()
.map(|variable| {
format!(
" {} : typing.Optional[str]\n Server URL variable for '{}'. Defaults to '{}'.\n\n",
variable.py_name,
variable.wire_name,
python_doc_line(&variable.default)
)
})
.collect();
let variable_ctor: String = e
.variables
.iter()
.map(|variable| {
format!(
" {}: typing.Optional[str] = None,\n",
variable.py_name
)
})
.collect();
let format_args = e
.variables
.iter()
.map(|variable| format!("{}=_{}", variable.wire_name, variable.py_name))
.collect::<Vec<_>>()
.join(", ");
let server_variable_init = if e.variables.is_empty() {
String::new()
} else {
let condition = e
.variables
.iter()
.map(|variable| format!("{} is not None", variable.py_name))
.collect::<Vec<_>>()
.join(" or ");
let defaults: String = e
.variables
.iter()
.map(|variable| {
format!(
" _{name} = {name} if {name} is not None else \"{default}\"\n",
name = variable.py_name,
default = escape_py_str(&variable.default),
)
})
.collect();
// The override formats the template of the environment the caller
// chose, the first templated one when it has none, and never replaces
// a `base_url` the caller passed (`openfigi.com`,
// `amazonaws.com-cloudformation`, `free5gc-pdu-session`; measured at
// fern-python-sdk 5.41.3).
let first = e
.url_templates
.first()
.map_or(e.url_template.as_str(), |(_, template)| template.as_str());
let templates: String = e
.url_templates
.iter()
.map(|(member, template)| {
format!(
" {enum_name}.{member}: \"{}\",\n",
escape_py_str(template)
)
})
.collect();
format!(
" if {condition}:\n{defaults} _environment_url_templates = {{\n{templates} }}\n _url_template = _environment_url_templates.get(environment, \"{first}\")\n if base_url is None:\n base_url = _url_template.format({format_args})\n",
first = escape_py_str(first),
)
};
// A multi-URL environment replaces `base_url`.
let base_url_ctor = match e.urls.is_empty() {
true => " base_url: typing.Optional[str] = None,\n",
false => "",
};
EnvClientParts {
// Fern's docstring here leaks the import statement onto the description
// line and pads the block with blank lines; reproduced verbatim.
doc: format!(
" environment : {enum_name}\n The environment to use for requests from the client. from .environment import {enum_name}\n\n\n\n Defaults to {default}\n\n\n\n"
),
ctor: format!(
"{base_url_ctor} environment: {enum_name} = {default},\n{variable_ctor}"
),
server_variable_doc,
server_variable_init,
}
}
}
/// The alias a root `client.py` imports a top-level sub-client class under, when
/// that class is named like one of the root client classes the same file defines
/// (`client_class_name: EcosystemClient` over an `ecosystem` resource). Fern then
/// imports it as `{module}_client_{Class}`, so the root class does not shadow it
/// and the sub-client property keeps the sub-client's type; `None` otherwise.
fn root_sub_client_alias(module: &str, class: &str, client_name: &str) -> Option<String> {
(class == client_name || class.strip_prefix("Async") == Some(client_name))
.then(|| format!("{module}_client_{class}"))
}
/// The import clause naming `class` in a root `client.py`: `{class}`, or
/// `{class} as {alias}` when [`root_sub_client_alias`] aliases it.
fn root_sub_client_import(module: &str, class: &str, client_name: &str) -> String {
match root_sub_client_alias(module, class, client_name) {
Some(alias) => format!("{class} as {alias}"),
None => class.to_string(),
}
}
/// The tag client class name for a module (`dispatch_bays` → `DispatchBaysClient`,
/// or `AsyncDispatchBaysClient`).
fn tag_client_name(module: &str, is_async: bool) -> String {
let pascal = naming::to_pascal_case(module_stem(module));
if is_async {
format!("Async{pascal}Client")
} else {
format!("{pascal}Client")
}
}
/// Shared context for emitting one per-tag `client.py`: the import package name,
/// the root client name, the module, and the type table the example generator
/// consults. Bundled so the client helpers stay within clippy's argument limit.
struct ClientCtx<'a> {
/// The document's unquoted YAML timestamps; see [`ExampleCtx::yaml_unquoted_timestamps`].
yaml_unquoted_timestamps: Option<&'a std::collections::BTreeSet<String>>,
pkg: &'a str,
client_name: &'a str,
module: &'a str,
types: &'a [TypeDecl],
/// How string enums render, for the example generator.
enum_type: EnumType,
/// Hoisted tag-scoped types, for the example generator's constructors.
tag_decls: &'a [TagTypeDecl],
auth: &'a Auth,
has_environment: bool,
tag_types: &'a BTreeMap<String, String>,
global_headers: &'a [GlobalHeader],
client_path_parameters: &'a [ClientPathParameter],
/// Whether Fern's isort pass files the SDK's own imports as first-party; see
/// [`ExampleCtx::sdk_first_party`].
sdk_first_party: bool,
/// Nested sub-client modules this client exposes as lazy properties, in the
/// order Fern writes them (see [`module_children`]). Empty for a leaf client.
children: &'a [String],
}
/// Assemble a per-tag `client.py`: the sync and async high-level clients that
/// wrap the raw client, return `_response.data`, and carry a worked `Examples`
/// docstring.
fn client_file(
env: &Environment<'static>,
cx: &ClientCtx,
endpoints: &[&Endpoint],
) -> Result<GeneratedFile> {
let stem = naming::to_pascal_case(module_stem(cx.module));
let mut imports = Imports::at(RefLoc::Client(cx.module.to_string()), cx.tag_types);
imports.add_plain("typing");
imports.add_core("client_wrapper", "AsyncClientWrapper");
imports.add_core("client_wrapper", "SyncClientWrapper");
// A parent client carries no methods of its own, so nothing there takes a
// `request_options` argument and Fern does not import the type.
if !endpoints.is_empty() {
imports.add_core("request_options", "RequestOptions");
}
imports.add_from(".raw_client", &format!("Raw{stem}Client"));
imports.add_from(".raw_client", &format!("AsyncRaw{stem}Client"));
let sync = client_class(env, cx, endpoints, false, &mut imports)?;
let async_class = client_class(env, cx, endpoints, true, &mut imports)?;
// The `OMIT` sentinel is declared above the classes whenever a request body
// is present, exactly as in the raw client.
let omit = if endpoints.iter().any(|e| e.request_body.is_some()) {
"OMIT = typing.cast(typing.Any, ...)\n\n\n"
} else {
""
};
// A client that exposes nested sub-clients annotates them with postponed
// annotations and imports their classes under `TYPE_CHECKING` only, the way
// the root client already does — the runtime import lives inside each
// property so the package's import graph stays acyclic.
let mut type_checking = String::new();
if !cx.children.is_empty() {
type_checking.push_str("\n\nif typing.TYPE_CHECKING:");
let mut sorted: Vec<&String> = cx.children.iter().collect();
sorted.sort();
for child in sorted {
let attr = module_stem(child);
let mut names = [tag_client_name(child, true), tag_client_name(child, false)];
names.sort_by_key(|name| name.to_lowercase());
type_checking.push_str(&format!(
"\n from .{attr}.client import {}, {}",
names[0], names[1]
));
}
}
let future = if cx.children.is_empty() {
""
} else {
"from __future__ import annotations\n\n"
};
let body = format!("{omit}{sync}\n\n\n{async_class}");
let mut contents = render(
env,
"raw_client.py",
cx.module,
context! {
header => HEADER,
imports => format!("{future}{}{type_checking}", imports.render()),
body => body,
},
)?;
// The `TYPE_CHECKING` block is followed by one blank line when `OMIT` comes
// next, not the two a class gets: TrueForge's `internal` client both nests
// sub-clients and posts a body, and its golden writes the pair that way.
if !type_checking.is_empty() && !omit.is_empty() {
contents = contents.replacen("\n\n\nOMIT = ", "\n\nOMIT = ", 1);
}
Ok(GeneratedFile {
path: PathBuf::from(format!("src/{}/{}/client.py", cx.pkg, cx.module)),
contents,
})
}
/// One high-level client class: `__init__`, the `with_raw_response` property, and
/// one wrapper method per operation. The class/wrapper/raw-client names are
/// derived from the module and `is_async`.
fn client_class(
env: &Environment<'static>,
cx: &ClientCtx,
endpoints: &[&Endpoint],
is_async: bool,
imports: &mut Imports,
) -> Result<String> {
let stem = naming::to_pascal_case(module_stem(cx.module));
let (class_name, wrapper, raw_client_cls) = if is_async {
(
format!("Async{stem}Client"),
"AsyncClientWrapper",
format!("AsyncRaw{stem}Client"),
)
} else {
(
format!("{stem}Client"),
"SyncClientWrapper",
format!("Raw{stem}Client"),
)
};
// Each method is computed in Rust (signature, docstring, delegation body); the
// template lays out the class skeleton and separates the methods.
let methods: Vec<String> = endpoints
.iter()
.map(|ep| client_method(cx, ep, is_async, imports))
.collect();
let children: Vec<_> = cx
.children
.iter()
.map(|child| {
let attr = module_stem(child);
context! {
attr => attr,
cls => tag_client_name(child, is_async),
}
})
.collect();
let view = context! {
class_name => class_name,
wrapper => wrapper,
raw_client_cls => raw_client_cls,
methods => methods,
children => children,
};
let rendered = render(env, "client_class.py", &class_name, view)?;
Ok(rendered.trim_end_matches('\n').to_string())
}
/// One high-level wrapper method: the signature, the docstring (with a worked
/// example), and a body delegating to the raw client and returning
/// `_response.data`.
fn client_method(cx: &ClientCtx, ep: &Endpoint, is_async: bool, imports: &mut Imports) -> String {
if ep.streaming {
return client_stream_method(cx, ep, is_async, imports);
}
if ep.binary_response {
return client_binary_stream_method(cx, ep, is_async, imports);
}
let mp = method_params(ep, imports);
let return_type = if ep.http_method == "HEAD" {
"typing.Dict[str, str]".to_string()
} else {
pager_return_type(ep, &mp.inner, is_async, imports).unwrap_or_else(|| mp.inner.clone())
};
let sig = signature(ep, &mp, &return_type, is_async);
let docstring = client_docstring(cx, ep, &mp, is_async);
// Delegation: path params positionally, the rest as keywords, then
// `request_options`, laid out with ruff's right-hand-split (line length 120).
let await_ = if is_async { "await " } else { "" };
let mut call_args: Vec<Doc> = Vec::new();
for (name, _, _) in &mp.path {
call_args.push(Doc::atom(name.clone()));
}
for dp in ordered_keyword_params(&mp.query, &mp.header, &mp.body) {
call_args.push(Doc::atom(format!("{}={}", dp.name, dp.name)));
}
call_args.push(Doc::atom("request_options=request_options"));
let open = format!("{await_}self._raw_client.{}(", ep.method_name);
// A paginated raw method already returns the pager, so the high-level one
// returns it straight through rather than unwrapping a buffered response.
if ep.pagination.is_some() {
let call = format!(" return {}", Doc::group(open, call_args, ")").flat());
return format!("{sig}\n{docstring}\n{call}");
}
let call = format!(
" _response = {}",
Doc::group(open, call_args, ")").flat()
);
let result = if ep.http_method == "HEAD" {
"_response.headers"
} else {
"_response.data"
};
format!("{sig}\n{docstring}\n{call}\n return {result}")
}
/// The high-level streaming method (sync or async): a plain generator that opens
/// the context-managed raw stream and yields each decoded chunk. Matches Fern's
/// `Iterator`/`AsyncIterator` wrapper over the raw client's SSE stream (issue #43).
fn client_stream_method(
cx: &ClientCtx,
ep: &Endpoint,
is_async: bool,
imports: &mut Imports,
) -> String {
let mp = method_params(ep, imports);
let iter_t = if is_async {
"typing.AsyncIterator"
} else {
"typing.Iterator"
};
let chunk = sse_chunk(ep, imports);
let return_type = format!("{iter_t}[{chunk}]");
let sig = signature(ep, &mp, &return_type, is_async);
let docstring = client_stream_docstring(cx, ep, &mp, is_async, &chunk);
// Delegation call arguments, in signature order (path positionally, the rest as
// keywords, then `request_options`) — identical to the buffered high-level method.
let mut call_args: Vec<Doc> = Vec::new();
for (name, _, _) in &mp.path {
call_args.push(Doc::atom(name.clone()));
}
for dp in ordered_keyword_params(&mp.query, &mp.header, &mp.body) {
call_args.push(Doc::atom(format!("{}={}", dp.name, dp.name)));
}
call_args.push(Doc::atom("request_options=request_options"));
let open = format!("self._raw_client.{}(", ep.method_name);
let raw_call = Doc::group(open, call_args, ")").flat();
let body = if is_async {
format!(
" async with {raw_call} as r:\n async for _chunk in r.data:\n yield _chunk"
)
} else {
format!(" with {raw_call} as r:\n yield from r.data")
};
format!("{sig}\n{docstring}\n{body}")
}
/// The streaming high-level method docstring (indent 8): `Parameters`, a `Yields`
/// section (matching Fern's streaming methods), then a worked `Examples` block that
/// iterates the returned stream.
fn client_stream_docstring(
cx: &ClientCtx,
ep: &Endpoint,
mp: &MethodParams,
is_async: bool,
chunk: &str,
) -> String {
let iter_t = if is_async {
"typing.AsyncIterator"
} else {
"typing.Iterator"
};
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
// The summary block every other high-level method carries.
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in &mp.path {
push_path_param(&mut lines, name, ty, desc);
}
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.for_each(&mut push_param);
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(" Request-specific configuration.".to_string());
lines.push(String::new());
lines.push(" Yields".to_string());
lines.push(" ------".to_string());
lines.push(format!(" {iter_t}[{chunk}]"));
push_return_doc(&mut lines, ep.response_doc.as_deref());
let mut ctx = ExampleCtx {
types: cx.types,
enum_type: cx.enum_type,
yaml_unquoted_timestamps: cx.yaml_unquoted_timestamps,
tag_decls: cx.tag_decls,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: cx.auth,
has_environment: cx.has_environment,
global_headers: cx.global_headers,
client_path_parameters: cx.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: cx.sdk_first_party,
};
if let Some(ex_lines) = build_example(ep, is_async, cx.module, cx.pkg, cx.client_name, &mut ctx)
{
lines.push(String::new());
lines.push(" Examples".to_string());
lines.push(" --------".to_string());
for l in ex_lines {
if l.is_empty() {
lines.push(String::new());
} else {
lines.push(format!(" {l}"));
}
}
}
lines.push(" \"\"\"".to_string());
if ep.module.is_empty() && ep.openapi_31 {
lines
.iter_mut()
.filter(|line| line.is_empty())
.for_each(|line| {
*line = " ".to_string();
});
}
lines.join("\n")
}
fn client_binary_stream_method(
cx: &ClientCtx,
ep: &Endpoint,
is_async: bool,
imports: &mut Imports,
) -> String {
imports.add_plain("typing");
let mp = method_params(ep, imports);
let iter_t = if is_async {
"typing.AsyncIterator"
} else {
"typing.Iterator"
};
let return_type = format!("{iter_t}[bytes]");
let sig = signature(ep, &mp, &return_type, is_async);
let docstring = client_binary_stream_docstring(cx, ep, &mp, is_async);
let mut call_args: Vec<Doc> = Vec::new();
for (name, _, _) in &mp.path {
call_args.push(Doc::atom(name.clone()));
}
for dp in ordered_keyword_params(&mp.query, &mp.header, &mp.body) {
call_args.push(Doc::atom(format!("{}={}", dp.name, dp.name)));
}
call_args.push(Doc::atom("request_options=request_options"));
let open = format!("self._raw_client.{}(", ep.method_name);
let raw_call = Doc::group(open, call_args, ")").flat();
let body = if is_async {
format!(" async with {raw_call} as r:\n async for _chunk in r.data:\n yield _chunk")
} else {
format!(" with {raw_call} as r:\n yield from r.data")
};
format!("{sig}\n{docstring}\n{body}")
}
fn client_binary_stream_docstring(
cx: &ClientCtx,
ep: &Endpoint,
mp: &MethodParams,
is_async: bool,
) -> String {
let iter_t = if is_async {
"typing.AsyncIterator"
} else {
"typing.Iterator"
};
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in &mp.path {
push_path_param(&mut lines, name, ty, desc);
}
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.for_each(&mut push_param);
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(
" Request-specific configuration. You can pass in configuration such as `chunk_size`, and more to customize the request and response.".to_string(),
);
lines.push(String::new());
lines.push(" Returns".to_string());
lines.push(" -------".to_string());
lines.push(format!(" {iter_t}[bytes]"));
push_return_doc(&mut lines, ep.response_doc.as_deref());
let mut ctx = ExampleCtx {
types: cx.types,
enum_type: cx.enum_type,
yaml_unquoted_timestamps: cx.yaml_unquoted_timestamps,
tag_decls: cx.tag_decls,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: cx.auth,
has_environment: cx.has_environment,
global_headers: cx.global_headers,
client_path_parameters: cx.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: cx.sdk_first_party,
};
if let Some(ex_lines) = (!root_binary_download_undocumented(ep))
.then(|| build_example(ep, is_async, cx.module, cx.pkg, cx.client_name, &mut ctx))
.flatten()
{
lines.push(String::new());
lines.push(" Examples".to_string());
lines.push(" --------".to_string());
for l in ex_lines {
if l.is_empty() {
lines.push(String::new());
} else {
lines.push(format!(" {l}"));
}
}
}
lines.push(" \"\"\"".to_string());
lines.join("\n")
}
/// The high-level method docstring (indent 8): the same summary + `Parameters` +
/// `Returns` as the raw client, followed by a worked `Examples` block. A raw
/// bytes body has no example (Fern omits the whole section).
fn client_docstring(cx: &ClientCtx, ep: &Endpoint, mp: &MethodParams, is_async: bool) -> String {
let mut lines: Vec<String> = vec![" \"\"\"".to_string()];
if let Some(summary) = &ep.docstring {
for line in summary.split('\n') {
lines.push(format!(" {}", python_doc_line(line)));
}
lines.push(String::new());
}
lines.push(" Parameters".to_string());
lines.push(" ----------".to_string());
for (name, ty, desc) in &mp.path {
push_path_param(&mut lines, name, ty, desc);
}
let mut push_param = |dp: &DocParam| {
lines.push(format!(" {} : {}", dp.name, dp.annotation));
if let Some(desc) = &dp.description {
push_param_doc(&mut lines, desc);
}
lines.push(String::new());
};
ordered_keyword_params(&mp.query, &mp.header, &mp.body)
.into_iter()
.for_each(&mut push_param);
lines.push(" request_options : typing.Optional[RequestOptions]".to_string());
lines.push(" Request-specific configuration.".to_string());
lines.push(String::new());
lines.push(" Returns".to_string());
lines.push(" -------".to_string());
let pager = ep
.pagination
.as_ref()
.map(|pagination| pager_doc_type(pagination, &mp.inner, is_async));
lines.push(format!(
" {}",
if ep.http_method == "HEAD" && ep.response.is_some() {
"typing.Dict[str, str]"
} else {
pager.as_deref().unwrap_or(&mp.inner)
}
));
// A concrete response carries its description (indented, blank when the spec
// gives none) under the return type.
if ep.response.is_some() {
push_return_doc(&mut lines, ep.response_doc.as_deref());
}
let mut ctx = ExampleCtx {
types: cx.types,
enum_type: cx.enum_type,
yaml_unquoted_timestamps: cx.yaml_unquoted_timestamps,
tag_decls: cx.tag_decls,
referenced: BTreeSet::new(),
referenced_doc_order: Vec::new(),
referenced_tag: BTreeSet::new(),
referenced_tag_doc_order: Vec::new(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth: cx.auth,
has_environment: cx.has_environment,
global_headers: cx.global_headers,
client_path_parameters: cx.client_path_parameters,
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: BTreeSet::new(),
sdk_first_party: cx.sdk_first_party,
};
// With an example, one blank line separates the `Returns` block from
// `Examples`; without one, close straight after (like the raw docstring).
if let Some(ex_lines) = build_example(ep, is_async, cx.module, cx.pkg, cx.client_name, &mut ctx)
{
lines.push(String::new());
lines.push(" Examples".to_string());
lines.push(" --------".to_string());
for l in ex_lines {
if l.is_empty() {
lines.push(String::new());
} else {
lines.push(format!(" {l}"));
}
}
}
lines.push(" \"\"\"".to_string());
if ep.module.is_empty() && ep.openapi_31 {
lines
.iter_mut()
.filter(|line| line.is_empty())
.for_each(|line| {
*line = " ".to_string();
});
}
lines.join("\n")
}
/// A synthesized Python example value, formatted the way Fern's snippet formatter
/// (ruff defaults) renders it.
#[derive(Clone)]
enum Example {
/// A leaf rendered verbatim (`"string"`, `1`, `True`, `{"key": "value"}`).
Atom(String),
/// A call `Name(arg, ...)`; each arg is `(Some("kw"), val)` or `(None, val)`
/// for a positional argument. A non-empty arg list always explodes.
Call(String, Vec<(Option<String>, Example)>),
/// A discriminated-union wrapper holding its payload whole, `Name(value=…)`:
/// it always explodes, and its one argument carries no trailing comma.
Wrapped(String, Box<Example>),
/// A list `[items]` — explodes only when an item does.
List(Vec<Example>),
/// A list supplied verbatim by the spec — wraps at the snippet line limit and
/// retains Python's trailing commas when exploded.
ExplicitList(Vec<Example>),
/// A list rendered by `reference.md`, which always expands supplied and
/// synthesized values recursively and omits only the final comma.
ReferenceList(Vec<Example>),
/// A dict with fixed string keys — explodes only when a value does.
Dict(Vec<(String, Example)>),
/// The same dict as written into `README.md`/`reference.md`. Fern's markdown
/// writer ends a length-wrapped dict without Python's magic trailing comma
/// (`mosip-esignet`'s `public_key`), while the docstring writer keeps it
/// (`free5gc-pdu-session`'s `global_ngenb_id`), so the flavor is the variant.
DocDict(Vec<(String, Example)>),
/// A reference-documentation dict. Fern's reference writer expands map
/// examples even when every value would fit on one line.
ReferenceDict(Vec<(String, Example)>),
}
impl Example {
/// Whether this value forces its enclosing line to break: a call with
/// arguments carries a magic trailing comma; a collection breaks when a child
/// does.
fn forces_multiline(&self) -> bool {
match self {
Example::Atom(_) => false,
Example::Call(_, args) => !args.is_empty(),
Example::Wrapped(..) => true,
Example::List(items) | Example::ExplicitList(items) => {
items.iter().any(Example::forces_multiline)
}
Example::ReferenceList(items) => !items.is_empty(),
Example::Dict(pairs) | Example::DocDict(pairs) => {
pairs.iter().any(|(_, v)| v.forces_multiline())
}
Example::ReferenceDict(pairs) => !pairs.is_empty(),
}
}
/// The same tree with every `Dict` in the markdown writers' flavor.
fn into_documentation_dicts(self) -> Example {
let convert_pairs = |pairs: Vec<(String, Example)>| {
pairs
.into_iter()
.map(|(key, value)| (key, value.into_documentation_dicts()))
.collect::<Vec<_>>()
};
let convert_items = |items: Vec<Example>| {
items
.into_iter()
.map(Example::into_documentation_dicts)
.collect::<Vec<_>>()
};
match self {
Example::Atom(value) => Example::Atom(value),
Example::Call(name, args) => Example::Call(
name,
args.into_iter()
.map(|(kw, value)| (kw, value.into_documentation_dicts()))
.collect(),
),
Example::Wrapped(name, value) => {
Example::Wrapped(name, Box::new(value.into_documentation_dicts()))
}
Example::List(items) => Example::List(convert_items(items)),
Example::ExplicitList(items) => Example::ExplicitList(convert_items(items)),
Example::ReferenceList(items) => Example::ReferenceList(convert_items(items)),
Example::Dict(pairs) | Example::DocDict(pairs) => {
Example::DocDict(convert_pairs(pairs))
}
Example::ReferenceDict(pairs) => Example::ReferenceDict(convert_pairs(pairs)),
}
}
/// The single-line rendering.
fn flat(&self) -> String {
match self {
Example::Atom(s) => s.clone(),
Example::Call(name, args) => {
let inner = args
.iter()
.map(|(kw, v)| match kw {
Some(k) => format!("{k}={}", v.flat()),
None => v.flat(),
})
.collect::<Vec<_>>()
.join(", ");
format!("{name}({inner})")
}
Example::Wrapped(name, value) => format!("{name}(value={})", value.flat()),
Example::List(items) | Example::ExplicitList(items) | Example::ReferenceList(items) => {
format!(
"[{}]",
items
.iter()
.map(Example::flat)
.collect::<Vec<_>>()
.join(", ")
)
}
Example::Dict(pairs) | Example::DocDict(pairs) | Example::ReferenceDict(pairs) => {
if pairs.is_empty() {
return "{}".to_string();
}
let inner = pairs
.iter()
.map(|(k, v)| format!("\"{k}\": {}", v.flat()))
.collect::<Vec<_>>()
.join(", ");
format!("{{{inner}}}")
}
}
}
/// Render the value starting on a line indented by `indent` spaces (the
/// indent of the line this value begins on). Continuation lines align to
/// `indent`; exploded children indent by four more.
///
/// The examples are laid out by hand rather than through `ruff format`
/// (unlike the SDK files) because Fern's committed examples are *not* a ruff
/// fixed point: ruff reformats a long `await <call>(...)` by parenthesizing
/// the awaited expression, whereas Fern leaves the long `await …(` line and
/// only explodes the call arguments. This renderer reproduces Fern's form.
fn render(&self, indent: usize) -> String {
self.render_at(indent, indent)
}
/// Render with separate physical indentation and starting column. A keyword
/// value starts after `name=`, which affects Fern's 88-column wrapping without
/// changing the indentation of any continuation lines.
fn render_at(&self, indent: usize, column: usize) -> String {
match self {
Example::Atom(s) => s.clone(),
Example::Call(name, args) => {
if args.is_empty() {
return format!("{name}()");
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
let mut out = format!("{name}(\n");
for (kw, v) in args {
let head = match kw {
Some(k) => format!("{inner_pad}{k}="),
None => inner_pad.clone(),
};
out.push_str(&head);
let value_column = indent + 4 + kw.as_ref().map_or(0, |key| key.len() + 1);
out.push_str(&v.render_at(indent + 4, value_column));
out.push_str(",\n");
}
out.push_str(&pad);
out.push(')');
out
}
Example::Wrapped(name, value) => format!(
"{name}(\n{}value={}\n{})",
" ".repeat(indent + 4),
value.render_at(indent + 4, indent + 10),
" ".repeat(indent)
),
Example::List(items) => {
// Width is counted in *characters*: Python and `ruff` measure a
// line that way, so VTEX's `"Alimentação"` example is 88 columns
// and stays flat where its 90 UTF-8 bytes would have wrapped it.
if !self.forces_multiline() && column + self.flat().chars().count() <= 80 {
return self.flat();
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
// Several wrapped items keep Python's magic trailing comma, as
// Groupe PSA's two-item `remote_types` example does; one does not.
let trailing_comma = if items.len() == 1 { "" } else { "," };
let body = items
.iter()
.map(|it| {
format!(
"{inner_pad}{}{trailing_comma}",
it.render_at(indent + 4, indent + 4)
)
})
.collect::<Vec<_>>()
.join("\n");
format!("[\n{body}\n{pad}]")
}
Example::ExplicitList(items) => {
let matrix = items.len() >= 3
&& items
.iter()
.all(|item| matches!(item, Example::List(_) | Example::ExplicitList(_)));
if !matrix && !self.forces_multiline() && column + self.flat().chars().count() <= 80
{
return self.flat();
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
// A one-item list that has to wrap carries no trailing comma —
// `mosip-esignet`'s single-member `client_auth_methods` and
// `grant_types` examples wrap exactly that way — while a list of
// several keeps Python's magic trailing comma.
let trailing_comma = if items.len() == 1 { "" } else { "," };
let body = items
.iter()
.map(|it| {
format!(
"{inner_pad}{}{trailing_comma}",
it.render_at(indent + 4, indent + 4)
)
})
.collect::<Vec<_>>()
.join("\n");
format!("[\n{body}\n{pad}]")
}
Example::ReferenceList(items) => {
if items.is_empty() {
return "[]".to_string();
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
let matrix = items.len() >= 3
&& items
.iter()
.all(|item| matches!(item, Example::List(_) | Example::ExplicitList(_)));
let body = items
.iter()
.enumerate()
.map(|(index, item)| {
let comma = if index + 1 == items.len() { "" } else { "," };
let rendered = if matrix {
item.flat()
} else {
item.render_at(indent + 4, indent + 4)
};
format!("{inner_pad}{rendered}{comma}")
})
.collect::<Vec<_>>()
.join("\n");
format!("[\n{body}\n{pad}]")
}
Example::Dict(pairs) | Example::DocDict(pairs) => {
let wraps_for_length =
!self.forces_multiline() && column + self.flat().chars().count() > 80;
if !self.forces_multiline() && !wraps_for_length {
return self.flat();
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
// Like a list, a wrapped dict of one entry takes no trailing
// comma: MockServer's `{"spec": "asyncapi: …"}` wraps bare.
let last_comma = !matches!(self, Example::DocDict(_)) && pairs.len() > 1;
// A dict a constructor value breaks separates its entries as a
// Python literal must: Supabase's `attribute_mapping.keys` maps
// names to models, and Fern's docstring ends every
// `CreateProviderBodyAttributeMappingKeysValue(…)` with a comma,
// where its Markdown writers leave the last one bare. One entry
// ends bare in either, as a length-wrapped one does.
let separated = wraps_for_length || self.forces_multiline();
let body = pairs
.iter()
.enumerate()
.map(|(index, (k, v))| {
let comma = if separated && (last_comma || index + 1 < pairs.len()) {
","
} else {
""
};
format!(
"{inner_pad}\"{k}\": {}{comma}",
v.render_at(indent + 4, indent + k.len() + 8),
)
})
.collect::<Vec<_>>()
.join("\n");
format!("{{\n{body}\n{pad}}}")
}
Example::ReferenceDict(pairs) => {
if pairs.is_empty() {
return "{}".to_string();
}
let pad = " ".repeat(indent);
let inner_pad = " ".repeat(indent + 4);
let body = pairs
.iter()
.enumerate()
.map(|(index, (key, value))| {
let comma = if index + 1 == pairs.len() { "" } else { "," };
format!(
"{inner_pad}\"{key}\": {}{comma}",
value.render_at(indent + 4, indent + key.len() + 8)
)
})
.collect::<Vec<_>>()
.join("\n");
format!("{{\n{body}\n{pad}}}")
}
}
}
}
/// Threads the type table and the imports/datetime a worked example accumulates.
struct ExampleCtx<'a> {
types: &'a [TypeDecl],
/// How string enums render: a literal enum's example is its plain string.
enum_type: EnumType,
/// The timestamp-like scalars a YAML document writes unquoted, `None` for
/// JSON. Such a scalar is a date to Fern's parser, not a string, so an example
/// spelled by one is not one a plain `str` field can take.
yaml_unquoted_timestamps: Option<&'a std::collections::BTreeSet<String>>,
/// Hoisted tag-scoped types, consulted so an example can construct one and
/// import it from its tag package (`from <pkg>.<tag> import ...`).
tag_decls: &'a [TagTypeDecl],
/// Generated type names referenced as constructors (drives the example's
/// `from <pkg> import ...`).
referenced: BTreeSet<String>,
/// First-use order retained for Fern's Markdown import writer.
referenced_doc_order: Vec<String>,
/// Referenced hoisted tag-types as `(tag module, type name)` — imported from
/// their tag package on a separate line above the main import.
referenced_tag: BTreeSet<(String, String)>,
/// Tag-scoped first-use order retained for Markdown imports.
referenced_tag_doc_order: Vec<(String, String)>,
/// Whether any value is a datetime/date (drives an `import datetime`).
uses_datetime: bool,
/// Whether that first datetime value was rendered before the first tag-scoped
/// type. Fern's Markdown writer lists imports in first-use order, so the
/// stdlib line sits above the tag import in `mosip-esignet`'s snippets (whose
/// `request_time` precedes the request model) and below it in
/// `openbankingproject-ch-kundenbeziehung`'s (whose `purpose` enum precedes
/// the `expiry_date`).
datetime_precedes_tag_import: bool,
/// The auth model, for the credential argument in the client instantiation.
auth: &'a Auth,
/// Whether the SDK has server environments — with them, the client
/// instantiation drops the `base_url=...` line.
has_environment: bool,
/// Promoted global headers, shown as `tenant="YOUR_TENANT"` example lines
/// before the auth credential in the client instantiation.
global_headers: &'a [GlobalHeader],
client_path_parameters: &'a [ClientPathParameter],
/// Named types currently being expanded on the active path, so a recursive
/// schema (a tree node, a recursive union) terminates instead of overflowing
/// the stack (issue #84): a list of an ancestor type renders empty, matching
/// Fern's `children=[]`.
building: std::collections::HashSet<String>,
/// The aliases whose target is being exampled with an example still
/// unconsumed, each with that example: a union alias whose matching member
/// is the alias itself (GitHub's REST description declares one) would
/// otherwise expand forever.
expanding_aliases: Vec<(String, String)>,
/// Whether values are being rendered for README/reference snippets rather
/// than executable method docstrings.
documentation: bool,
/// Whether the active Markdown snippet belongs to `reference.md`, whose
/// literal and parameter-order writer differs from README examples.
reference: bool,
/// Whether a model field's own declared example is passed over, as Fern's IR
/// fallback generator passes it over when its importer built no example.
field_examples_ignored: bool,
/// The keyword arguments whose value is the placeholder of an *unknown*
/// (`typing.Any`) field, which the Markdown writers leave on one line.
untyped_arguments: BTreeSet<String>,
/// Whether Fern's isort pass files the SDK's own imports as first-party,
/// which it does when ruff can find the package: always in the packaged tree
/// (`src/<pkg>/`), and in the flat tree only for a package named `fern`,
/// which ruff resolves inside Fern's `/fern` container (the same split
/// `scaffolding_files` applies to `tests/test_aiohttp_autodetect.py`). A
/// docstring example then puts a tag package's import in a group of its own
/// above the root import; otherwise both are third-party, one sorted group.
sdk_first_party: bool,
}
/// The slot a string value sits in, which decides its placeholder text: a named
/// field/param uses the name, a map value uses `"value"`, anything else `"string"`.
/// A path parameter is named too, and differs only in how a union is exampled
/// (see [`ExampleCtx::union_value`]).
#[derive(Clone, Copy)]
enum Slot<'a> {
Named(&'a str),
Path(&'a str),
Map,
Plain,
}
impl<'a> ExampleCtx<'a> {
fn example_is_scalar(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.example_is_scalar(&alias.target),
_ => false,
},
_ => example_scalar(t),
}
}
fn example_is_composite(&self, t: &TypeRef) -> bool {
match t {
TypeRef::List(_) | TypeRef::Set(_) | TypeRef::Dict(_, _) => true,
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.example_is_composite(&alias.target),
_ => false,
},
_ => false,
}
}
fn example_is_object(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => self.example_is_object(inner),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Object(_)) => true,
Some(TypeDecl::Alias(alias)) => self.example_is_object(&alias.target),
_ => false,
},
_ => false,
}
}
fn example_is_temporal(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => self.example_is_temporal(inner),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.example_is_temporal(&alias.target),
_ => false,
},
TypeRef::Primitive(Prim::Datetime | Prim::Date) => true,
_ => false,
}
}
fn value_from_example(&mut self, t: &TypeRef, example: &str) -> Option<Example> {
if let TypeRef::Optional(inner) = t {
return self.value_from_example(inner, example);
}
if let TypeRef::Named(name) = t {
if let Some(TypeDecl::Alias(alias)) = self.find(name) {
let expanding = (name.clone(), example.to_string());
if self.expanding_aliases.contains(&expanding) {
return None;
}
let target = alias.target.clone();
self.expanding_aliases.push(expanding);
let value = self.value_from_example(&target, example);
self.expanding_aliases.pop();
return value;
}
}
if matches!(t, TypeRef::Primitive(Prim::Datetime | Prim::Date)) {
let mut value: String = serde_json::from_str(example).ok()?;
self.note_datetime();
let constructor = if matches!(t, TypeRef::Primitive(Prim::Datetime)) {
value = datetime_example(&value);
// The docstring writer spaces the date from the time; that
// separator is the only character it changes.
if !self.documentation {
value.replace_range(10..11, " ");
}
"datetime.datetime.fromisoformat"
} else {
"datetime.date.fromisoformat"
};
return Some(Example::Call(
constructor.to_string(),
vec![(None, Example::Atom(format!("{value:?}")))],
));
}
if self.example_is_scalar(t) {
let literal = match t {
TypeRef::Primitive(Prim::Float)
if !self.documentation && example.parse::<i64>().is_ok() =>
{
format!("{example}.0")
}
TypeRef::Primitive(Prim::Bool | Prim::LiteralBool(_)) => match example {
"true" => "True".to_string(),
"false" => "False".to_string(),
_ => example.to_string(),
},
TypeRef::Primitive(Prim::Str | Prim::Bytes)
if self.reference && example.starts_with('\'') && example.ends_with('\'') =>
{
let inner = &example[1..example.len() - 1];
format!("\"{}\"", inner.replace('"', "\\\""))
}
TypeRef::Primitive(Prim::Str | Prim::Bytes)
if self.documentation && example.starts_with('"') =>
{
example.replace('\'', "\\'")
}
// A string holding `"` but no `'` is spelled single-quoted, as
// Python's `repr` spells it: MockServer's file-store `content`
// example is `'{"status":"ok"}'` in the golden.
TypeRef::Primitive(Prim::Str | Prim::Bytes)
if !self.documentation && example.starts_with('"') =>
{
serde_json::from_str::<serde_json::Value>(example)
.ok()
.filter(|value| {
value
.as_str()
.is_some_and(|s| s.contains('"') && !s.contains('\''))
})
.and_then(|value| crate::ir::example_literal(&value))
.unwrap_or_else(|| example.to_string())
}
_ => example.to_string(),
};
return Some(Example::Atom(literal));
}
if example.starts_with('{') && self.resolves_to_any(t) {
let value: serde_json::Value = serde_json::from_str(example).ok()?;
// A `null` member is no argument: Primula Tracker's student update
// declares `{transition_date: …, transition_room_override_id: null}`
// for its `Union[Any]` body, and the golden passes only the date.
let value = fern_unknown_example(value)?;
let rendered = example_from_json_as(value, !self.documentation);
return Some(if self.reference {
Example::Atom(rendered.flat())
} else {
rendered
});
}
if let TypeRef::List(inner) | TypeRef::Set(inner) = t {
let value = parse_example_value(example)?;
// Handed a non-array, Fern's importer examples one item from nothing,
// and an unknown item from nothing is no item: Short.io's opengraph
// body is a list of `[name, value]` tuples (`List[List[Any]]`) whose
// media example is the flat `["title", "My title"]`, and the golden
// passes `[[], []]`, measured at Fern CLI 5.163.1.
if !value.is_array() && self.resolves_to_any(inner) {
return Some(if self.documentation {
Example::ReferenceList(Vec::new())
} else {
Example::ExplicitList(Vec::new())
});
}
let values: Vec<serde_json::Value> = serde_json::from_value(value).ok()?;
let mut unique = Vec::with_capacity(values.len());
for value in values {
if matches!(t, TypeRef::List(_))
|| !self.example_is_temporal(inner)
|| !unique.contains(&value)
{
unique.push(value);
}
}
let items = unique
.into_iter()
.map(|value| {
if value.is_null() {
return self.value(inner, Slot::Plain);
}
let literal = value.to_string();
self.value_from_example(inner, &literal)
.unwrap_or_else(|| example_from_json_as(value, !self.documentation))
})
.collect();
return Some(if self.documentation {
Example::ReferenceList(items)
} else {
Example::ExplicitList(items)
});
}
if let TypeRef::Union(variants) = t {
let value: serde_json::Value = parse_example_value(example)?;
if value
.as_object()
.is_some_and(|object| object.len() == 1 && object.contains_key("$ref"))
{
return variants
.iter()
.find(|variant| matches!(variant, TypeRef::List(_) | TypeRef::Set(_)))
.map(|variant| self.value(variant, Slot::Plain));
}
// Fern's importer builds the example for the alternative its
// heuristic scores highest, keeping only what that alternative
// declares; the SDK generator then renders the result with the first
// alternative that accepts it. MockServer's `debugMismatch` body
// `{method, path}` scores best as `OpenAPIDefinition`, which declares
// neither, so the golden renders the empty result as `HttpRequest()`.
let projected = self
.fern_union_example(variants, &value, &mut Vec::new())
.filter(|projected| *projected != value);
let (value, example) = match projected {
Some(projected) => {
let text = projected.to_string();
(projected, std::borrow::Cow::Owned(text))
}
None => (value, std::borrow::Cow::Borrowed(example)),
};
for variant in variants {
if self.expands_back(variant, &example) {
continue;
}
if self.example_matches_type(variant, &value) {
return self.value_from_example(variant, &example);
}
}
return None;
}
if let TypeRef::Named(name) = t {
if let Some(TypeDecl::Object(object)) = self.find(name) {
let fields = self.object_fields(object);
if fields.is_empty() {
self.record_ref(name);
return Some(Example::Call(name.clone(), Vec::new()));
}
let values = parse_example_value(example)?;
let values = values.as_object()?;
self.record_ref(name);
let args = self.example_call_args(fields, values);
return Some(Example::Call(name.clone(), args));
}
}
if self.example_is_composite(t) && (example.starts_with('[') || example.starts_with('{')) {
let mut value: serde_json::Value = serde_json::from_str(example).ok()?;
// A free-form map's values are unknowns, pruned the way Fern prunes
// one: MockServer's Pact import `body: {orders: []}` is `{}`.
if let (TypeRef::Dict(_, inner), serde_json::Value::Object(fields)) = (t, &mut value) {
if self.resolves_to_any(inner) {
*fields = std::mem::take(fields)
.into_iter()
.filter_map(|(key, field)| {
fern_unknown_example(field).map(|field| (key, field))
})
.collect();
}
}
// An empty map carries no example value, so Fern synthesizes one as it
// does for an empty array (`dot-ai`'s `metadata: {}` is exampled
// `{"key": "value"}`).
if matches!(t, TypeRef::Dict(_, _))
&& value.as_object().is_some_and(serde_json::Map::is_empty)
{
return None;
}
// A map of models constructs each value: MockServer's chaos stage
// `profiles: {api.example.com: {errorStatus: 500, …}}` is
// `{"api.example.com": HttpChaosProfile(error_status=500, …)}`.
if let (TypeRef::Dict(_, inner), serde_json::Value::Object(fields)) = (t, &value) {
if self.resolves_to_object(inner) {
let pairs = fields
.iter()
.map(|(key, field)| {
let rendered = self
.value_from_example(inner, &field.to_string())
.unwrap_or_else(|| {
example_from_json_as(field.clone(), !self.documentation)
});
(key.clone(), rendered)
})
.collect();
return Some(if self.reference {
Example::ReferenceDict(pairs)
} else {
Example::Dict(pairs)
});
}
}
if let (true, TypeRef::Dict(_, inner)) = (self.reference, t) {
let serde_json::Value::Object(fields) = value else {
return None;
};
// `reference.md` writes a free-form value on one line and a
// typed one by its type: MockServer's HAR `log` is one line,
// while its WASM test's `Dict[str, List[str]]` headers wrap
// `["acme"]` as a list.
let unknown = self.resolves_to_any(inner);
return Some(Example::ReferenceDict(
fields
.into_iter()
.map(|(key, value)| {
let rendered = if unknown {
Example::Atom(example_from_json_as(value, false).flat())
} else {
self.value_from_example(inner, &value.to_string())
.unwrap_or_else(|| example_from_json_as(value, false))
};
(key, rendered)
})
.collect(),
));
}
return Some(example_from_json_as(value, !self.documentation));
}
let TypeRef::Named(name) = t else {
return None;
};
match self.find(name)? {
TypeDecl::Object(_) => None,
TypeDecl::Alias(alias) => {
let target = alias.target.clone();
self.value_from_example(&target, example)
}
TypeDecl::Enum(enum_type) => {
let value = example.strip_prefix('"')?.strip_suffix('"')?;
let member = enum_type
.members
.iter()
.find(|member| member.value == value)?;
if self.enum_type == EnumType::Literals {
return Some(Example::Atom(literal_enum_value(&member.value)));
}
let member_name = member.name.clone();
self.record_ref(name);
Some(Example::Atom(format!("{name}.{member_name}")))
}
TypeDecl::DiscriminatedUnion(union) => {
let union = union.clone();
self.union_value_from_example(&union, example)
}
}
}
/// A discriminated union's example: the wrapper of the member the example's
/// discriminant value selects, constructed from the example's values as a
/// model is. Supabase's `DiskRequestBody` example `{attributes: {type: gp3,
/// size_gb: 100, iops: 3000, throughput_mibps: 125}}` is
/// `DiskRequestBodyAttributes_Gp3(iops=3000, size_gb=100,
/// throughput_mibps=125)` in Fern's example, and LangChain's `post_a2a` parts
/// construct `…PartsItem_Text(text=…)` and `…PartsItem_Data(data=…)`. Both
/// are unions of inline alternatives; a member flattened from a `$ref`
/// keeps the synthesized example, as HelixDB's `QueryRequest_Read` does
/// beside a declared request example. So do a wrapper holding a union
/// whole, a union with a shared base, and a member whose tag is an enum of
/// its own.
fn union_value_from_example(
&mut self,
union: &crate::ir::DiscriminatedUnion,
example: &str,
) -> Option<Example> {
if !union.base_fields.is_empty() {
return None;
}
let values = parse_example_value(example)?;
let values = values.as_object()?;
let discriminant = values.get(&union.discriminant_property)?.as_str()?;
let member = union
.members
.iter()
.find(|member| member.discriminant == discriminant)?;
// A tag the synthesized example writes back (see `value`) is not
// measured from an example, so that member keeps the synthesized one.
let writes_tag = member.discriminant_index.is_some()
&& member
.source
.as_deref()
.map(|source| naming::child_class_name(source, &union.discriminant_property))
.is_some_and(|enum_name| matches!(self.find(&enum_name), Some(TypeDecl::Enum(_))));
if member.wrapped || writes_tag || member.source.is_some() {
return None;
}
let fields = member
.fields
.iter()
.map(|field| ExampleField {
py_name: field.py_name.clone(),
wire_name: field.wire_name.clone(),
type_ref: field.type_ref.clone(),
requirement: match (field.spec_required, field.optional) {
(true, false) => Requirement::Required,
(true, true) => Requirement::RequiredNullable,
(false, _) => Requirement::Optional,
},
example: field.example.clone(),
parent_example: false,
deprecated: field.deprecated,
lengths: (field.min_length, field.max_length),
})
.collect();
self.record_ref(&member.class_name);
let args = self.example_call_args(fields, values);
Some(Example::Call(member.class_name.clone(), args))
}
/// The keyword arguments of a model constructor built from an example's
/// `values`, one per field Fern's example writers pass: a required field
/// whatever the example holds, and an optional one the example gives a value.
fn example_call_args(
&mut self,
fields: Vec<ExampleField>,
values: &serde_json::Map<String, serde_json::Value>,
) -> Vec<(Option<String>, Example)> {
let documentation = self.documentation;
fields
.into_iter()
// An example value of `{}` selects nothing: Fern renders no
// argument for an optional model field the example leaves
// empty (`mosip-esignet`'s `claims.id_token: {}`).
// Nor does `null`, but in the Markdown writers, which write a
// required nullable field's `null` as `None`; and an optional
// field whose schema is deprecated is left out even where the
// example names it.
.filter(|field| {
let value = values.get(&field.wire_name);
field.requirement == Requirement::Required
|| field.requirement == Requirement::RequiredNullable
&& documentation
&& value.is_some_and(serde_json::Value::is_null)
|| !field.deprecated
&& value.is_some_and(|value| {
!value.is_null()
&& !value.as_object().is_some_and(serde_json::Map::is_empty)
})
})
.filter_map(
|ExampleField {
py_name,
wire_name,
type_ref,
requirement,
..
}| {
let recorded = self.recorded();
let rendered = match values.get(&wire_name) {
Some(serde_json::Value::Null)
if self.documentation
&& requirement == Requirement::RequiredNullable =>
{
Example::Atom("None".to_string())
}
Some(serde_json::Value::Null) => {
self.value(&type_ref, Slot::Named(&wire_name))
}
Some(serde_json::Value::String(value))
if self.example_is_temporal(&type_ref)
&& value.starts_with("2000-01-23T04:56:07") =>
{
self.value(&type_ref, Slot::Named(&wire_name))
}
// A YAML timestamp is a date to Fern's parser, so it
// is no example for a field that is not temporal; see
// [`yaml_resolves_as_timestamp`].
Some(serde_json::Value::String(value))
if self
.yaml_unquoted_timestamps
.is_some_and(|unquoted| unquoted.contains(value))
&& !self.example_is_temporal(&type_ref)
&& yaml_resolves_as_timestamp(value) =>
{
self.value(&type_ref, Slot::Named(&wire_name))
}
// An empty array carries no example value, so Fern
// synthesizes one from the field name rather than
// rendering `[]` (`mosip-esignet`'s
// `permittedAuthorizeScopes: []`).
Some(serde_json::Value::Array(items)) if items.is_empty() => {
self.value(&type_ref, Slot::Named(&wire_name))
}
Some(value) => {
let literal = value.to_string();
self.value_from_example(&type_ref, &literal)
.unwrap_or_else(|| {
example_from_json_as(value.clone(), !self.documentation)
})
}
_ => self.value(&type_ref, Slot::Named(&wire_name)),
};
// An optional field whose example narrows to an empty model
// is no argument (measured at fern-python-sdk 5.41.3):
// MockServer's baseline `Expectation` gives `httpRequest`
// `{method, path}`, which its `RequestDefinition` union keeps
// as an empty `OpenAPIDefinition`, and the golden passes only
// `http_response`.
if requirement == Requirement::Optional
&& matches!(&rendered, Example::Call(_, args) if args.is_empty())
{
// Nor does the model it would have constructed import.
self.restore_recorded(recorded);
return None;
}
Some((Some(py_name), rendered))
},
)
.collect()
}
/// Whether `t` leads, through aliases alone, back into an alias this example
/// is already being expanded under: a union member that is the union itself
/// again, which could only repeat the expansion it sits in.
fn expands_back(&self, t: &TypeRef, example: &str) -> bool {
let mut followed: Vec<&str> = Vec::new();
let mut current = t;
while let TypeRef::Named(name) = current {
if followed.contains(&name.as_str()) {
return false;
}
if self
.expanding_aliases
.iter()
.any(|(alias, text)| alias == name && text == example)
{
return true;
}
let Some(TypeDecl::Alias(alias)) = self.find(name) else {
return false;
};
followed.push(name);
current = &alias.target;
}
false
}
/// The example Fern's importer keeps for an undiscriminated union: the
/// alternative [`Self::fern_example_score`] ranks highest (the first, on a
/// tie), with `value` narrowed to what that alternative declares. `None`
/// where the narrowing is not modelled, leaving `value` as it is.
///
/// `visiting` holds the named types entered at this level of `value`: a
/// union reaching itself again through its aliases before consuming any of
/// the value is a cycle (GitHub's REST description declares such unions),
/// which scores nothing and narrows nothing rather than recursing forever.
fn fern_union_example(
&self,
variants: &[TypeRef],
value: &serde_json::Value,
visiting: &mut Vec<String>,
) -> Option<serde_json::Value> {
let mut best: Option<(&TypeRef, i64)> = None;
for variant in variants {
let score = self.fern_example_score(variant, value, visiting);
if best.is_none_or(|(_, top)| score > top) {
best = Some((variant, score));
}
}
self.fern_example_projection(best?.0, value, visiting)
}
/// Fern's `calcExampleHeuristic`: how well `value` fits `t`, counting each
/// declared property the value supplies up and each it omits down.
fn fern_example_score(
&self,
t: &TypeRef,
value: &serde_json::Value,
visiting: &mut Vec<String>,
) -> i64 {
match t {
TypeRef::Optional(inner) => self.fern_example_score(inner, value, visiting),
TypeRef::Literal(literals) => {
i64::from(
value
.as_str()
.is_some_and(|text| literals.iter().any(|literal| literal == text)),
) * 5
}
TypeRef::Named(name) if visiting.contains(name) => 0,
TypeRef::Named(name) => {
visiting.push(name.clone());
let score = match self.find(name) {
Some(TypeDecl::Enum(decl)) => {
i64::from(value.as_str().is_some_and(|text| {
decl.members.iter().any(|member| member.value == text)
})) * 5
}
Some(TypeDecl::Object(object)) => {
let Some(values) = value.as_object() else {
return 0;
};
self.object_fields(object)
.iter()
.map(
|ExampleField {
wire_name,
type_ref,
..
}| {
match values.get(wire_name).filter(|field| !field.is_null()) {
Some(field) => {
1 + self.fern_example_score(
type_ref,
field,
&mut Vec::new(),
)
}
None => -1,
}
},
)
.sum()
}
Some(TypeDecl::Alias(alias)) => {
self.fern_example_score(&alias.target, value, visiting)
}
_ => 0,
};
visiting.pop();
score
}
TypeRef::Union(variants) => variants
.iter()
.map(|variant| self.fern_example_score(variant, value, visiting))
.max()
.unwrap_or(0),
TypeRef::List(inner) | TypeRef::Set(inner) => value.as_array().map_or(0, |items| {
items
.iter()
.map(|item| self.fern_example_score(inner, item, &mut Vec::new()))
.sum()
}),
TypeRef::Dict(_, inner) => value.as_object().map_or(0, |values| {
values
.values()
.map(|item| self.fern_example_score(inner, item, &mut Vec::new()))
.sum()
}),
TypeRef::Primitive(Prim::Any) => 0,
TypeRef::Primitive(_) if value.is_null() => 0,
TypeRef::Primitive(_) => {
if self.example_matches_type(t, value) {
1
} else {
-1
}
}
}
}
/// `value` as Fern's importer builds it for `t`: an object keeps only the
/// properties it declares, and an array handed a non-array builds one item
/// with no example — MockServer's `headers: {Host: [...]}` against
/// `List[KeyToMultiValueZeroItem]` is `[KeyToMultiValueZeroItem()]`.
fn fern_example_projection(
&self,
t: &TypeRef,
value: &serde_json::Value,
visiting: &mut Vec<String>,
) -> Option<serde_json::Value> {
match t {
TypeRef::Optional(inner) if !value.is_null() => {
self.fern_example_projection(inner, value, visiting)
}
TypeRef::Named(name) if visiting.contains(name) => None,
TypeRef::Named(name) => {
visiting.push(name.clone());
let projected = self.fern_named_projection(name, value, visiting);
visiting.pop();
projected
}
TypeRef::Union(variants) => self.fern_union_example(variants, value, visiting),
TypeRef::List(inner) | TypeRef::Set(inner) => match value.as_array() {
Some(items) => items
.iter()
.map(|item| self.fern_example_projection(inner, item, &mut Vec::new()))
.collect::<Option<Vec<_>>>()
.map(serde_json::Value::Array),
None if self.resolves_to_object(inner) => {
let item = self.fern_example_projection(
inner,
&serde_json::Value::Object(serde_json::Map::new()),
&mut Vec::new(),
)?;
Some(serde_json::Value::Array(vec![item]))
}
None => None,
},
TypeRef::Dict(_, inner) => {
let values = value.as_object()?;
values
.iter()
.map(|(key, item)| {
self.fern_example_projection(inner, item, &mut Vec::new())
.map(|item| (key.clone(), item))
})
.collect::<Option<serde_json::Map<_, _>>>()
.map(serde_json::Value::Object)
}
_ => Some(value.clone()),
}
}
/// [`Self::fern_example_projection`] of the named type `name`.
fn fern_named_projection(
&self,
name: &str,
value: &serde_json::Value,
visiting: &mut Vec<String>,
) -> Option<serde_json::Value> {
match self.find(name) {
Some(TypeDecl::Object(object)) => {
let fields = self.object_fields(object);
let empty = serde_json::Map::new();
let values = value.as_object().unwrap_or(&empty);
let mut kept = serde_json::Map::new();
for ExampleField {
wire_name,
type_ref,
requirement,
..
} in fields
{
let required = requirement == Requirement::Required;
match values.get(&wire_name).filter(|field| !field.is_null()) {
Some(field) => {
let field =
self.fern_example_projection(&type_ref, field, &mut Vec::new())?;
kept.insert(wire_name, field);
}
// A required property the example omits is built
// with no example — a string is its own name, as
// MockServer's `ExpectationId(id="id")` shows.
None if required && self.resolves_to_string(&type_ref) => {
kept.insert(wire_name.clone(), serde_json::Value::String(wire_name));
}
None if required => return None,
None => {}
}
}
Some(serde_json::Value::Object(kept))
}
Some(TypeDecl::Alias(alias)) => {
self.fern_example_projection(&alias.target, value, visiting)
}
_ => Some(value.clone()),
}
}
fn example_matches_type(&self, t: &TypeRef, value: &serde_json::Value) -> bool {
self.example_matches_type_through(t, value, &mut Vec::new())
}
/// [`Self::example_matches_type`], with the aliases already followed: none
/// of its arms reads into `value`, so an alias met again is a union that
/// names itself (GitHub's REST description declares such unions) and takes
/// nothing more than its other members do.
fn example_matches_type_through(
&self,
t: &TypeRef,
value: &serde_json::Value,
aliases: &mut Vec<String>,
) -> bool {
match t {
TypeRef::Optional(inner) => {
value.is_null() || self.example_matches_type_through(inner, value, aliases)
}
TypeRef::Named(name) => match self.find(name) {
// A model with no fields takes any example: Milvus's `upsert`
// example sends `data` as a list, and Fern still renders the
// union's empty-object member, `PostV1VectorUpsertRequestDataZero()`.
Some(TypeDecl::Object(object)) if self.object_fields(object).is_empty() => true,
Some(TypeDecl::Object(object)) => value.as_object().is_some_and(|values| {
let fields = self.object_fields(object);
fields
.iter()
.filter(|field| field.requirement == Requirement::Required)
.all(|field| values.contains_key(&field.wire_name))
&& values
.keys()
.all(|key| fields.iter().any(|field| field.wire_name == *key))
}),
Some(TypeDecl::Alias(_)) if aliases.contains(name) => false,
Some(TypeDecl::Alias(alias)) => {
aliases.push(name.clone());
let matches = self.example_matches_type_through(&alias.target, value, aliases);
aliases.pop();
matches
}
// An enum takes only its own values: Primula Tracker's
// `intended_start_date` is `Union[<TBD enum>, datetime.date]`, and
// the golden constructs its `2030-09-01` example as the date.
Some(TypeDecl::Enum(decl)) => value
.as_str()
.is_some_and(|text| decl.members.iter().any(|member| member.value == text)),
_ => true,
},
TypeRef::Union(variants) => variants
.iter()
.any(|variant| self.example_matches_type_through(variant, value, aliases)),
TypeRef::List(_) | TypeRef::Set(_) => value.is_array(),
TypeRef::Dict(_, _) => value.is_object(),
TypeRef::Primitive(Prim::Str | Prim::Bytes | Prim::Datetime | Prim::Date)
| TypeRef::Literal(_) => value.is_string(),
TypeRef::Primitive(Prim::Int | Prim::Long) => value.as_i64().is_some(),
TypeRef::Primitive(Prim::Float) => value.is_number(),
TypeRef::Primitive(Prim::Bool) => value.is_boolean(),
TypeRef::Primitive(Prim::LiteralBool(expected)) => value.as_bool() == Some(*expected),
TypeRef::Primitive(Prim::Any) => true,
}
}
/// Look up a generated type by name — a package-root type, else a hoisted
/// tag-scoped one (so an example can construct an inline-hoisted model).
fn find(&self, name: &str) -> Option<&'a TypeDecl> {
self.types.iter().find(|d| d.name() == name).or_else(|| {
self.tag_decls
.iter()
.find_map(|tt| (tt.decl.name() == name).then_some(&tt.decl))
})
}
/// Record the first datetime value, and whether it preceded every tag import.
fn note_datetime(&mut self) {
if !self.uses_datetime {
self.datetime_precedes_tag_import = self.referenced_tag_doc_order.is_empty();
}
self.uses_datetime = true;
}
/// Record a referenced constructor: a tag-scoped type is tracked with its tag
/// module (imported separately), a package-root type in the main import set.
fn record_ref(&mut self, name: &str) {
if let Some(tt) = self.tag_decls.iter().find(|tt| {
tt.decl.name() == name
|| matches!(&tt.decl, TypeDecl::DiscriminatedUnion(union)
if union.members.iter().any(|member| member.class_name == name))
}) {
let entry = (tt.module.clone(), name.to_string());
if self.referenced_tag.insert(entry.clone()) {
self.referenced_tag_doc_order.push(entry);
}
} else {
let name = name.to_string();
if self.referenced.insert(name.clone()) {
self.referenced_doc_order.push(name);
}
}
}
/// Whether a type reaches, through optionals/aliases/unions, a named type
/// currently being expanded — i.e. constructing an example for it would
/// re-enter an ancestor and loop. Drives the empty-list break for recursive
/// schemas (issue #84).
fn resolves_to_building(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Named(n) => {
self.building.contains(n)
|| matches!(self.find(n), Some(TypeDecl::Alias(a)) if self.resolves_to_building(&a.target))
}
TypeRef::Optional(inner) => self.resolves_to_building(inner),
TypeRef::Union(variants) => variants.iter().any(|v| self.resolves_to_building(v)),
_ => false,
}
}
/// Whether a type resolves (through aliases/optionals) to a generated model.
fn resolves_to_object(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => self.resolves_to_object(inner),
TypeRef::Named(n) => match self.find(n) {
Some(TypeDecl::Object(_)) => true,
Some(TypeDecl::Alias(a)) => self.resolves_to_object(&a.target),
_ => false,
},
_ => false,
}
}
/// Whether a type resolves (through aliases/optionals) to an unknown value —
/// which Fern cannot example, so a map of it renders `{}`.
fn resolves_to_any(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Primitive(Prim::Any) => true,
TypeRef::Optional(inner) => self.resolves_to_any(inner),
TypeRef::Named(n) => match self.find(n) {
Some(TypeDecl::Alias(a)) => self.resolves_to_any(&a.target),
_ => false,
},
_ => false,
}
}
/// Whether the type is, after alias resolution, a union whose every member
/// is unknown (`Union[typing.Any]`).
fn resolves_to_unknown_union(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Union(variants) => {
!variants.is_empty() && variants.iter().all(|variant| self.resolves_to_any(variant))
}
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.resolves_to_unknown_union(&alias.target),
_ => false,
},
_ => false,
}
}
/// Whether the type is, after alias resolution, a union whose first
/// alternative is unknown, so that its example is the unknown's placeholder.
fn resolves_to_union_led_by_unknown(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Union(variants) => variants
.first()
.is_some_and(|first| matches!(first, TypeRef::Primitive(Prim::Any))),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => {
self.resolves_to_union_led_by_unknown(&alias.target)
}
_ => false,
},
_ => false,
}
}
/// Whether the type is, after alias resolution, a map whose value slot is
/// *directly* free-form (`Dict[str, Any]`) — the shape [`Self::value`]
/// renders as Fern's `{"key": "value"}` placeholder.
fn resolves_to_unknown_map(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => self.resolves_to_unknown_map(inner),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.resolves_to_unknown_map(&alias.target),
_ => false,
},
other => is_unknown_map(other),
}
}
/// Whether Fern can synthesize no example for this type at all. Its
/// `unknown` kind supplies none, and an object one of whose *required*
/// fields is a bare unknown inherits that: oSPARC's
/// `ProjectInputUpdate.value` declares only a title and a description, and
/// the golden's worked call for the array body that holds it is
/// `request=[]` — where otoroshi's `PatchItem`, whose `value` is optional,
/// is exampled field by field.
fn example_has_no_value(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => self.example_has_no_value(inner),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Object(object)) => object.fields.iter().any(|field| {
field.spec_required
&& !field.optional
&& matches!(field.type_ref, TypeRef::Primitive(Prim::Any))
}),
Some(TypeDecl::Alias(alias)) => self.example_has_no_value(&alias.target),
_ => false,
},
_ => false,
}
}
fn resolves_to_string(&self, t: &TypeRef) -> bool {
match t {
TypeRef::Primitive(Prim::Str) => true,
TypeRef::Optional(inner) => self.resolves_to_string(inner),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Alias(alias)) => self.resolves_to_string(&alias.target),
_ => false,
},
_ => false,
}
}
/// Synthesize an example value for a type in a given slot.
fn value(&mut self, t: &TypeRef, slot: Slot) -> Example {
match t {
TypeRef::Primitive(Prim::Str) => {
let s = match slot {
Slot::Named(name) | Slot::Path(name) => name,
Slot::Map => "value",
Slot::Plain => "string",
};
Example::Atom(format!("\"{s}\""))
}
TypeRef::Primitive(Prim::Bytes) => Example::Atom(
if self.reference {
"\"string\""
} else {
"b\"string\""
}
.to_string(),
),
TypeRef::Primitive(Prim::Int) => Example::Atom("1".to_string()),
TypeRef::Primitive(Prim::Long) => Example::Atom("1000000".to_string()),
TypeRef::Primitive(Prim::Float) => Example::Atom("1.1".to_string()),
TypeRef::Primitive(Prim::Bool) => Example::Atom("True".to_string()),
TypeRef::Primitive(Prim::LiteralBool(value)) => {
Example::Atom(if *value { "True" } else { "False" }.to_string())
}
TypeRef::Primitive(Prim::Datetime) => {
self.note_datetime();
Example::Call(
"datetime.datetime.fromisoformat".to_string(),
vec![(
None,
Example::Atom(
if self.documentation {
"\"2024-01-15T09:30:00+00:00\""
} else {
"\"2024-01-15 09:30:00+00:00\""
}
.to_string(),
),
)],
)
}
TypeRef::Primitive(Prim::Date) => {
self.note_datetime();
Example::Call(
"datetime.date.fromisoformat".to_string(),
vec![(None, Example::Atom("\"2023-01-15\"".to_string()))],
)
}
TypeRef::Primitive(Prim::Any) => Example::Dict(vec![(
"key".to_string(),
Example::Atom("\"value\"".to_string()),
)]),
TypeRef::Optional(inner) => self.value(inner, slot),
TypeRef::List(inner) | TypeRef::Set(inner) => {
// A list whose element is a type currently being expanded would
// recurse forever (a tree node, a recursive union); Fern renders it
// empty (`children=[]`), which also terminates the walk (issue #84).
// The element inherits the list's slot, so a `List[str]` field's
// example uses the field name (`all_field=["all_field"]`).
if self.resolves_to_building(inner)
|| self.resolves_to_any(inner)
|| self.example_has_no_value(inner)
{
if self.documentation {
Example::ReferenceList(vec![])
} else {
Example::List(vec![])
}
} else {
// Fern's IR fallback writes two items where its importer's
// example writes one.
let items =
vec![self.value(inner, slot); 1 + usize::from(self.field_examples_ignored)];
if self.documentation {
Example::ReferenceList(items)
} else {
Example::List(items)
}
}
}
TypeRef::Dict(_, v) => {
// A *directly* free-form value (`Optional[Any]`/`Any` — a bare-object
// alias like bunq's `AttachmentPublic`) takes the `{"key": "value"}`
// example Fern gives a bare `Any`. A value that only *resolves* to Any
// through a named alias (a documented-unknown map) renders empty.
let directly_any = matches!(v.as_ref(), TypeRef::Primitive(Prim::Any))
|| matches!(
v.as_ref(),
TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Primitive(Prim::Any))
);
if directly_any {
let pairs = vec![("key".to_string(), Example::Atom("\"value\"".to_string()))];
if self.reference {
Example::ReferenceDict(pairs)
} else {
Example::Dict(pairs)
}
} else if self.resolves_to_any(v) || matches!(v.as_ref(), TypeRef::Optional(_)) {
// An *optional* map value has no value to show — VolView's
// `values: Dict[str, Optional[RunTaskRequestValuesValue]]`
// (its `oneOf` carries a `null` member) is exampled `{}` —
// where a required one takes the `{"key": ...}` pair
// `squareup.com`'s `team_members` shows.
if self.reference {
Example::ReferenceDict(vec![])
} else {
Example::Dict(vec![])
}
} else {
let val = self.value(v, Slot::Map);
let pairs = vec![("key".to_string(), val)];
if self.reference {
Example::ReferenceDict(pairs)
} else {
Example::Dict(pairs)
}
}
}
TypeRef::Union(variants) => self.union_value(variants, slot),
TypeRef::Literal(values) => {
Example::Atom(format!("\"{}\"", values.first().map_or("", String::as_str)))
}
TypeRef::Named(name) => self.named_value(name, slot),
}
}
/// The example for a union: the first alternative that names concrete values,
/// else its first alternative. A free-form scalar can only supply a
/// placeholder, so Fern reaches past helios' `Union[Uint, BlockTag, Hash32]`
/// to `BlockTag`'s `"earliest"`. In a path parameter — helios' `block_id` —
/// that is the plain string the union's own annotation accepts; anywhere else
/// it is the enum member a `BlockTag` argument would take.
fn union_value(&mut self, variants: &[TypeRef], slot: Slot<'_>) -> Example {
if let Some(TypeRef::Named(name)) = variants.first() {
if matches!(self.find(name), Some(TypeDecl::Enum(_))) {
let name = name.clone();
return self.named_value(&name, slot);
}
}
// A later enum alternative is exampled by its first member (marimo's
// `totalRows`, `Union[float, MarimoTableDataTotalRowsOne]`, is
// `MarimoTableDataTotalRowsOne.TOO_MANY`), except in a path, where the
// value below is its plain string. So it is where a `str` alternative
// before the enum already holds that value: a query parameter's
// `anyOf: [string, $ref Weave]` is exampled `"plain"`, not `Weave.PLAIN`.
if !matches!(slot, Slot::Path(_)) {
if let Some((index, name)) =
variants
.iter()
.enumerate()
.skip(1)
.find_map(|(index, variant)| match variant {
TypeRef::Named(name)
if matches!(self.find(name), Some(TypeDecl::Enum(_))) =>
{
Some((index, name.clone()))
}
_ => None,
})
{
if !variants[..index].contains(&TypeRef::Primitive(Prim::Str)) {
return self.named_value(&name, slot);
}
}
}
if let Some(value) = variants
.iter()
.skip(1)
.find_map(|variant| self.enumerated_value(variant))
{
return Example::Atom(format!("{value:?}"));
}
match variants.first() {
Some(first) => {
let first = first.clone();
self.value(&first, slot)
}
None => Example::Dict(vec![(
"key".to_string(),
Example::Atom("\"value\"".to_string()),
)]),
}
}
/// The first value an alternative spells out, when it spells any out.
fn enumerated_value(&self, t: &TypeRef) -> Option<String> {
match t {
TypeRef::Literal(values) => values.first().cloned(),
TypeRef::Named(name) => match self.find(name) {
Some(TypeDecl::Enum(declaration)) => declaration
.members
.first()
.map(|member| member.value.clone()),
_ => None,
},
_ => None,
}
}
/// The example for a named type: an object is constructed from its
/// spec-required fields (base classes first); an alias resolves to its target.
fn named_value(&mut self, name: &str, slot: Slot) -> Example {
// A named type already on the active path is a cycle. Recursion normally
// terminates at the empty-list break (a recursive field is a list), so this
// is only reached by a direct (non-list) self-reference; break with `None`
// rather than overflow the stack (issue #84).
if self.building.contains(name) {
return Example::Atom("None".to_string());
}
let inserted = self.building.insert(name.to_string());
let result = self.named_value_inner(name, slot);
if inserted {
self.building.remove(name);
}
result
}
/// The body of [`named_value`], wrapped by the cycle-guard bookkeeping.
fn named_value_inner(&mut self, name: &str, slot: Slot) -> Example {
match self.find(name) {
Some(TypeDecl::Object(obj)) => {
self.record_ref(name);
// Include inherited fields (pydantic construction takes them too),
// base classes first, then the object's own fields.
let fields = self.object_fields(obj);
// A required-but-nullable field is shown as a top-level body
// field is (see `build_example_inner`): by its declared example,
// else as `None` in the Markdown writers alone. An unknown-typed
// one too: marimo-plugins' chat message item documents
// `content=None` and `metadata=None` (`anyOf: [{}, null]`) in
// `reference.md`, and neither in the docstring.
let documentation = self.documentation;
let fallback = self.field_examples_ignored;
let nullable_shown = |field: &ExampleField| {
field.requirement == Requirement::RequiredNullable
&& !fallback
&& (field.example.is_some() || documentation)
};
let args = fields
.into_iter()
.filter(|field| {
field.requirement == Requirement::Required
|| nullable_shown(field)
|| field.parent_example && !field.deprecated
})
.map(|field| {
if is_any_type(&field.type_ref) {
self.untyped_arguments.insert(field.py_name.clone());
}
let value = if nullable_shown(&field) && field.example.is_none() {
Example::Atom("None".to_string())
} else {
self.field_example(
&field.type_ref,
&field.wire_name,
field.example.as_deref(),
field.lengths,
)
};
(Some(field.py_name), value)
})
.collect::<Vec<_>>();
Example::Call(name.to_string(), args)
}
// A string alias is sized by its own lengths in the IR fallback:
// nexmo-messages' `to` is typed by `ToNumber` (`minLength: 7`) and
// its fallback call passes `to="apricot"`.
Some(TypeDecl::Alias(a)) => {
let target = a.target.clone();
let lengths = (a.min_length, a.max_length);
self.sized_fallback(&target, lengths)
.unwrap_or_else(|| self.value(&target, slot))
}
// A literal enum's example is its first value as a plain string, which
// needs no import. The Markdown writers escape its `'` as they do a
// string example's (`"You\'ll find it"`); docstrings keep it bare.
Some(TypeDecl::Enum(e)) if self.enum_type == EnumType::Literals => {
match e.example_member() {
Some(m) if self.documentation => {
Example::Atom(literal_enum_value(&m.value).replace('\'', "\\'"))
}
Some(m) => Example::Atom(literal_enum_value(&m.value)),
None => Example::Atom("None".to_string()),
}
}
// An enum's example is member access on its first member
// (`TypesWeatherReport.SUNNY`), importing the enum by name.
Some(TypeDecl::Enum(e)) => {
self.record_ref(name);
match e.example_member() {
Some(m) => Example::Atom(format!("{name}.{}", m.name)),
None => Example::Atom("None".to_string()),
}
}
Some(TypeDecl::DiscriminatedUnion(u)) => match u.members.first() {
// A wrapper holding a union whole passes its payload as `value`,
// the one argument Fern's docstring writer leaves without a
// trailing comma (the hand-written `kitchen-nested-mapping-target`
// fixture's `Course_Grill(value=SteakOrder(…))`).
Some(m) if m.wrapped => {
self.record_ref(&m.class_name);
let payload = m.fields.first().map(|field| field.type_ref.clone());
let value = payload.map_or_else(
|| Example::Atom("None".to_string()),
|payload| self.value(&payload, slot),
);
Example::Wrapped(m.class_name.clone(), Box::new(value))
}
Some(m) => {
self.record_ref(&m.class_name);
// The discriminant field carries a default (`= "circle"`), so
// Fern's example omits it and sets only the required fields —
// *unless* the variant's own declaration of the tag hoisted an
// enum type of its own, which happens when that declaration
// lists more than the one value the variant is. The Open
// Integration Hub's four auth-client variants each declare all
// four `type` values, so `MutableOa1TwoLeggedClientType` exists
// and the example passes it, back in the schema position the
// model moved the tag out of.
// A required-but-nullable field is shown as a top-level body
// field is (see `build_example_inner`): by its declared
// example, else as `None` in the Markdown writers alone —
// osparc-simcore-webserver's `RegisterFunctionRequest_Project`
// passes `default_inputs=None` in `reference.md` only.
let documentation = self.documentation;
let fallback = self.field_examples_ignored;
let shown = |f: &Field| {
f.spec_required
&& (!f.optional
|| !fallback
&& !is_any_type(&f.type_ref)
&& (f.example.is_some() || documentation))
};
let mut args = Vec::new();
for (py_name, wire_name, ty, example, empty_only, null, lengths) in m
.fields
.iter()
.filter(|f| shown(f))
.map(|f| {
(
f.py_name.clone(),
f.wire_name.clone(),
f.type_ref.clone(),
f.example.clone(),
f.admits_only_empty_object,
f.optional && f.example.is_none(),
(f.min_length, f.max_length),
)
})
.collect::<Vec<_>>()
{
let value = if null {
Example::Atom("None".to_string())
} else if empty_only {
self.empty_object()
} else {
self.field_example(&ty, &wire_name, example.as_deref(), lengths)
};
args.push((Some(py_name), value));
}
if let Some(index) = m.discriminant_index {
let enum_name = m.source.as_deref().map(|source| {
naming::child_class_name(source, &u.discriminant_property)
});
// The tag is a member access on its enum, imported by name,
// or — for a literal enum — the discriminant string alone.
let member =
enum_name
.as_deref()
.and_then(|enum_name| match self.find(enum_name) {
Some(TypeDecl::Enum(declared)) => declared
.members
.iter()
.find(|value| value.value == m.discriminant)
.map(|value| {
if self.enum_type == EnumType::Literals {
(literal_enum_value(&value.value), false)
} else {
(format!("{enum_name}.{}", value.name), true)
}
}),
_ => None,
});
if let (Some(enum_name), Some((member, imported))) = (enum_name, member) {
// The Python docstring writer puts the tag back in
// its schema position; the Markdown writers (README
// and `reference.md`) append it after every other
// argument.
let at = if self.documentation {
args.len()
} else {
m.fields.iter().take(index).filter(|f| shown(f)).count()
};
if imported {
self.record_ref(&enum_name);
}
args.insert(
at.min(args.len()),
(Some(discriminant_field_name(u)), Example::Atom(member)),
);
}
}
Example::Call(m.class_name.clone(), args)
}
None => Example::Dict(vec![(
"key".to_string(),
Example::Atom("\"value\"".to_string()),
)]),
},
None => Example::Dict(vec![(
"key".to_string(),
Example::Atom("\"value\"".to_string()),
)]),
}
}
/// Run `render` and then forget every import it registered — the constructor
/// names, the tag-scoped ones and the `datetime` flag alike.
///
/// The docstring writer needs to know whether an object-typed path parameter's
/// model *can* be constructed while documenting the plain name placeholder
/// instead of the model, so it renders the value and throws it away. Without
/// this the discarded render still imports the model, and the docstring's
/// `from fern import FernApi` gains a `ProbeParam` nothing in it names.
fn without_recording<T>(&mut self, render: impl FnOnce(&mut Self) -> T) -> T {
let recorded = self.recorded();
let out = render(self);
self.restore_recorded(recorded);
out
}
/// Every import an example has registered so far, to roll back to.
fn recorded(&self) -> Recorded {
Recorded {
referenced: self.referenced.clone(),
referenced_doc_order: self.referenced_doc_order.clone(),
referenced_tag: self.referenced_tag.clone(),
referenced_tag_doc_order: self.referenced_tag_doc_order.clone(),
uses_datetime: self.uses_datetime,
datetime_precedes_tag_import: self.datetime_precedes_tag_import,
}
}
/// Forget every import registered since `recorded` was taken.
fn restore_recorded(&mut self, recorded: Recorded) {
self.referenced = recorded.referenced;
self.referenced_doc_order = recorded.referenced_doc_order;
self.referenced_tag = recorded.referenced_tag;
self.referenced_tag_doc_order = recorded.referenced_tag_doc_order;
self.uses_datetime = recorded.uses_datetime;
self.datetime_precedes_tag_import = recorded.datetime_precedes_tag_import;
}
/// The example Fern renders for an **object-typed path parameter**, or `None`
/// where Fern renders the endpoint no example at all.
///
/// A path parameter over an object schema is a shape no registered source
/// declares, so it is measured on
/// `docs/openapi-surface/probes/parameter-style-simple-path-object.yml` and
/// the field-kind variants recorded beside it in `docs/fern-limitations.md`'s
/// Round 6. Fern renders the model over its **required** fields alone, and
/// each value comes from the field's *type* rather than from its name —
/// `role="string"`, not the `role="role"` a request body's field takes. Two
/// kinds render empty rather than populated (an array is `[]`, a map `{}`),
/// and a required field the writer cannot render drops the whole example:
/// Fern emits no `Examples` docstring, no README call and a bare
/// `client.probe(...)` in `reference.md`. Which kinds those are is
/// [`path_field_render`], the one place the rule is stated.
fn path_object_value(&mut self, t: &TypeRef) -> Option<Example> {
let obj = path_object_decl(t, self.types, self.tag_decls)?;
let fields = path_object_required_fields(obj, self.types, self.tag_decls);
let name = obj.name.clone();
self.record_ref(&name);
let mut args = Vec::new();
for (py_name, type_ref) in fields {
let rendered = match path_field_render(&type_ref, self.types, self.tag_decls)? {
PathFieldRender::EmptyList => Example::List(Vec::new()),
PathFieldRender::EmptyDict => Example::Dict(Vec::new()),
PathFieldRender::Plain => self.value(&type_ref, Slot::Plain),
};
args.push((Some(py_name), rendered));
}
Some(Example::Call(name, args))
}
/// The example of a value whose schema admits only the empty object: `{}`,
/// where Fern writes its `{"key": "value"}` placeholder, which the schema
/// rejects (the `closed-empty-object-example` departure).
fn empty_object(&self) -> Example {
if self.reference {
Example::ReferenceDict(Vec::new())
} else {
Example::Dict(Vec::new())
}
}
/// One field's example value: its declared `example` where that literal is
/// what Fern shows for the field's type, and the synthesized placeholder
/// otherwise. A union wrapper's fields are exampled the same way an object's
/// are — NDW's `AreaRequest_Municipality` opens with the `GM0344` its
/// `MunicipalityAreaRequest.id` declares.
/// A string sized by its declared lengths, as Fern's IR fallback example
/// writes it, where that fallback writes the example and the lengths call
/// for one ([`crate::ir::fallback_string_sample`]); `None` elsewhere.
fn sized_fallback(&self, ty: &TypeRef, lengths: (Option<u64>, Option<u64>)) -> Option<Example> {
if !self.field_examples_ignored || *ty != TypeRef::Primitive(Prim::Str) {
return None;
}
crate::ir::fallback_string_sample(lengths.0, lengths.1)
.map(|sample| Example::Atom(format!("\"{sample}\"")))
}
fn field_example(
&mut self,
ty: &TypeRef,
wire: &str,
example: Option<&str>,
lengths: (Option<u64>, Option<u64>),
) -> Example {
// A field typed by a map to unknown (`Dict[str, Any]`) takes Fern's fixed
// `{"key": "value"}` placeholder whatever the schema declares: HelixDB's
// `OperationTree` is `type: object` + `additionalProperties: true` and
// carries two `examples`, and the golden's worked call for the `root`
// field that names it shows the placeholder. A value the *enclosing*
// object's own example spells out is a different question and reaches
// this map through `value_from_example`, which is why the suppression is
// here rather than there — `mosip-esignet`'s `Purpose.title` keeps the
// `{"@none": "Title"}` its request example gives it.
// Without an importer example Fern's IR fallback reads no field's own
// example: NPQ's `accept_an_npq_application` documents `type="type"`
// over the `npq-application-accept` its schema declares.
let example =
example.filter(|_| !self.field_examples_ignored && !self.resolves_to_unknown_map(ty));
let literal = match example {
// A date/date-time field's declared example is used like any other:
// EN 18222's `last_updated` is typed by the `Timestamp` alias and
// exampled from that alias's own `2026-06-08T15:30:00Z`, not from the
// placeholder.
Some(example)
if self.example_is_scalar(ty)
|| example.starts_with(['{', '['])
|| self.example_is_temporal(ty) =>
{
self.value_from_example(ty, example)
}
_ => None,
};
literal
.or_else(|| self.sized_fallback(ty, lengths))
.unwrap_or_else(|| self.value(ty, Slot::Named(wire)))
}
/// An object's fields including those inherited from its base classes (bases
/// first, in declaration order).
fn object_fields(&self, obj: &ObjectType) -> Vec<ExampleField> {
let mut out = Vec::new();
for base in &obj.bases {
if let Some(TypeDecl::Object(b)) = self.find(base) {
out.extend(self.object_fields(b));
}
}
// In a docstring a flattened parent's fields lead the model's own, as
// an extended parent's do: TAMS's `FlowVideo` docstring example opens
// with `FlowCore`'s `id` and `source_id` though the model writes
// `codec` first, where `reference.md` keeps the model's order. A model
// still extending a base keeps its own order: the Vonage Messages API's
// `SendMessageRequestZero` extends `ChannelOptionsSms` and
// `BaseMessageType`, and its example writes the flattened `Text`'s
// `message_type` after its own `text`.
let leads = |f: &&Field| {
!self.documentation && obj.bases.is_empty() && f.inherited_from_flattened_parent
};
let inherited_first = obj
.fields
.iter()
.filter(leads)
.chain(obj.fields.iter().filter(|f| !leads(f)));
for f in inherited_first {
// An example spells a field Fern renames only on the model under its
// plain name: VisKit's `ThreePieceIn.copy` is the model field `copy_`,
// and Fern's example constructs `ThreePieceIn(copy="copy", …)`.
let py_name = if f.py_name == naming::model_field_name(&f.wire_name) {
naming::field_name(&f.wire_name)
} else {
f.py_name.clone()
};
out.push(ExampleField {
py_name,
wire_name: f.wire_name.clone(),
type_ref: f.type_ref.clone(),
requirement: match (f.spec_required, f.optional) {
(true, false) => Requirement::Required,
(true, true) => Requirement::RequiredNullable,
(false, _) => Requirement::Optional,
},
example: f.example.clone(),
parent_example: obj.example_fields.contains(&f.wire_name),
deprecated: f.deprecated,
lengths: (f.min_length, f.max_length),
});
}
out
}
}
/// The imports an [`ExampleCtx`] has registered, as [`ExampleCtx::recorded`]
/// captures them.
struct Recorded {
referenced: BTreeSet<String>,
referenced_doc_order: Vec<String>,
referenced_tag: BTreeSet<(String, String)>,
referenced_tag_doc_order: Vec<(String, String)>,
uses_datetime: bool,
datetime_precedes_tag_import: bool,
}
/// Whether a model field's property is required, as example synthesis reads it.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Requirement {
/// Not in the schema's `required` set.
Optional,
/// In `required`, and not `Optional` in Python.
Required,
/// In `required` but `Optional` in Python. Fern's importer reads such a
/// property as optional, so its example omits it — ramu-shogi's
/// `evalCp: {type: [integer, null]}` and its `$ref`s to `type: [object,
/// null]` components are required and absent — except that the Markdown
/// writers write an example's `null` for it as `None`.
RequiredNullable,
}
/// One field of a model, as example synthesis reads it: the field and its marks.
struct ExampleField {
/// The keyword the model constructor takes.
py_name: String,
/// The JSON property name an example's value is keyed by.
wire_name: String,
/// The field's type.
type_ref: TypeRef,
/// Whether the property is required, and how Python types it.
requirement: Requirement,
/// The field's own example literal.
example: Option<String>,
/// Whether the model's schema-level example names the field.
parent_example: bool,
/// [`crate::ir::Field::deprecated`].
deprecated: bool,
/// [`crate::ir::Field::min_length`] and [`crate::ir::Field::max_length`].
lengths: (Option<u64>, Option<u64>),
}
/// How one required field of an **object-typed path parameter** renders inside
/// Fern's documentation example, or `None` where Fern documents the endpoint no
/// example at all.
///
/// The kinds are the ones measured, one probe variant each, against
/// `fernapi/fern-python-sdk` 5.20.0 (`docs/fern-limitations.md`, Round 6; the
/// variants are not re-measured at the certified pair): a
/// string, an integer, a `date-time` and an enum member render from the type;
/// an array renders `[]` and a map `{}`, populated in neither case; and a
/// required field that is itself a generated model drops the example entirely.
/// Every other kind is unmeasured and drops it too, which invents no bytes.
enum PathFieldRender {
/// Rendered from the type, as [`Slot::Plain`] renders it.
Plain,
/// `[]` — an array field is never populated here.
EmptyList,
/// `{}` — nor is a map.
EmptyDict,
}
fn path_field_render(
t: &TypeRef,
types: &[TypeDecl],
tag_decls: &[TagTypeDecl],
) -> Option<PathFieldRender> {
match t {
TypeRef::Optional(inner) => path_field_render(inner, types, tag_decls),
TypeRef::List(_) | TypeRef::Set(_) => Some(PathFieldRender::EmptyList),
TypeRef::Dict(_, _) => Some(PathFieldRender::EmptyDict),
TypeRef::Primitive(
Prim::Str
| Prim::Int
| Prim::Long
| Prim::Float
| Prim::Bool
| Prim::LiteralBool(_)
| Prim::Datetime
| Prim::Date,
) => Some(PathFieldRender::Plain),
TypeRef::Named(name) => match lookup_decl(types, tag_decls, name) {
Some(TypeDecl::Enum(_)) => Some(PathFieldRender::Plain),
Some(TypeDecl::Alias(alias)) => path_field_render(&alias.target, types, tag_decls),
_ => None,
},
_ => None,
}
}
/// One declaration by name, over the package-root types and the tag-scoped ones —
/// the lookup [`ExampleCtx::find`] performs, as a free function so the
/// example writer and the structural predicates that gate it share one answer.
fn lookup_decl<'a>(
types: &'a [TypeDecl],
tag_decls: &'a [TagTypeDecl],
name: &str,
) -> Option<&'a TypeDecl> {
types.iter().find(|d| d.name() == name).or_else(|| {
tag_decls
.iter()
.find_map(|tt| (tt.decl.name() == name).then_some(&tt.decl))
})
}
/// The generated object a path parameter's type resolves to, through `Optional`
/// and named aliases; `None` when the parameter is not object-typed at all.
fn path_object_decl<'a>(
t: &TypeRef,
types: &'a [TypeDecl],
tag_decls: &'a [TagTypeDecl],
) -> Option<&'a ObjectType> {
match t {
TypeRef::Optional(inner) => path_object_decl(inner, types, tag_decls),
TypeRef::Named(name) => match lookup_decl(types, tag_decls, name) {
Some(TypeDecl::Object(obj)) => Some(obj),
Some(TypeDecl::Alias(alias)) => path_object_decl(&alias.target, types, tag_decls),
_ => None,
},
_ => None,
}
}
/// An object's required fields as `(python name, type)`, inherited ones first, in
/// the order a constructed example passes them.
fn path_object_required_fields(
obj: &ObjectType,
types: &[TypeDecl],
tag_decls: &[TagTypeDecl],
) -> Vec<(String, TypeRef)> {
let mut out = Vec::new();
for base in &obj.bases {
if let Some(TypeDecl::Object(parent)) = lookup_decl(types, tag_decls, base) {
out.extend(path_object_required_fields(parent, types, tag_decls));
}
}
out.extend(
obj.fields
.iter()
.filter(|field| field.spec_required)
.map(|field| (field.py_name.clone(), field.type_ref.clone())),
);
out
}
/// Whether Fern documents a worked example for this endpoint's path parameters —
/// false only where one is object-typed and [`path_field_render`] cannot render a
/// required field of it. The example writer reaches the same answer by rendering;
/// this is what the *structural* callers (README endpoint selection, the
/// `reference.md` snippet gap) ask before there is a value to look at.
fn path_object_documented(ep: &Endpoint, types: &[TypeDecl], tag_decls: &[TagTypeDecl]) -> bool {
ep.path_params.iter().all(|pp| {
path_object_decl(&pp.type_ref, types, tag_decls).is_none_or(|obj| {
path_object_required_fields(obj, types, tag_decls)
.iter()
.all(|(_, type_ref)| path_field_render(type_ref, types, tag_decls).is_some())
})
})
}
/// A map whose value slot is *directly* free-form (`Dict[str, Any]`) — the shape
/// [`ExampleBuilder::value`] renders as Fern's `{"key": "value"}` placeholder.
fn is_unknown_map(t: &TypeRef) -> bool {
let TypeRef::Dict(_, value) = t else {
return false;
};
matches!(value.as_ref(), TypeRef::Primitive(Prim::Any))
|| matches!(value.as_ref(), TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Primitive(Prim::Any)))
}
/// A free-form (`Any`) example as Fern's importer keeps it: a `null` and an
/// empty array carry no value, so they leave the map or list holding them, at
/// any depth — MockServer's HAR import drops each entry's `"headers": []`.
fn fern_unknown_example(value: serde_json::Value) -> Option<serde_json::Value> {
match value {
serde_json::Value::Null => None,
serde_json::Value::Array(items) if items.is_empty() => None,
serde_json::Value::Array(items) => Some(serde_json::Value::Array(
items.into_iter().filter_map(fern_unknown_example).collect(),
)),
serde_json::Value::Object(fields) => Some(serde_json::Value::Object(
fields
.into_iter()
.filter_map(|(key, field)| fern_unknown_example(field).map(|field| (key, field)))
.collect(),
)),
scalar => Some(scalar),
}
}
/// A JSON example value as an [`Example`], spelling a string that holds `"`
/// but no `'` the way Python's `repr` does when `python_repr` is set — a method docstring's
/// example does (MockServer's nested `"body": '{"orders":[]}'`), while
/// `reference.md` keeps the escaped JSON spelling.
fn example_from_json_as(value: serde_json::Value, python_repr: bool) -> Example {
match value {
serde_json::Value::Array(items) => Example::ExplicitList(
items
.into_iter()
.map(|item| example_from_json_as(item, python_repr))
.collect(),
),
serde_json::Value::Object(fields) => Example::Dict(
fields
.into_iter()
.map(|(key, value)| (key, example_from_json_as(value, python_repr)))
.collect(),
),
serde_json::Value::String(value)
if python_repr && value.contains('"') && !value.contains('\'') =>
{
Example::Atom(
crate::ir::example_literal(&serde_json::Value::String(value.clone()))
.unwrap_or_else(|| format!("{value:?}")),
)
}
serde_json::Value::String(value) => Example::Atom(format!("{value:?}")),
serde_json::Value::Number(value) => Example::Atom(value.to_string()),
serde_json::Value::Bool(value) => {
Example::Atom(if value { "True" } else { "False" }.to_string())
}
serde_json::Value::Null => Example::Atom("None".to_string()),
}
}
/// Build the worked `Examples` block for a method as logical (indent-0) lines,
/// or `None` when the request is not exampleable (a raw bytes body).
/// Whether a spec `example` is shown verbatim for a field of this type. Fern uses
/// the literal only for a plain scalar (a `str`/number/`bool`); a named type (enum,
/// object, union) gets its own synthesized example (an enum member, a constructor),
/// and a `datetime`/`date`/container is built through a call, so the raw literal
/// would be wrong there.
fn example_scalar(t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => example_scalar(inner),
TypeRef::Primitive(p) => {
matches!(
p,
Prim::Str
| Prim::Bytes
| Prim::Int
| Prim::Long
| Prim::Float
| Prim::Bool
| Prim::LiteralBool(_)
)
}
_ => false,
}
}
/// Whether a type is the unknown `Any` value (optionally wrapped). A required field
/// of this type is still shown in a worked example, unlike a required-but-nullable
/// concrete field, so it is exempt from the nullable exclusion.
fn is_any_type(t: &TypeRef) -> bool {
match t {
TypeRef::Optional(inner) => is_any_type(inner),
TypeRef::Primitive(Prim::Any) => true,
_ => false,
}
}
fn build_example(
ep: &Endpoint,
is_async: bool,
module: &str,
pkg: &str,
client_name: &str,
ctx: &mut ExampleCtx,
) -> Option<Vec<String>> {
build_example_inner(
ep,
is_async,
module,
pkg,
client_name,
ctx,
None,
false,
None,
false,
)
}
#[allow(
clippy::too_many_arguments,
reason = "documentation example emission also selects the README/reference writer"
)]
fn build_documentation_example(
ep: &Endpoint,
is_async: bool,
module: &str,
pkg: &str,
client_name: &str,
ctx: &mut ExampleCtx,
environment: Option<&crate::ir::Environment>,
reference: bool,
) -> Option<Vec<String>> {
build_example_inner(
ep,
is_async,
module,
pkg,
client_name,
ctx,
reference
.then_some(ep.reference_body_example.as_ref())
.flatten(),
true,
environment,
reference,
)
}
/// The rendered value of a required query parameter in a header-first worked
/// example — the ordering a wildcard or form request body takes. The value is the
/// one the ordinary ordering renders: a literal the type cannot hold is not an
/// example Fern shows, so an enum parameter whose example names no member falls
/// back to the enum's own synthesized value rather than to the raw literal.
fn header_first_query_example(ctx: &mut ExampleCtx, qp: &QueryParam) -> Example {
if let Some(example) = qp.example.as_ref().filter(|_| qp.example_is_scalar) {
return ctx
.value_from_example(&qp.type_ref, example)
.unwrap_or_else(|| ctx.value(&qp.type_ref, Slot::Named(&qp.wire_name)));
}
if let TypeRef::List(inner) = &qp.type_ref {
return Example::List(vec![ctx.value(inner, Slot::Named(&qp.wire_name))]);
}
ctx.value(&qp.type_ref, Slot::Named(&qp.wire_name))
}
#[allow(
clippy::too_many_arguments,
reason = "example emission needs endpoint, client, value context, and documentation-mode inputs"
)]
fn build_example_inner(
ep: &Endpoint,
is_async: bool,
module: &str,
pkg: &str,
client_name: &str,
ctx: &mut ExampleCtx,
body_example: Option<&serde_json::Value>,
documentation: bool,
environment: Option<&crate::ir::Environment>,
reference: bool,
) -> Option<Vec<String>> {
if matches!(ep.request_body, Some(RequestBody::Bytes { .. }))
|| !endpoint_has_worked_example(ep)
{
return None;
}
ctx.documentation = documentation;
if !documentation
&& ctx
.global_headers
.iter()
.any(|header| header.py_type() == HeaderType::Date)
{
ctx.uses_datetime = true;
}
ctx.reference = reference;
ctx.field_examples_ignored = ep.importer_example_missing;
// A `*/*` binary download documents its required arguments only: an optional
// parameter is shown by its declared example, and Fern reads none for it.
// apideck-file-storage's `filesDownload` takes a required `id` beside an
// optional `fields` exampled `id,updated_at`, and documents
// `download(id="id")` (measured at fern-python-sdk 5.41.3, where 5.20.0
// documented no argument at all).
let declared_examples = !ep.wildcard_binary_response;
// A path parameter whose schema declares no type is one Fern's example
// generator cannot sample, and the endpoint is then exampled by its other
// writer: the parameter by its own name, and a free-form map body as one
// `"string"` entry of an unknown value. Measured on the one golden that
// declares such a parameter: Deep Search's `update_project_knowledge_graph_metadata`
// documents `bag_key="bag_key", request={"string": {"key": "value"}}`, while
// `create_project_knowledge_graph` posts the same `{type: object}` body beside
// a typed path parameter and documents `request={"key": "value"}`.
let untyped_path_parameter = ep
.path_params
.iter()
.any(|param| matches!(param.type_ref, TypeRef::Primitive(Prim::Any)));
// The example call's keyword arguments, in signature order: path params,
// required query/header params, then the request body (a single `request`, or
// each required inlined field).
let mut args: Vec<(Option<String>, Example)> = Vec::new();
let header_slot = |wire: &str| {
if ep.text_response || ep.binary_response {
let mut words = naming::split_words(wire).into_iter();
let mut value = words.next().unwrap_or_default();
for word in words {
value.push_str(&naming::to_pascal_case(&word));
}
value
} else {
wire.to_string()
}
};
for pp in &ep.path_params {
if (reference || documentation) && matches!(pp.type_ref, TypeRef::List(_) | TypeRef::Set(_))
{
continue;
}
let v = if matches!(
pp.type_ref,
TypeRef::List(_) | TypeRef::Set(_) | TypeRef::Primitive(Prim::Any)
) {
// Fern's path placeholder stays a string even for the unusual array
// path parameters accepted by its OpenAPI importer, and for an
// untyped one (see `untyped_path_parameter` above).
Example::Atom(format!("{:?}", pp.wire_name))
} else if path_object_decl(&pp.type_ref, ctx.types, ctx.tag_decls).is_some() {
// An object-typed path parameter is documented two different ways
// by Fern's two example writers, and the difference is measured on
// `parameter-style-simple-path-object.yml`: the Markdown writers
// construct the model, while the docstring writer keeps the plain
// name placeholder a scalar parameter would take. Where the model
// cannot be constructed at all the endpoint documents nothing, in
// either writer — see [`ExampleCtx::path_object_value`].
if documentation {
ctx.path_object_value(&pp.type_ref)?
} else {
// The value is rendered and discarded: what the docstring
// takes from it is only whether Fern documents the endpoint
// at all, so the imports it would register are rolled back.
ctx.without_recording(|ctx| ctx.path_object_value(&pp.type_ref))?;
Example::Atom(format!("{:?}", pp.wire_name))
}
} else {
pp.example
.as_ref()
// Fern's example generator substitutes the parameter's own
// name for a declared example on any endpoint whose success
// body is not JSON — a binary download, and equally a
// `text/*` one. Audiobookshelf declares one `id` parameter on
// `/api/authors/{id}` and Fern splits its own path item on
// this: `getAuthorById` and `updateAuthorById` answer
// `application/json` and document
// `id="e4bb1afb-4a4f-4dd6-8be0-e615d233185b"`, while
// `deleteAuthorById` answers `text/plain` and documents
// `id="id"`.
.filter(|_| {
!ep.binary_response && !ep.text_response && ctx.example_is_scalar(&pp.type_ref)
})
.and_then(|example| ctx.value_from_example(&pp.type_ref, example))
.unwrap_or_else(|| ctx.value(&pp.type_ref, Slot::Path(&pp.wire_name)))
};
args.push((Some(pp.py_name.clone()), v));
}
// Fern normally follows required/optional grouping for parameters. Two request
// bodies are imported with header examples before query examples instead: a
// wildcard (`*/*`) body, and a form body (`multipart/form-data` or
// urlencoded) — api.video's `POST /upload` shows its optional `Content-Range`
// header ahead of its required `token` query parameter. Both keep the ordinary
// query-then-header order in the Markdown writers, so the flag is read
// alongside `reference` rather than on its own.
let header_first_request = ep
.request_body
.as_ref()
.is_some_and(|body| body.is_wildcard_media() || matches!(body, RequestBody::Form(_)));
let parameters_start = args.len();
let mut omits_required_query_arrays = ep.text_response
|| ep.query_params.iter().any(|qp| {
qp.required
&& (matches!(qp.type_ref, TypeRef::Dict(..)) || ctx.example_is_object(&qp.type_ref))
});
// So does one whose synthesized value is the unknown map placeholder
// `{"key": "value"}`: rice-shower's `time_start` is an `anyOf` led by an
// untyped `type: Date` member, and Fern leaves `channels` out beside it.
for qp in ep.query_params.iter().filter(|qp| {
qp.required
&& qp.example.is_none()
&& !matches!(qp.type_ref, TypeRef::List(_) | TypeRef::Set(_))
}) {
if omits_required_query_arrays {
break;
}
let value =
ctx.without_recording(|ctx| ctx.value(&qp.type_ref, Slot::Named(&qp.wire_name)));
omits_required_query_arrays = matches!(
value,
Example::Dict(_) | Example::DocDict(_) | Example::ReferenceDict(_)
);
}
for qp in ep
.query_params
.iter()
.filter(|qp| !header_first_request && qp.required)
{
// A required array is shown with one sampled item, whatever its item
// type (`colors=[Color.RED]`, `spots=[Spot()]`), except that Fern leaves
// every one out of an operation whose success body is `text/*`, or that
// takes a required object or map query parameter. Measured on variants
// of one operation: `application/xml`, `+json`, `204` and error-only
// responses all show it, as does an optional map beside it. The
// signature makes every query array optional, so either call runs.
// A declared example is kept where Fern drops it (the
// `optional-lowered-required-query-array-example` departure): the
// specification's own value documents the argument the operation
// requires. A synthesized one is left out, as Fern leaves it.
if omits_required_query_arrays
&& matches!(qp.type_ref, TypeRef::List(_) | TypeRef::Set(_))
&& !(qp.example.is_some() && qp.example_is_scalar)
{
continue;
}
let v = if let Some(ex) = qp.example.as_ref().filter(|_| qp.example_is_scalar) {
// A literal the type cannot hold is not an example Fern renders: an
// enum parameter whose example names no member takes the enum's own
// synthesized value (`mosip-esignet`'s `scope: openid profile`, which
// is two members' values in one string). Every type that *can* hold a
// literal is scalar, and [`ExampleCtx::value_from_example`] renders
// one for each of those, so the fallback is only ever the synthesized
// value.
ctx.value_from_example(&qp.type_ref, ex)
.unwrap_or_else(|| ctx.value(&qp.type_ref, Slot::Named(&qp.wire_name)))
} else if let TypeRef::List(inner) = &qp.type_ref {
if qp.one_or_many && !reference {
ctx.value(inner, Slot::Named(&qp.wire_name))
} else if matches!(inner.as_ref(), TypeRef::Optional(_)) && !documentation {
// Nullable items: a docstring passes an empty list. The Markdown
// passes one element, the non-null one Fern renders for the same
// items without `nullable`, where Fern's own passes `None`, which
// the signature's `Sequence[int]` rejects (the
// `nullable-items-docs` departure).
Example::List(Vec::new())
} else {
Example::List(vec![ctx.value(inner, Slot::Named(&qp.wire_name))])
}
} else if let TypeRef::Dict(_, value) = &qp.type_ref {
let pairs = vec![(
qp.wire_name.clone(),
ctx.value(value, Slot::Named(&qp.wire_name)),
)];
if reference {
Example::ReferenceDict(pairs)
} else {
Example::Dict(pairs)
}
} else {
// A union led by an unknown takes that unknown's placeholder, which
// the Markdown writers keep on its line as they keep any untyped
// value's: rice-shower's `time_start={"key": "value"}`.
if ctx.resolves_to_union_led_by_unknown(&qp.type_ref) {
ctx.untyped_arguments.insert(qp.py_name.clone());
}
ctx.value(&qp.type_ref, Slot::Named(&qp.wire_name))
};
args.push((Some(qp.py_name.clone()), v));
}
for header_param in ep
.header_params
.iter()
.filter(|header| header.required && !reference)
{
let v = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), v));
}
if header_first_request && !reference {
for header_param in ep
.header_params
.iter()
.filter(|header| !header.required && header.example.is_some() && declared_examples)
{
let value = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), value));
}
for qp in ep.query_params.iter().filter(|qp| qp.required) {
args.push((
Some(qp.py_name.clone()),
header_first_query_example(ctx, qp),
));
}
}
if header_first_request && reference {
for qp in ep.query_params.iter().filter(|qp| qp.required) {
args.push((
Some(qp.py_name.clone()),
header_first_query_example(ctx, qp),
));
}
for header_param in ep.header_params.iter().filter(|header| header.required) {
let value = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), value));
}
for header_param in ep
.header_params
.iter()
.filter(|header| !header.required && header.example.is_some() && declared_examples)
{
let value = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), value));
}
}
for qp in ep.query_params.iter().filter(|qp| !qp.required) {
if qp.example.is_none() || !qp.example_is_scalar || !declared_examples {
continue;
}
let example = qp.example.as_deref().unwrap_or_default();
// A YAML timestamp is a date to Fern's parser, so it is no example for a
// parameter that is not temporal, and an optional one then goes
// unshown: the hand-written `ref-pointer-walk` fixture's `since` writes
// `example: 2018-01-01 10:00:00` unquoted over a `format: dateTime` string.
if let Ok(serde_json::Value::String(value)) =
serde_json::from_str::<serde_json::Value>(example)
{
if ctx
.yaml_unquoted_timestamps
.is_some_and(|unquoted| unquoted.contains(&value))
&& !ctx.example_is_temporal(&qp.type_ref)
&& yaml_resolves_as_timestamp(&value)
{
continue;
}
}
args.push((
Some(qp.py_name.clone()),
ctx.value_from_example(&qp.type_ref, example)
.unwrap_or_else(|| Example::Atom(example.to_string())),
));
}
if reference && !header_first_request {
for header_param in ep.header_params.iter().filter(|header| header.required) {
let value = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), value));
}
}
for header_param in ep.header_params.iter().filter(|header| {
!header_first_request && !header.required && header.example.is_some() && declared_examples
}) {
let value = header_param
.example
.as_ref()
.filter(|_| ctx.example_is_scalar(&header_param.type_ref))
.and_then(|example| ctx.value_from_example(&header_param.type_ref, example))
.unwrap_or_else(|| {
let slot = header_slot(&header_param.wire_name);
header_example(ctx.value(&header_param.type_ref, Slot::Named(&slot)))
});
args.push((Some(header_param.py_name.clone()), value));
}
// The docstring writer walks an example's headers before its query
// parameters, whatever the body (`EndpointFunctionSnippetGenerator`); the
// Markdown writers keep the order above. Sim's `create_table_import_part_urls`
// documents its `upload-token` header ahead of its `workspaceId` query in
// `client.py` and after it in `reference.md`.
if !documentation {
let (headers, rest): (Vec<_>, Vec<_>) =
args.drain(parameters_start..).partition(|(name, _)| {
ep.header_params
.iter()
.any(|header| Some(&header.py_name) == name.as_ref())
});
args.extend(headers);
args.extend(rest);
}
// A constant header is no argument, so no call passes it, though Fern's
// Markdown does (the `constant-header-docs-arguments` departure).
match &ep.request_body {
Some(RequestBody::Single(s)) => {
let mut v = if let Some(example) = body_example {
ctx.value_from_example(&s.type_ref, &example.to_string())
.unwrap_or_else(|| ctx.value(&s.type_ref, Slot::Plain))
} else {
s.example
.as_deref()
.and_then(|example| ctx.value_from_example(&s.type_ref, example))
.unwrap_or_else(|| ctx.value(&s.type_ref, Slot::Plain))
};
// The JSON binary example must have its generated bytes type. The
// matched method still passes the value directly to `json=`.
if s.type_ref == TypeRef::Primitive(Prim::Bytes)
&& s.content_type
&& s.content_type_override.is_none()
&& matches!(&v, Example::Atom(value) if value == "\"string\"")
{
v = Example::Atom("b\"string\"".to_string());
}
if untyped_path_parameter
&& body_example.is_none()
&& s.example.is_none()
&& is_unknown_map(&s.type_ref)
{
let pairs = vec![(
"string".to_string(),
ctx.value(&TypeRef::Primitive(Prim::Any), Slot::Map),
)];
v = if reference {
Example::ReferenceDict(pairs)
} else {
Example::Dict(pairs)
};
}
// A binary download's array body is exampled with TWO synthesized
// elements where every other endpoint's takes one. SFTPGo's
// `streamzip` posts `{type: array, items: {type: string}}` and streams
// a zip back, and its golden documents `request=["string", "string"]`;
// gambitcomm's `set_protocols` posts the same shape — its item is
// described too — and answers JSON, and its golden documents
// `request=["string"]`, as do audiobookshelf's `downloadEpisodes`,
// komga's `markAnnouncementsRead`.
if ep.binary_response && body_example.is_none() && s.example.is_none() {
v = match &v {
Example::List(items) => match items.as_slice() {
[only] => Example::List(vec![only.clone(), only.clone()]),
_ => v,
},
Example::ReferenceList(items) => match items.as_slice() {
[only] => Example::ReferenceList(vec![only.clone(), only.clone()]),
_ => v,
},
_ => v,
};
}
// Without an importer example, Fern's IR fallback keys a map to
// unknown by its key type's sample: Spendesk's `request_access_token`
// posts a bare `type: object` and answers an undeclared `$ref`, and
// its golden documents `request={"string": {"key": "value"}}`.
// So does a map the body names through its component alias:
// CloudPDF's `doc.pages.extract` posts `$ref DocPagesExtractRequest`.
if ep.importer_example_missing
&& body_example.is_none()
&& s.example.is_none()
&& (matches!(&s.type_ref, TypeRef::Dict(_, value) if is_any_type(value))
|| ctx.resolves_to_unknown_map(&s.type_ref))
{
v = if ctx.reference {
Example::ReferenceDict(vec![("string".to_string(), Example::Atom(v.flat()))])
} else {
Example::Dict(vec![("string".to_string(), v)])
};
}
// An optional body Fern types `Optional[Any]` has nothing to show, in
// either document version: letta declares the shape in 3.1 and
// braintrust's proxy endpoints in 3.0.3, and neither golden passes
// `request=`. Nor does one naming an alias of it: marimo-plugins'
// `write_marimo_chatbot_cancel_prompt_output` takes a `type: "null"`
// component and is documented with no argument.
if s.required
|| body_example.is_some()
|| s.example.is_some()
|| !(is_any_type(&s.type_ref) || ctx.resolves_to_any(&s.type_ref))
{
args.push((Some("request".to_string()), v));
}
}
Some(RequestBody::Inline(fields)) => {
// Fern's importer example gives a required-but-nullable field (one
// whose signature is still `Optional[...] = OMIT`) its own example
// where it declares one, and `null` where it does not (measured at
// Fern CLI 5.163.1 with fern-python-sdk 5.41.3; the 5.20.0 pair left
// every such field out). apideck's `Lead.company_name` declares
// `Spacex`, which apideck-lead's `add`/`update` examples pass in every
// writer; osparc-simcore-webserver's `phone` declares none, so its
// docstring leaves it out and `reference.md` writes `phone=None` —
// the same as an example's explicit `null` below.
// Without an importer example, Fern's IR fallback reads no example the
// request media or its fields declare: a `text/plain` answer leaves
// only the required fields' placeholders, and no nullable one.
let fallback = ep.importer_example_missing;
let reference_fields = body_example
.filter(|_| !fallback)
.and_then(serde_json::Value::as_object);
// An example's `null` is no value: Fern's docstring leaves the field
// out, or synthesizes it where the signature requires it; its
// Markdown writers do the same but write a required nullable
// field's `null` as `None`.
let required_nullable =
|f: &BodyField| f.spec_required && f.optional && !is_any_type(&f.type_ref);
let example_null = |f: &BodyField| match reference_fields {
Some(values) => values
.get(&f.wire_name)
.is_some_and(serde_json::Value::is_null),
None => {
f.media_example && f.example.as_deref() == Some("null")
|| !fallback && required_nullable(f) && f.example.is_none()
}
};
let named_present = |f: &BodyField| {
reference_fields
.and_then(|values| values.get(&f.wire_name))
.is_some_and(|value| !value.is_null())
};
// An empty array names the field without a value, as in the
// docstring's example (see `apply_body_example`): the field is
// passed its own example or a synthesized one.
let named_value = |f: &BodyField| {
reference_fields
.and_then(|values| values.get(&f.wire_name))
.filter(|value| {
!value.is_null() && !value.as_array().is_some_and(Vec::is_empty)
})
};
// A required field is shown whatever its marks; an optional one whose
// schema is deprecated is left out even where the example names it.
let selected = |f: &&BodyField| {
if example_null(f) {
return f.spec_required
&& (!f.optional || is_any_type(&f.type_ref) || documentation);
}
f.spec_required
&& !(required_nullable(f) && (fallback || reference_fields.is_some()))
|| !f.deprecated
&& (named_present(f)
|| reference_fields.is_none() && f.media_example && !fallback)
};
let composed_request_media_example = ep.body_composition.is_all_of()
&& fields
.iter()
.any(|field| field.media_example && !field.schema_body_example);
let mut example_fields: Vec<&BodyField> = if composed_request_media_example {
fields
.iter()
.filter(|field| field.spec_required)
.filter(selected)
.collect()
} else {
fields.iter().filter(selected).collect()
};
if composed_request_media_example {
if let Some(values) = reference_fields {
// The reference writer takes required arguments in signature
// order, then optional arguments in the selected named
// example's declaration order.
example_fields.extend(values.keys().filter_map(|wire_name| {
fields
.iter()
.find(|field| !field.spec_required && field.wire_name == *wire_name)
.filter(selected)
}));
} else {
example_fields.extend(
fields
.iter()
.filter(|field| !field.spec_required)
.filter(selected),
);
}
}
// A composed body's example lists its bases' fields ahead of its own:
// the docstring every base's first all the way down, the Markdown
// writers each base's own fields before that base's bases (TAMS'
// `WebhookPut` documents `id`, then `WebhookWithId`'s base `Webhook`'s
// `url` and `events`, where its docstring writes `id` third), and the
// body's own first when it is itself an `allOf` parent. Measured on
// variants of one `allOf: [inline, $ref Base]` body, `required` and
// the members' order in `allOf` change none of these.
if ep.body_composition.is_all_of() {
if documentation && ep.body_composition == crate::ir::BodyComposition::AllOfParent {
example_fields.sort_by_key(|field| {
fields
.iter()
.position(|candidate| candidate.wire_name == field.wire_name)
});
} else if documentation {
example_fields.sort_by_key(|field| field.markdown_order);
} else {
example_fields.sort_by_key(|field| field.declaration_order);
}
}
// The field a stream condition fixes is no argument, so no example
// passes it, even where the schema's own example writes it.
example_fields.retain(|field| {
ep.stream_condition
.as_ref()
.is_none_or(|(wire, _)| *wire != field.wire_name)
});
for f in example_fields {
let recorded = ctx.recorded();
let supplied_example = f
.example
.as_deref()
.filter(|_| {
(reference_fields.is_none() || named_present(f) && named_value(f).is_none())
&& !example_null(f)
&& !fallback
})
// A binary download's inline body drops its media example:
// MockServer's file retrieval shows Fern's `name="name"`.
.filter(|_| !(ep.binary_response && f.media_example && !f.schema_body_example))
.map(str::to_string)
.or_else(|| named_value(f).map(serde_json::Value::to_string));
let supplied_example = supplied_example.map(|example| {
if matches!(&f.type_ref, TypeRef::List(inner) | TypeRef::Set(inner) if ctx.resolves_to_string(inner))
&& example.starts_with('[')
&& example.contains("null")
{
if let Ok(mut values) = serde_json::from_str::<Vec<serde_json::Value>>(&example) {
values.iter_mut().filter(|value| value.is_null()).for_each(|value| {
*value = serde_json::Value::String(f.wire_name.clone());
});
return serde_json::to_string(&values).unwrap_or(example);
}
}
example
});
let mut v = supplied_example
.as_deref()
.and_then(|example| {
if f.schema_body_example
&& ctx.example_is_temporal(&f.type_ref)
&& example.starts_with("\"2000-01-23T04:56:07")
{
return None;
}
if reference_fields.is_some()
|| ep.body_schema_is_beta && !ep.body_schema_example_wrapped
|| f.media_example
|| example.starts_with('{') && ctx.resolves_to_any(&f.type_ref)
|| ctx.example_is_scalar(&f.type_ref)
|| ctx.example_is_composite(&f.type_ref)
|| ctx.example_is_temporal(&f.type_ref)
{
ctx.value_from_example(&f.type_ref, example)
} else {
None
}
})
.unwrap_or_else(|| {
if documentation
&& f.optional
&& !is_any_type(&f.type_ref)
&& example_null(f)
{
Example::Atom("None".to_string())
} else if f.admits_only_empty_object {
ctx.empty_object()
} else {
ctx.value(&f.type_ref, Slot::Named(&f.wire_name))
}
});
let synthesized_items =
((ep.text_response || ep.binary_response || ep.importer_example_missing)
&& f.spec_required)
.then_some(2);
if let Some(min_items) = synthesized_items {
if let Example::List(items)
| Example::ExplicitList(items)
| Example::ReferenceList(items) = &mut v
{
if let Some(first) = items.first().cloned() {
items.resize(min_items, first);
}
}
}
if (ep.text_response || ep.binary_response)
&& matches!(&f.type_ref, TypeRef::Dict(_, value) if is_any_type(value))
{
v = if reference {
Example::ReferenceDict(vec![(f.wire_name.clone(), Example::Atom(v.flat()))])
} else {
Example::Dict(vec![(f.wire_name.clone(), v)])
};
}
let example_name = if (reference
|| ep.request_body_required
|| ep.request_body_has_multipart_related)
&& f.example.is_some()
&& f.wire_name
.starts_with(|character: char| character.is_ascii_digit())
{
if reference {
format!(
"f_{}",
naming::model_field_name(&f.wire_name)
.rsplit('_')
.next()
.unwrap_or(&f.py_name)
)
} else {
naming::model_field_name(&f.wire_name)
}
} else {
f.py_name.clone()
};
// An optional field whose example narrows to an empty model is
// no argument, as a nested one is (see `example_call_args`).
if !f.spec_required && matches!(&v, Example::Call(_, args) if args.is_empty()) {
ctx.restore_recorded(recorded);
continue;
}
args.push((Some(example_name), v));
}
}
// A form body: required non-file fields, plus any field the media type's
// own example names (a file cannot be shown as a literal, so Fern omits it
// from the example). The example's reach is what adds the optional
// `code`/`redirect_uri`/`code_verifier` of
// `openbankingproject-ch-kundenbeziehung`'s `/token` beside its two
// required fields. A *related* multipart body — one whose `encoding`
// gives a part its own content type — additionally shows every file part
// in the documentation writers, which is the whole example for
// `free5gc-pdu-session` (no part is declared required there) and one
// argument beside the required `descriptor` for VolView's `/stage`.
Some(RequestBody::Form(form)) => {
let related = form
.fields
.iter()
.any(|field| field.form_content_type.is_some());
let mut example_fields: Vec<_> = form
.fields
.iter()
.filter(|f| {
// The Markdown writers show every multipart file input,
// required or not — DaniWeb's optional `csv` is the corpus's
// first in `reference.md`, and the README passes an optional
// scan beside a required photo — while the client docstring
// shows an optional one only when the media example or a
// per-part content type reaches it.
(f.spec_required
|| f.media_example
|| ((related || documentation) && f.is_file)
// A part that is a LIST of files is shown wherever the
// example is written, required or not: SFTPGo's optional
// `filenames` reaches `README.md` and the client docstring
// as well as `reference.md`, where DaniWeb's optional
// scalar `csv` reaches only the reference writer.
|| f.is_file && matches!(f.type_ref, TypeRef::List(_) | TypeRef::Set(_)))
// Fern's docstring leaves every file out, even a
// required one the call cannot bind without; crozier
// passes the required ones (the
// `multipart-object-required-file-example` departure).
&& (documentation
|| !f.is_file
|| form.multipart && f.spec_required && !f.optional)
})
.collect();
// Fern's Markdown writers list multipart file inputs before the other
// fields, even though executable signatures retain the schema's
// required-first declaration order.
if form.multipart && documentation {
example_fields.sort_by_key(|field| !field.is_file);
}
for f in example_fields {
let v = if f.is_file && (documentation || form.multipart) {
if matches!(f.type_ref, TypeRef::List(_) | TypeRef::Set(_)) {
// A list of files is one flat placeholder list, not an
// exploded one: SFTPGo's `filenames` documents
// `filenames=["example_filenames"]` on a single line in
// both `README.md` and `reference.md`.
Example::Atom(format!("[\"example_{}\"]", f.wire_name))
} else {
Example::Atom(format!("\"example_{}\"", f.wire_name))
}
} else if f.admits_only_empty_object {
ctx.empty_object()
} else {
// A declared example is shown where a JSON field's would be:
// Zulip's `to` is a union whose example `[9, 10]` Fern passes
// over for the sample `"to"`.
f.example
.as_deref()
.filter(|_| {
ctx.example_is_scalar(&f.type_ref)
|| ctx.example_is_composite(&f.type_ref)
|| ctx.example_is_temporal(&f.type_ref)
// A model takes its object example: Zulip's
// `draft` is exampled from its annotation.
|| ctx.example_is_object(&f.type_ref)
})
.and_then(|example| ctx.value_from_example(&f.type_ref, example))
.unwrap_or_else(|| ctx.value(&f.type_ref, Slot::Named(&f.wire_name)))
};
args.push((Some(f.py_name.clone()), v));
}
}
_ => {}
}
let example_name = if is_async {
format!("Async{client_name}")
} else {
client_name.to_string()
};
// The call, rendered at logical indent 0 (sync) or 4 (inside `main`, async).
let call_indent = if is_async { 4 } else { 0 };
// A `stream-condition` split shows the streaming half's call in `reference.md`
// under both halves; every other writer uses the method's own name.
let method_name = if reference {
ep.reference_method_name
.as_deref()
.unwrap_or(&ep.method_name)
} else {
&ep.method_name
};
// An async streaming method is an async generator function, which cannot be
// awaited, so its async examples iterate the call directly (measured at
// fern-python-sdk 5.41.3; 5.20.0 wrote `await`): exa-gate's docstring
// documents `response = client.get_proxy_events()` inside `async def main()`,
// and the README's async client `async for chunk in …: print(chunk)`.
let awaited = is_async && !ep.streaming;
let await_kw = if awaited { "await " } else { "" };
let receiver = if module.is_empty() {
format!("{await_kw}client.{method_name}")
} else {
format!("{await_kw}client.{}.{method_name}", module_path(module))
};
// `render` positions continuation lines but leaves the first line for the
// caller to place, so prepend the base indent to it.
let pad = " ".repeat(call_indent);
let call = {
// The markdown writers render a length-wrapped dict without its magic
// trailing comma, so the whole argument tree switches dict flavor here
// rather than at each site that builds one.
let args = if documentation {
args.into_iter()
.map(|(kw, value)| (kw, value.into_documentation_dicts()))
.collect()
} else {
args
};
let readme_stream = ep.streaming && documentation && is_async;
if readme_stream {
// The README's async client elides the arguments: `(...)` wherever
// the method takes any (huatuo-node's `watch_events(...)`, beside a
// sync call passing none), `()` where it takes none (sse-streaming's
// `streammessages()`). Nothing an argument would import is written.
ctx.uses_datetime = false;
ctx.referenced_tag.clear();
ctx.referenced_tag_doc_order.clear();
}
let takes_arguments = !ep.path_params.is_empty()
|| !ep.query_params.is_empty()
|| !ep.header_params.is_empty()
|| ep.request_body.is_some();
let stream_call = format!("{receiver}({})", if takes_arguments { "..." } else { "" });
let example = Example::Call(receiver, args);
let rendered = example.render(call_indent);
if readme_stream {
format!("{pad}async for chunk in {stream_call}:\n{pad} print(chunk)")
} else if ep.streaming && !documentation {
let for_kw = if is_async { "async for" } else { "for" };
// Fern's snippet formatter (ruff at width 80) parenthesizes the
// assignment's right-hand side when `response = <call>(` overflows
// and the call's opening line fits once indented a level deeper.
let head = rendered.lines().next().unwrap_or_default();
let assigned = if pad.len() + "response = ".len() + head.len() > 80
&& pad.len() + 4 + head.len() <= 80
{
format!("(\n{pad} {}\n{pad})", example.render(call_indent + 4))
} else {
rendered
};
format!(
"{pad}response = {assigned}\n{pad}{for_kw} chunk in response:\n{pad} yield chunk"
)
} else if ep.pagination.is_some() && !documentation {
// A pager's worked example shows both ways of consuming it: item by
// item, and page by page. The async form separates the two with a
// blank line; the sync form does not.
let for_kw = if is_async { "async for" } else { "for" };
let gap = if is_async { "\n" } else { "" };
[
format!("{pad}response = {rendered}"),
format!("{pad}{for_kw} item in response:"),
format!("{pad} yield item"),
format!("{gap}{pad}# alternatively, you can paginate page-by-page"),
format!("{pad}{for_kw} page in response.iter_pages():"),
format!("{pad} yield page"),
]
.join("\n")
} else {
format!("{pad}{rendered}")
}
};
// Preamble stdlib imports: `asyncio` for the async form, `datetime` when a
// value needs it.
let mut preamble: Vec<String> = Vec::new();
if is_async {
preamble.push("import asyncio".to_string());
}
if ctx.uses_datetime {
preamble.push("import datetime".to_string());
}
// `from <pkg> import <client + referenced types>`, alphabetical, wrapped at
// the snippet's import width (80). Fern's examples are laid out by hand
// (not `ruff format`) — see `Example::render` for why.
let mut names: Vec<String> = if documentation && is_async {
Vec::new()
} else if documentation {
ctx.referenced_doc_order.clone()
} else {
ctx.referenced.iter().cloned().collect()
};
if documentation {
names.insert(0, example_name.clone());
} else {
names.push(example_name.clone());
// The same discriminated-union ordering the tag-scoped imports below use:
// Fern writes `StageInputDescriptor_Labelmap` before
// `StageInputDescriptorLabelmapReferenceImage`, where a plain sort puts
// the underscore last.
names.sort_by(|left, right| example_import_cmp(left, right));
}
let import_flat = format!("from {pkg} import {}", names.join(", "));
let import_width = if documentation && !is_async {
usize::MAX
} else {
80
};
let import_lines: Vec<String> = if import_flat.len() <= import_width {
vec![import_flat]
} else {
let mut v = vec![format!("from {pkg} import (")];
for n in &names {
v.push(format!(" {n},"));
}
v.push(")".to_string());
v
};
// Hoisted tag-scoped types import from their tag package, one line per tag,
// as a separate group above the main import (`from fern.items import ...`).
let mut tag_import_lines: Vec<String> = Vec::new();
{
let mut by_module: Vec<(&str, Vec<&str>)> = Vec::new();
let mut tag_refs: Vec<(&String, &String)> = if documentation {
ctx.referenced_tag_doc_order
.iter()
.map(|(module, name)| (module, name))
.collect()
} else {
ctx.referenced_tag
.iter()
.map(|(module, name)| (module, name))
.collect()
};
if !documentation {
tag_refs.sort_by(|(left_module, left_name), (right_module, right_name)| {
left_module
.cmp(right_module)
.then_with(|| example_import_cmp(left_name, right_name))
});
}
for (module, name) in tag_refs {
if let Some((_, names)) = by_module.iter_mut().find(|(group, _)| *group == module) {
names.push(name);
} else {
by_module.push((module, vec![name]));
}
}
for (module, tag_names) in by_module {
// A nested client module imports by its dotted path (CloudPDF's
// `from fern.doc.signatures import …`).
let module = module_path(module);
let flat = format!("from {pkg}.{module} import {}", tag_names.join(", "));
let import_width = if documentation { usize::MAX } else { 80 };
if flat.len() > import_width {
tag_import_lines.push(format!("from {pkg}.{module} import ("));
tag_import_lines.extend(tag_names.iter().map(|name| format!(" {name},")));
tag_import_lines.push(")".to_string());
} else {
tag_import_lines.push(flat);
}
}
}
let mut client_args = Vec::new();
// Method docstrings follow constructor order (global headers, auth). Fern's
// Markdown snippets instead lead with auth and omit optional global headers.
if documentation {
for arg in documentation_client_example_args(
ctx.auth,
ctx.global_headers,
ctx.client_path_parameters,
) {
client_args.push(format!(" {arg},"));
}
} else {
for parameter in ctx.client_path_parameters {
client_args.push(format!(" {},", client_path_parameter_example(parameter)));
}
let leading: Vec<GlobalHeader> = ctx
.global_headers
.iter()
.filter(|h| h.default().is_none())
.cloned()
.collect();
for h in distinct_global_header_params(&leading) {
client_args.push(format!(" {},", global_header_example(h)));
}
// A credential sharing a promoted header's parameter is passed once (the
// `repeated-credential-example-keyword` departure): Fern repeats it.
for arg in auth_example_args(ctx.auth) {
if !ctx
.global_headers
.iter()
.any(|h| h.default().is_none() && keyword_name(&arg) == h.py_name)
{
client_args.push(format!(" {arg},"));
}
}
}
if !ctx.has_environment {
client_args.push(" base_url=\"https://yourhost.com/path/to/api\",".to_string());
}
let client_block = if client_args.is_empty() {
vec![format!("client = {example_name}()")]
} else {
let mut block = vec![format!("client = {example_name}(")];
block.extend(client_args);
block.push(")".to_string());
block
};
let mut out: Vec<String> = Vec::new();
if !preamble.is_empty() {
out.extend(preamble);
if !(documentation && is_async && !tag_import_lines.is_empty()) {
out.push(String::new());
}
}
if documentation || ctx.sdk_first_party {
if !tag_import_lines.is_empty() {
out.extend(tag_import_lines);
out.push(String::new());
}
out.extend(import_lines);
} else {
// A third-party SDK's imports sort as one group, where the root module
// precedes its tag packages: `from acme import AcmeApi`, then
// `from acme.pet import …`, with no blank line between.
out.extend(import_lines);
out.extend(tag_import_lines);
}
out.push(String::new());
out.extend(client_block);
if is_async {
out.push(String::new());
out.push(String::new());
out.push("async def main() -> None:".to_string());
out.extend(call.split('\n').map(String::from));
out.push(String::new());
out.push(String::new());
out.push("asyncio.run(main())".to_string());
} else {
out.extend(call.split('\n').map(String::from));
}
if documentation {
// An untyped value's placeholder stays on its line: Fern writes NextGen's
// `request={"key": "value"},` and NPQ's model field
// `attributes={"key": "value"},` flat, where a map-typed one (bunq's
// `AttachmentPublic`) is wrapped by `compact_documentation_values`.
let mut untyped_arguments = std::mem::take(&mut ctx.untyped_arguments);
// So is a body naming a union of nothing but unknowns: People Data
// Labs' search bodies are `Union[typing.Any]` aliases, and its README
// writes `request={"key": "value"},` on one line.
if matches!(&ep.request_body, Some(RequestBody::Single(single))
if matches!(single.type_ref, TypeRef::Primitive(Prim::Any))
|| ctx.resolves_to_unknown_union(&single.type_ref))
{
untyped_arguments.insert("request".to_string());
}
Some(format_documentation_example(
out,
is_async,
pkg,
environment,
reference,
ctx.datetime_precedes_tag_import,
&untyped_arguments,
))
} else {
Some(out)
}
}
/// The client-constructor arguments Fern's Markdown snippets pass: auth first,
/// then every *required* global header (optional ones are left out).
fn documentation_client_example_args(
auth: &Auth,
global_headers: &[GlobalHeader],
client_path_parameters: &[ClientPathParameter],
) -> Vec<String> {
// Fern's Markdown passes a lifted base-path parameter to each method, which
// does not take it, and leaves it out of the constructor; crozier passes it
// to the constructor, first, by keyword: its default, or the placeholder the
// method docstrings use (the `lifted-base-path-docs-examples` departure).
client_path_parameters
.iter()
.map(client_path_parameter_example)
.chain(documentation_auth_example_args(auth))
.chain(
global_headers
.iter()
// Nor does it pass an array header, required or not.
.filter(|header| header.required() && !header.py_type().is_list())
.map(|header| format!("{}=\"<{}>\"", header.py_name, header.wire_name)),
)
.fold(Vec::new(), |mut args: Vec<String>, arg| {
// Each keyword once (the `repeated-credential-example-keyword`
// departure): a credential and a promoted header sharing a parameter
// are one argument, where Fern writes the keyword twice.
if !args
.iter()
.any(|seen| keyword_name(seen) == keyword_name(&arg))
{
args.push(arg);
}
args
})
}
fn keyword_name(arg: &str) -> &str {
arg.split_once('=').map_or(arg, |(name, _)| name)
}
fn documentation_auth_example_args(auth: &Auth) -> Vec<String> {
match auth {
Auth::ApiKey { credential, .. } => vec![format!("{}=\"<value>\"", credential.param)],
Auth::Bearer { credential, .. } => vec![format!("{}=\"<token>\"", credential.param)],
Auth::Basic {
username, password, ..
} => vec![
format!("{}=\"<username>\"", username.param),
format!("{}=\"<password>\"", password.param),
],
Auth::None => Vec::new(),
}
}
fn format_documentation_example(
lines: Vec<String>,
is_async: bool,
pkg: &str,
environment: Option<&crate::ir::Environment>,
reference: bool,
datetime_first: bool,
untyped_arguments: &BTreeSet<String>,
) -> Vec<String> {
let client_index = lines
.iter()
.position(|line| line.starts_with("client = "))
.unwrap_or(0);
let imports: Vec<String> = lines[..client_index]
.iter()
.filter(|line| !line.is_empty())
.cloned()
.collect();
let mut out = Vec::new();
if is_async {
out.extend_from_slice(&lines[..client_index]);
} else {
let main_start = imports
.iter()
.position(|line| line.starts_with(&format!("from {pkg} import ")))
.unwrap_or(0);
let main_end = if imports[main_start].ends_with('(') {
imports[main_start..]
.iter()
.position(|line| line == ")")
.map_or(main_start, |offset| main_start + offset)
} else {
main_start
};
out.extend_from_slice(&imports[main_start..=main_end]);
if let Some(environment) = environment {
out.push(format!(
"from {pkg}.environment import {}",
environment.enum_name
));
}
// The package import leads; the stdlib line and the tag imports follow in
// the order the snippet first uses them.
let stdlib = imports
.iter()
.filter(|line| line.starts_with("import "))
.cloned();
let from_imports = imports
.iter()
.enumerate()
.filter(|(index, line)| {
(*index < main_start || *index > main_end) && !line.starts_with("import ")
})
.map(|(_, line)| line.clone());
if datetime_first {
out.extend(stdlib);
out.extend(from_imports);
} else {
out.extend(from_imports);
out.extend(stdlib);
}
out.push(String::new());
}
let mut body = lines[client_index..].to_vec();
if let Some(environment) = environment {
let default_arg = format!(" environment={},", environment.default_ref());
if body.first().is_some_and(|line| line.ends_with("()")) {
let class = body[0].trim_end_matches("()").to_string();
body.splice(0..1, [format!("{class}("), default_arg, ")".to_string()]);
} else if let Some(close) = body.iter().position(|line| line == ")") {
body.insert(close, default_arg);
}
}
if !is_async {
if body.first().is_some_and(|line| line.ends_with("()")) {
body.insert(1, String::new());
} else if let Some(close) = body.iter().position(|line| line == ")") {
if body.get(close + 1).is_some_and(|line| !line.is_empty()) {
body.insert(close + 1, String::new());
}
}
}
out.extend(body);
compact_documentation_values(out, reference, untyped_arguments)
}
fn compact_documentation_values(
lines: Vec<String>,
reference: bool,
untyped_arguments: &BTreeSet<String>,
) -> Vec<String> {
let mut compact = Vec::with_capacity(lines.len());
let mut index = 0;
while index < lines.len() {
if index + 2 < lines.len()
&& lines[index].contains("datetime.")
&& lines[index].trim_end().ends_with(".fromisoformat(")
&& matches!(lines[index + 2].trim(), ")" | "),")
{
let value = lines[index + 1].trim().trim_end_matches(',');
let comma = if lines[index + 2].trim().ends_with(',') {
","
} else {
""
};
compact.push(format!("{}{value}){comma}", lines[index]));
index += 3;
continue;
}
let line = &lines[index];
let trimmed = line.trim();
if let Some((head, items)) = trimmed
.strip_suffix("],")
.and_then(|value| value.split_once("=["))
.filter(|(_, items)| !items.is_empty())
// A multipart part that is a LIST of files is the one list the
// markdown writers leave on its own line: SFTPGo documents
// `filenames=["example_filenames"]` flat, where every other list
// argument of the same writers is exploded one item per line.
.filter(|(head, items)| *items != format!("\"example_{}\"", head.trim()))
{
let indent = line.len() - line.trim_start().len();
compact.push(format!("{}{head}=[", " ".repeat(indent)));
let items: Vec<&str> = items.split(", ").collect();
compact.extend(items.iter().enumerate().map(|(index, item)| {
let comma = if index + 1 == items.len() { "" } else { "," };
format!("{}{item}{comma}", " ".repeat(indent + 4))
}));
compact.push(format!("{}],", " ".repeat(indent)));
index += 1;
continue;
}
// The Markdown writers wrap a one-pair dict argument onto its own lines:
// bunq's `{"key": "value"}` placeholder and the Auto Agent Protocol's
// `data={"type": "dealer.information.request"}` alike.
if !reference {
if let Some((head, item)) = trimmed
.strip_suffix("},")
.and_then(|value| value.split_once("={"))
.filter(|(_, item)| {
item.starts_with('"')
&& item.contains("\": ")
&& !item.contains(['{', '[', '}', ']'])
&& item.matches("\", \"").count() == 0
})
.filter(|(head, _)| !untyped_arguments.contains(*head))
{
let indent = line.len() - line.trim_start().len();
compact.push(format!("{}{head}={{", " ".repeat(indent)));
compact.push(format!("{}{item}", " ".repeat(indent + 4)));
compact.push(format!("{}}},", " ".repeat(indent)));
index += 1;
continue;
}
}
if line.trim_end().ends_with(".0,") {
compact.push(format!("{},", line.trim_end_matches(".0,")));
} else {
compact.push(line.clone());
}
index += 1;
}
for index in 0..compact.len().saturating_sub(1) {
if compact[index].trim_end().ends_with(',') && compact[index + 1].trim().starts_with(']') {
compact[index].pop();
}
}
compact
}
/// Whether a binary download is one Fern documents with no worked example: a
/// root-client method of a 3.0 document whose success schema itself declares
/// `format: binary`. Its docstring has no `Examples`, and the README anchors on
/// the next eligible operation instead (`binary-download-with-body-has-no-example`).
fn root_binary_download_undocumented(ep: &Endpoint) -> bool {
ep.binary_response && ep.module.is_empty() && ep.binary_schema_response && !ep.openapi_31
}
fn endpoint_has_worked_example(ep: &Endpoint) -> bool {
// Array query params always default to `None` in the generated signature, so a
// binary download with only those params has no exampleable required argument.
// That is a `GET`'s loss alone: HuaTuo's Pyroscope queries are argument-less
// `POST`s streaming bytes, and Fern documents each of them.
let binary_get_has_no_required_args = ep.http_method == "GET"
&& ep.path_params.is_empty()
&& ep
.query_params
.iter()
.all(|param| matches!(param.type_ref, TypeRef::List(_)))
&& ep.header_params.is_empty()
&& ep.request_body.is_none();
// A binary download substitutes the parameter's own name for each path
// placeholder whatever `pattern` the schema declares: discord's
// `get_guild_widget_png` documents `guild_id="guild_id"` although its
// `SnowflakeType` is `^(0|[1-9][0-9]*)$` (measured at fern-python-sdk 5.41.3;
// 5.20.0 dropped the whole example).
let opaque_multipart_example = ep.body_media_has_example
&& matches!(&ep.request_body, Some(RequestBody::Form(form)) if form.fields.iter().any(|field| field.is_file));
// A list-typed path parameter beside a request body defeats Fern's example
// generator: it renders every path parameter as a plain string, which the
// list type rejects once a body makes the wrapper validate, so the endpoint
// gets no worked example at all (DaniWeb's `POST /conversations/{ID}/schedules`
// and `/searches`, and `POST /groups/{ID}/schedules`). Either half alone still
// gets one — `GET /apps/{ID}` shows the bogus `id="ID"`, and every scalar-path
// body endpoint of the same document keeps its example.
// …and only when that body is one the example actually passes: braintrust's
// catch-all proxy takes a `Sequence[str]` path parameter beside an optional
// unknown body the example omits, and Fern documents it.
let body_shown = match &ep.request_body {
Some(RequestBody::Single(single)) => {
single.required || single.example.is_some() || !is_any_type(&single.type_ref)
}
_ => true,
};
let list_path_param_with_body = ep.request_body.is_some()
&& body_shown
&& ep
.path_params
.iter()
.any(|param| matches!(param.type_ref, TypeRef::List(_)));
// A binary download the importer declines has only Fern's IR-generated
// examples, and the success one fails (*File download unsupported*): what
// the docstring shows is the first *error* example, so a download declaring
// no error response shows none. Codat Assess's `get_excel_report` answers a
// lone `200` of `application/octet-stream` and its golden has no example.
let binary_download_without_errors =
ep.binary_response && ep.importer_example_missing && ep.errors.is_empty();
!(ep.binary_response && binary_get_has_no_required_args
|| binary_download_without_errors
|| opaque_multipart_example
|| list_path_param_with_body)
}
/// Assemble the `raw_client.py` file for one client module: the sync and async
/// raw client classes, each wrapping every operation.
fn raw_client_file(
env: &Environment<'static>,
pkg: &str,
root_client_name: &str,
module: &str,
endpoints: &[&Endpoint],
tag_types: &BTreeMap<String, String>,
) -> Result<GeneratedFile> {
let loc = if module.is_empty() {
RefLoc::PackageRoot
} else {
RefLoc::Client(module.to_string())
};
let mut imports = Imports::at(loc.clone(), tag_types);
// Imports every raw client needs regardless of operation shape — except for a
// parent client, whose two classes hold nothing but the wrapper they were
// constructed with, so only the wrapper types are imported.
imports.add_core("client_wrapper", "AsyncClientWrapper");
imports.add_core("client_wrapper", "SyncClientWrapper");
if !endpoints.is_empty() {
imports.add_plain("typing");
imports.add_from("json.decoder", "JSONDecodeError");
imports.add_core("api_error", "ApiError");
// A client every one of whose methods returns a pager never names the
// buffered response wrapper, so Fern does not import it.
if endpoints.iter().any(|ep| ep.pagination.is_none()) {
imports.add_core("http_response", "AsyncHttpResponse");
imports.add_core("http_response", "HttpResponse");
}
imports.add_core("request_options", "RequestOptions");
}
let class_stem = if module.is_empty() {
root_client_name.to_string()
} else {
naming::to_pascal_case(module_stem(module))
};
let sync_class = if module.is_empty() {
format!("Raw{class_stem}")
} else {
format!("Raw{class_stem}Client")
};
let async_class_name = if module.is_empty() {
format!("AsyncRaw{class_stem}")
} else {
format!("AsyncRaw{class_stem}Client")
};
// A first pass registers every import the two classes need; a name arriving
// from two modules (a component schema called `ApiError` beside the core one)
// is aliased in both, and the real pass writes those aliases at every
// occurrence. Rendering twice is what lets the *body* carry the alias — the
// collision is only knowable once every reference has been walked.
{
let mut probe = Imports::at(loc, tag_types);
probe.from = imports.from.clone();
raw_client_class(
&sync_class,
"SyncClientWrapper",
endpoints,
false,
&mut probe,
);
raw_client_class(
&async_class_name,
"AsyncClientWrapper",
endpoints,
true,
&mut probe,
);
imports.aliases = probe.collision_aliases();
}
let sync = raw_client_class(
&sync_class,
"SyncClientWrapper",
endpoints,
false,
&mut imports,
);
let async_class = raw_client_class(
&async_class_name,
"AsyncClientWrapper",
endpoints,
true,
&mut imports,
);
// A module with any request body declares the `OMIT` sentinel above the
// classes; `omit=OMIT` drops unset optional fields from the serialized body.
let omit = if endpoints.iter().any(|e| e.request_body.is_some()) {
"OMIT = typing.cast(typing.Any, ...)\n\n\n"
} else {
""
};
let body = format!("{omit}{sync}\n\n\n{async_class}");
let contents = render(
env,
"raw_client.py",
module,
context! {
header => HEADER,
imports => imports.render(),
body => body,
},
)?;
Ok(GeneratedFile {
path: if module.is_empty() {
PathBuf::from(format!("src/{pkg}/raw_client.py"))
} else {
PathBuf::from(format!("src/{pkg}/{module}/raw_client.py"))
},
contents,
})
}
/// Render one raw-client class: the `__init__` binding the client wrapper, then
/// one method per operation separated by a blank line.
fn raw_client_class(
class_name: &str,
wrapper_type: &str,
endpoints: &[&Endpoint],
is_async: bool,
imports: &mut Imports,
) -> String {
let mut out = format!(
"class {class_name}:\n def __init__(self, *, client_wrapper: {wrapper_type}):\n self._client_wrapper = client_wrapper"
);
for ep in endpoints {
out.push_str("\n\n");
out.push_str(&raw_method(ep, is_async, imports));
}
out
}
/// Render a named template with a context, mapping errors to [`Error::Render`].
fn render(
env: &Environment<'static>,
template: &str,
file: &str,
ctx: minijinja::value::Value,
) -> Result<String> {
env.get_template(template)
.and_then(|t| t.render(ctx))
.map_err(|e| Error::Render {
file: file.to_string(),
message: e.to_string(),
})
}
/// Remove the generated package tree (`src/<package>`) before a fresh write so
/// regeneration is idempotent: a schema or endpoint removed from the spec must
/// not leave an orphaned module behind. Scoped to `src/<package>`, which is
/// entirely crozier's own namespace — anything the user keeps elsewhere under
/// `--output` is untouched. A missing tree is not an error (first run).
pub fn clean_package_tree(root: &std::path::Path, package: &str) -> Result<()> {
let tree = root.join("src").join(package);
match std::fs::remove_dir_all(&tree) {
Ok(()) => Ok(()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(Error::WriteOutput { path: tree, source }),
}
}
/// The flat layout's counterpart of [`clean_package_tree`]: there the package *is*
/// the output root, so a previous generation's modules sit directly under it.
///
/// A root counts as a previous generation only when it carries the
/// `.fern/metadata.json` crozier writes; any other directory (a fresh one, or one
/// the user owns) is written into without deleting anything. A previous
/// generation loses every entry except the dot-entries beside `.fern/` (`.git`,
/// `.fernignore`, editor state), which crozier never writes.
pub fn clean_flat_tree(root: &std::path::Path) -> Result<()> {
if !root.join(".fern").join("metadata.json").is_file() {
return Ok(());
}
let entries = std::fs::read_dir(root)
.and_then(|dir| dir.collect::<std::io::Result<Vec<_>>>())
.map_err(|source| Error::WriteOutput {
path: root.to_path_buf(),
source,
})?;
for entry in entries {
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') && name != ".fern" {
continue;
}
let path = entry.path();
let removed = if path.is_dir() && !path.is_symlink() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
removed.map_err(|source| Error::WriteOutput { path, source })?;
}
Ok(())
}
/// Format every crozier-*generated* `.py` file in place with `ruff format`,
/// delegating line-wrapping to the tool Fern uses. Two classes of file are left
/// untouched:
///
/// - **Vendored Python assets** — the `core/` runtime, default-client helper, and
/// generated aiohttp tests are already Fern-formatted. Re-formatting would
/// change their post-comment-strip whitespace and break the byte match.
/// - **Whitespace-only files** — the empty `py.typed` marker and the comment-only
/// endpoint `__init__.py` (emitted in its four-blank-line stripped form): there
/// is nothing to wrap and `ruff` would collapse them.
///
/// The lazy-loader `__init__.py` aggregators *are* formatted: they overflow (long
/// `_dynamic_imports`/`__all__`/import lines) and `ruff` wraps them like any other
/// file. Their leading blank lines collapse under `ruff`, but that is a
/// comment-strip artifact the comparison drops on both sides (`crate::parity`),
/// so the byte match is preserved.
fn format_python_files(pkg: &str, files: &mut [GeneratedFile]) -> Result<()> {
let core_root = PathBuf::from(format!("src/{pkg}/core"));
let root_client = PathBuf::from(format!("src/{pkg}/client.py"));
let root_raw_client = PathBuf::from(format!("src/{pkg}/raw_client.py"));
let default_clients = PathBuf::from(format!("src/{pkg}/_default_clients.py"));
let generated_aiohttp_test = PathBuf::from("tests/test_aiohttp_autodetect.py");
let generated_conftest = PathBuf::from("tests/conftest.py");
for file in files.iter_mut() {
let is_py = file.path.extension().and_then(|e| e.to_str()) == Some("py");
// The `core/` tree is vendored verbatim (already ruff-formatted) EXCEPT
// `client_wrapper.py`, which crozier generates from the auth/global-header
// model and so must be wrapped like every other generated file.
let is_vendored = file.path.starts_with(&core_root)
&& file.path.file_name().and_then(|n| n.to_str()) != Some("client_wrapper.py");
let is_vendored_scaffolding = file.path == default_clients
|| file.path == generated_aiohttp_test
|| file.path == generated_conftest;
let has_code = file.contents.chars().any(|c| !c.is_whitespace());
if is_py && !is_vendored && !is_vendored_scaffolding && has_code {
// Fern's code writer replaces every tab it writes with four spaces
// (`WriterImpl.write`, `TAB_LENGTH = 4`): People Data Labs' field
// description `HQ country\tunited states` is four spaces apart.
if file.contents.contains('\t') {
file.contents = file.contents.replace('\t', " ");
}
let name = file.path.to_string_lossy();
file.contents =
crate::pyfmt::format_source(&name, &file.contents, crate::pyfmt::LINE_LENGTH)?;
if file.path == root_client || file.path == root_raw_client {
preserve_fenced_docstring_blank_indent(&mut file.contents);
}
}
}
Ok(())
}
fn preserve_fenced_docstring_blank_indent(source: &mut String) {
let had_final_newline = source.ends_with('\n');
let mut lines: Vec<String> = source.lines().map(String::from).collect();
let mut start = 0;
while start < lines.len() {
if lines[start] != " \"\"\"" {
start += 1;
continue;
}
let Some(relative_end) = lines[start + 1..]
.iter()
.position(|line| line == " \"\"\"")
else {
break;
};
let end = start + 1 + relative_end;
if lines[start + 1..end]
.iter()
.any(|line| line.trim_start().starts_with("```"))
{
for line in &mut lines[start + 1..end] {
if line.is_empty() {
*line = " ".to_string();
}
}
}
start = end + 1;
}
*source = lines.join("\n");
if had_final_newline {
source.push('\n');
}
}
/// Write each generated file under `root`, creating parent directories as needed.
pub fn write_files(root: &std::path::Path, files: &[GeneratedFile]) -> Result<()> {
for file in files {
let full = root.join(&file.path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).map_err(|source| Error::WriteOutput {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(&full, &file.contents).map_err(|source| Error::WriteOutput {
path: full.clone(),
source,
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
abbrev_call, auth_client_parts, auth_example_args, auth_wrapper_parts,
build_documentation_example, build_example, client_method, cycle_components, environment,
escape_py_str, example_from_json_as, example_import_cmp, field_decl, forward_repair_map,
generate, natural_cmp, path_field_render, path_object_decl, path_object_documented,
raw_method, raw_type_str, readme_endpoint, readme_endpoint_eligible, reference_entry,
reference_param_annotation, render, render_class_body, render_enum, render_type_decl,
root_sub_client_alias, root_sub_client_import, url_arg, BodySchemaShape, ClientCtx,
DeclSettings, Example, ExampleCtx, FieldView, ForwardRepair, Imports, ParamRow, RefLoc,
ReferenceEntryView, RenderedField, RootClientView, RootModuleView, Slot,
};
use crate::ir::Credential;
use crate::ir::{
AliasType, Auth, BodyField, DiscriminatedUnion, Endpoint, EnumMember, EnumType,
ErrorResponse, Field, FormBody, GlobalHeader, HeaderParam, HeaderType, Ir, ObjectType,
PathParam, Prim, QueryParam, RequestBody, SingleBody, TagTypeDecl, TypeDecl, TypeRef,
UnionMember,
};
use crate::wrap::Doc;
/// Every file the generator writes for `document`, before `ruff format`.
fn files_for(document: serde_json::Value) -> Vec<(String, String)> {
let doc: crate::openapi::OpenApi =
serde_json::from_value(document).expect("document deserializes");
let config = crate::config::GenerateConfig::new(
std::path::PathBuf::from("openapi.yml"),
std::path::PathBuf::from("out"),
Some("api".to_string()),
None,
None,
crate::settings::ExtraFields::default(),
"Api",
)
.expect("the config is well formed");
generate(&crate::ir::build(&doc, &config))
.expect("the document generates")
.into_iter()
.map(|file| {
(
file.path.to_string_lossy().replace('\\', "/"),
file.contents,
)
})
.collect()
}
#[test]
fn the_ir_fallback_example_sizes_strings_by_their_declared_lengths() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/": {"post": {
"operationId": "send",
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"type": "object", "required": ["message"], "properties": {
"message": {"$ref": "#/components/schemas/Message"}
}}
}}},
"responses": {"204": {"description": "OK"}}
}}},
"components": {"schemas": {
"ToNumber": {"type": "string", "minLength": 7, "maxLength": 15,
"example": "447700900000"},
"FromNumber": {"type": "string", "example": "447700900001"},
"Message": {
"type": "object", "required": ["to", "from", "code", "text"],
"properties": {
"to": {"$ref": "#/components/schemas/ToNumber"},
"from": {"$ref": "#/components/schemas/FromNumber"},
"code": {"type": "string", "maxLength": 4, "example": "ab12"},
"text": {"description": "Untyped and unexampled."}
}
}
}}
}));
let client = files
.iter()
.find(|(path, _)| path.ends_with("/client.py") && path.matches('/').count() == 2)
.map(|(_, contents)| contents)
.expect("the root client is generated");
// The untyped required `text` leaves Fern's importer no example, so its
// IR fallback writes the call, reading no declared example.
assert!(
client.contains("to=\"apricot\",")
&& client.contains("from_=\"from\",")
&& client.contains("code=\"buzz\",")
&& !client.contains("447700900000"),
"{client}"
);
}
#[test]
fn a_flat_example_for_a_list_of_unknown_lists_examples_empty_lists() {
let files = files_for(serde_json::json!({
"openapi": "3.1.0",
"info": {"title": "Api", "version": "1"},
"paths": {"/tags": {"put": {
"operationId": "setTags",
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"type": "array", "items": {
"type": "array",
"prefixItems": [{"type": "string"}, {"type": "string"}],
"items": false
}},
"example": ["title", "My title"]
}}},
"responses": {"204": {"description": "OK"}}
}}}
}));
let client = files
.iter()
.find(|(path, _)| path.ends_with("/client.py") && path.matches('/').count() == 2)
.map(|(_, contents)| contents)
.expect("the root client is generated");
assert!(
client.contains("request=[[], []]") && !client.contains("\"My title\""),
"{client}"
);
}
/// `GET /probe` carrying the header `probeParam` with `schema`, beside a
/// `GET /other` that carries it only when `everywhere` is set.
fn header_array_document(
schema: serde_json::Value,
required: bool,
everywhere: bool,
) -> serde_json::Value {
let header = serde_json::json!([
{"name": "probeParam", "in": "header", "required": required, "schema": schema}
]);
let other = if everywhere {
header.clone()
} else {
serde_json::json!([])
};
serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {
"/probe": {"get": {"operationId": "probe", "parameters": header,
"responses": {"204": {"description": "OK"}}}},
"/other": {"get": {"operationId": "other", "parameters": other,
"responses": {"204": {"description": "OK"}}}}
}
})
}
/// The `_AUTH_HEADERS` line of the `core/http_client.py` emitted for a
/// document whose only security scheme is `scheme`.
fn auth_headers_line(scheme: serde_json::Value) -> String {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"components": {"securitySchemes": {"credential": scheme}},
"security": [{"credential": []}],
"paths": {"/probe": {"get": {"operationId": "probe",
"responses": {"204": {"description": "OK"}}}}}
}));
let (_, http_client) = files
.iter()
.find(|(path, _)| path == "src/api/core/http_client.py")
.expect("core/http_client.py is emitted");
http_client
.lines()
.find(|line| line.starts_with("_AUTH_HEADERS"))
.expect("http_client.py declares _AUTH_HEADERS")
.to_owned()
}
#[test]
fn an_api_key_header_is_redacted_by_name_and_a_bearer_token_is_not_listed() {
assert_eq!(
auth_headers_line(
serde_json::json!({"type": "apiKey", "in": "header", "name": "X-Api-Key"})
),
"_AUTH_HEADERS: typing.FrozenSet[str] = frozenset({\"x-api-key\"})"
);
// `Authorization` is in the runtime's own sensitive set, so Fern lists no
// bearer or basic credential's header here.
for scheme in [
serde_json::json!({"type": "http", "scheme": "bearer"}),
serde_json::json!({"type": "http", "scheme": "basic"}),
] {
assert_eq!(
auth_headers_line(scheme),
"_AUTH_HEADERS: typing.FrozenSet[str] = frozenset()"
);
}
}
/// The `src/api/{path}` file emitted for a document whose only security scheme
/// is `scheme`, required on every operation when `required` is set.
fn emitted_for_scheme(
scheme: serde_json::Value,
required: bool,
servers: serde_json::Value,
path: &str,
) -> String {
let security = if required {
serde_json::json!([{"credential": []}])
} else {
serde_json::json!([])
};
files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"servers": servers,
"components": {"securitySchemes": {"credential": scheme}},
"security": security,
"paths": {"/probe": {"get": {"operationId": "probe",
"responses": {"204": {"description": "OK"}}}}}
}))
.into_iter()
.find(|(emitted, _)| *emitted == format!("src/api/{path}"))
.map(|(_, contents)| contents)
.unwrap_or_else(|| panic!("{path} is emitted"))
}
#[test]
fn a_bearer_wrapper_skips_the_sync_token_when_an_async_one_is_set() {
let bearer = serde_json::json!({"type": "http", "scheme": "bearer"});
for required in [true, false] {
let wrapper = emitted_for_scheme(
bearer.clone(),
required,
serde_json::json!([]),
"core/client_wrapper.py",
);
assert!(
wrapper.contains(
" def get_headers(self, *, include_token: bool = True) -> typing.Dict[str, str]:\n"
),
"{wrapper}"
);
let token_write = if required {
" if include_token:\n headers[\"Authorization\"] = f\"Bearer {self._get_token()}\"\n"
} else {
" if include_token:\n token = self._get_token()\n if token is not None:\n headers[\"Authorization\"] = f\"Bearer {token}\"\n"
};
assert!(wrapper.contains(token_write), "{wrapper}");
assert!(
wrapper.contains(
" headers = self.get_headers(include_token=self._async_token is None)\n"
),
"{wrapper}"
);
}
// Any other credential keeps the flag-free `get_headers`.
for scheme in [
serde_json::json!({"type": "apiKey", "in": "header", "name": "X-Api-Key"}),
serde_json::json!({"type": "http", "scheme": "basic"}),
] {
let wrapper = emitted_for_scheme(
scheme,
true,
serde_json::json!([]),
"core/client_wrapper.py",
);
assert!(
wrapper.contains(" def get_headers(self) -> typing.Dict[str, str]:\n"),
"{wrapper}"
);
assert!(!wrapper.contains("include_token"), "{wrapper}");
assert!(
wrapper.contains(" headers = self.get_headers()\n"),
"{wrapper}"
);
}
}
#[test]
fn a_server_variable_override_formats_the_chosen_environments_template() {
let client = emitted_for_scheme(
serde_json::json!({"type": "apiKey", "in": "header", "name": "X-Api-Key"}),
true,
serde_json::json!([
{"url": "https://{region}.example.com", "description": "Production",
"variables": {"region": {"default": "eu"}}},
{"url": "https://sandbox.example.com", "description": "Sandbox"},
]),
"client.py",
);
// Only the server declaring variables has a template, and a caller's
// `base_url` wins over the formatted one.
assert!(
client.contains(concat!(
" if region is not None:\n",
" _region = region if region is not None else \"eu\"\n",
" _environment_url_templates = {\n",
" ApiApiEnvironment.PRODUCTION: \"https://{region}.example.com\",\n",
" }\n",
" _url_template = _environment_url_templates.get(environment, \"https://{region}.example.com\")\n",
" if base_url is None:\n",
" base_url = _url_template.format(region=_region)\n",
)),
"{client}"
);
}
#[test]
fn multipart_json_module_imports_follow_each_methods_shadowing() {
for order in [[true, false], [false, true]] {
let mut imports = super::Imports::default();
for shadowed in order {
assert_eq!(
imports.json_module(shadowed),
if shadowed { "_json" } else { "json" }
);
}
assert_eq!(imports.render(), "import json\nimport json as _json");
}
let mut imports = super::Imports::default();
imports.json_module(true);
assert_eq!(imports.render(), "import json as _json");
}
#[test]
fn a_method_array_header_is_a_sequence_documented_as_a_list() {
let files = files_for(header_array_document(
serde_json::json!({"type": "array", "items": {"type": "string"}}),
true,
false,
));
let client = file(&files, "src/api/client.py");
assert!(
client.contains("probe_param: typing.Sequence[str]"),
"{client}"
);
// Fern fills an array header's example with two copies of its name.
assert!(
client.contains("probe_param=[\"probeParam\", \"probeParam\"]"),
"{client}"
);
let reference = file(&files, "reference.md");
assert!(
reference.contains("**probe_param:** `typing.List[str]`"),
"{reference}"
);
// And the abbreviated README calls take no `...` for it.
let readme = file(&files, "README.md");
assert!(readme.contains("client.probe()"), "{readme}");
assert!(!readme.contains("client.probe(...)"), "{readme}");
}
#[test]
fn a_promoted_array_header_is_a_list_field_left_out_of_markdown_examples() {
let files = files_for(header_array_document(
serde_json::json!({"type": "array", "items": {"type": "string"}}),
true,
true,
));
let wrapper = file(&files, "src/api/core/client_wrapper.py");
assert!(
wrapper.contains("probe_param: typing.List[str],"),
"{wrapper}"
);
assert!(
wrapper.contains("headers[\"probeParam\"] = str(self._probe_param)"),
"{wrapper}"
);
let readme = file(&files, "README.md");
assert!(!readme.contains("probe_param=\"<probeParam>\""), "{readme}");
}
#[test]
fn a_defaulted_promoted_header_trails_logging_and_sends_its_default() {
let files = files_for(header_array_document(
serde_json::json!({"type": "array", "items": {"type": "string"}, "default": "all"}),
false,
true,
));
let wrapper = file(&files, "src/api/core/client_wrapper.py");
let logging = " logging: typing.Optional[typing.Union[LogConfig, Logger]] = None,\n";
let field = " probe_param: typing.Optional[str] = None,\n";
assert_eq!(
wrapper.matches(&format!("{logging}{field}")).count(),
3,
"{wrapper}"
);
assert!(wrapper.contains(
" self._logging = logging\n self._probe_param = probe_param\n"
));
assert!(wrapper.contains(
"headers[\"probeParam\"] = self._probe_param if self._probe_param is not None else \"all\""
));
assert_eq!(
wrapper
.matches(" logging=logging,\n probe_param=probe_param,\n")
.count(),
2,
"{wrapper}"
);
let client = file(&files, "src/api/client.py");
assert!(
client.contains(&format!("{logging}{field} ):")),
"{client}"
);
assert!(
client.contains(" probe_param : typing.Optional[str]\n Examples"),
"{client}"
);
assert!(
client.contains(" logging=logging,\n probe_param=probe_param,\n")
);
assert!(
!client.contains("probe_param=\"YOUR_PROBE_PARAM\""),
"{client}"
);
}
fn file<'a>(files: &'a [(String, String)], suffix: &str) -> &'a str {
files
.iter()
.find(|(path, _)| path.ends_with(suffix))
.map(|(_, contents)| contents.as_str())
.unwrap_or_else(|| panic!("no generated file ends with {suffix}"))
}
#[test]
fn a_nested_client_package_owns_its_types_and_its_typed_header() {
let operation = |group: serde_json::Value, method: &str| {
serde_json::json!({
"x-fern-sdk-group-name": group,
"x-fern-sdk-method-name": method,
"parameters": [{
"name": "Request-Timeout", "in": "header",
"schema": { "type": "integer" }
}],
"requestBody": { "content": { "application/json": { "schema": {
"type": "object",
"properties": { "unit": { "type": "string", "enum": ["mm", "pt"] } }
} } } },
"responses": { "200": { "description": "ok" } }
})
};
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "nested", "version": "1" },
"paths": {
"/doc/scale": { "post": operation(serde_json::json!(["doc", "pages"]), "setScale") },
"/doc/rotate": { "post": operation(serde_json::json!(["doc", "pages"]), "rotate") }
}
}));
// CloudPDF's layout: the nested package's types are re-exported one level
// up, and not at the root.
let parent = file(&files, "src/api/doc/__init__.py");
assert!(parent.contains("from .pages import"), "{parent}");
assert!(
parent.contains("\"SetScalePagesRequestUnit\": \".pages\""),
"{parent}"
);
let root = file(&files, "src/api/__init__.py");
assert!(!root.contains("SetScalePagesRequestUnit"), "{root}");
// Its enum imports `core` from four levels up.
let unit = file(&files, "doc/pages/types/set_scale_pages_request_unit.py");
assert!(unit.contains("from ....core import enum"), "{unit}");
// Milvus's integer `Request-Timeout`, promoted to the wrapper.
let wrapper = file(&files, "src/api/core/client_wrapper.py");
assert!(
wrapper.contains("request_timeout: typing.Optional[int] = None"),
"{wrapper}"
);
assert!(
wrapper.contains("headers[\"Request-Timeout\"] = str(self._request_timeout)"),
"{wrapper}"
);
}
/// Render a class body through the real `class_body.py` template — the fast
/// feedback loop for layout/filter changes (no binary, no `ruff`). Returns the
/// body exactly as emitted, before the file-level `ruff format` pass.
fn class_body(docstring: Option<&str>, fields: Vec<FieldView>) -> String {
render_class_body(
&environment(),
docstring.map(str::to_string),
fields,
Some(crate::settings::ExtraFields::Allow),
)
.expect("renders")
}
/// Render any registered template with a serializable view — the direct
/// "minijinja in the loop" harness for the presentational templates.
fn render_tmpl(name: &str, view: impl serde::Serialize) -> String {
render(
&environment(),
name,
name,
minijinja::Value::from_serialize(&view),
)
.expect("renders")
}
fn field(decl: &str, docstring: Option<&str>) -> FieldView {
FieldView {
decl: decl.to_string(),
docstring: docstring.map(str::to_string),
}
}
#[test]
fn example_literals_parse_as_json_whether_python_or_json_spelled() {
assert_eq!(
super::parse_example_value(r#"[{"on": True, "off": False, "none": None}]"#),
Some(serde_json::json!([{"on": true, "off": false, "none": null}]))
);
// Inside a string the Python words are text.
assert_eq!(
super::parse_example_value(r#"{"text": "True \"None\"", "on": True}"#),
Some(serde_json::json!({"text": "True \"None\"", "on": true}))
);
assert_eq!(
super::parse_example_value("[1, 2]"),
Some(serde_json::json!([1, 2]))
);
assert_eq!(super::parse_example_value("not json"), None);
}
#[test]
fn class_body_separates_documented_fields_with_a_blank_line() {
let body = class_body(
None,
vec![
field("a: str = pydantic.Field()", Some("Doc A.")),
field("b: str = pydantic.Field()", Some("Doc B.")),
],
);
assert_eq!(
body,
r#" a: str = pydantic.Field()
"""
Doc A.
"""
b: str = pydantic.Field()
"""
Doc B.
"""
if IS_PYDANTIC_V2:
model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2
else:
class Config:
frozen = True
smart_union = True
extra = pydantic.Extra.allow
"#
);
}
#[test]
fn class_body_omits_blank_after_undocumented_field() {
let body = class_body(
None,
vec![
field("a: str", None),
field("b: typing.Optional[int] = None", None),
],
);
// Undocumented fields sit on consecutive lines; one blank precedes config.
assert!(body.starts_with(
" a: str\n b: typing.Optional[int] = None\n\n if IS_PYDANTIC_V2:"
));
}
#[test]
fn class_body_renders_class_docstring_then_a_blank_line() {
let body = class_body(Some("A model."), vec![field("a: str", None)]);
assert!(body.starts_with(" \"\"\"\n A model.\n \"\"\"\n\n a: str\n"));
}
/// The `extra-fields` config drives both pydantic-version config blocks. Fern
/// omits the v2 `extra=` kwarg for `ignore` (v2's own default) but always spells
/// out the v1 `pydantic.Extra.<name>` member — verified against real Fern output
/// in `tests/fixtures/pydantic-extra-fields`.
#[test]
fn class_body_extra_fields_drives_both_config_blocks() {
let render = |extra| {
render_class_body(&environment(), None, vec![field("a: str", None)], extra)
.expect("renders")
};
let allow = render(Some(crate::settings::ExtraFields::Allow));
assert!(
allow.contains("pydantic.ConfigDict(extra=\"allow\", frozen=True)")
&& allow.contains("extra = pydantic.Extra.allow"),
"{allow}"
);
// `ignore` drops the v2 `extra=` kwarg (v2 defaults to ignore) but keeps
// the explicit v1 member.
let ignore = render(Some(crate::settings::ExtraFields::Ignore));
assert!(
ignore.contains("pydantic.ConfigDict(frozen=True)")
&& !ignore.contains("extra=\"ignore\"")
&& ignore.contains("extra = pydantic.Extra.ignore"),
"{ignore}"
);
let forbid = render(Some(crate::settings::ExtraFields::Forbid));
assert!(
forbid.contains("pydantic.ConfigDict(extra=\"forbid\", frozen=True)")
&& forbid.contains("extra = pydantic.Extra.forbid"),
"{forbid}"
);
}
#[test]
fn reference_entry_renders_details_line_and_param_row() {
let view = ReferenceEntryView {
client_prefix: "client.endpoints_urls.".to_string(),
href: "src/fern/endpoints_urls/client.py".to_string(),
pkg: "fern".to_string(),
method: "get".to_string(),
dots: "...",
return_type: " -> Widget".to_string(),
description: None,
example: "client.endpoints_urls.get()".to_string(),
example_gap: "\n",
params: vec![ParamRow {
name: "id".to_string(),
annot: "str".to_string(),
suffix: " ".to_string(),
}],
};
let out = render_tmpl("reference_entry.md", &view);
assert!(out.contains(
"<details><summary><code>client.endpoints_urls.\
<a href=\"src/fern/endpoints_urls/client.py\">get</a>(...) -> Widget</code></summary>"
));
// The param value line carries the suffix's trailing space, then the fixed
// four-space line (driven by an expression, not fragile template whitespace).
assert!(out.contains("**id:** `str` \n \n</dd>"));
// No description block when there is no description.
assert!(!out.contains("Description"));
}
#[test]
fn root_client_loops_the_tag_properties() {
let view = RootClientView {
class_name: "FernApi".to_string(),
pkg: "fern".to_string(),
httpx_type: "httpx.Client".to_string(),
wrapper: "SyncClientWrapper".to_string(),
ctor_param: " token: str,\n".to_string(),
wrapper_arg: " token=token,".to_string(),
doc_param: " token : str\n\n".to_string(),
example_line: " token=\"YOUR_TOKEN\",\n".to_string(),
auth_check: String::new(),
base_url_doc:
" base_url : str\n The base url to use for requests from the client.\n\n"
.to_string(),
env_doc: String::new(),
server_variable_doc: String::new(),
base_url_ctor: " base_url: str,\n".to_string(),
server_variable_init: String::new(),
wrapper_location: "base_url=base_url".to_string(),
example_base_url: " base_url=\"https://yourhost.com/path/to/api\",\n"
.to_string(),
client_param_doc: String::new(),
client_param_ctor: String::new(),
client_param_example: String::new(),
client_example_imports: String::new(),
client_param_wrapper: String::new(),
tr_doc: String::new(),
tr_ctor: String::new(),
tr_wrapper: String::new(),
raw_client_cls: String::new(),
root_methods: Vec::new(),
modules: vec![RootModuleView {
attr: "dispatch_bays".to_string(),
cls: "DispatchBaysClient".to_string(),
import: "DispatchBaysClient".to_string(),
wrap: false,
}],
is_async: false,
async_token_doc: String::new(),
async_token_ctor: String::new(),
async_token_wrapper: String::new(),
default_max_retries: 2,
};
let out = render_tmpl("root_client.py", &view);
assert!(out.starts_with("class FernApi:"));
// The auth slots land in the ctor and the wrapper call.
assert!(out.contains(" token: str,\n headers:"));
assert!(out.contains("token=token,"));
// One lazy slot and one cached property per module.
assert!(
out.contains(" self._dispatch_bays: typing.Optional[DispatchBaysClient] = None")
);
assert!(out.contains(
" @property\n def dispatch_bays(self):\n if self._dispatch_bays is None:"
));
}
#[test]
fn root_sub_client_alias_names_only_the_classes_the_root_defines() {
// `client_class_name: EcosystemClient` defines `EcosystemClient` and
// `AsyncEcosystemClient`, so both of the `ecosystem` sub-client's classes
// are aliased and an unrelated sub-client is not.
assert_eq!(
root_sub_client_import("ecosystem", "EcosystemClient", "EcosystemClient"),
"EcosystemClient as ecosystem_client_EcosystemClient"
);
assert_eq!(
root_sub_client_alias("ecosystem", "AsyncEcosystemClient", "EcosystemClient"),
Some("ecosystem_client_AsyncEcosystemClient".to_string())
);
assert_eq!(
root_sub_client_import("category", "CategoryClient", "EcosystemClient"),
"CategoryClient"
);
// A root named like a sub-client's async class collides with that class
// alone: the root's own async class is `AsyncAsyncFooClient`.
assert_eq!(
root_sub_client_alias("foo", "AsyncFooClient", "AsyncFooClient"),
Some("foo_client_AsyncFooClient".to_string())
);
assert_eq!(
root_sub_client_alias("foo", "FooClient", "AsyncFooClient"),
None
);
}
#[test]
fn client_class_lays_out_skeleton_and_methods() {
let view = minijinja::context! {
class_name => "DispatchBaysClient",
wrapper => "SyncClientWrapper",
raw_client_cls => "RawDispatchBaysClient",
methods => vec![" def add(self):\n ...", " def remove(self):\n ..."],
};
let out = render_tmpl("client_class.py", view);
assert!(out.starts_with(
"class DispatchBaysClient:\n def __init__(self, *, client_wrapper: SyncClientWrapper):"
));
assert!(out.contains(" def with_raw_response(self) -> RawDispatchBaysClient:"));
// Methods are separated by one blank line, in order.
assert!(out.contains(" return self._raw_client\n\n def add(self):"));
assert!(out.contains(" ...\n\n def remove(self):"));
}
#[test]
fn field_decl_is_flat_name_annotation_default() {
let rf = RenderedField {
py_name: "count".to_string(),
annotation: Doc::group("typing.Optional[", vec![Doc::atom("int")], "]"),
default: " = None".to_string(),
docstring: None,
};
assert_eq!(field_decl(&rf), "count: typing.Optional[int] = None");
}
#[test]
fn abbrev_call_keeps_fern_520_placeholders_inline() {
// No complex body → empty parens.
assert_eq!(abbrev_call(4, "client.a.b", false), " client.a.b()");
// Complex + short → inline `...`.
assert_eq!(abbrev_call(4, "client.a.b", true), " client.a.b(...)");
// Fern keeps even unusually long descriptive operation names flat.
let long = format!("client.tag.{}", "m".repeat(120));
assert_eq!(abbrev_call(0, &long, true), format!("{long}(...)"));
// Raw-response assignments follow the same literal placeholder rule.
let root_raw =
"response = client.with_raw_response.get_all_colors_of_the_default_color_name_list";
assert_eq!(
abbrev_call(0, root_raw, true),
"response = client.with_raw_response.get_all_colors_of_the_default_color_name_list(...)"
);
let nested_raw =
"response = client.account_access.with_raw_response.create_account_access_consents";
assert_eq!(
abbrev_call(0, nested_raw, true),
"response = client.account_access.with_raw_response.create_account_access_consents(...)"
);
let fitting_prefix =
"response = client.attachment_public.with_raw_response.create_attachment_public";
assert_eq!(
abbrev_call(0, fitting_prefix, true),
"response = client.attachment_public.with_raw_response.create_attachment_public(...)"
);
}
#[test]
fn natural_comparison_orders_numeric_runs_and_leading_zero_ties() {
use std::cmp::Ordering::{Equal, Greater, Less};
assert_eq!(natural_cmp("item9", "item10"), Less);
assert_eq!(natural_cmp("item10", "item9"), Greater);
assert_eq!(natural_cmp("item1", "item01"), Less);
assert_eq!(natural_cmp("item001", "item01"), Greater);
assert_eq!(natural_cmp("item0", "item00"), Less);
assert_eq!(natural_cmp("item12a", "item12b"), Less);
assert_eq!(natural_cmp("alpha2", "beta1"), Less);
assert_eq!(natural_cmp("item", "item0"), Less);
assert_eq!(natural_cmp("same007tail", "same007tail"), Equal);
}
#[test]
fn example_imports_put_union_wrappers_before_their_generated_children() {
use std::cmp::Ordering::{Greater, Less};
let wrapper = "MetadataSendTelemetryRequestEventsItem_SessionStart";
let child = "MetadataSendTelemetryRequestEventsItemSessionStartData";
assert_eq!(example_import_cmp(wrapper, child), Less);
assert_eq!(example_import_cmp(child, wrapper), Greater);
assert_eq!(
example_import_cmp(
"ScheduledMessagesScheduleAgentMessageRequestMessagesItemRole",
"ScheduledMessagesScheduleAgentMessageRequestMessagesItemContentZeroItem_Text"
),
Greater
);
}
#[test]
fn example_imports_order_names_case_insensitively() {
use std::cmp::Ordering::{Greater, Less};
assert_eq!(
example_import_cmp("RouteGroupsTable", "RouteGroupUser"),
Less
);
assert_eq!(
example_import_cmp("RouteGroupUser", "RouteGroupsTable"),
Greater
);
assert_eq!(example_import_cmp("FernApi", "fernApi"), Less);
}
#[test]
fn bytes_body_endpoint_is_not_readme_eligible() {
let mut bytes = endpoint("/bytes", Vec::new(), None);
bytes.request_body = Some(RequestBody::Bytes {
content_type: "application/octet-stream".to_string(),
});
assert!(!readme_endpoint_eligible(&bytes, &[], &[]));
}
#[test]
fn optional_single_body_conversion_keeps_optional_annotation() {
let mut ep = endpoint("/approval", Vec::new(), None);
ep.request_body = Some(RequestBody::Single(SingleBody {
type_ref: TypeRef::Named("ModifyApprovalRequest".to_string()),
required: false,
convert: true,
content_type: true,
content_type_override: None,
example: None,
}));
let mut lines = Vec::new();
let mut imports = Imports::default();
super::append_request_call_args(&mut lines, &ep, &mut imports);
let rendered = lines.join("\n");
assert!(
rendered.contains("annotation=typing.Optional[ModifyApprovalRequest]"),
"{rendered}"
);
ep.request_body = Some(RequestBody::Inline(vec![BodyField {
wire_name: "search".to_string(),
py_name: "search".to_string(),
type_ref: TypeRef::List(Box::new(TypeRef::Named("SearchItem".to_string()))),
optional: true,
nullable: false,
spec_required: false,
docstring: None,
convert: true,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
}]));
let mut lines = Vec::new();
super::append_request_call_args(&mut lines, &ep, &mut imports);
let rendered = lines.join("\n");
assert!(
rendered.contains("annotation=typing.Sequence[SearchItem]"),
"{rendered}"
);
assert!(!rendered.contains("annotation=typing.Optional[typing.Sequence"));
}
/// An optional body Fern types `Optional[Any]` carries nothing a worked
/// example could show, and the document version does not change that: letta
/// declares the shape in `openapi: 3.1` and braintrust's proxy endpoints
/// declare it in `3.0.3` (corpus row 126), and neither golden passes
/// `request=`.
#[test]
fn optional_unknown_body_is_omitted_from_worked_example() {
let mut ep = endpoint("/projects/{id}", Vec::new(), None);
ep.openapi_31 = true;
ep.request_body = Some(RequestBody::Single(SingleBody {
type_ref: TypeRef::Primitive(Prim::Any),
required: false,
convert: false,
content_type: true,
content_type_override: None,
example: None,
}));
let auth = Auth::None;
let mut ctx = example_ctx(&[], &[], &auth);
let rendered = build_example(&ep, false, "projects", "fern", "FernApi", &mut ctx)
.expect("endpoint has an example")
.join("\n");
assert!(!rendered.contains("request="), "{rendered}");
ep.openapi_31 = false;
let mut ctx = example_ctx(&[], &[], &auth);
let rendered = build_example(&ep, false, "projects", "fern", "FernApi", &mut ctx)
.expect("legacy endpoint has an example")
.join("\n");
assert!(!rendered.contains("request="), "{rendered}");
// A *required* unknown body is still shown, which is what keeps the
// exclusion keyed on optionality rather than on the unknown type.
if let Some(RequestBody::Single(body)) = ep.request_body.as_mut() {
body.required = true;
}
let mut ctx = example_ctx(&[], &[], &auth);
let rendered = build_example(&ep, false, "projects", "fern", "FernApi", &mut ctx)
.expect("required unknown body has an example")
.join("\n");
assert!(
rendered.contains("request={\"key\": \"value\"}"),
"{rendered}"
);
}
fn api_key_auth(header: &str, required: bool) -> Auth {
Auth::ApiKey {
header: header.to_string(),
required,
credential: Credential::plain("api_key"),
prefix: None,
}
}
fn bearer_auth(required: bool) -> Auth {
Auth::Bearer {
required,
credential: Credential::plain("token"),
}
}
fn basic_auth(required: bool) -> Auth {
Auth::Basic {
required,
username: Credential::plain("username"),
password: Credential::plain("password"),
}
}
#[test]
fn auth_fragments_cover_every_scheme() {
let free = std::collections::HashSet::new();
// api-key required: public `api_key`, unconditional header, no token helper.
let w = auth_wrapper_parts(&api_key_auth("X-API-Key", true));
assert_eq!(w.param(&free), " api_key: str,\n");
assert_eq!(w.assign, " self.api_key = api_key\n");
assert_eq!(
w.header_block,
" headers[\"X-API-Key\"] = self.api_key\n"
);
assert!(w.token_method.is_empty());
// The `super().__init__` arg carries a trailing separator so the following
// `headers=` kwarg needs no leading comma (which the no-auth arm would omit).
assert_eq!(w.super_arg(&free), "api_key=api_key, ");
// No auth: every credential fragment is empty, so the wrapper carries no
// token and `super().__init__(headers=...)` has no dangling comma.
let none = auth_wrapper_parts(&Auth::None);
assert!(none.param(&free).is_empty());
assert!(none.assign.is_empty());
assert!(none.header_block.is_empty());
assert!(none.token_method.is_empty());
assert!(none.super_arg(&free).is_empty());
assert!(auth_client_parts(&Auth::None, &free).ctor_param.is_empty());
assert!(auth_example_args(&Auth::None).is_empty());
// api-key optional: nullable param and a guarded header write.
let w = auth_wrapper_parts(&api_key_auth("X-Key", false));
assert_eq!(
w.param(&free),
" api_key: typing.Optional[str] = None,\n"
);
assert_eq!(
w.header_block,
" if self.api_key is not None:\n headers[\"X-Key\"] = self.api_key\n"
);
// bearer required vs optional: the token helper's signature differs.
let req = auth_wrapper_parts(&bearer_auth(true));
assert!(req.token_method.contains("-> str:"));
assert!(req.header_block.contains("f\"Bearer {self._get_token()}\""));
let opt = auth_wrapper_parts(&bearer_auth(false));
assert!(opt.token_method.contains("-> typing.Optional[str]:"));
assert!(opt
.param(&free)
.contains("typing.Optional[typing.Union[str"));
// The root-client fragments and example arg track the same schemes.
let c = auth_client_parts(&api_key_auth("X-API-Key", true), &free);
assert_eq!(c.ctor_param, " api_key: str,\n");
assert_eq!(c.doc_param, " api_key : str\n");
assert_eq!(c.example_line, " api_key=\"YOUR_API_KEY\",\n");
assert!(c.check.is_empty());
// The remaining root-client arms (the docstring drops a ` = None` default).
let c = auth_client_parts(&api_key_auth("X-Key", false), &free);
assert_eq!(
c.ctor_param,
" api_key: typing.Optional[str] = None,\n"
);
assert_eq!(c.doc_param, " api_key : typing.Optional[str]\n");
let c = auth_client_parts(&bearer_auth(true), &free);
assert_eq!(
c.ctor_param,
" token: typing.Union[str, typing.Callable[[], str]],\n"
);
let c = auth_client_parts(&bearer_auth(false), &free);
assert!(c.ctor_param.contains("typing.Optional[typing.Union[str"));
assert!(!c.doc_param.contains(" = None"));
// basic: a required `username`/`password` pair everywhere a single
// credential would otherwise appear.
let w = auth_wrapper_parts(&basic_auth(true));
assert!(w.param(&free).contains("username: typing.Union[str"));
assert!(w.param(&free).contains("password: typing.Union[str"));
assert!(w.assign.contains("self._username = username"));
assert!(w.assign.contains("self._password = password"));
assert!(w.header_block.contains("httpx.BasicAuth("));
assert!(w.token_method.contains("def _get_username(self) -> str:"));
assert!(w.token_method.contains("def _get_password(self) -> str:"));
assert_eq!(w.super_arg(&free), "username=username, password=password, ");
let c = auth_client_parts(&basic_auth(true), &free);
assert_eq!(
c.ctor_param,
" username: typing.Union[str, typing.Callable[[], str]],\n password: typing.Union[str, typing.Callable[[], str]],\n"
);
assert_eq!(
c.doc_param,
" username : typing.Union[str, typing.Callable[[], str]]\n password : typing.Union[str, typing.Callable[[], str]]\n"
);
assert_eq!(
c.wrapper_arg,
" username=username,\n password=password,"
);
assert_eq!(
c.example_line,
" username=\"YOUR_USERNAME\",\n password=\"YOUR_PASSWORD\",\n"
);
let w = auth_wrapper_parts(&basic_auth(false));
assert!(w
.header_block
.contains("if username is not None and password is not None:"));
assert_eq!(
auth_example_args(&bearer_auth(true)),
vec!["token=\"YOUR_TOKEN\""]
);
assert_eq!(
auth_example_args(&api_key_auth("X", false)),
vec!["api_key=\"YOUR_API_KEY\""]
);
assert_eq!(
auth_example_args(&basic_auth(true)),
vec!["username=\"YOUR_USERNAME\"", "password=\"YOUR_PASSWORD\""]
);
}
#[test]
fn named_credentials_carry_their_name_prefix_and_environment() {
let free = std::collections::HashSet::new();
let meter = Auth::ApiKey {
header: "X-Meter-Key".to_string(),
required: true,
credential: Credential::plain("meter_token"),
prefix: Some("Meter".to_string()),
};
let w = auth_wrapper_parts(&meter);
assert_eq!(
w.header_block,
" headers[\"X-Meter-Key\"] = f\"Meter {self.meter_token}\"\n"
);
assert_eq!(
auth_example_args(&meter),
vec!["meter_token=\"YOUR_METER_TOKEN\""]
);
let lift = Auth::Bearer {
required: true,
credential: Credential {
param: "lift_pass".to_string(),
env: Some("LIFT_PASS".to_string()),
},
};
let c = auth_client_parts(&lift, &free);
assert_eq!(
c.ctor_param,
" lift_pass: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None,\n"
);
assert_eq!(
c.doc_param,
" lift_pass : typing.Optional[typing.Union[str, typing.Callable[[], str]]]\n"
);
// The variable is read when the client is built, then a required
// credential is checked.
assert_eq!(
c.check,
concat!(
" lift_pass = lift_pass if lift_pass is not None else os.getenv(\"LIFT_PASS\")\n",
" if lift_pass is None:\n",
" raise ApiError(body=\"The client must be instantiated be either passing in lift_pass or setting LIFT_PASS\")\n",
)
);
// An optional one is read the same way but never checked.
let optional_lift = Auth::Bearer {
required: false,
credential: Credential {
param: "lift_pass".to_string(),
env: Some("LIFT_PASS".to_string()),
},
};
assert_eq!(
auth_client_parts(&optional_lift, &free).check,
" lift_pass = lift_pass if lift_pass is not None else os.getenv(\"LIFT_PASS\")\n"
);
assert!(auth_wrapper_parts(&lift)
.token_method
.contains("def _get_lift_pass(self) -> str:"));
// A credential a promoted header already declares is neither declared,
// passed nor exampled again, but still documented and assigned.
let taken: std::collections::HashSet<&str> = ["api_key"].into_iter().collect();
let shared = api_key_auth("X-Api-Key", true);
let c = auth_client_parts(&shared, &taken);
assert!(c.ctor_param.is_empty() && c.wrapper_arg.is_empty() && c.example_line.is_empty());
assert_eq!(c.doc_param, " api_key : str\n");
let w = auth_wrapper_parts(&shared);
assert!(w.param(&taken).is_empty() && w.super_arg(&taken).is_empty());
assert_eq!(w.assign, " self.api_key = api_key\n");
}
#[test]
fn an_offset_pager_defaults_and_steps_its_offset() {
let mut ep = endpoint(
"/entries",
Vec::new(),
Some(TypeRef::Named("EntryPage".to_string())),
);
ep.query_params = vec![QueryParam {
wire_name: "page".to_string(),
py_name: "page".to_string(),
type_ref: TypeRef::Primitive(Prim::Int),
required: false,
nullable: false,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: None,
example_is_scalar: false,
aliased_datetime: None,
docstring: None,
default: None,
}];
ep.pagination = Some(crate::ir::EndpointPagination {
results: "entries".to_string(),
advance: crate::ir::PageAdvance::Offset,
cursor_param: "page".to_string(),
item_type: TypeRef::Named("Entry".to_string()),
});
let out = raw_method(&ep, false, &mut Imports::default());
assert!(
out.contains("page = page if page is not None else 1"),
"{out}"
);
assert!(out.contains("_has_next = len(_items or []) > 0"), "{out}");
assert!(out.contains("page=page + 1,"), "{out}");
assert!(
!out.contains("_parsed_next") && !out.contains("_get_next = None"),
"{out}"
);
}
#[test]
fn endpoint_extensions_add_an_idempotency_argument_and_disable_retries() {
let mut ep = endpoint("/parcels", Vec::new(), None);
ep.http_method = "POST";
ep.extensions = crate::ir::EndpointExtensions {
idempotency_headers: vec![HeaderParam {
wire_name: "X-Dedupe-Token".to_string(),
py_name: "dedupe_token".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: false,
docstring: None,
example: None,
enum_value: false,
}],
retries_disabled: true,
environment_field: None,
};
let out = raw_method(&ep, false, &mut Imports::default());
assert!(
out.contains("dedupe_token: typing.Optional[str] = None"),
"{out}"
);
assert!(
out.contains(
"\"X-Dedupe-Token\": str(dedupe_token) if dedupe_token is not None else None,"
),
"{out}"
);
assert!(out.contains(
"_request_options_with_retries_disabled: typing.Optional[RequestOptions] = ("
));
assert!(out.contains("request_options=_request_options_with_retries_disabled,"));
let plain = raw_method(
&endpoint("/parcels", Vec::new(), None),
false,
&mut Imports::default(),
);
assert!(!plain.contains("retries_disabled") && !plain.contains("dedupe_token"));
}
fn endpoint(path: &str, params: Vec<PathParam>, response: Option<TypeRef>) -> Endpoint {
Endpoint {
openapi_31: false,
module: "m".to_string(),
method_name: "op".to_string(),
stream_condition: None,
reference_method_name: None,
pagination: None,
http_method: "GET",
path: path.to_string(),
path_params: params,
client_path_params: Vec::new(),
query_params: Vec::new(),
header_params: Vec::new(),
extensions: crate::ir::EndpointExtensions::default(),
constant_headers: Vec::new(),
header_order: Vec::new(),
request_body: None,
request_body_required: false,
request_body_has_multipart_related: false,
reference_body_type: None,
request_body_doc: None,
request_body_schema_doc: None,
body_codegen_named: false,
body_description_missing: false,
body_declared_required: false,
body_component_ref: false,
body_media_alternatives: false,
body_collapses_to_type_reference: false,
body_content_type_override: None,
body_json_media_type: None,
body_schema_shape: BodySchemaShape::Other,
body_schema_dropped: false,
body_schema_shared: false,
body_schema_metadata_missing: false,
body_schema_has_example: false,
body_schema_example_wrapped: false,
body_schema_is_beta: false,
body_schema_example_covers_all_fields: false,
body_media_has_example: false,
reference_body_example: None,
body_schema_documented: false,
body_schema_titled: false,
body_schema_is_response_heavy: false,
body_schema_is_open: false,
body_schema_implicit_object: false,
body_composition: crate::ir::BodyComposition::Plain,
body_response_same_ref: false,
body_schema_is_success_response: false,
response,
response_may_be_empty: false,
response_names_unknown: false,
response_doc: Some(String::new()),
errors: Vec::new(),
docstring: None,
reference_description_suffix: String::new(),
streaming: false,
stream_chunk: None,
stream_protocol: None,
text_response: false,
binary_response: false,
importer_example_missing: false,
binary_schema_response: false,
wildcard_binary_response: false,
emittable: true,
}
}
fn ir_with(endpoints: Vec<Endpoint>) -> Ir {
let endpoint_modules = endpoints
.iter()
.filter(|endpoint| !endpoint.module.is_empty())
.map(|endpoint| endpoint.module.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
Ir {
yaml_unquoted_timestamps: None,
openapi_31: false,
package_name: "fern".to_string(),
project_name: "default_package_name".to_string(),
client_name: "FernApi".to_string(),
types: Vec::new(),
tag_types: Vec::new(),
endpoint_modules,
empty_endpoint_namespace: false,
empty_namespace_types: Vec::new(),
endpoint_module_titles: Default::default(),
endpoints,
errors: Vec::new(),
auth: Auth::None,
global_headers: Vec::new(),
client_path_parameters: Vec::new(),
environment: None,
extra_fields: crate::settings::ExtraFields::Allow,
enum_type: crate::settings::EnumType::PythonEnums,
default_max_retries: crate::settings::DEFAULT_MAX_RETRIES,
layout: crate::settings::Layout::Packaged,
}
}
#[test]
fn readme_endpoint_does_not_promote_a_non_post_body_over_the_first_endpoint() {
// Corpus row 123 declares no POST at all, and Fern anchors its README on
// the document's first GET rather than on the later PUT that carries a
// flattened JSON body.
let mut first_get = endpoint("/organisations", Vec::new(), None);
first_get.method_name = "get_all_organisations".to_string();
first_get.module = "organisations".to_string();
let mut put_with_body = endpoint("/softwarestatements", Vec::new(), None);
put_with_body.http_method = "PUT";
put_with_body.method_name = "update_a_software_statement_by_id".to_string();
put_with_body.module = "software_statements".to_string();
put_with_body.request_body = Some(RequestBody::Single(SingleBody {
type_ref: TypeRef::Primitive(Prim::Str),
required: true,
convert: false,
content_type: true,
content_type_override: None,
example: None,
}));
let ir = ir_with(vec![first_get, put_with_body]);
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("get_all_organisations")
);
}
#[test]
fn readme_endpoint_prefers_the_first_non_binary_post() {
let mut first_get = endpoint(
"/one/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Primitive(Prim::Int),
docstring: None,
example: None,
pattern_constrained: false,
convert: false,
}],
None,
);
first_get.method_name = "first_get".to_string();
let mut no_arg_post = endpoint("/two", Vec::new(), None);
no_arg_post.http_method = "POST";
no_arg_post.method_name = "no_arg_post".to_string();
let ir = ir_with(vec![first_get, no_arg_post]);
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("no_arg_post")
);
let mut root_get = endpoint("/status", Vec::new(), None);
root_get.method_name = "status".to_string();
let mut tagged_post = endpoint("/queues", Vec::new(), None);
tagged_post.http_method = "POST";
tagged_post.method_name = "create_queue".to_string();
tagged_post.module = "queues".to_string();
let ir = ir_with(vec![root_get, tagged_post]);
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("create_queue")
);
let mut root_post = endpoint("/token", Vec::new(), None);
root_post.http_method = "POST";
root_post.method_name = "get_token".to_string();
let mut tagged_post = endpoint("/queues", Vec::new(), None);
tagged_post.http_method = "POST";
tagged_post.method_name = "create_queue".to_string();
tagged_post.module = "queues".to_string();
let ir = ir_with(vec![root_post, tagged_post]);
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("get_token")
);
let mut no_arg_post = endpoint("/two", Vec::new(), None);
no_arg_post.http_method = "POST";
no_arg_post.method_name = "no_arg_post".to_string();
let mut body = endpoint("/three", Vec::new(), None);
body.method_name = "body".to_string();
body.request_body = Some(RequestBody::Single(SingleBody {
type_ref: TypeRef::Primitive(Prim::Str),
required: true,
convert: false,
content_type: true,
content_type_override: None,
example: None,
}));
let ir = ir_with(vec![no_arg_post, body]);
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("no_arg_post")
);
let mut first_module_get = endpoint("/a", Vec::new(), None);
first_module_get.module = "first".to_string();
first_module_get.method_name = "first_get".to_string();
let mut second_module_post = endpoint("/b", Vec::new(), None);
second_module_post.module = "second".to_string();
second_module_post.http_method = "POST";
second_module_post.method_name = "second_post".to_string();
let mut first_module_post = endpoint("/c", Vec::new(), None);
first_module_post.module = "first".to_string();
first_module_post.http_method = "POST";
first_module_post.method_name = "first_post".to_string();
let mut ir = ir_with(vec![
first_module_get,
second_module_post,
first_module_post,
]);
ir.endpoint_modules = vec!["first".to_string(), "second".to_string()];
assert_eq!(
readme_endpoint(&ir).map(|e| e.method_name.as_str()),
Some("first_post")
);
}
#[test]
fn escape_py_str_escapes_quotes_backslashes_and_newlines() {
// Ordinary spec values are untouched (byte-exact output preserved).
assert_eq!(
escape_py_str("https://api.example.com"),
"https://api.example.com"
);
assert_eq!(escape_py_str("X-Tenant"), "X-Tenant");
// A quote/backslash/newline that would break out of the literal is escaped.
assert_eq!(escape_py_str("a\"b\\c\nd\re"), "a\\\"b\\\\c\\nd\\re");
}
#[test]
fn reference_normalizes_annotations_without_wrapping_long_names() {
assert_eq!(
super::reference_list_annotation("typing.Optional[typing.Sequence[str]]"),
"typing.Optional[typing.List[str]]"
);
let long =
"typing.Optional[typing.Sequence[PostMyNegotiationsIdCounterRequestOfferItemsItem]]";
assert_eq!(reference_param_annotation(long), long);
assert_eq!(
reference_param_annotation("typing.Optional[typing.Sequence[str]]"),
"typing.Optional[typing.Sequence[str]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[ResourceToSkip, typing.Sequence[ResourceToSkip]]]"
),
"typing.Optional[typing.Union[ResourceToSkip, typing.Sequence[ResourceToSkip]]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[ResourceToImport, typing.Sequence[ResourceToImport]]]"
),
"typing.Optional[typing.Union[ResourceToImport, typing.Sequence[ResourceToImport]]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[TypeConfigurationIdentifier, typing.Sequence[TypeConfigurationIdentifier]]]"
),
"typing.Optional[typing.Union[TypeConfigurationIdentifier, typing.Sequence[TypeConfigurationIdentifier]]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[StackResourceDriftStatus, typing.Sequence[StackResourceDriftStatus]]]"
),
"typing.Optional[typing.Union[StackResourceDriftStatus, typing.Sequence[StackResourceDriftStatus]]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Sequence[PostMyNegotiationsIdCounterRequestOfferItemsItemWithEnoughAdditionalContextToExceedTheReferenceWriterWidth]]"
),
"typing.Optional[typing.Sequence[PostMyNegotiationsIdCounterRequestOfferItemsItemWithEnoughAdditionalContextToExceedTheReferenceWriterWidth]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[TypeConfigurationIdentifierWithMoreContext, typing.Sequence[TypeConfigurationIdentifierWithMoreContext]]]"
),
"typing.Optional[typing.Union[TypeConfigurationIdentifierWithMoreContext, typing.Sequence[TypeConfigurationIdentifierWithMoreContext]]]"
);
assert_eq!(
reference_param_annotation(
"typing.Optional[typing.Union[TypeConfigurationIdentifierWithEnoughAdditionalContext, typing.Sequence[TypeConfigurationIdentifierWithEnoughAdditionalContext]]]"
),
"typing.Optional[typing.Union[TypeConfigurationIdentifierWithEnoughAdditionalContext, typing.Sequence[TypeConfigurationIdentifierWithEnoughAdditionalContext]]]"
);
assert_eq!(
reference_param_annotation("typing.Optional[typing.Optional[dt.date]]"),
"typing.Optional[datetime.date]"
);
}
#[test]
fn reference_return_type_spells_datetime_in_full() {
let endpoint = endpoint("/w", Vec::new(), None);
// The `<summary>` line is prose, so it uses Fern's full spelling rather
// than the `dt.` alias the generated Python imports the types under.
assert_eq!(
super::reference_return_type(&endpoint, "dt.datetime", "bytes"),
" -> datetime.datetime"
);
assert_eq!(
super::reference_return_type(&endpoint, "typing.Optional[dt.date]", "bytes"),
" -> typing.Optional[datetime.date]"
);
// A type that merely contains `dt` as part of a name is untouched, and a
// `None` response still renders no suffix at all.
assert_eq!(
super::reference_return_type(&endpoint, "UpdtStatus", "bytes"),
" -> UpdtStatus"
);
assert_eq!(super::reference_return_type(&endpoint, "None", "bytes"), "");
}
#[test]
fn raw_type_str_renders_every_shape() {
let mut i = Imports::at(
RefLoc::Client("m".to_string()),
&std::collections::BTreeMap::new(),
);
let str_of = |t: &TypeRef, i: &mut Imports| raw_type_str(t, i);
assert_eq!(str_of(&TypeRef::Primitive(Prim::Str), &mut i), "str");
assert_eq!(str_of(&TypeRef::Primitive(Prim::Int), &mut i), "int");
assert_eq!(str_of(&TypeRef::Primitive(Prim::Float), &mut i), "float");
assert_eq!(str_of(&TypeRef::Primitive(Prim::Bool), &mut i), "bool");
assert_eq!(str_of(&TypeRef::Primitive(Prim::Any), &mut i), "typing.Any");
assert_eq!(
str_of(&TypeRef::Primitive(Prim::Datetime), &mut i),
"dt.datetime"
);
assert_eq!(str_of(&TypeRef::Primitive(Prim::Date), &mut i), "dt.date");
assert_eq!(
str_of(&TypeRef::Named("FooBar".to_string()), &mut i),
"FooBar"
);
assert_eq!(
str_of(
&TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Str))),
&mut i
),
"typing.Optional[str]"
);
assert_eq!(
str_of(
&TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))),
&mut i
),
"typing.List[int]"
);
assert_eq!(
str_of(
&TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Str))),
&mut i
),
"typing.Set[str]"
);
assert_eq!(
str_of(
&TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Named("Owner".to_string()))
),
&mut i
),
"typing.Dict[str, Owner]"
);
assert_eq!(
str_of(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Str),
TypeRef::Primitive(Prim::Int)
]),
&mut i
),
"typing.Union[str, int]"
);
assert_eq!(
str_of(
&TypeRef::Literal(vec!["a".to_string(), "b".to_string()]),
&mut i
),
"typing.Literal[\"a\", \"b\"]"
);
let rendered = i.render();
assert!(rendered.contains("from ..types.foo_bar import FooBar"));
assert!(rendered.contains("import datetime as dt"));
}
#[test]
fn url_arg_is_plain_or_interpolated() {
let mut imports = Imports::default();
let plain = endpoint(
"/urls/MixedCase",
vec![],
Some(TypeRef::Primitive(Prim::Str)),
);
assert_eq!(url_arg(&plain, &mut imports), "\"urls/MixedCase\"");
let interp = endpoint(
"/things/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
docstring: None,
example: None,
pattern_constrained: false,
convert: false,
}],
Some(TypeRef::Primitive(Prim::Str)),
);
assert_eq!(
url_arg(&interp, &mut imports),
"f\"things/{encode_path_param(id)}\""
);
// An object-typed path parameter is wrapped, and the wrapper's import is
// registered by the same call — the shape
// `docs/openapi-surface/probes/parameter-style-simple-path-object.yml`
// measures.
let converted = endpoint(
"/things/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Named("Thing".to_string()),
docstring: None,
example: None,
pattern_constrained: false,
convert: true,
}],
Some(TypeRef::Primitive(Prim::Str)),
);
assert_eq!(
url_arg(&converted, &mut imports),
"f\"things/{encode_path_param(convert_and_respect_annotation_metadata(object_=id, annotation=Thing, direction='write'))}\""
);
assert!(
imports
.render()
.contains("import convert_and_respect_annotation_metadata"),
"the wrapper's import is registered by the same call; imports render as:\n{}",
imports.render()
);
}
#[test]
fn raw_method_inlines_object_body_fields() {
let mut i = Imports::default();
let mut ep = endpoint(
"/req/inline",
vec![],
Some(TypeRef::Named("Resp".to_string())),
);
ep.method_name = "inline_body".to_string();
ep.http_method = "POST";
ep.request_body = Some(RequestBody::Inline(vec![
// A required scalar → bare arg, plain json entry.
BodyField {
wire_name: "string".to_string(),
py_name: "string".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: false,
spec_required: true,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
nullable: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
// An optional list → `Optional[Sequence[..]] = OMIT` in request context.
BodyField {
wire_name: "list".to_string(),
py_name: "list_".to_string(),
type_ref: TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
optional: true,
spec_required: false,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
nullable: false,
reference_order: 1,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
// An optional convert field keeps `Optional` in both its signature and
// serialization annotation.
BodyField {
wire_name: "NestedObject".to_string(),
py_name: "nested_object".to_string(),
type_ref: TypeRef::Named("Nested".to_string()),
optional: true,
spec_required: false,
docstring: None,
convert: true,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
nullable: false,
reference_order: 2,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
]));
let out = raw_method(&ep, false, &mut i);
// Hoisted keyword-only args, with the reserved-name munge and request-context
// `Sequence`, plus the OMIT default on the optional field.
assert!(out.contains("string: str"), "{out}");
assert!(
out.contains("list_: typing.Optional[typing.Sequence[str]] = OMIT"),
"{out}"
);
assert!(
out.contains("nested_object: typing.Optional[Nested] = OMIT"),
"{out}"
);
// The json dict maps wire names to args, convert-wrapping the object field.
assert!(out.contains("json={"), "{out}");
assert!(out.contains("\"string\": string,"), "{out}");
assert!(out.contains("\"list\": list_,"), "{out}");
assert!(
out.contains("\"NestedObject\": convert_and_respect_annotation_metadata("),
"{out}"
);
assert!(out.contains("annotation=Nested"), "{out}");
// An inlined object body always carries the content-type header.
assert!(
out.contains("\"content-type\": \"application/json\","),
"{out}"
);
}
#[test]
fn raw_method_sends_constant_headers_without_public_parameters() {
let mut ep = endpoint(
"/embeddings/size",
vec![],
Some(TypeRef::Primitive(Prim::Float)),
);
ep.constant_headers = vec![("storage-unit".to_string(), "GB".to_string())];
let out = raw_method(&ep, false, &mut Imports::default());
assert!(!out.contains("storage_unit:"), "{out}");
assert!(out.contains("\"storage-unit\": \"GB\","), "{out}");
let ir = ir_with(vec![]);
let reference = reference_entry(
&environment(),
&ir,
&ep,
&ep.module,
&ir.package_name,
&std::collections::BTreeMap::new(),
)
.expect("reference entry renders");
// The reference documents no argument the method does not take.
assert!(!reference.contains("storage_unit"), "{reference}");
ep.request_body = Some(RequestBody::Inline(vec![BodyField {
wire_name: "1st".to_string(),
py_name: "f_1st".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: false,
spec_required: true,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
nullable: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
}]));
let reference = reference_entry(
&environment(),
&ir,
&ep,
&ep.module,
&ir.package_name,
&std::collections::BTreeMap::new(),
)
.expect("reference entry renders");
assert!(reference.contains("**1st:** `str` "), "{reference}");
}
#[test]
fn wrapped_schema_example_keeps_content_type_for_optional_fields() {
let mut ep = endpoint("/widgets", vec![], Some(TypeRef::Primitive(Prim::Str)));
ep.http_method = "POST";
ep.request_body = Some(RequestBody::Inline(vec![
BodyField {
wire_name: "name".to_string(),
py_name: "name".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: true,
spec_required: false,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: true,
nullable: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
BodyField {
wire_name: "note".to_string(),
py_name: "note".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: true,
spec_required: false,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: true,
nullable: false,
reference_order: 1,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
]));
ep.body_schema_has_example = true;
ep.body_schema_example_wrapped = true;
ep.body_schema_example_covers_all_fields = true;
ep.body_schema_documented = true;
let documented = raw_method(&ep, false, &mut Imports::default());
assert!(
documented.contains("\"content-type\": \"application/json\","),
"{documented}"
);
ep.body_schema_documented = false;
let undocumented = raw_method(&ep, false, &mut Imports::default());
assert!(
undocumented.contains("\"content-type\": \"application/json\","),
"{undocumented}"
);
if let Some(RequestBody::Inline(fields)) = &mut ep.request_body {
fields[0].convert = true;
}
let nested_undocumented = raw_method(&ep, false, &mut Imports::default());
assert!(
!nested_undocumented.contains("\"content-type\": \"application/json\","),
"{nested_undocumented}"
);
}
#[test]
fn raw_type_str_request_context_uses_sequence() {
use super::raw_type_str_ctx;
let mut i = Imports::default();
// In request context, both list and set collapse to `typing.Sequence`.
assert_eq!(
raw_type_str_ctx(
&TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
&mut i,
true
),
"typing.Sequence[str]"
);
assert_eq!(
raw_type_str_ctx(
&TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Int))),
&mut i,
true
),
"typing.Sequence[int]"
);
// Optionals, dicts, and unions recurse in the same context.
assert_eq!(
raw_type_str_ctx(
&TypeRef::Optional(Box::new(TypeRef::Set(Box::new(TypeRef::Primitive(
Prim::Str
))))),
&mut i,
true
),
"typing.Optional[typing.Sequence[str]]"
);
assert_eq!(
raw_type_str_ctx(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Str),
TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))),
]),
&mut i,
true
),
"typing.Union[str, typing.Sequence[int]]"
);
// Without request context, list and set keep their concrete forms.
assert_eq!(
raw_type_str_ctx(
&TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Str))),
&mut i,
false
),
"typing.Set[str]"
);
}
#[test]
fn raw_method_streams_a_bytes_body_and_raises_errors() {
let mut i = Imports::default();
let mut ep = endpoint(
"/upload/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
docstring: None,
example: None,
pattern_constrained: false,
convert: false,
}],
Some(TypeRef::Named("Resp".to_string())),
);
ep.method_name = "upload".to_string();
ep.http_method = "POST";
ep.request_body = Some(RequestBody::Bytes {
content_type: "application/octet-stream".to_string(),
});
ep.errors = vec![
ErrorResponse {
status: crate::ir::ErrorStatus::Exact(400),
class_name: "BadRequestError".to_string(),
body_type: TypeRef::Named("BadInfo".to_string()),
},
ErrorResponse {
status: crate::ir::ErrorStatus::ServerRange,
class_name: "ServerError".to_string(),
body_type: TypeRef::Named("BadInfo".to_string()),
},
];
let out = raw_method(&ep, false, &mut i);
// A bytes body streams via `content=` under the octet-stream content type.
assert!(
out.contains(
"request: typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]"
),
"{out}"
);
assert!(out.contains("content=request,"), "{out}");
assert!(
out.contains("\"content-type\": \"application/octet-stream\","),
"{out}"
);
// The declared 400 raises the generated exception, parsing its body.
assert!(out.contains("if _response.status_code == 400:"), "{out}");
assert!(out.contains("raise BadRequestError("), "{out}");
assert!(i
.render()
.contains("from ..errors.bad_request_error import BadRequestError"));
// A `5XX` range raises for any status in it, handing the exception the
// actual status; the concrete status alone gets no `status_code`.
assert!(
out.contains(
" if 500 <= _response.status_code < 600:\n raise ServerError("
),
"{out}"
);
assert_eq!(
out.matches("\n status_code=_response.status_code,\n")
.count(),
1,
"{out}"
);
}
#[test]
fn raw_method_renders_array_query_params_as_allow_multiple() {
use crate::ir::QueryParam;
let mut i = Imports::default();
let mut ep = endpoint("/q", vec![], None);
ep.query_params = vec![QueryParam {
wire_name: "tag".to_string(),
py_name: "tag".to_string(),
type_ref: TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
required: true,
nullable: false,
default: None,
convert: false,
comma_separated: true,
allow_multiple: true,
one_or_many: false,
example: None,
example_is_scalar: false,
aliased_datetime: None,
docstring: None,
}];
let out = raw_method(&ep, false, &mut i);
// An array query param allows multiple values and is always optional.
assert!(
out.contains("tag: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None"),
"{out}"
);
assert!(
out.contains(
"\"tag\": \",\".join(map(str, tag)) if isinstance(tag, (list, tuple, set)) else tag,"
),
"{out}"
);
ep.query_params[0].required = false;
ep.query_params[0].comma_separated = false;
ep.query_params[0].allow_multiple = false;
let out = raw_method(&ep, false, &mut Imports::default());
assert!(
out.contains("tag: typing.Optional[typing.Sequence[str]] = None"),
"{out}"
);
assert!(
!out.contains("typing.Union[str, typing.Sequence[str]]"),
"{out}"
);
}
#[test]
fn raw_method_serializes_datetime_query_params() {
use crate::ir::QueryParam;
let mut i = Imports::default();
let mut ep = endpoint("/q", vec![], None);
ep.query_params = vec![QueryParam {
wire_name: "start".to_string(),
py_name: "start".to_string(),
type_ref: TypeRef::Primitive(Prim::Datetime),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: None,
example_is_scalar: false,
aliased_datetime: None,
docstring: None,
}];
let out = raw_method(&ep, false, &mut i);
assert!(
out.contains("\"start\": serialize_datetime(start) if start is not None else None,"),
"{out}"
);
assert!(i
.render()
.contains("from ..core.datetime_utils import serialize_datetime"));
}
#[test]
fn raw_method_handles_a_none_response_and_a_summary() {
let mut i = Imports::default();
let mut ep = endpoint("/x", vec![], None);
ep.docstring = Some("Does a thing.".to_string());
let out = raw_method(&ep, false, &mut i);
// Summary line, a `None` return with no parse, and no dangling parser import.
assert!(out.contains("Does a thing."), "{out}");
assert!(out.contains("-> HttpResponse[None]:"), "{out}");
assert!(
out.contains("return HttpResponse(response=_response, data=None)"),
"{out}"
);
assert!(!out.contains("parse_obj_as"), "{out}");
}
fn example_ctx<'a>(
types: &'a [TypeDecl],
tag_decls: &'a [TagTypeDecl],
auth: &'a Auth,
) -> ExampleCtx<'a> {
ExampleCtx {
types,
enum_type: crate::settings::EnumType::default(),
yaml_unquoted_timestamps: None,
tag_decls,
referenced: Default::default(),
referenced_doc_order: Default::default(),
referenced_tag: Default::default(),
referenced_tag_doc_order: Default::default(),
uses_datetime: false,
datetime_precedes_tag_import: false,
auth,
has_environment: false,
global_headers: &[],
client_path_parameters: &[],
building: Default::default(),
expanding_aliases: Vec::new(),
documentation: false,
reference: false,
field_examples_ignored: false,
untyped_arguments: std::collections::BTreeSet::new(),
sdk_first_party: true,
}
}
fn model_field(name: &str, type_ref: TypeRef, required: bool) -> Field {
Field {
wire_name: name.to_string(),
py_name: name.to_string(),
type_ref,
optional: !required,
nullable: false,
spec_required: required,
docstring: None,
example: None,
declared_name: None,
deprecated: false,
admits_only_empty_object: false,
xml: None,
inherited_from_flattened_parent: false,
min_length: None,
max_length: None,
}
}
fn object_of(name: &str, fields: Vec<(&str, TypeRef)>) -> TypeDecl {
TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: name.to_string(),
module: crate::naming::module_name(name),
bases: Vec::new(),
fields: fields
.into_iter()
.map(|(field, type_ref)| model_field(field, type_ref, false))
.collect(),
example_fields: Default::default(),
docstring: None,
xml: None,
})
}
fn named(name: &str) -> TypeRef {
TypeRef::Named(name.to_string())
}
fn sorted_names(repair: &ForwardRepair) -> Vec<&str> {
let mut names: Vec<&str> = repair.names.iter().map(String::as_str).collect();
names.sort_unstable();
names
}
#[test]
fn a_repair_names_only_the_cycles_its_direct_references_close() {
// `Captain` and `Vessel` close one cycle, which points into the
// self-recursive `Harbor`; `Roster` sits outside both.
let types = vec![
object_of("Roster", vec![("captain", named("Captain"))]),
object_of(
"Captain",
vec![("vessels", TypeRef::List(Box::new(named("Vessel"))))],
),
object_of(
"Vessel",
vec![("skipper", named("Captain")), ("homeport", named("Harbor"))],
),
object_of("Harbor", vec![("district", named("Harbor"))]),
];
let map = forward_repair_map(&types, &[]);
assert_eq!(sorted_names(&map["Roster"]), ["Captain", "Vessel"]);
assert_eq!(sorted_names(&map["Captain"]), ["Vessel"]);
assert_eq!(sorted_names(&map["Vessel"]), ["Captain", "Harbor"]);
assert_eq!(sorted_names(&map["Harbor"]), Vec::<&str>::new());
let components = cycle_components(&std::collections::HashMap::from([
("A".to_string(), vec!["B".to_string()]),
("B".to_string(), vec!["A".to_string(), "C".to_string()]),
(
"C".to_string(),
vec!["C".to_string(), "Elsewhere".to_string()],
),
]));
assert_eq!(components["A"], components["B"]);
assert_ne!(components["A"], components["C"]);
assert!(!components.contains_key("Elsewhere"));
}
#[test]
fn deferred_imports_follow_the_fields_cycle_by_cycle() {
// Fields reaching two one-member cycles and then a two-member one, in an
// order no sort reproduces.
let types = vec![
object_of(
"Timetable",
vec![
("wharf", named("Wharf")),
("buoys", TypeRef::List(Box::new(named("Buoy")))),
("route", named("Route")),
],
),
object_of("Wharf", vec![("annex", named("Wharf"))]),
object_of(
"Buoy",
vec![("tethered", TypeRef::List(Box::new(named("Buoy"))))],
),
object_of(
"Route",
vec![("stops", TypeRef::List(Box::new(named("Stop"))))],
),
object_of("Stop", vec![("route", named("Route"))]),
];
let map = forward_repair_map(&types, &[]);
assert_eq!(
map["Timetable"].deferred_order,
["Wharf", "Buoy", "Route", "Stop"]
);
// A field reaching a cycle without naming it registers the cycle's
// non-union members first; the union arrives with the field that names
// its cycle, so `Knot` precedes `Alpha`.
let types = vec![
object_of(
"Holder",
vec![("lead", named("Lead")), ("knot", named("Knot"))],
),
object_of("Lead", vec![("knot", named("Knot"))]),
object_of("Knot", vec![("twist", named("Alpha"))]),
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "Alpha".to_string(),
module: "alpha".to_string(),
target: TypeRef::Union(vec![named("Knot"), TypeRef::Primitive(Prim::Str)]),
docstring: None,
min_length: None,
max_length: None,
}),
];
let map = forward_repair_map(&types, &[]);
assert_eq!(map["Holder"].deferred_order, ["Knot", "Alpha", "Lead"]);
assert_eq!(sorted_names(&map["Holder"]), ["Alpha", "Knot"]);
}
#[test]
fn examples_import_tag_scoped_discriminated_union_members_from_the_tag() {
let tag_decls = vec![TagTypeDecl {
module: "agents".to_string(),
decl: TypeDecl::DiscriminatedUnion(DiscriminatedUnion {
base_fields: Vec::new(),
discriminant_name: None,
name: "ModifyMessageRequestBody".to_string(),
module: "modify_message_request_body".to_string(),
discriminant_property: "message_type".to_string(),
members: vec![UnionMember {
wrapped: false,
class_name: "ModifyMessageRequestBody_SystemMessage".to_string(),
discriminant: "system_message".to_string(),
discriminant_index: None,
fields: Vec::new(),
source: None,
docstring: None,
}],
variant_targets: Vec::new(),
docstring: None,
}),
}];
let auth = Auth::None;
let mut ctx = example_ctx(&[], &tag_decls, &auth);
ctx.record_ref("ModifyMessageRequestBody_SystemMessage");
assert_eq!(
ctx.referenced_tag_doc_order,
[(
"agents".to_string(),
"ModifyMessageRequestBody_SystemMessage".to_string()
)]
);
assert!(ctx.referenced.is_empty());
}
#[test]
fn datetime_examples_name_their_instant_in_utc() {
use super::datetime_example as written;
// Values Fern's reading accepts: `Z`, a positive offset (with or without
// its colon), no zone at all, a date alone, no seconds, a fraction, and
// a day past the month's end.
assert_eq!(written("2023-03-04T05:06:07Z"), "2023-03-04T05:06:07+00:00");
assert_eq!(
written("2025-12-02T00:00:00+13:00"),
"2025-12-01T11:00:00+00:00"
);
assert_eq!(
written("2023-03-04T05:06:07+0200"),
"2023-03-04T03:06:07+00:00"
);
assert_eq!(
written("2023-03-04 05:06:07+02:00"),
"2023-03-04T03:06:07+00:00"
);
assert_eq!(written("2023-03-04 05:06:07"), "2023-03-04T05:06:07+00:00");
assert_eq!(written("2023-03-04T05:06:07"), "2023-03-04T05:06:07+00:00");
assert_eq!(written("2023-03-04t05:06:07Z"), "2023-03-04T05:06:07+00:00");
assert_eq!(written("2023-03-04"), "2023-03-04T00:00:00+00:00");
assert_eq!(written("2023-03-04T05:06Z"), "2023-03-04T05:06:00+00:00");
assert_eq!(
written("2023-03-04T05:06:07.250Z"),
"2023-03-04T05:06:07+00:00"
);
assert_eq!(
written("2023-03-04T05:06:07.5+01:00"),
"2023-03-04T04:06:07+00:00"
);
assert_eq!(written("2023-02-30T05:06:07Z"), "2023-03-02T05:06:07+00:00");
assert_eq!(
written("2024-12-31T23:30:00+00:00"),
"2024-12-31T23:30:00+00:00"
);
// Values Fern's reading rejects take its replacement: a zone name, a
// missing separator, an hour, month, day or second out of range, a
// basic-format value, leading space, and every negative offset or
// lower-case `z`, which the appended `Z` breaks.
for invalid in [
"2023-03-04T05:06:07-05:00",
"2024-02-28T23:30:00.5-01:00",
"2023-03-04T05:06:07-00:00",
"2023-03-04t05:06:07z",
"2023-12-31 22:00:00-03:00",
"2022-08-11 14:00:00 CDT",
"20230304T050607Z",
"2023-03-04X05:06:07Z",
"2023-03-04T25:06:07Z",
"2023-13-04T05:06:07Z",
"2023-03-32T05:06:07Z",
"2023-03-04T05:06:60Z",
" 2023-03-04T05:06:07Z",
"2023-03-04T05:06-05:00",
"2023-02-30T05:06:07-05:00",
"2023-03-04T05:06:07.-05:00",
"2023-03-04T05:06:07+25:00",
"2023-03-04T05:06+a\u{e9}1",
"2023-03-04T05:06:07+0\u{e9}",
"not a date",
"2023/03/04T05:06:07Z",
"2023-03-04T05x06:07Z",
"",
] {
assert_eq!(written(invalid), super::FERN_DEFAULT_DATETIME, "{invalid}");
}
}
#[test]
fn example_layout_handles_nested_calls_explicit_lists_and_dicts() {
let call = Example::Call(
"Widget".to_string(),
vec![
(
Some("name".to_string()),
Example::Atom("\"sample\"".to_string()),
),
(None, Example::Atom("1".to_string())),
],
);
assert_eq!(call.flat(), "Widget(name=\"sample\", 1)");
assert_eq!(
call.render(4),
"Widget(\n name=\"sample\",\n 1,\n )"
);
let nested = Example::List(vec![call]);
assert_eq!(
nested.render(0),
"[\n Widget(\n name=\"sample\",\n 1,\n )\n]"
);
let long = Example::ExplicitList(vec![Example::Atom(format!("\"{}\"", "x".repeat(90)))]);
assert!(long.render(0).starts_with("[\n \"xxx"));
// One item, so no trailing comma; two of them carry one.
assert!(long.render(0).ends_with("\"\n]"));
let long_pair = Example::ExplicitList(vec![
Example::Atom(format!("\"{}\"", "x".repeat(90))),
Example::Atom("\"y\"".to_string()),
]);
assert!(long_pair.render(0).ends_with("\"y\",\n]"));
let long_inferred = Example::List(vec![Example::Atom(
"VeryLongGeneratedEnumName::MEMBER".repeat(3),
)]);
assert!(long_inferred.render_at(8, 20).starts_with("[\n"));
let dict = Example::Dict(vec![(
"widget".to_string(),
Example::Call(
"Widget".to_string(),
vec![(None, Example::Atom("1".to_string()))],
),
)]);
assert_eq!(
dict.render(0),
"{\n \"widget\": Widget(\n 1,\n )\n}"
);
assert_eq!(Example::Dict(Vec::new()).flat(), "{}");
assert_eq!(
Example::ExplicitList(vec![Example::Atom("1".to_string())]).render(0),
"[1]"
);
assert_eq!(
Example::ReferenceList(vec![
Example::List(Vec::new()),
Example::List(Vec::new()),
Example::List(Vec::new()),
])
.render(0),
"[\n [],\n [],\n []\n]"
);
assert!(
Example::ReferenceDict(vec![("key".to_string(), Example::Atom("1".to_string()))])
.forces_multiline()
);
assert!(Example::ReferenceList(vec![Example::Atom("1".to_string())]).forces_multiline());
}
#[test]
fn example_context_resolves_literals_aliases_temporals_and_json_values() {
let types = vec![
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "ScalarAlias".to_string(),
module: "scalar_alias".to_string(),
target: TypeRef::Primitive(Prim::Str),
docstring: None,
min_length: None,
max_length: None,
}),
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "AnyAlias".to_string(),
module: "any_alias".to_string(),
target: TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Any))),
docstring: None,
min_length: None,
max_length: None,
}),
TypeDecl::Enum(EnumType {
name: "Color".to_string(),
module: "color".to_string(),
members: vec![EnumMember {
name: "RED".to_string(),
visit_param: "red".to_string(),
value: "red".to_string(),
docstring: None,
}]
.into(),
docstring: None,
}),
];
let auth = Auth::None;
let mut ctx = example_ctx(&types, &[], &auth);
assert_eq!(
ctx.value_from_example(
&TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Datetime))),
"\"2024-01-15T09:30:00.000Z\"",
)
.unwrap()
.flat(),
"datetime.datetime.fromisoformat(\"2024-01-15 09:30:00+00:00\")"
);
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Date), "\"2024-02-03\"")
.unwrap()
.flat(),
"datetime.date.fromisoformat(\"2024-02-03\")"
);
assert!(ctx.uses_datetime);
assert_eq!(
ctx.value_from_example(&TypeRef::Named("ScalarAlias".to_string()), "\"given\"")
.unwrap()
.flat(),
"\"given\""
);
assert_eq!(
ctx.value_from_example(
&TypeRef::Named("AnyAlias".to_string()),
r#"{"enabled":true}"#,
)
.unwrap()
.flat(),
r#"{"enabled": True}"#
);
assert_eq!(
ctx.value_from_example(
&TypeRef::List(Box::new(TypeRef::Primitive(Prim::Date))),
r#"["2024-02-03"]"#,
)
.unwrap()
.flat(),
"[datetime.date.fromisoformat(\"2024-02-03\")]"
);
assert_eq!(
ctx.value_from_example(&TypeRef::Named("Color".to_string()), "\"red\"")
.unwrap()
.flat(),
"Color.RED"
);
assert!(ctx
.value_from_example(&TypeRef::Named("Color".to_string()), "\"blue\"")
.is_none());
assert!(ctx
.value_from_example(&TypeRef::Primitive(Prim::Bool), "not-json")
.is_some());
assert!(ctx
.value_from_example(&TypeRef::Primitive(Prim::Datetime), "not-json")
.is_none());
let json = example_from_json_as(
serde_json::json!({
"items": ["x", 2, false, null]
}),
false,
);
assert_eq!(json.flat(), r#"{"items": ["x", 2, False, None]}"#);
let quoted = serde_json::json!({"body": "{\"orders\":[]}", "note": "it's \"x\""});
assert_eq!(
example_from_json_as(quoted.clone(), true).flat(),
r#"{"body": '{"orders":[]}', "note": "it's \"x\""}"#
);
assert_eq!(
example_from_json_as(quoted, false).flat(),
r#"{"body": "{\"orders\":[]}", "note": "it's \"x\""}"#
);
}
#[test]
fn example_context_covers_composite_examples_and_type_matching_edges() {
let payload = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Payload".to_string(),
module: "payload".to_string(),
bases: Vec::new(),
fields: vec![
model_field("id", TypeRef::Primitive(Prim::Int), true),
model_field("label", TypeRef::Primitive(Prim::Str), false),
],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let types = vec![
payload,
TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Event".to_string(),
module: "event".to_string(),
bases: Vec::new(),
fields: vec![
model_field("id", TypeRef::Primitive(Prim::Int), true),
model_field("when", TypeRef::Primitive(Prim::Datetime), true),
],
example_fields: Default::default(),
docstring: None,
xml: None,
}),
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "PayloadAlias".to_string(),
module: "payload_alias".to_string(),
target: TypeRef::Named("Payload".to_string()),
docstring: None,
min_length: None,
max_length: None,
}),
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "CompositeAlias".to_string(),
module: "composite_alias".to_string(),
target: TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Primitive(Prim::Bool)),
),
docstring: None,
min_length: None,
max_length: None,
}),
TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "BoolAlias".to_string(),
module: "bool_alias".to_string(),
target: TypeRef::Primitive(Prim::Bool),
docstring: None,
min_length: None,
max_length: None,
}),
TypeDecl::Enum(EnumType {
name: "Color".to_string(),
module: "color".to_string(),
members: vec![EnumMember {
name: "RED".to_string(),
visit_param: "red".to_string(),
value: "red".to_string(),
docstring: None,
}]
.into(),
docstring: None,
}),
TypeDecl::DiscriminatedUnion(DiscriminatedUnion {
base_fields: Vec::new(),
discriminant_name: None,
name: "Shape".to_string(),
module: "shape".to_string(),
discriminant_property: "kind".to_string(),
members: Vec::new(),
variant_targets: Vec::new(),
docstring: None,
}),
];
let auth = Auth::None;
let mut ctx = example_ctx(&types, &[], &auth);
assert!(ctx.example_is_scalar(&TypeRef::Named("BoolAlias".to_string())));
assert!(!ctx.example_is_scalar(&TypeRef::Named("Payload".to_string())));
assert!(ctx.example_is_composite(&TypeRef::Named("CompositeAlias".to_string())));
assert!(ctx.example_is_composite(&TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Str)))));
assert!(!ctx.example_is_composite(&TypeRef::Primitive(Prim::Int)));
assert!(
ctx.example_is_object(&TypeRef::Optional(Box::new(TypeRef::Named(
"PayloadAlias".to_string()
))))
);
assert!(!ctx.example_is_object(&TypeRef::Named("Color".to_string())));
assert!(!ctx.example_is_object(&TypeRef::Primitive(Prim::Any)));
assert!(
ctx.example_is_temporal(&TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Date))))
);
assert!(!ctx.example_is_temporal(&TypeRef::Primitive(Prim::Str)));
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Float), "2")
.expect("integer-shaped float is accepted")
.flat(),
"2.0"
);
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Float), "2.5")
.expect("decimal float is accepted")
.flat(),
"2.5"
);
for (literal, expected) in [("true", "True"), ("false", "False"), ("other", "other")] {
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Bool), literal)
.expect("boolean example branch returns an atom")
.flat(),
expected
);
}
assert_eq!(
ctx.value_from_example(
&TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Int))),
"[1,null]",
)
.expect("set example is accepted")
.flat(),
"[1, 1]"
);
assert!(ctx
.value_from_example(
&TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))),
"not-json",
)
.is_none());
assert_eq!(
ctx.value_from_example(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Int),
TypeRef::Primitive(Prim::Str),
]),
"\"chosen\"",
)
.expect("the matching union variant is selected")
.flat(),
"\"chosen\""
);
assert!(ctx
.value_from_example(
&TypeRef::Union(vec![
TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Primitive(Prim::Str)),
),
]),
"false",
)
.is_none());
assert_eq!(
ctx.value_from_example(
&TypeRef::Named("CompositeAlias".to_string()),
r#"{"enabled":true}"#,
)
.expect("composite alias accepts JSON")
.flat(),
r#"{"enabled": True}"#
);
assert_eq!(
ctx.value_from_example(
&TypeRef::Named("PayloadAlias".to_string()),
r#"{"id":7,"label":"sample"}"#,
)
.expect("object alias constructs the target")
.flat(),
"Payload(id=7, label=\"sample\")"
);
assert_eq!(
ctx.value_from_example(&TypeRef::Named("Payload".to_string()), r#"{"id":7}"#)
.expect("missing optional fields are omitted")
.flat(),
"Payload(id=7)"
);
assert!(ctx
.value_from_example(&TypeRef::Named("Shape".to_string()), "{}")
.is_none());
assert_eq!(
ctx.value_from_example(
&TypeRef::Named("Event".to_string()),
r#"{"when":"2000-01-23T04:56:07Z"}"#,
)
.expect("sentinel temporal example uses generated placeholders")
.flat(),
"Event(id=1, when=datetime.datetime.fromisoformat(\"2024-01-15 09:30:00+00:00\"))"
);
assert_eq!(
ctx.value_from_example(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Str),
TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))),
]),
r##"{"$ref":"#/components/schemas/Items"}"##,
)
.expect("reference-shaped union examples select their collection")
.flat(),
"[1]"
);
ctx.reference = true;
assert_eq!(
ctx.value_from_example(
&TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Primitive(Prim::Any)),
),
r#"{"enabled":true,"count":2}"#,
)
.expect("reference dictionaries retain keyed layout")
.render(0),
"{\n \"enabled\": True,\n \"count\": 2\n}"
);
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Str), "'quoted'")
.expect("reference strings normalize quote style")
.flat(),
"\"quoted\""
);
ctx.reference = false;
ctx.documentation = true;
assert_eq!(
ctx.value_from_example(&TypeRef::Primitive(Prim::Str), r#""can't""#)
.expect("documentation strings escape apostrophes")
.flat(),
r#""can\'t""#
);
assert_eq!(
ctx.value_from_example(
&TypeRef::List(Box::new(TypeRef::Primitive(Prim::Date))),
r#"["2024-02-03","2024-02-03"]"#,
)
.expect("documentation temporal lists preserve duplicate values")
.flat(),
"[datetime.date.fromisoformat(\"2024-02-03\"), datetime.date.fromisoformat(\"2024-02-03\")]"
);
let payload_type = TypeRef::Named("Payload".to_string());
assert!(ctx.example_matches_type(&payload_type, &serde_json::json!({ "id": 1 })));
assert!(!ctx.example_matches_type(&payload_type, &serde_json::json!({})));
assert!(!ctx.example_matches_type(
&payload_type,
&serde_json::json!({ "id": 1, "extra": true }),
));
assert!(!ctx.example_matches_type(&payload_type, &serde_json::json!(1)));
assert!(ctx.example_matches_type(
&TypeRef::Named("PayloadAlias".to_string()),
&serde_json::json!({ "id": 1 }),
));
// An enum matches only one of its own values.
let color = TypeRef::Named("Color".to_string());
assert!(ctx.example_matches_type(&color, &serde_json::json!("red")));
assert!(!ctx.example_matches_type(&color, &serde_json::json!("2030-09-01")));
assert!(!ctx.example_matches_type(&color, &serde_json::json!(false)));
assert!(ctx.example_matches_type(
&TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Int))),
&serde_json::Value::Null,
));
assert!(ctx.example_matches_type(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Bool),
TypeRef::Primitive(Prim::Str),
]),
&serde_json::json!("value"),
));
let cases = [
(
TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
serde_json::json!([]),
),
(
TypeRef::Set(Box::new(TypeRef::Primitive(Prim::Str))),
serde_json::json!([]),
),
(
TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Primitive(Prim::Str)),
),
serde_json::json!({}),
),
(TypeRef::Primitive(Prim::Str), serde_json::json!("x")),
(TypeRef::Primitive(Prim::Datetime), serde_json::json!("x")),
(TypeRef::Primitive(Prim::Date), serde_json::json!("x")),
(
TypeRef::Literal(vec!["x".to_string()]),
serde_json::json!("x"),
),
(TypeRef::Primitive(Prim::Int), serde_json::json!(1)),
(TypeRef::Primitive(Prim::Long), serde_json::json!(1)),
(TypeRef::Primitive(Prim::Float), serde_json::json!(1.5)),
(TypeRef::Primitive(Prim::Bool), serde_json::json!(true)),
(TypeRef::Primitive(Prim::Any), serde_json::Value::Null),
];
for (type_ref, value) in cases {
assert!(ctx.example_matches_type(&type_ref, &value));
}
let matrix = Example::ExplicitList(vec![
Example::List(vec![Example::Atom("1".to_string())]),
Example::ExplicitList(vec![Example::Atom("2".to_string())]),
Example::List(vec![Example::Atom("3".to_string())]),
]);
assert_eq!(matrix.render(0), "[\n [1],\n [2],\n [3],\n]");
let single_call = Example::ExplicitList(vec![Example::Call(
"Payload".to_string(),
vec![(Some("id".to_string()), Example::Atom("1".to_string()))],
)]);
assert_eq!(
single_call.render(0),
"[\n Payload(\n id=1,\n )\n]"
);
assert_eq!(
Example::Call("Empty".to_string(), Vec::new()).render(0),
"Empty()"
);
assert_eq!(
Example::List(vec![Example::Atom("1".to_string())]).render(0),
"[1]"
);
}
/// The object-typed path parameter's own rendering rules, over each field kind
/// the Round 6 probe variants measured (`docs/fern-limitations.md`). The e2e
/// journeys drive the binary over the whole shape; this pins the rule table
/// itself, kind by kind, so a kind that silently changed answer fails here.
#[test]
fn a_path_object_renders_measured_field_kinds_and_refuses_the_rest() {
let inner = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Inner".to_string(),
module: "inner".to_string(),
bases: Vec::new(),
fields: vec![model_field("label", TypeRef::Primitive(Prim::Str), true)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let kind = TypeDecl::Enum(EnumType {
name: "Kind".to_string(),
module: "kind".to_string(),
members: vec![EnumMember {
name: "ALPHA".to_string(),
value: "alpha".to_string(),
visit_param: "alpha".to_string(),
docstring: None,
}]
.into(),
docstring: None,
});
let alias = TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "Label".to_string(),
module: "label".to_string(),
target: TypeRef::Primitive(Prim::Str),
docstring: None,
min_length: None,
max_length: None,
});
let base = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Base".to_string(),
module: "base".to_string(),
bases: Vec::new(),
fields: vec![model_field("role", TypeRef::Primitive(Prim::Str), true)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let probe = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "ProbeParam".to_string(),
module: "probe_param".to_string(),
bases: vec!["Base".to_string()],
fields: vec![
model_field("count", TypeRef::Primitive(Prim::Int), true),
model_field(
"tags",
TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
true,
),
model_field(
"meta",
TypeRef::Dict(
Box::new(TypeRef::Primitive(Prim::Str)),
Box::new(TypeRef::Primitive(Prim::Str)),
),
true,
),
model_field("kind", TypeRef::Named("Kind".to_string()), true),
model_field("nickname", TypeRef::Named("Label".to_string()), true),
// Optional fields are not passed at all, as Fern's own example omits them.
model_field("note", TypeRef::Primitive(Prim::Str), false),
],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let types = vec![inner, kind, alias, base, probe];
let auth = Auth::None;
let mut ctx = example_ctx(&types, &[], &auth);
let rendered = ctx
.path_object_value(&TypeRef::Optional(Box::new(TypeRef::Named(
"ProbeParam".to_string(),
))))
.expect("every required field is a measured kind");
assert_eq!(
"ProbeParam(role=\"string\", count=1, tags=[], meta={}, kind=Kind.ALPHA, nickname=\"string\")",
rendered.flat(),
"inherited field first, values read off each field's type, collections empty"
);
// A required field that is itself a generated model is the one kind Fern
// was measured dropping the whole example for.
let nested = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Nested".to_string(),
module: "nested".to_string(),
bases: Vec::new(),
fields: vec![model_field(
"nested",
TypeRef::Named("Inner".to_string()),
true,
)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let mut with_nested = types.clone();
with_nested.push(nested);
let mut ctx = example_ctx(&with_nested, &[], &auth);
assert!(ctx
.path_object_value(&TypeRef::Named("Nested".to_string()))
.is_none());
// …and so is a kind no variant measured — a union, here.
assert!(path_field_render(
&TypeRef::Union(vec![
TypeRef::Primitive(Prim::Str),
TypeRef::Primitive(Prim::Int)
]),
&with_nested,
&[]
)
.is_none());
// A parameter that is not object-typed at all is not this rule's business.
assert!(path_object_decl(&TypeRef::Primitive(Prim::Str), &with_nested, &[]).is_none());
assert!(path_object_decl(&TypeRef::Named("Kind".to_string()), &with_nested, &[]).is_none());
}
/// `path_object_documented` is what the README's endpoint selection and the
/// `reference.md` snippet gap read, so it answers for the endpoint rather than
/// for one type — and a scalar path parameter is always documented.
#[test]
fn an_endpoint_is_documented_unless_a_path_object_field_is_unrenderable() {
let inner = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Inner".to_string(),
module: "inner".to_string(),
bases: Vec::new(),
fields: vec![model_field("label", TypeRef::Primitive(Prim::Str), true)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let renderable = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Renderable".to_string(),
module: "renderable".to_string(),
bases: Vec::new(),
fields: vec![model_field("role", TypeRef::Primitive(Prim::Str), true)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let unrenderable = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Unrenderable".to_string(),
module: "unrenderable".to_string(),
bases: Vec::new(),
fields: vec![model_field(
"nested",
TypeRef::Named("Inner".to_string()),
true,
)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let types = vec![inner, renderable, unrenderable];
let path_param = |type_ref: TypeRef| PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref,
docstring: None,
example: None,
pattern_constrained: false,
convert: false,
};
let scalar = endpoint(
"/things/{id}",
vec![path_param(TypeRef::Primitive(Prim::Str))],
Some(TypeRef::Primitive(Prim::Str)),
);
assert!(path_object_documented(&scalar, &types, &[]));
let ok = endpoint(
"/things/{id}",
vec![path_param(TypeRef::Named("Renderable".to_string()))],
Some(TypeRef::Primitive(Prim::Str)),
);
assert!(path_object_documented(&ok, &types, &[]));
let dropped = endpoint(
"/things/{id}",
vec![path_param(TypeRef::Named("Unrenderable".to_string()))],
Some(TypeRef::Primitive(Prim::Str)),
);
assert!(!path_object_documented(&dropped, &types, &[]));
}
/// The docstring writer renders the model only to learn whether Fern documents
/// the endpoint, and must not import it for an example that never names it.
#[test]
fn rendering_without_recording_leaves_the_import_set_untouched() {
let probe = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "ProbeParam".to_string(),
module: "probe_param".to_string(),
bases: Vec::new(),
fields: vec![model_field(
"when",
TypeRef::Primitive(Prim::Datetime),
true,
)],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let types = vec![probe];
let auth = Auth::None;
let mut ctx = example_ctx(&types, &[], &auth);
let recorded = ctx.path_object_value(&TypeRef::Named("ProbeParam".to_string()));
assert!(recorded.is_some());
assert_eq!(vec!["ProbeParam".to_string()], ctx.referenced_doc_order);
assert!(ctx.uses_datetime, "the datetime value registers its import");
let mut ctx = example_ctx(&types, &[], &auth);
let discarded = ctx.without_recording(|ctx| {
ctx.path_object_value(&TypeRef::Named("ProbeParam".to_string()))
});
assert!(discarded.is_some(), "the value is still rendered");
assert!(
ctx.referenced_doc_order.is_empty(),
"and its imports rolled back"
);
assert!(ctx.referenced.is_empty());
assert!(!ctx.uses_datetime);
}
#[test]
fn example_context_constructs_inherited_recursive_and_union_models() {
let mut base_id = model_field("id", TypeRef::Primitive(Prim::Int), true);
base_id.example = Some("7".to_string());
let base = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Base".to_string(),
module: "base".to_string(),
bases: Vec::new(),
fields: vec![base_id],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let mut metadata = model_field("metadata", TypeRef::Primitive(Prim::Any), true);
metadata.example = Some(r#"{"active":true}"#.to_string());
let mut invalid = model_field("invalid", TypeRef::Primitive(Prim::Any), true);
invalid.example = Some("{invalid".to_string());
let recursive = model_field("parent", TypeRef::Named("Child".to_string()), true);
let children = model_field(
"children",
TypeRef::List(Box::new(TypeRef::Named("Child".to_string()))),
true,
);
let child = TypeDecl::Object(ObjectType {
reach_refs: Vec::new(),
name: "Child".to_string(),
module: "child".to_string(),
bases: vec!["Base".to_string()],
fields: vec![metadata, invalid, recursive, children],
example_fields: Default::default(),
docstring: None,
xml: None,
});
let empty_enum = TypeDecl::Enum(EnumType {
name: "EmptyEnum".to_string(),
module: "empty_enum".to_string(),
members: Vec::new().into(),
docstring: None,
});
let mut nullable_required = model_field("server_url", TypeRef::Primitive(Prim::Str), true);
nullable_required.optional = true;
nullable_required.nullable = true;
let union = TypeDecl::DiscriminatedUnion(DiscriminatedUnion {
base_fields: Vec::new(),
discriminant_name: None,
name: "Shape".to_string(),
module: "shape".to_string(),
discriminant_property: "kind".to_string(),
members: vec![UnionMember {
wrapped: false,
class_name: "Shape_Circle".to_string(),
discriminant: "circle".to_string(),
discriminant_index: None,
fields: vec![
model_field("radius", TypeRef::Primitive(Prim::Float), true),
nullable_required,
],
source: None,
docstring: None,
}],
variant_targets: Vec::new(),
docstring: None,
});
let empty_union = TypeDecl::DiscriminatedUnion(DiscriminatedUnion {
base_fields: Vec::new(),
discriminant_name: None,
name: "Nothing".to_string(),
module: "nothing".to_string(),
discriminant_property: "kind".to_string(),
members: Vec::new(),
variant_targets: Vec::new(),
docstring: None,
});
let types = vec![base, child, empty_enum, union, empty_union];
let auth = Auth::None;
let mut ctx = example_ctx(&types, &[], &auth);
let rendered = ctx
.value(&TypeRef::Named("Child".to_string()), Slot::Plain)
.render(0);
assert!(rendered.contains("id=7"), "{rendered}");
assert!(
rendered.contains("metadata={\"active\": True}"),
"{rendered}"
);
assert!(
rendered.contains("invalid={\"key\": \"value\"}"),
"{rendered}"
);
assert!(rendered.contains("parent=None"), "{rendered}");
assert!(rendered.contains("children=[]"), "{rendered}");
assert_eq!(
ctx.value(&TypeRef::Named("Shape".to_string()), Slot::Plain)
.flat(),
"Shape_Circle(radius=1.1)"
);
assert_eq!(
ctx.value(&TypeRef::Named("EmptyEnum".to_string()), Slot::Plain)
.flat(),
"None"
);
assert_eq!(
ctx.value(&TypeRef::Named("Nothing".to_string()), Slot::Plain)
.flat(),
r#"{"key": "value"}"#
);
assert_eq!(
ctx.value(&TypeRef::Named("Missing".to_string()), Slot::Plain)
.flat(),
r#"{"key": "value"}"#
);
assert_eq!(
ctx.union_value(&[], Slot::Plain).flat(),
r#"{"key": "value"}"#
);
assert_eq!(
ctx.value(&TypeRef::Literal(Vec::new()), Slot::Plain).flat(),
"\"\""
);
assert!(ctx.referenced.contains("Child"));
assert!(ctx.referenced.contains("Shape_Circle"));
}
#[test]
fn worked_example_orders_wildcard_parameters_and_renders_body_examples() {
let mut ep = endpoint(
"/widgets/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
docstring: Some("Widget identifier.".to_string()),
example: None,
pattern_constrained: false,
convert: false,
}],
Some(TypeRef::Primitive(Prim::Str)),
);
ep.http_method = "POST";
ep.query_params = vec![
QueryParam {
wire_name: "tags".to_string(),
py_name: "tags".to_string(),
type_ref: TypeRef::List(Box::new(TypeRef::Primitive(Prim::Str))),
required: true,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: None,
example_is_scalar: false,
aliased_datetime: None,
docstring: Some("Tags to include.".to_string()),
},
QueryParam {
wire_name: "limit".to_string(),
py_name: "limit".to_string(),
type_ref: TypeRef::Primitive(Prim::Int),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: Some("3".to_string()),
example_is_scalar: true,
aliased_datetime: None,
docstring: Some("Maximum results.".to_string()),
},
];
ep.header_params = vec![HeaderParam {
wire_name: "X-Trace".to_string(),
py_name: "trace".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: false,
docstring: Some("Trace token.".to_string()),
example: Some("\"trace-1\"".to_string()),
enum_value: false,
}];
ep.request_body = Some(RequestBody::Single(SingleBody {
type_ref: TypeRef::Primitive(Prim::Str),
required: true,
convert: false,
content_type: true,
content_type_override: Some("*/*".to_string()),
example: Some("\"payload\"".to_string()),
}));
ep.response_doc = Some("Created widget.".to_string());
let auth = Auth::Bearer {
required: true,
credential: Credential::plain("token"),
};
let global_headers = [GlobalHeader {
wire_name: "X-Tenant".to_string(),
py_name: "tenant".to_string(),
presence: crate::ir::HeaderPresence::Required(HeaderType::Str),
credential: None,
}];
let mut ctx = example_ctx(&[], &[], &auth);
ctx.global_headers = &global_headers;
let rendered = build_example(&ep, false, "widgets", "acme", "AcmeApi", &mut ctx)
.expect("wildcard request has a worked example")
.join("\n");
let trace = rendered.find("trace=\"trace-1\"").unwrap();
let tags = rendered.find("tags=[\"tags\"]").unwrap();
assert!(trace < tags, "{rendered}");
assert!(rendered.contains("limit=3"), "{rendered}");
assert!(rendered.contains("request=\"payload\""), "{rendered}");
assert!(rendered.contains("tenant=\"YOUR_TENANT\""), "{rendered}");
assert!(rendered.contains("token=\"YOUR_TOKEN\""), "{rendered}");
let mut reference_ctx = example_ctx(&[], &[], &auth);
reference_ctx.global_headers = &global_headers;
let reference = build_documentation_example(
&ep,
false,
"widgets",
"acme",
"AcmeApi",
&mut reference_ctx,
None,
true,
)
.expect("wildcard request has a reference example")
.join("\n");
assert!(reference.contains("tags=["), "{reference}");
assert!(reference.contains("\"tags\""), "{reference}");
assert!(reference.contains("trace=\"trace-1\""), "{reference}");
assert!(reference.contains("request=\"payload\""), "{reference}");
ep.request_body = Some(RequestBody::Inline(vec![
BodyField {
wire_name: "when".to_string(),
py_name: "when".to_string(),
type_ref: TypeRef::Primitive(Prim::Datetime),
optional: false,
nullable: false,
spec_required: true,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: Some("\"2024-01-15T09:30:00Z\"".to_string()),
media_example: false,
schema_body_example: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
BodyField {
wire_name: "metadata".to_string(),
py_name: "metadata".to_string(),
type_ref: TypeRef::Optional(Box::new(TypeRef::Primitive(Prim::Any))),
optional: true,
nullable: true,
spec_required: true,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: Some(r#"{"source":"test"}"#.to_string()),
media_example: true,
schema_body_example: false,
reference_order: 1,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
]));
let mut ctx = example_ctx(&[], &[], &Auth::None);
let rendered = build_example(&ep, true, "widgets", "acme", "AcmeApi", &mut ctx)
.expect("inline request has an async example")
.join("\n");
assert!(rendered.contains("import asyncio"), "{rendered}");
assert!(rendered.contains("import datetime"), "{rendered}");
assert!(
rendered.contains("datetime.datetime.fromisoformat"),
"{rendered}"
);
assert!(
rendered.contains("metadata={\"source\": \"test\"}"),
"{rendered}"
);
assert!(rendered.contains("await client.widgets.op("), "{rendered}");
}
#[test]
fn a_list_path_parameter_beside_a_request_body_has_no_worked_example() {
// DaniWeb's `POST /conversations/{ID}/schedules` takes an array path
// parameter and a body, and Fern emits no example for it while both
// halves alone keep one.
let with_body = |type_ref: TypeRef| {
let mut ep = endpoint(
"/conversations/{id}/schedules",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref,
docstring: None,
example: None,
pattern_constrained: false,
convert: false,
}],
Some(TypeRef::Primitive(Prim::Str)),
);
ep.http_method = "POST";
ep.request_body = Some(RequestBody::Inline(vec![BodyField {
wire_name: "date".to_string(),
py_name: "date".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: false,
nullable: false,
spec_required: true,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
}]));
ep
};
let auth = Auth::None;
let list_path = with_body(TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))));
let mut ctx = example_ctx(&[], &[], &auth);
assert!(
build_example(
&list_path,
false,
"conversations",
"fern",
"FernApi",
&mut ctx,
)
.is_none(),
"a list path parameter beside a body suppresses the example"
);
let scalar_path = with_body(TypeRef::Primitive(Prim::Int));
let mut ctx = example_ctx(&[], &[], &auth);
assert!(
build_example(
&scalar_path,
false,
"conversations",
"fern",
"FernApi",
&mut ctx,
)
.is_some(),
"a scalar path parameter beside a body keeps it"
);
let mut bodyless = with_body(TypeRef::List(Box::new(TypeRef::Primitive(Prim::Int))));
bodyless.request_body = None;
let mut ctx = example_ctx(&[], &[], &auth);
assert!(
build_example(
&bodyless,
false,
"conversations",
"fern",
"FernApi",
&mut ctx,
)
.is_some(),
"a list path parameter with no body keeps it"
);
}
#[test]
fn referenced_request_examples_preserve_fern_importer_names() {
let alias = TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "EpsBearerContainer".to_string(),
module: "eps_bearer_container".to_string(),
target: TypeRef::Primitive(Prim::Str),
docstring: None,
min_length: None,
max_length: None,
});
let mut ep = endpoint("/contexts", Vec::new(), Some(TypeRef::Primitive(Prim::Str)));
ep.http_method = "POST";
ep.request_body_required = true;
ep.request_body_has_multipart_related = true;
ep.reference_body_example = Some(serde_json::json!({
"epsBearerSetup": [null, null],
"5gMmCauseValue": 0,
}));
ep.request_body = Some(RequestBody::Inline(vec![
BodyField {
wire_name: "epsBearerSetup".to_string(),
py_name: "eps_bearer_setup".to_string(),
type_ref: TypeRef::List(Box::new(TypeRef::Named("EpsBearerContainer".to_string()))),
optional: true,
nullable: false,
spec_required: false,
docstring: None,
convert: true,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: Some("[null,null]".to_string()),
media_example: true,
schema_body_example: true,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
BodyField {
wire_name: "5gMmCauseValue".to_string(),
py_name: "_5g_mm_cause_value".to_string(),
type_ref: TypeRef::Primitive(Prim::Int),
optional: true,
nullable: false,
spec_required: false,
docstring: None,
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: Some("0".to_string()),
media_example: true,
schema_body_example: true,
reference_order: 1,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
},
]));
let auth = Auth::None;
let types = [alias];
let mut ctx = example_ctx(&types, &[], &auth);
let executable = build_example(&ep, false, "contexts", "fern", "FernApi", &mut ctx)
.expect("referenced request has an executable example")
.join("\n");
assert!(executable.contains("eps_bearer_setup=[\"epsBearerSetup\", \"epsBearerSetup\"]"));
assert!(executable.contains("f_5g_mm_cause_value=0"));
let mut ctx = example_ctx(&types, &[], &auth);
let reference = build_documentation_example(
&ep, false, "contexts", "fern", "FernApi", &mut ctx, None, true,
)
.expect("referenced request has a reference example")
.join("\n");
assert!(reference.contains("f_value=0"), "{reference}");
assert!(reference.contains("\"epsBearerSetup\""), "{reference}");
ep.request_body_required = false;
ep.request_body_has_multipart_related = false;
let mut ctx = example_ctx(&types, &[], &auth);
let optional = build_example(&ep, false, "contexts", "fern", "FernApi", &mut ctx)
.expect("optional request has an example")
.join("\n");
assert!(optional.contains("_5g_mm_cause_value=0"), "{optional}");
ep.request_body = Some(RequestBody::Form(FormBody {
multipart: true,
fields: vec![BodyField {
wire_name: "binaryDataN1SmMessage".to_string(),
py_name: "binary_data_n1sm_message".to_string(),
type_ref: TypeRef::Primitive(Prim::Bytes),
optional: true,
nullable: false,
spec_required: false,
docstring: None,
convert: false,
is_file: true,
form_json: false,
form_content_type: Some("application/vnd.3gpp.5gnas".to_string()),
collision_prefix: None,
inline_object: false,
example: None,
media_example: false,
schema_body_example: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
}],
}));
ep.body_schema_shape = BodySchemaShape::Ref;
assert!(readme_endpoint_eligible(&ep, &[], &[]));
let mut ctx = example_ctx(&types, &[], &auth);
let form = build_documentation_example(
&ep, false, "contexts", "fern", "FernApi", &mut ctx, None, false,
)
.expect("multipart related request has a README example")
.join("\n");
assert!(
form.contains("binary_data_n1sm_message=\"example_binaryDataN1SmMessage\""),
"{form}"
);
}
#[test]
fn the_empty_namespace_is_an_importable_underscore_package() {
let mut root = endpoint("/root", Vec::new(), Some(TypeRef::Primitive(Prim::Str)));
root.openapi_31 = true;
root.module.clear();
root.docstring = Some("Root operation.".to_string());
root.response_doc = Some("Root response.".to_string());
root.query_params.push(QueryParam {
wire_name: "filter".to_string(),
py_name: "filter".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: Some("\"active\"".to_string()),
example_is_scalar: true,
aliased_datetime: None,
docstring: Some("Optional filter.".to_string()),
});
let mut explicit_empty =
endpoint("/empty", Vec::new(), Some(TypeRef::Primitive(Prim::Str)));
explicit_empty.module = "_".to_string();
explicit_empty.method_name = "empty".to_string();
let mut ir = ir_with(vec![root, explicit_empty]);
ir.endpoint_modules = vec!["_".to_string()];
ir.tag_types = vec![TagTypeDecl {
module: "_".to_string(),
decl: TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "EmptyResult".to_string(),
module: "empty_result".to_string(),
target: TypeRef::Primitive(Prim::Str),
docstring: None,
min_length: None,
max_length: None,
}),
}];
let files = generate(&ir).expect("root and empty namespaces generate");
let file = |rel: &str| {
files
.iter()
.find(|file| file.path == std::path::Path::new(rel))
.unwrap_or_else(|| panic!("{rel} is not generated"))
};
// The `_` namespace is a package of its own, its types inside it, and
// the root client reaches it through a `_` property.
for rel in [
"src/fern/_/__init__.py",
"src/fern/_/client.py",
"src/fern/_/raw_client.py",
"src/fern/_/types/empty_result.py",
"src/fern/raw_client.py",
] {
file(rel);
}
assert!(!files
.iter()
.any(|file| file.path == std::path::Path::new("src/fern/types/empty_result.py")));
let root = &file("src/fern/client.py").contents;
assert!(root.contains("class FernApi:") && root.contains(" def _(self):"));
assert!(file("src/fern/_/client.py")
.contents
.contains("from ..core.client_wrapper import"));
assert!(file("reference.md").contents.contains("client._.empty("));
}
#[test]
fn worked_example_renders_every_optional_query_example_it_is_given() {
let mut ep = endpoint("/widgets", Vec::new(), Some(TypeRef::Primitive(Prim::Str)));
ep.query_params = vec![
QueryParam {
wire_name: "where".to_string(),
py_name: "where_".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: Some("\"active\"".to_string()),
example_is_scalar: true,
aliased_datetime: None,
docstring: None,
},
QueryParam {
wire_name: "order".to_string(),
py_name: "order".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: Some("\"name\"".to_string()),
example_is_scalar: true,
aliased_datetime: None,
docstring: None,
},
QueryParam {
wire_name: "page".to_string(),
py_name: "page".to_string(),
type_ref: TypeRef::Primitive(Prim::Int),
required: false,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: Some("1".to_string()),
example_is_scalar: true,
aliased_datetime: None,
docstring: None,
},
];
let mut ctx = example_ctx(&[], &[], &Auth::None);
let rendered = build_example(&ep, false, "widgets", "acme", "AcmeApi", &mut ctx)
.expect("bodyless query endpoint has a worked example")
.join("\n");
// Whether an optional query example survives at all is decided while
// building the IR (see `ir::parameter_example_value`) — Fern discards a
// parameter-level example on a non-string parameter there, so by the time
// the emitter sees one it renders it whatever its type is, and a mixed-type
// run comes out whole (`med-anvisa-price`, `sac-backend`).
assert!(rendered.contains("where_=\"active\""), "{rendered}");
assert!(rendered.contains("order=\"name\""), "{rendered}");
assert!(rendered.contains("page=1"), "{rendered}");
}
#[test]
fn stream_methods_document_and_delegate_every_parameter_in_both_modes() {
let mut ep = endpoint(
"/events/{id}",
vec![PathParam {
wire_name: "id".to_string(),
py_name: "id".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
docstring: Some("Event identifier.\nSecond line.".to_string()),
example: None,
pattern_constrained: false,
convert: false,
}],
Some(TypeRef::Primitive(Prim::Str)),
);
ep.streaming = true;
ep.docstring = Some("Streams events.\nAs they arrive.".to_string());
ep.response_doc = Some("Decoded chunks.".to_string());
ep.query_params.push(QueryParam {
wire_name: "cursor".to_string(),
py_name: "cursor".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: true,
nullable: false,
default: None,
convert: false,
comma_separated: false,
allow_multiple: false,
one_or_many: false,
example: None,
example_is_scalar: true,
aliased_datetime: None,
docstring: Some("Starting cursor.".to_string()),
});
ep.header_params.push(HeaderParam {
wire_name: "X-Mode".to_string(),
py_name: "mode".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
required: true,
docstring: Some("Stream mode.".to_string()),
example: None,
enum_value: false,
});
ep.request_body = Some(RequestBody::Form(FormBody {
fields: vec![BodyField {
wire_name: "filter".to_string(),
py_name: "filter_".to_string(),
type_ref: TypeRef::Primitive(Prim::Str),
optional: false,
nullable: false,
spec_required: true,
docstring: Some("Filter expression.".to_string()),
convert: false,
is_file: false,
form_json: false,
form_content_type: None,
collision_prefix: None,
inline_object: false,
example: Some("\"open\"".to_string()),
media_example: false,
schema_body_example: false,
reference_order: 0,
deprecated: false,
declaration_order: 0,
markdown_order: 0,
admits_only_empty_object: false,
}],
multipart: false,
}));
let auth = Auth::None;
let tags = std::collections::BTreeMap::new();
let cx = ClientCtx {
yaml_unquoted_timestamps: None,
pkg: "acme",
client_name: "AcmeApi",
module: "events",
types: &[],
enum_type: crate::settings::EnumType::default(),
tag_decls: &[],
auth: &auth,
has_environment: false,
tag_types: &tags,
global_headers: &[],
client_path_parameters: &[],
sdk_first_party: true,
children: &[],
};
let mut imports = Imports::at(RefLoc::Client("events".to_string()), &tags);
let raw_sync = raw_method(&ep, false, &mut imports);
let raw_async = raw_method(&ep, true, &mut imports);
let client_sync = client_method(&cx, &ep, false, &mut imports);
let client_async = client_method(&cx, &ep, true, &mut imports);
for output in [&raw_sync, &raw_async, &client_sync, &client_async] {
assert!(output.contains("id : str"), "{output}");
assert!(output.contains("cursor : str"), "{output}");
assert!(output.contains("mode : str"), "{output}");
assert!(output.contains("filter_ : str"), "{output}");
}
assert!(raw_sync.contains("yield _stream()"), "{raw_sync}");
assert!(raw_async.contains("yield await _stream()"), "{raw_async}");
assert!(client_sync.contains("yield from r.data"), "{client_sync}");
assert!(
client_async.contains("async for _chunk in r.data"),
"{client_async}"
);
ep.streaming = false;
ep.binary_response = true;
let raw_binary = raw_method(&ep, true, &mut imports);
let client_binary = client_method(&cx, &ep, true, &mut imports);
assert!(raw_binary.contains("aiter_bytes"), "{raw_binary}");
assert!(raw_binary.contains("Streams events."), "{raw_binary}");
assert!(
client_binary.contains("typing.AsyncIterator[bytes]"),
"{client_binary}"
);
assert!(
client_binary.contains("Filter expression."),
"{client_binary}"
);
}
#[test]
fn recursive_alias_enum_docs_and_render_failures_keep_context() {
let alias = TypeDecl::Alias(AliasType {
reach_refs: Vec::new(),
name: "NodeList".to_string(),
module: "node_list".to_string(),
target: TypeRef::List(Box::new(TypeRef::Named("Node".to_string()))),
docstring: None,
min_length: None,
max_length: None,
});
let rendered = render_type_decl(
&environment(),
&alias,
RefLoc::RootTypes,
&Default::default(),
DeclSettings::default(),
&std::collections::HashSet::from(["Node".to_string()]),
None,
)
.expect("recursive alias renders");
assert!(rendered.contains("from __future__ import annotations"));
assert!(rendered.contains("if typing.TYPE_CHECKING:"));
assert!(rendered.contains("from .node import Node"));
assert!(rendered.contains("NodeList = typing.List[\"Node\"]"));
let singleton = TypeDecl::DiscriminatedUnion(DiscriminatedUnion {
base_fields: Vec::new(),
discriminant_name: None,
name: "Content".to_string(),
module: "content".to_string(),
discriminant_property: "type".to_string(),
members: vec![UnionMember {
wrapped: false,
class_name: "Content_Text".to_string(),
discriminant: "text".to_string(),
discriminant_index: None,
fields: vec![model_field("text", TypeRef::Primitive(Prim::Str), true)],
source: None,
docstring: None,
}],
variant_targets: Vec::new(),
docstring: None,
});
let rendered = render_type_decl(
&environment(),
&singleton,
RefLoc::RootTypes,
&Default::default(),
DeclSettings::default(),
&Default::default(),
None,
)
.expect("singleton union renders");
assert!(rendered.contains("Content = Content_Text"), "{rendered}");
assert!(!rendered.contains("typing_extensions"), "{rendered}");
let enum_output = render_enum(
&environment(),
&EnumType {
name: "State".to_string(),
module: "state".to_string(),
members: vec![EnumMember {
name: "READY".to_string(),
visit_param: "ready".to_string(),
value: "ready\"now".to_string(),
docstring: Some("Ready member.".to_string()),
}]
.into(),
docstring: Some("State enum.".to_string()),
},
&RefLoc::RootTypes,
)
.expect("documented enum renders");
assert!(enum_output.contains("from ..core import enum"));
assert!(enum_output.contains("class State(enum.StrEnum):"));
assert!(enum_output.contains("State enum."));
assert!(enum_output.contains("READY = \"ready\\\"now\""));
assert!(enum_output.contains("Ready member."));
let error = render(
&environment(),
"missing-template.py",
"broken.py",
minijinja::Value::UNDEFINED,
)
.expect_err("missing template is reported");
assert!(error.to_string().contains("broken.py"), "{error}");
let mut source = " \"\"\"\n ```python\n\n pass\n ```\n \"\"\"\n\n \"\"\"\n plain\n\n".to_string();
super::preserve_fenced_docstring_blank_indent(&mut source);
assert!(source.contains(" ```python\n \n pass"));
assert!(source.ends_with(" plain\n\n"));
}
#[test]
fn colliding_query_and_body_arguments_use_their_own_values() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3", "info": {"title": "Lantern Circuits", "version": "1"},
"paths": {"/circuits": {"post": {
"operationId": "setVoltage",
"parameters": [{"name": "voltage", "in": "query", "schema": {"type": "integer"}}],
"requestBody": {"content": {"application/json": {
"schema": {"$ref": "#/components/schemas/PowerProfile"}
}}},
"responses": {"200": {"description": "Recorded", "content": {
"application/json": {"schema": {"$ref": "#/components/schemas/PowerProfile"}}
}}}
}}},
"components": {"schemas": {"PowerProfile": {"type": "object", "properties": {
"voltage": {"type": "integer"}, "channel": {"type": "string"}
}}}}
}));
let raw = files
.iter()
.find(|(path, _)| path.ends_with("/raw_client.py"))
.unwrap()
.1
.as_str();
assert!(raw.contains("power_profile_voltage:"), "{raw}");
assert!(
raw.contains("json={\n \"voltage\": power_profile_voltage,"),
"{raw}"
);
assert!(
raw.contains("params={\n \"voltage\": voltage,"),
"{raw}"
);
}
/// A 3.0 document posting `body` to `/leads` and answering JSON.
fn lead_document(body: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/leads": {"post": {
"operationId": "leadsAdd",
"tags": ["Leads"],
"requestBody": {"required": true,
"content": {"application/json": {"schema": body}}},
"responses": {"200": {"description": "OK", "content": {"application/json":
{"schema": {"type": "object", "properties": {"id": {"type": "string"}}}}}}}
}}}
})
}
#[test]
fn a_required_nullable_body_field_is_shown_by_its_example_or_as_none_in_markdown() {
let files = files_for(lead_document(serde_json::json!({
"type": "object",
"required": ["name", "company_name", "phone"],
"properties": {
"name": {"type": "string", "example": "Elon Musk"},
"company_name": {"type": "string", "nullable": true, "example": "Spacex"},
"phone": {"type": "string", "nullable": true}
}
})));
let client = file(&files, "leads/client.py");
// The signature still defaults both nullable fields.
assert!(
client.contains("company_name: typing.Optional[str] = OMIT"),
"{client}"
);
assert!(
client.contains(
"client.leads.add(\n name=\"Elon Musk\",\n company_name=\"Spacex\",\n )"
),
"{client}"
);
let reference = file(&files, "reference.md");
assert!(
reference.contains(
"client.leads.add(\n name=\"Elon Musk\",\n company_name=\"Spacex\",\n phone=None,\n)"
),
"{reference}"
);
}
#[test]
fn a_union_variants_required_nullable_field_is_none_in_markdown_only() {
let files = files_for({
let mut document = lead_document(serde_json::json!({
"oneOf": [{"$ref": "#/components/schemas/Project"},
{"$ref": "#/components/schemas/Solver"}],
"discriminator": {"propertyName": "kind", "mapping": {
"PROJECT": "#/components/schemas/Project",
"SOLVER": "#/components/schemas/Solver"}}
}));
document["components"] = serde_json::json!({"schemas": {
"Project": {"type": "object", "required": ["kind", "projectId", "defaults"],
"properties": {
"kind": {"type": "string", "enum": ["PROJECT"]},
"projectId": {"type": "string"},
"defaults": {"type": "string", "nullable": true}
}},
"Solver": {"type": "object", "required": ["kind", "solverKey"],
"properties": {
"kind": {"type": "string", "enum": ["SOLVER"]},
"solverKey": {"type": "string"}
}}
}});
document
});
let client = file(&files, "leads/client.py");
let docstring_call = client
.split("client.leads.add(")
.nth(1)
.and_then(|rest| rest.split("\"\"\"").next())
.expect("the docstring has a worked example");
assert!(
docstring_call.contains("project_id=\"projectId\""),
"{docstring_call}"
);
assert!(!docstring_call.contains("defaults"), "{docstring_call}");
let reference = file(&files, "reference.md");
assert!(
reference.contains(" defaults=None,\n"),
"{reference}"
);
}
/// `GET /files/{id}/download` answering `media` with a binary string, its
/// path parameter and optional `fields` query both declaring examples.
fn download_document(media: &str) -> serde_json::Value {
serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/files/{id}/download": {"get": {
"operationId": "filesDownload",
"tags": ["Files"],
"parameters": [
{"name": "id", "in": "path", "required": true,
"schema": {"type": "string", "example": "12345"}},
{"name": "fields", "in": "query", "required": false,
"schema": {"type": "string", "example": "id,updated_at"}}
],
"responses": {
"200": {"description": "OK", "content": {media:
{"schema": {"type": "string", "format": "binary"}}}},
"404": {"description": "Missing", "content": {"application/json":
{"schema": {"type": "object",
"properties": {"message": {"type": "string"}}}}}}
}
}}}
})
}
#[test]
fn a_wildcard_download_documents_its_required_arguments_by_name() {
let files = files_for(download_document("*/*"));
let client = file(&files, "files/client.py");
assert!(
client.contains("client.files.download(\n id=\"id\",\n )"),
"{client}"
);
// Any other binary media keeps the optional parameter's example.
let files = files_for(download_document("application/octet-stream"));
let client = file(&files, "files/client.py");
assert!(client.contains("fields=\"id,updated_at\""), "{client}");
}
#[test]
fn a_markdown_answer_documents_its_path_arguments() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/connectors/{id}/docs/{doc_id}": {"get": {
"operationId": "connectorDocsOne",
"tags": ["Docs"],
"parameters": [
{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
{"name": "doc_id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {"200": {"description": "OK", "content": {"text/markdown":
{"schema": {"type": "string", "format": "markdown"}}}}}
}}}
}));
let client = file(&files, "docs/client.py");
assert!(
client.contains(
"client.docs.connector_docs_one(\n id=\"id\",\n doc_id=\"doc_id\",\n )"
),
"{client}"
);
let reference = file(&files, "reference.md");
assert!(
reference.contains(" doc_id=\"doc_id\",\n"),
"{reference}"
);
}
#[test]
fn an_async_stream_is_iterated_without_await_in_the_docstring() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/events": {"get": {
"operationId": "getEvents",
"tags": ["Events"],
"responses": {"200": {"description": "OK", "content": {"text/event-stream":
{"schema": {"type": "string"}}}}}
}}}
}));
let client = file(&files, "events/client.py");
assert!(
client.contains(
" async def main() -> None:\n response = client.events.get_events()\n async for chunk in response:"
),
"{client}"
);
assert!(
client.contains(
" response = client.events.get_events()\n for chunk in response:"
),
"{client}"
);
assert!(
!client.contains("await client.events.get_events()"),
"{client}"
);
}
#[test]
fn a_wildcard_binary_body_is_documented_as_bytes() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/files": {"post": {
"operationId": "filesUpload",
"tags": ["Files"],
"requestBody": {"required": true, "content": {"*/*":
{"schema": {"type": "string", "format": "binary"}}}},
"responses": {"200": {"description": "OK", "content": {"application/json":
{"schema": {"type": "object", "properties": {"id": {"type": "string"}}}}}}}
}}}
}));
let reference = file(&files, "reference.md");
assert!(reference.contains("**request:** `bytes` "), "{reference}");
}
#[test]
fn the_readme_iterates_an_async_stream_with_its_arguments_elided() {
let stream = |parameters: serde_json::Value| {
files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/events": {"get": {
"operationId": "watchEvents",
"tags": ["Events"],
"parameters": parameters,
"responses": {"200": {"description": "OK", "content": {"text/event-stream":
{"schema": {"type": "string"}}}}}
}}}
}))
};
let files = stream(serde_json::json!([]));
let readme = file(&files, "README.md");
assert!(
readme.contains(
"async def main() -> None:\n async for chunk in client.events.watch_events():\n print(chunk)\n"
),
"{readme}"
);
// An optional parameter the sync call leaves out is still elided as `...`.
let files = stream(serde_json::json!([
{"name": "since", "in": "query", "schema": {"type": "string"}}
]));
let readme = file(&files, "README.md");
assert!(
readme.contains("client.events.watch_events()\n```"),
"{readme}"
);
assert!(
readme.contains(" async for chunk in client.events.watch_events(...):\n"),
"{readme}"
);
}
#[test]
fn a_patterned_path_parameter_keeps_a_downloads_worked_example() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/guilds/{guild_id}/widget.png": {"get": {
"operationId": "getGuildWidgetPng",
"tags": ["Guilds"],
"parameters": [{"name": "guild_id", "in": "path", "required": true,
"schema": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$"}}],
"responses": {
"200": {"description": "OK", "content": {"image/png":
{"schema": {"type": "string", "format": "binary"}}}},
"404": {"description": "Missing", "content": {"application/json":
{"schema": {"type": "object",
"properties": {"message": {"type": "string"}}}}}}
}
}}}
}));
let client = file(&files, "guilds/client.py");
assert!(client.contains("guild_id=\"guild_id\","), "{client}");
}
#[test]
fn an_optional_field_narrowed_to_an_empty_model_is_no_argument_nor_import() {
let files = files_for(serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"paths": {"/expectations": {"put": {
"operationId": "putExpectation",
"tags": ["Expectations"],
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Expectation"},
"example": {"matcher": {"method": "GET"}, "status": 200}
}}},
"responses": {"204": {"description": "Stored"}}
}}},
"components": {"schemas": {
"Expectation": {"type": "object", "properties": {
"matcher": {"$ref": "#/components/schemas/Matcher"},
"status": {"type": "integer"}
}},
"Matcher": {"oneOf": [
{"$ref": "#/components/schemas/PathMatcher"},
{"$ref": "#/components/schemas/SpecMatcher"}
]},
"PathMatcher": {"type": "object", "additionalProperties": false,
"properties": {"path": {"type": "string"}}},
"SpecMatcher": {"type": "object", "additionalProperties": false,
"properties": {"operation": {"type": "string"}}}
}}
}));
let client = file(&files, "expectations/client.py");
let example = client
.split("Examples\n --------\n")
.nth(1)
.and_then(|rest| rest.split("\"\"\"").next())
.expect("the docstring has a worked example");
assert!(example.contains("status=200"), "{example}");
assert!(!example.contains("matcher"), "{example}");
assert!(!example.contains("PathMatcher"), "{example}");
assert!(!example.contains("SpecMatcher"), "{example}");
}
#[test]
fn a_nested_required_unknown_nullable_field_is_none_in_markdown_only() {
let mut document = lead_document(serde_json::json!({
"type": "object",
"required": ["messages"],
"properties": {"messages": {"type": "array", "items": {
"$ref": "#/components/schemas/Message"}}}
}));
document["components"] = serde_json::json!({"schemas": {"Message": {
"type": "object",
"required": ["id", "metadata"],
"properties": {
"id": {"type": "string"},
"metadata": {"anyOf": [{}, {"type": "null"}]}
}
}}});
let files = files_for(document);
let client = file(&files, "leads/client.py");
assert!(client.contains("Message(\n"), "{client}");
assert!(!client.contains("metadata="), "{client}");
let reference = file(&files, "reference.md");
assert!(reference.contains("metadata=None,"), "{reference}");
}
}
#[cfg(test)]
mod parameter_lowering_tests {
//! Each parameter-lowering rule driven through the whole generator, one
//! document per rule, asserting on the files a user receives.
use serde_json::{json, Value};
/// Every file the generator writes for `document`, keyed by path.
fn files(document: Value) -> std::collections::BTreeMap<String, String> {
let doc: crate::openapi::OpenApi =
serde_json::from_value(document).expect("document deserializes");
let config = crate::config::GenerateConfig::new(
std::path::PathBuf::from("openapi.yml"),
std::path::PathBuf::from("out"),
Some("api".to_string()),
None,
None,
crate::settings::ExtraFields::default(),
"Api",
)
.expect("the config is well formed");
super::generate(&crate::ir::build(&doc, &config))
.expect("the document generates")
.into_iter()
.map(|file| {
(
file.path.to_string_lossy().replace('\\', "/"),
file.contents,
)
})
.collect()
}
#[test]
fn extension_parameters_keep_their_wire_names_and_defaults() {
let document: Value = serde_json::from_str(include_str!(
"../docs/openapi-surface/handwritten/observatory-query-extensions/openapi.yml"
))
.unwrap();
let generated = files(document);
let client = &generated["src/api/client.py"];
let raw = &generated["src/api/raw_client.py"];
assert!(
client.contains("band: typing.Optional[str] = None"),
"{client}"
);
assert!(
client.contains("size: typing.Optional[int] = \"12\""),
"{client}"
);
assert!(raw.contains("\"channel\": band"), "{raw}");
}
#[test]
fn document_fields_and_nullable_arrays_reach_emitter_consumers() {
for (source, expected) in [
(
include_str!(
"../docs/openapi-surface/handwritten/observatory-client-date/openapi.yml"
),
"sampling_day: typing.Optional[dt.date] = None",
),
(
include_str!(
"../docs/openapi-surface/handwritten/observatory-client-headers/openapi.yml"
),
"station: str",
),
(
include_str!(
"../docs/openapi-surface/handwritten/observatory-client-variable/openapi.yml"
),
"station_code: str",
),
] {
let generated = files(serde_json::from_str(source).unwrap());
assert!(
generated["src/api/client.py"].contains(expected),
"{expected}"
);
}
let generated = files(
serde_json::from_str(include_str!(
"../docs/openapi-surface/handwritten/observatory-nullable-query/openapi.yml"
))
.unwrap(),
);
assert!(generated["src/api/raw_client.py"].contains("\"kinds\": kinds"));
assert!(!generated["src/api/raw_client.py"].contains("join(map"));
}
fn departure_guard(case: &str, rel: &str, id: &str) {
let case_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("docs/openapi-surface/handwritten")
.join(case);
let document: Value =
serde_json::from_str(&std::fs::read_to_string(case_root.join("openapi.yml")).unwrap())
.unwrap();
let doc: crate::openapi::OpenApi = serde_json::from_value(document).unwrap();
let config = crate::config::GenerateConfig::new(
"openapi.yml".into(),
"out".into(),
Some("fern".into()),
None,
None,
crate::settings::ExtraFields::default(),
"FernApi",
)
.unwrap();
let generated = super::generate(&crate::ir::build(&doc, &config)).unwrap();
let out = tempfile::tempdir().unwrap();
for file in generated {
let path = out.path().join(file.path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, file.contents).unwrap();
}
let expected = case_root.join("fern-expected");
let context = crate::departures::Context::from_trees(&expected, out.path());
let reference = std::fs::read_to_string(expected.join(rel)).unwrap();
let actual = std::fs::read_to_string(out.path().join(rel)).unwrap();
let compared = crate::parity::compare_file(&context, rel, &actual, &reference).unwrap();
assert!(compared.matches());
assert!(compared
.departures
.iter()
.any(|departure| departure.id == id));
let changed = actual.replacen(
if rel.ends_with(".py") {
"import typing"
} else {
"# Reference"
},
"unexplained adjacent change",
1,
);
assert!(
!crate::parity::compare_file(&context, rel, &changed, &reference)
.unwrap()
.matches()
);
}
#[test]
fn sdk_variable_documentation_departure_rejects_an_adjacent_mismatch() {
departure_guard(
"observatory-client-variable",
"reference.md",
"sdk-variable-docs-examples",
);
}
#[test]
fn date_header_example_departure_rejects_an_adjacent_mismatch() {
departure_guard(
"observatory-client-date",
"src/fern/client.py",
"date-header-constructor-example",
);
}
#[test]
fn reference_string_map_body_uses_declared_example_or_placeholder() {
for example in [
Some(serde_json::json!({"cloud.provider": "aws", "tasks.max": "2"})),
None,
] {
let mut media = serde_json::json!({
"schema": {"type": "object", "additionalProperties": {"type": "string"}}
});
if let Some(example) = example.as_ref() {
media["example"] = example.clone();
}
let generated = files(serde_json::json!({
"openapi": "3.0.0", "info": {"title": "Connector configuration", "version": "1"},
"paths": {"/config": {"put": {
"operationId": "validateConfig",
"requestBody": {"content": {"application/json": media}},
"responses": {"200": {"description": "Validated"}}
}}}
}));
for reference in [&generated["reference.md"], &generated["src/api/client.py"]] {
if example.is_some() {
assert!(
reference.contains("\"cloud.provider\": \"aws\""),
"{reference}"
);
assert!(reference.contains("\"tasks.max\": \"2\""), "{reference}");
assert!(!reference.contains("\"key\": \"value\""), "{reference}");
} else {
assert!(reference.contains("\"key\": \"value\""), "{reference}");
}
}
}
}
/// A document of `GET` operations tagged `sky`, one per `(path, parameters)`.
fn document(version: &str, operations: &[(&str, Value)]) -> Value {
let mut paths = serde_json::Map::new();
for (name, parameters) in operations {
paths.insert(
format!("/sky/{name}"),
json!({"get": {
"operationId": name,
"tags": ["sky"],
"parameters": parameters,
"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"type": "string"}}}}}
}}),
);
}
json!({"openapi": version, "info": {"title": "Api", "version": "1"}, "paths": paths})
}
fn query(required: bool, schema: Value) -> Value {
json!([{"name": "band", "in": "query", "required": required, "schema": schema}])
}
fn has(files: &std::collections::BTreeMap<String, String>, path: &str) -> bool {
files.contains_key(path)
}
#[test]
fn a_query_union_sits_in_the_tag_exactly_when_required_iff_not_nullable() {
let mix = json!([{"type": "string"}, {"type": "integer"}]);
let mix_null = json!([{"type": "string"}, {"type": "integer"}, {"type": "null"}]);
let consts = json!([{"const": "near"}, {"const": "far"}]);
let files = files(document(
"3.1.0",
&[
("reqMix", query(true, json!({"anyOf": mix}))),
(
"optMix",
query(false, json!({"anyOf": mix, "title": "Band"})),
),
("optNullMix", query(false, json!({"anyOf": mix_null}))),
("reqNullMix", query(true, json!({"oneOf": mix_null}))),
("optConsts", query(false, json!({"anyOf": consts}))),
(
"optNullable",
query(false, json!({"anyOf": mix, "nullable": true})),
),
(
"headerMix",
json!([{"name": "X-Band", "in": "header", "required": false, "schema": {"anyOf": mix}}]),
),
(
"soleEnum",
query(
true,
json!({"anyOf": [{"type": "string", "enum": ["a", "b"]}, {"type": "null"}]}),
),
),
],
));
for tag_local in [
"src/api/sky/types/req_mix_request_band.py",
"src/api/sky/types/opt_null_mix_request_band.py",
"src/api/sky/types/opt_nullable_request_band.py",
"src/api/sky/types/header_mix_request_x_band.py",
"src/api/sky/types/sole_enum_request_band.py",
] {
assert!(has(&files, tag_local), "{tag_local} missing");
}
for root in [
"src/api/types/opt_mix_request_band.py",
"src/api/types/req_null_mix_request_band.py",
"src/api/types/opt_consts_request_band.py",
] {
assert!(has(&files, root), "{root} missing");
}
// A 3.0 `nullable: true` beside the composition makes the argument optional.
let raw = &files["src/api/sky/raw_client.py"];
assert!(
raw.contains("band: typing.Optional[OptNullableRequestBand] = None"),
"{raw}"
);
}
#[test]
fn an_array_query_parameter_names_its_union_element() {
let files = files(document(
"3.1.0",
&[
(
"focus",
query(
false,
json!({"type": "array", "items": {"anyOf": [
{"type": "string", "enum": ["wide", "tele"]}, {"type": "integer"}
]}}),
),
),
(
"nullItems",
query(
true,
json!({"type": "array", "items": {"anyOf": [
{"type": "string"}, {"type": "integer"}, {"type": "null"}
]}}),
),
),
],
));
assert_eq!(
files["src/api/sky/types/focus_request_band_item.py"]
.lines()
.last()
.unwrap_or_default(),
"FocusRequestBandItem = typing.Union[FocusRequestBandItemZero, int]"
);
assert!(has(
&files,
"src/api/sky/types/focus_request_band_item_zero.py"
));
let raw = &files["src/api/sky/raw_client.py"];
assert!(
raw.contains(
"typing.Union[FocusRequestBandItem, typing.Sequence[FocusRequestBandItem]]"
),
"{raw}"
);
// A `null` member leaves the alias, and the signature names it bare.
assert!(
raw.contains("typing.Sequence[NullItemsRequestBandItem]"),
"{raw}"
);
// The scalar element reaches the URL as text.
assert!(raw.contains("\"band\": band,"), "{raw}");
// A required one's docstring passes an empty list, as certified Fern's
// does for any items admitting `null`.
let client = &files["src/api/sky/client.py"];
assert!(client.contains("band=[],"), "{client}");
assert!(!client.contains("band=[\"band\"]"), "{client}");
}
#[test]
fn a_scalar_or_array_query_parameter_is_one_or_many_only_where_required_iff_nullable() {
let one_or_many = |null: bool| {
let mut members = vec![
json!({"type": "integer"}),
json!({"type": "array", "items": {"type": "integer"}}),
];
if null {
members.push(json!({"type": "null"}));
}
json!({"oneOf": members})
};
let files = files(document(
"3.1.0",
&[
("required", query(true, one_or_many(false))),
("requiredNull", query(true, one_or_many(true))),
("optional", query(false, one_or_many(false))),
(
"enumOrMany",
query(
false,
json!({"anyOf": [
{"type": "string", "enum": ["x", "y"]},
{"type": "array", "items": {"type": "string", "enum": ["x", "y"]}}
]}),
),
),
],
));
let raw = &files["src/api/sky/raw_client.py"];
// Required and not nullable: a named, required, converted union.
assert!(has(&files, "src/api/sky/types/required_request_band.py"));
assert!(raw.contains("band: RequiredRequestBand,"), "{raw}");
assert!(
raw.contains("object_=band, annotation=RequiredRequestBand, direction=\"write\""),
"{raw}"
);
// The other diagonal keeps the optional one-or-many shorthand.
assert!(
raw.contains("band: typing.Optional[typing.Union[int, typing.Sequence[int]]] = None"),
"{raw}"
);
assert!(!has(&files, "src/api/types/optional_request_band.py"));
// An enum spelling is a union whose array member names its element.
assert!(has(
&files,
"src/api/types/enum_or_many_request_band_one_item.py"
));
// A required, nullable one is exampled by its scalar in the docstring and
// as a list in the reference.
let client = &files["src/api/sky/client.py"];
assert!(
client.contains("client.sky.required_null(\n band=1,\n )"),
"{client}"
);
assert!(
client.contains("client.sky.required(\n band=1,\n )"),
"{client}"
);
let reference = &files["reference.md"];
assert!(
reference.contains("client.sky.required_null(\n band=[\n 1\n ],\n)"),
"{reference}"
);
}
#[test]
fn a_query_union_over_a_component_string_enum_is_written_raw() {
let mut document = document(
"3.1.0",
&[(
"weave",
query(
true,
json!({"anyOf": [{"type": "string"}, {"$ref": "#/components/schemas/Weave"}]}),
),
)],
);
document["components"] =
json!({"schemas": {"Weave": {"type": "string", "enum": ["plain", "twill"]}}});
let files = files(document);
let raw = &files["src/api/sky/raw_client.py"];
assert!(raw.contains("\"band\": band,"), "{raw}");
assert!(
!raw.contains("convert_and_respect_annotation_metadata"),
"{raw}"
);
// The `str` member ahead of the enum holds its value as plain text.
let client = &files["src/api/sky/client.py"];
assert!(client.contains("band=\"plain\","), "{client}");
}
fn header_document(schemes: Value, names: &[&str]) -> Value {
let operations: Vec<(String, Value)> = names
.iter()
.enumerate()
.map(|(index, name)| {
(
format!("fly{index}"),
json!([{"name": name, "in": "header", "required": false, "schema": {"type": "string"}}]),
)
})
.collect();
let borrowed: Vec<(&str, Value)> = operations
.iter()
.map(|(name, value)| (name.as_str(), value.clone()))
.collect();
let mut document = document("3.0.3", &borrowed);
document["components"] = json!({"securitySchemes": schemes});
document["security"] = json!([{"tok": []}]);
document
}
#[test]
fn only_the_exact_spelling_of_a_credential_header_is_dropped() {
let bearer = files(header_document(
json!({"tok": {"type": "http", "scheme": "bearer"}}),
&["authorization", "AUTHORIZATION", "Authorization"],
));
let raw = &bearer["src/api/sky/raw_client.py"];
assert!(
raw.contains("\"authorization\": str(authorization)"),
"{raw}"
);
assert!(
raw.contains("\"AUTHORIZATION\": str(authorization)"),
"{raw}"
);
assert!(!raw.contains("\"Authorization\""), "{raw}");
let key = files(header_document(
json!({"tok": {"type": "apiKey", "in": "header", "name": "X-Kite-Key"}}),
&["X-Kite-Key", "x-kite-key"],
));
let raw = &key["src/api/sky/raw_client.py"];
assert!(raw.contains("\"x-kite-key\""), "{raw}");
assert!(!raw.contains("\"X-Kite-Key\": str("), "{raw}");
// A header argument is no argument to the README's abbreviated calls.
assert!(
key["README.md"].contains("client.sky.fly0()"),
"{}",
key["README.md"]
);
}
#[test]
fn a_subset_promoted_string_header_with_a_default_is_a_literal() {
let mut operations = Vec::new();
for index in 0..4 {
let mut parameters = vec![
json!({"name": "X-Count", "in": "header", "required": false, "schema": {"type": "integer", "default": 3}}),
];
if index < 3 {
parameters.push(json!({"name": "Dry-Run", "in": "header", "required": false, "schema": {"type": "string", "default": "off"}}));
} else {
parameters.clear();
}
operations.push((format!("op{index}"), Value::Array(parameters)));
}
let borrowed: Vec<(&str, Value)> = operations
.iter()
.map(|(name, value)| (name.as_str(), value.clone()))
.collect();
let files = files(document("3.0.3", &borrowed));
let client = &files["src/api/client.py"];
assert!(
client.contains("dry_run: typing.Optional[typing.Literal[\"off\"]] = None,"),
"{client}"
);
// A non-string default keeps the plain scalar.
assert!(
client.contains("count: typing.Optional[int] = None,"),
"{client}"
);
let wrapper = &files["src/api/core/client_wrapper.py"];
assert!(
wrapper.contains("headers[\"Dry-Run\"] = self._dry_run\n"),
"{wrapper}"
);
}
fn base_path_document(extension: (&str, Value), include: bool) -> Value {
let prefix = if include { "/{edition}" } else { "" };
let mut document = json!({
"openapi": "3.0.3",
"info": {"title": "Api", "version": "1"},
"servers": [{"url": "https://maps.example.org"}],
"paths": {
format!("{prefix}/regions"): {"post": {
"operationId": "createRegion",
"tags": ["regions"],
"parameters": if include {
json!([{"name": "edition", "in": "path", "required": true, "schema": {"type": "string"}}])
} else {
json!([])
},
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Region"}}}},
"responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Region"}}}}}
}}
},
"components": {"schemas": {"Region": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}}
});
document[extension.0] = extension.1;
document
}
#[test]
fn a_base_path_lifts_its_placeholders_to_the_client() {
let defaulted = files(base_path_document(
(
"x-fern-base-path",
json!({"path": "/{edition}", "paths-include-base-path": true, "parameters": {"edition": {"type": "string", "default": "v2"}}}),
),
true,
));
let client = &defaulted["src/api/client.py"];
assert!(
client.contains("edition: typing.Optional[str] = \"v2\","),
"{client}"
);
assert!(client.contains(" edition=\"v2\",\n"), "{client}");
let wrapper = &defaulted["src/api/core/client_wrapper.py"];
assert!(
wrapper.contains("edition: typing.Optional[str] = None,"),
"{wrapper}"
);
assert!(!wrapper.contains("headers[\"edition\"]"), "{wrapper}");
let raw = &defaulted["src/api/regions/raw_client.py"];
assert!(
raw.contains("f\"{encode_path_param(self._client_wrapper._edition)}/regions\""),
"{raw}"
);
assert!(
raw.contains("def create_region(\n self, *, name: str"),
"{raw}"
);
assert!(!raw.contains("content-type"), "{raw}");
// The Markdown passes it by keyword with its default to the three
// constructors that name arguments (sync, async, environments), and to
// no method.
let readme = &defaulted["README.md"];
assert!(
readme.contains("client = ApiApi(\n edition=\"v2\",\n)"),
"{readme}"
);
assert_eq!(readme.matches("edition=").count(), 3, "{readme}");
assert!(
!readme.contains(" edition=\"v2\",\n name="),
"{readme}"
);
// Without a default the argument is required, and the Markdown constructs
// the client with it.
let required = files(base_path_document(
("x-crozier-base-path", json!({"path": "/{edition}"})),
false,
));
assert!(required["src/api/client.py"].contains(" edition: str,\n"));
assert!(
required["README.md"].contains("client = ApiApi(\n edition=\"YOUR_EDITION\",\n)"),
"{}",
required["README.md"]
);
assert!(required["src/api/regions/raw_client.py"]
.contains("self._client_wrapper._edition)}/regions"));
// `x-crozier-base-path` wins over `x-fern-base-path`.
let mut both = base_path_document(("x-fern-base-path", json!("/v1")), false);
both["x-crozier-base-path"] = json!("/v2/");
let both = files(both);
assert!(
both["src/api/regions/raw_client.py"].contains("\"v2/regions\""),
"{}",
both["src/api/regions/raw_client.py"]
);
}
#[test]
fn a_map_value_under_a_one_letter_owner_is_named_across_the_join() {
let files = files(json!({
"openapi": "3.0.0",
"info": {"title": "Api", "version": "1"},
"paths": {},
"components": {"schemas": {"Dto": {"type": "object", "properties": {
"f": {"type": "object", "properties": {
"w": {"type": "object", "additionalProperties": {
"type": "object", "properties": {"id": {"type": "number"}}
}}
}}
}}}}
}));
assert!(
files
.keys()
.any(|path| path.ends_with("types/dto_fw_value.py")),
"{:?}",
files.keys()
);
assert!(files
.values()
.any(|text| text.contains("class DtoFwValue(")));
}
/// One `GET /beds` operation (beside a second operation, so no header is
/// promoted) taking `parameters`.
fn beds_document(parameters: Value) -> Value {
json!({
"openapi": "3.0.3",
"info": {"title": "Beds", "version": "1"},
"paths": {
"/beds": {"get": {
"operationId": "listBeds",
"tags": ["beds"],
"parameters": parameters,
"responses": {"204": {"description": "ok"}}
}},
"/beds/count": {"get": {
"operationId": "countBeds",
"tags": ["beds"],
"responses": {"204": {"description": "ok"}}
}}
}
})
}
#[test]
fn nullable_array_items_lose_their_optional_in_the_signature_and_docs() {
let files = files(beds_document(json!([{
"name": "trays", "in": "query", "required": true,
"schema": {"type": "array", "items": {"type": "string", "nullable": true}}
}])));
let annotation = "typing.Optional[typing.Union[str, typing.Sequence[str]]]";
let client = &files["src/api/beds/client.py"];
assert!(
client.contains(&format!("trays: {annotation} = None,")),
"{client}"
);
// A docstring passes an empty list; the Markdown one non-null element.
assert!(client.contains(" trays=[],\n"), "{client}");
let reference = &files["reference.md"];
assert!(
reference.contains(&format!("**trays:** `{annotation}`")),
"{reference}"
);
assert!(
reference.contains(" trays=[\n \"trays\"\n ],"),
"{reference}"
);
assert!(!reference.contains("None"), "{reference}");
}
#[test]
fn constant_headers_are_sent_in_order_and_left_out_of_the_docs() {
let header = |name: &str, required: bool, schema: Value| json!({"name": name, "in": "header", "required": required, "schema": schema});
let files = files(beds_document(json!([
header("X-Watts", true, json!({"type": "integer", "default": 40})),
header("X-Hue", true, json!({"type": "string", "default": "amber"})),
header(
"X-Mist",
false,
json!({"type": "string", "enum": ["fine", "coarse"], "default": "fine"})
),
])));
let raw = &files["src/api/beds/raw_client.py"];
assert!(
raw.contains(
" \"X-Watts\": str(watts) if watts is not None else None,\n \"X-Hue\": \"amber\",\n \"X-Mist\": \"fine\",\n"
),
"{raw}"
);
for doc in ["README.md", "reference.md"] {
let text = &files[doc];
assert!(text.contains("watts=1"), "{doc}: {text}");
assert!(
!text.contains("hue") && !text.contains("mist"),
"{doc}: {text}"
);
}
}
}
#[cfg(test)]
mod wire_tests {
//! How a method builds its request and reads its response, measured at
//! fern-python-sdk 5.41.3, each rule driven through the whole generator.
use serde_json::{json, Value};
/// Every file the generator writes for `document` in `layout`, keyed by path.
fn files_in(
document: Value,
layout: crate::settings::Layout,
) -> std::collections::BTreeMap<String, String> {
let doc: crate::openapi::OpenApi =
serde_json::from_value(document).expect("document deserializes");
let mut config = crate::config::GenerateConfig::new(
std::path::PathBuf::from("openapi.yml"),
std::path::PathBuf::from("out"),
Some("api".to_string()),
None,
None,
crate::settings::ExtraFields::default(),
"Api",
)
.expect("the config is well formed");
config.layout = layout;
super::generate(&crate::ir::build(&doc, &config))
.expect("the document generates")
.into_iter()
.map(|file| {
(
file.path.to_string_lossy().replace('\\', "/"),
file.contents,
)
})
.collect()
}
fn files(document: Value) -> std::collections::BTreeMap<String, String> {
files_in(document, crate::settings::Layout::default())
}
fn raw_client(files: &std::collections::BTreeMap<String, String>, module: &str) -> String {
files
.iter()
.find(|(path, _)| path.ends_with(&format!("{module}/raw_client.py")))
.map(|(_, contents)| contents.clone())
.unwrap_or_else(|| panic!("no {module}/raw_client.py in {:?}", files.keys()))
}
#[test]
fn an_sse_stream_skips_events_with_empty_data() {
let tick = json!({"type": "object", "properties": {"n": {"type": "integer"}}});
let generated = files(json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/ticks": {"get": {
"operationId": "stream", "tags": ["ticks"],
"responses": {"200": {"description": "ok",
"content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/Tick"}}}}}
}}},
"components": {"schemas": {"Tick": tick}}
}));
let raw = raw_client(&generated, "ticks");
let skip = "if _sse.data == None:\n return\n if len(_sse.data) == 0:\n continue\n";
// Once in the sync method, once in the async one.
assert_eq!(raw.matches(skip).count(), 2, "{raw}");
}
#[test]
fn an_event_dispatched_stream_parses_each_event_as_its_envelope() {
let event = |name: &str| {
json!({"type": "object", "required": ["event", "data"], "properties": {
"event": {"type": "string", "enum": [name]},
"data": {"type": "object", "properties": {"n": {"type": "integer"}}}
}})
};
let generated = files(json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/ticks": {"get": {
"operationId": "stream", "tags": ["ticks"],
"responses": {"200": {"description": "ok",
"content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/Change"}}}}}
}}},
"components": {"schemas": {
"Change": {
"oneOf": [
{"$ref": "#/components/schemas/Opened"},
{"$ref": "#/components/schemas/Closed"}
],
"discriminator": {"propertyName": "event"}
},
"Opened": event("opened"),
"Closed": event("closed")
}}
}));
let raw = raw_client(&generated, "ticks");
for model in ["Opened", "Closed"] {
let parse = format!(
"type_={model},\n object_={{\"data\": json.loads(_sse.data)}},"
);
assert_eq!(raw.matches(&parse).count(), 2, "{model}: {raw}");
}
assert_eq!(raw.matches("if len(_sse.data) == 0:").count(), 2, "{raw}");
}
#[test]
fn a_multipart_list_of_primitives_repeats_its_field_and_other_lists_stay_json() {
let generated = files(json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/files": {"post": {
"operationId": "upload", "tags": ["files"],
"requestBody": {"content": {"multipart/form-data": {"schema": {
"type": "object",
"required": ["file", "labels"],
"properties": {
"file": {"type": "string", "format": "binary"},
"labels": {"type": "array", "items": {"type": "string"}},
"read": {"type": "array", "items": {"type": "string"}},
"kinds": {"type": "array", "items": {"$ref": "#/components/schemas/Kind"}},
"ids": {"type": "array", "items": {"type": "string", "format": "uuid"}},
"points": {"type": "array", "items": {"type": "object",
"properties": {"x": {"type": "integer"}}}}
}
}}}},
"responses": {"204": {"description": "ok"}}
}}},
"components": {"schemas": {"Kind": {"type": "string", "enum": ["a", "b"]}}}
}));
let raw = raw_client(&generated, "files");
assert!(
raw.contains("\"labels\": jsonable_encoder(labels),\n"),
"{raw}"
);
for name in ["read", "kinds"] {
assert!(
raw.contains(&format!(
"\"{name}\": jsonable_encoder({name}) if {name} is not OMIT else OMIT,\n"
)),
"{name}: {raw}"
);
}
for name in ["ids", "points"] {
assert!(
raw.contains(&format!(
"\"{name}\": json.dumps(jsonable_encoder({name})) if {name} is not OMIT else OMIT,\n"
)),
"{name}: {raw}"
);
}
}
/// A `PATCH /folders/{id}` whose `$ref` body declares an `id` of its own.
fn folder_document(body_also_returned: bool, read_only_id: bool) -> Value {
let mut id = json!({"type": "string"});
if read_only_id {
id["readOnly"] = json!(true);
}
let response = if body_also_returned {
json!({"200": {"description": "ok", "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Folder"}}}}})
} else {
json!({"204": {"description": "ok"}})
};
json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/folders/{id}": {"patch": {
"operationId": "update", "tags": ["folders"],
"parameters": [{"name": "id", "in": "path", "required": true,
"schema": {"type": "string"}}],
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Folder"}}}},
"responses": response
}}},
"components": {"schemas": {"Folder": {"type": "object", "properties": {
"id": id, "name": {"type": "string"}}}}}
})
}
#[test]
fn a_path_parameter_a_request_only_body_property_claims_is_renamed() {
// A body schema only a request references claims its property names,
// `readOnly` ones included, so the path parameter becomes `id_path_param`.
for read_only in [true, false] {
let raw = raw_client(&files(folder_document(false, read_only)), "folders");
assert!(raw.contains("id_path_param: str,"), "{raw}");
assert!(
raw.contains("f\"folders/{encode_path_param(id_path_param)}\""),
"{raw}"
);
}
// A body schema a response also returns claims only `body`: the
// parameter keeps its name, and the generator's own collision rule
// suffixes it when the body sends an `id` argument too.
let raw = raw_client(&files(folder_document(true, false)), "folders");
assert!(raw.contains("id_: str,"), "{raw}");
assert!(!raw.contains("id_path_param"), "{raw}");
// So does one that is the body of a second operation.
let mut shared = folder_document(false, false);
shared["paths"]["/folders"] = json!({"post": {
"operationId": "create", "tags": ["folders"],
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Folder"}}}},
"responses": {"204": {"description": "ok"}}
}});
let raw = raw_client(&files(shared), "folders");
assert!(raw.contains("id_: str,"), "{raw}");
assert!(!raw.contains("id_path_param"), "{raw}");
}
#[test]
fn a_path_parameter_a_query_parameter_shares_a_name_with_is_renamed() {
let generated = files(json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/items/{kind}": {"get": {
"operationId": "list", "tags": ["items"],
"parameters": [
{"name": "kind", "in": "path", "required": true, "schema": {"type": "string"}},
{"name": "kind", "in": "query", "schema": {"type": "string"}}
],
"responses": {"204": {"description": "ok"}}
}}}
}));
let raw = raw_client(&generated, "items");
assert!(raw.contains("kind_path_param: str,"), "{raw}");
}
#[test]
fn the_flat_layout_pages_like_the_packaged_one() {
let page = json!({"type": "object", "properties": {
"items": {"type": "array", "items": {"type": "string"}},
"next": {"type": "string"}}});
let generated = files_in(
json!({
"openapi": "3.0.3",
"info": {"title": "t", "version": "1"},
"paths": {"/items": {"get": {
"operationId": "list", "tags": ["items"],
"parameters": [{"name": "cursor", "in": "query", "schema": {"type": "string"}}],
"x-fern-pagination": {"cursor": "$request.cursor",
"next_cursor": "$response.next", "results": "$response.items"},
"responses": {"200": {"description": "ok", "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Page"}}}}}
}}},
"components": {"schemas": {"Page": page}}
}),
crate::settings::Layout::Flat,
);
let raw = raw_client(&generated, "items");
assert!(raw.contains("SyncPager["), "{raw}");
}
}