soothfast-sdk 0.1.8

Soothfast SDK engine: native client emitters (Python/TypeScript) over the spec IR
Documentation
//! Render the package scaffolding: `pyproject.toml`, `README.md`,
//! `__init__.py`.

use std::fmt::Write;

use soothfast_spec::dialect::Info;

use crate::SdkOptions;
use crate::model::Sdk;
use crate::python::HEADER;

pub(crate) fn pyproject(info: &Info, opts: &SdkOptions) -> String {
    let description = opts
        .description
        .clone()
        .or_else(|| info.description.clone())
        .unwrap_or_else(|| format!("Generated client for {}", info.title));
    let mut out = String::new();
    let _ = writeln!(out, "{HEADER}");
    let _ = writeln!(out, "[build-system]");
    let _ = writeln!(out, "requires = [\"hatchling\"]");
    let _ = writeln!(out, "build-backend = \"hatchling.build\"");
    let _ = writeln!(out);
    let _ = writeln!(out, "[project]");
    let _ = writeln!(out, "name = {:?}", opts.package);
    let _ = writeln!(out, "version = {:?}", opts.version);
    let _ = writeln!(out, "description = {description:?}");
    let _ = writeln!(out, "readme = \"README.md\"");
    let _ = writeln!(out, "requires-python = \">=3.10\"");
    let _ = writeln!(out, "dependencies = [\"httpx>=0.27\"]");
    if let Some(repo) = &opts.repository {
        let _ = writeln!(out);
        let _ = writeln!(out, "[project.urls]");
        let _ = writeln!(out, "Repository = {repo:?}");
    }
    let _ = writeln!(out);
    // Hatchling excludes anything the surrounding repo's .gitignore
    // matches. What belongs in a generated package is decided here, not by
    // whatever the host repo happens to ignore — a blanket `*.py` upstream
    // otherwise ships a wheel with no Python in it.
    let _ = writeln!(out, "[tool.hatch.build]");
    let _ = writeln!(out, "ignore-vcs = true");
    let _ = writeln!(out);
    let _ = writeln!(out, "[tool.hatch.build.targets.wheel]");
    let _ = writeln!(out, "packages = [\"src/{}\"]", opts.module);
    if opts.embed.is_some() {
        let _ = writeln!(out);
        let _ = writeln!(out, "[tool.hatch.build.targets.wheel.hooks.custom]");
        let _ = writeln!(out, "path = \"hatch_build.py\"");
        let _ = writeln!(out);
        // The hook has to reach the sdist, or a source install cannot
        // rebuild the wheel it came from.
        let _ = writeln!(out, "[tool.hatch.build.targets.sdist]");
        let _ = writeln!(
            out,
            "include = [\"README.md\", \"hatch_build.py\", \"src\"]"
        );
    }
    out
}

/// A copyable example: the knobs the template leaves unset are the ones a
/// caller actually has to supply, so lead with those.
fn example_env(opts: &SdkOptions) -> String {
    let unset: Vec<&crate::envtemplate::EnvVar> = opts
        .embed_env_vars
        .iter()
        .filter(|v| v.optional())
        .collect();
    let picks = if unset.is_empty() {
        opts.embed_env_vars.iter().collect()
    } else {
        unset
    };
    picks
        .iter()
        .take(2)
        .map(|v| format!("{:?}: \"...\"", v.name))
        .collect::<Vec<_>>()
        .join(", ")
}

pub(crate) fn readme(info: &Info, sdk: &Sdk, opts: &SdkOptions, base_url: Option<&str>) -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        "<!-- Code generated by `cargo soothfast sdk gen`. DO NOT EDIT. -->"
    );
    let _ = writeln!(out, "# {}", opts.package);
    let _ = writeln!(out);
    let _ = write!(
        out,
        "Generated Python client for **{}** ({})",
        info.title, info.version
    );
    match (&opts.embed, base_url) {
        (Some(_), _) => {
            let _ = writeln!(
                out,
                ", bundling the server it talks to — there is nothing to \
                 deploy and no endpoint to configure."
            );
        }
        (None, Some(url)) => {
            let _ = writeln!(out, ", talking to `{url}`.");
        }
        (None, None) => {
            let _ = writeln!(out, ".");
        }
    }
    let _ = writeln!(out);
    let _ = writeln!(out, "```bash");
    let _ = writeln!(out, "pip install {}", opts.package);
    let _ = writeln!(out, "```");
    let _ = writeln!(out);
    let _ = writeln!(out, "```python");
    let _ = writeln!(out, "from {} import Client", opts.module);
    let _ = writeln!(out);
    let _ = writeln!(out, "with Client() as client:");
    match sdk.methods.first() {
        Some(m) => {
            let _ = writeln!(out, "    result = client.{}(...)", m.name);
        }
        None => {
            let _ = writeln!(out, "    ...");
        }
    }
    let _ = writeln!(out, "```");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "An `AsyncClient` with the same methods is importable alongside \
         `Client`. Errors raise subclasses of `ApiError`; rate limits retry \
         automatically, honoring `Retry-After`."
    );
    if let Some(binary) = &opts.embed {
        let prefix = crate::naming::env_prefix(&opts.package);
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "## Embedded server\n\n\
             Constructing a client starts the bundled `{binary}` on a port \
             it picks itself, and the process is reaped when yours exits. \
             Two environment variables change that:\n\n\
             - `{prefix}_BASE_URL` — talk to an already-running instance \
             and spawn nothing.\n\
             - `{prefix}_SERVER_BIN` — use a different server binary.\n\n\
             `stop_embedded_servers()` shuts it down early; \
             `EmbeddedServer.start()` manages one by hand."
        );
        if let Some(url) = base_url {
            let _ = writeln!(
                out,
                "\nPass `DEFAULT_BASE_URL` (`{url}`) to use the hosted \
                 deployment instead."
            );
        }
        if !opts.embed_env_vars.is_empty() {
            let _ = writeln!(out);
            let _ = writeln!(
                out,
                "### Configuring it\n\n\
                 The server reads the same environment a deployment sets it \
                 with. `server_env` supplies that per client:\n\n\
                 ```python\n\
                 client = Client(server_env={{{}}})\n\
                 ```\n\n\
                 Those values win over the ambient environment; anything left \
                 out falls back to what this process — or a `.env` file the \
                 server itself reads — already provides. Clients asking for \
                 different `server_env` get their own server process. The \
                 `ServerEnv` TypedDict spells every knob:\n",
                example_env(opts),
            );
            let _ = write!(
                out,
                "{}",
                crate::envtemplate::markdown_table(&opts.embed_env_vars)
            );
        }
    }
    let _ = writeln!(out);
    let _ = writeln!(out, "## Methods");
    let _ = writeln!(out);
    for m in &sdk.methods {
        let doc = m.doc.as_deref().unwrap_or("");
        let _ = writeln!(
            out,
            "- `{}` — `{} {}`{}{}",
            m.name,
            m.http_method,
            m.path,
            if doc.is_empty() { "" } else { ": " },
            doc,
        );
    }
    out
}

pub(crate) fn init(info: &Info, sdk: &Sdk, opts: &SdkOptions) -> String {
    let mut exports: Vec<String> = vec![
        "ApiError".into(),
        "AsyncClient".into(),
        "BadRequestError".into(),
        "Client".into(),
        "NotFoundError".into(),
        "Page".into(),
        "PageInfo".into(),
        "RateLimitError".into(),
        "ServerError".into(),
        "ServiceUnavailableError".into(),
    ];
    if opts.embed.is_some() {
        exports.extend(["EmbeddedServer".into(), "stop_embedded_servers".into()]);
    }
    let mut model_names: Vec<&str> = sdk.models.iter().map(|m| m.name.as_str()).collect();
    model_names.extend(sdk.aliases.iter().map(|(n, _)| n.as_str()));
    model_names.sort_unstable();
    exports.extend(model_names.iter().map(|n| n.to_string()));
    exports.sort_unstable();

    let mut out = String::new();
    let _ = writeln!(out, "{HEADER}");
    let _ = writeln!(
        out,
        "\"\"\"{} — generated client for {}.\"\"\"",
        opts.package, info.title
    );
    let _ = writeln!(out, "from __future__ import annotations");
    let _ = writeln!(out);
    let _ = writeln!(out, "from ._runtime import (");
    for name in [
        "ApiError",
        "BadRequestError",
        "NotFoundError",
        "Page",
        "PageInfo",
        "RateLimitError",
        "ServerError",
        "ServiceUnavailableError",
    ] {
        let _ = writeln!(out, "    {name},");
    }
    let _ = writeln!(out, ")");
    let _ = writeln!(out, "from .client import AsyncClient, Client");
    if opts.embed.is_some() {
        let _ = writeln!(
            out,
            "from ._server import EmbeddedServer, stop_embedded_servers"
        );
    }
    if !model_names.is_empty() {
        let _ = writeln!(out, "from .models import (");
        for name in &model_names {
            let _ = writeln!(out, "    {name},");
        }
        let _ = writeln!(out, ")");
    }
    let _ = writeln!(out);
    let _ = writeln!(out, "__version__ = {:?}", opts.version);
    let _ = writeln!(out);
    let _ = writeln!(out, "__all__ = [");
    for name in &exports {
        let _ = writeln!(out, "    {name:?},");
    }
    let _ = writeln!(out, "]");
    out
}