soothfast-sdk 0.4.3

Soothfast SDK engine: native client emitters (Python/TypeScript) over the spec IR
Documentation
//! The Python emitter: models + clients over one static runtime module.

mod client;
mod models;
pub(crate) mod naming;
mod package;

use soothfast_spec::dialect::Info;

use crate::model::{Sdk, Ty};
use crate::{SdkFileSet, SdkOptions};

pub(crate) const HEADER: &str = "# Code generated by `cargo soothfast sdk gen`. DO NOT EDIT.";

const RUNTIME: &str = include_str!("../../assets/python/_runtime.py");
const SERVER: &str = include_str!("../../assets/python/_server.py");
/// The one asset that is not emitted verbatim: the wheel hook has to name
/// the import package it drops the binary into.
const HATCH_HOOK: &str = include_str!("../../assets/python/hatch_build.py");

pub(crate) fn emit(info: &Info, sdk: &Sdk, opts: &SdkOptions) -> Result<SdkFileSet, String> {
    let base_url = crate::base_url_for(info, opts)?;

    let src = format!("src/{}", opts.module);
    let mut out = SdkFileSet {
        notes: sdk.notes.clone(),
        ..SdkFileSet::default()
    };
    let files = &mut out.files;
    files.insert("pyproject.toml".into(), package::pyproject(info, opts));
    files.insert(
        "README.md".into(),
        package::readme(info, sdk, opts, base_url.as_deref()),
    );
    files.insert(format!("{src}/py.typed"), String::new());
    files.insert(format!("{src}/_runtime.py"), RUNTIME.to_string());
    files.insert(format!("{src}/__init__.py"), package::init(info, sdk, opts));
    files.insert(format!("{src}/models.py"), models::render(sdk, opts));
    files.insert(
        format!("{src}/client.py"),
        client::render(sdk, opts, base_url.as_deref()),
    );
    if opts.embed.is_some() {
        files.insert(format!("{src}/_server.py"), SERVER.to_string());
        files.insert(
            "hatch_build.py".into(),
            HATCH_HOOK.replace("{module}", &opts.module),
        );
    }
    Ok(out)
}

/// Render a type as a Python annotation. `prefix` qualifies named models
/// (`models.` in client code, empty inside `models.py` itself).
pub(crate) fn annotation(ty: &Ty, prefix: &str) -> String {
    match ty {
        Ty::Str => "str".into(),
        Ty::Int => "int".into(),
        Ty::Float => "float".into(),
        Ty::Bool => "bool".into(),
        Ty::Any => "typing.Any".into(),
        Ty::Unit => "None".into(),
        Ty::List(t) => format!("list[{}]", annotation(t, prefix)),
        Ty::Map(t) => format!("dict[str, {}]", annotation(t, prefix)),
        Ty::Optional(t) => format!("{} | None", annotation(t, prefix)),
        Ty::Named(n) => format!("{prefix}{n}"),
        Ty::Union(arms) => arms
            .iter()
            .map(|a| annotation(a, prefix))
            .collect::<Vec<_>>()
            .join(" | "),
        Ty::Literal(values) => {
            let quoted: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
            format!("typing.Literal[{}]", quoted.join(", "))
        }
    }
}

/// The `into=` expression handed to the transport: `None` means "no decode
/// step" — raw JSON for open shapes, nothing at all for empty bodies.
pub(crate) fn into_expr(ty: &Ty) -> String {
    match ty {
        Ty::Any | Ty::Unit => "None".into(),
        _ => annotation(ty, "models."),
    }
}

/// Escape a string into a Python docstring body.
pub(crate) fn doc_escape(text: &str) -> String {
    text.replace('\\', "\\\\").replace("\"\"\"", "\\\"\\\"\\\"")
}