Skip to main content

usage/sdk/python/
mod.rs

1use std::path::PathBuf;
2
3use crate::case::{AsPascalCase, AsSnakeCase};
4
5use crate::Framing;
6use crate::sdk::{
7    ChoiceTypeMap, CodeWriter, SdkFile, SdkOptions, SdkOutput, collect_choice_types,
8    collect_type_imports, command_type_name, escape_py_docstring, escape_py_string, flag_names,
9    generated_header,
10};
11use crate::spec::arg::SpecDoubleDashChoices;
12use crate::spec::cmd::SpecCommand;
13use crate::spec::config::{SpecConfigProp, SpecConfigValue};
14use crate::spec::data_types::SpecDataTypes;
15use crate::{Spec, SpecArg, SpecFlag};
16
17fn sanitize_py_comment(text: &str) -> String {
18    text.replace(['\n', '\r'], " ")
19}
20
21mod runtime;
22
23pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput {
24    let package_name = opts
25        .package_name
26        .clone()
27        .unwrap_or_else(|| spec.bin.clone());
28
29    SdkOutput {
30        files: vec![
31            SdkFile {
32                path: PathBuf::from("types.py"),
33                content: render_types(spec, &package_name, &opts.source_file),
34            },
35            SdkFile {
36                path: PathBuf::from("client.py"),
37                content: render_client(spec, &package_name, &opts.source_file),
38            },
39            SdkFile {
40                path: PathBuf::from("runtime.py"),
41                content: runtime::RUNTIME_PY.to_string(),
42            },
43            SdkFile {
44                path: PathBuf::from("__init__.py"),
45                content: render_init(&package_name),
46            },
47        ],
48    }
49}
50
51fn render_init(package_name: &str) -> String {
52    let class_name = AsPascalCase(package_name).to_string();
53    format!(
54        "from .client import {class_name}\n\
55         from .runtime import CliError, CliJsonResult, CliResult, CliRunner, CliStream\n\
56         from .types import *\n"
57    )
58}
59
60// ---------------------------------------------------------------------------
61// types.py
62// ---------------------------------------------------------------------------
63
64fn render_types(spec: &Spec, package_name: &str, source_file: &Option<String>) -> String {
65    let mut w = CodeWriter::with_indent("    ");
66
67    w.line(&generated_header("#", source_file));
68    w.line("from __future__ import annotations");
69    w.line("from dataclasses import dataclass");
70    // `Any` only where an output alias needs it, so a client with no declared outputs is
71    // generated exactly as before.
72    if any_outputs(&spec.cmd, spec, package_name) {
73        w.line("from typing import Any, Literal, Optional");
74    } else {
75        w.line("from typing import Literal, Optional");
76    }
77    w.line("");
78
79    // spec metadata
80    if let Some(version) = &spec.version {
81        w.line(&format!("VERSION = \"{}\"", escape_py_string(version)));
82    }
83    if let Some(about) = &spec.about {
84        w.line(&format!("ABOUT = \"{}\"", escape_py_string(about)));
85    }
86    if let Some(author) = &spec.author {
87        w.line(&format!("AUTHOR = \"{}\"", escape_py_string(author)));
88    }
89
90    let choice_types = collect_choice_types(&spec.cmd);
91    let root_global_flags: Vec<&SpecFlag> = spec
92        .cmd
93        .flags
94        .iter()
95        .filter(|f| f.global && !f.hide)
96        .collect();
97    let has_global_flags = !root_global_flags.is_empty();
98
99    if !choice_types.is_empty() {
100        w.line("");
101        for (name, choices) in choice_types.iter() {
102            let union = choices
103                .iter()
104                .map(|c| format!("\"{}\"", escape_py_string(c)))
105                .collect::<Vec<_>>()
106                .join(", ");
107            w.line(&format!("{name} = Literal[{union}]"));
108        }
109    }
110
111    if has_global_flags {
112        w.line("");
113        render_flags_dataclass(
114            "GlobalFlags",
115            &spec.cmd.name,
116            &spec.cmd.name,
117            &root_global_flags,
118            &choice_types,
119            &mut w,
120        );
121    }
122
123    render_command_types(
124        &spec.cmd,
125        package_name,
126        &spec.cmd.name,
127        &choice_types,
128        has_global_flags,
129        &root_global_flags,
130        spec,
131        &mut w,
132    );
133
134    if !spec.config.props.is_empty() {
135        w.line("");
136        let config_name = format!("{}Config", AsPascalCase(package_name));
137        w.line("");
138        w.line("@dataclass");
139        w.line(&format!("class {config_name}:"));
140        w.indent();
141        // Python dataclass requires non-default fields before default fields
142        let (required, optional): (Vec<_>, Vec<_>) = spec
143            .config
144            .props
145            .iter()
146            .partition(|(_, p)| p.default.is_none());
147
148        for (name, prop) in required.iter().chain(optional.iter()) {
149            let py_type = config_prop_type(prop);
150            // Rendered by the *declared* type, falling back to the literal's own when
151            // none is declared — a spec may say `data_type=float default="1.5"`, and the
152            // declaration is what the generated field is typed as. No `#true`-spelling or
153            // quote-stripping to undo any more: the spec keeps values, not source text.
154            // A bare Python literal only for a value that *is* one. Anything else is a
155            // quoted string, escaped.
156            //
157            // This is generated code that gets imported, so text from the spec must never
158            // reach it unquoted: a `data_type="integer"` whose default is the string
159            // `__import__("os").system(…)` would otherwise be written as an expression and
160            // run on import. Numeric strings are read as numbers when the spec parses, so
161            // by here a `String` is a string.
162            let default = match &prop.default {
163                None => String::new(),
164                Some(SpecConfigValue::Bool(b)) => {
165                    format!(" = {}", if *b { "True" } else { "False" })
166                }
167                Some(SpecConfigValue::Int(i)) => format!(" = {i}"),
168                Some(SpecConfigValue::Float(f)) => format!(" = {f:?}"),
169                Some(SpecConfigValue::String(s)) => {
170                    format!(" = \"{}\"", escape_py_string(s))
171                }
172            };
173            if let Some(help) = &prop.help {
174                w.line(&format!("# {}", sanitize_py_comment(help)));
175            }
176            w.line(&format!("{name}: {py_type}{default}"));
177        }
178        w.dedent();
179    }
180
181    w.finish()
182}
183
184#[allow(clippy::too_many_arguments)]
185fn render_command_types(
186    cmd: &SpecCommand,
187    package_name: &str,
188    root_cmd_name: &str,
189    choice_types: &ChoiceTypeMap,
190    has_global_flags: bool,
191    global_flags: &[&SpecFlag],
192    spec: &Spec,
193    w: &mut CodeWriter,
194) {
195    if cmd.hide {
196        return;
197    }
198
199    let name = command_type_name(cmd, package_name);
200    let cmd_name = &cmd.name;
201    let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect();
202    let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
203
204    if !visible_args.is_empty() {
205        w.line("");
206        render_args_dataclass(
207            &format!("{name}Args"),
208            cmd_name,
209            &visible_args,
210            choice_types,
211            w,
212        );
213    }
214
215    if !visible_flags.is_empty() {
216        w.line("");
217        let all_flags: Vec<&SpecFlag> = if has_global_flags {
218            global_flags
219                .iter()
220                .copied()
221                .chain(
222                    visible_flags
223                        .iter()
224                        .filter(|f| !global_flags.iter().any(|gf| gf.name == f.name))
225                        .copied(),
226                )
227                .collect()
228        } else {
229            visible_flags
230        };
231        render_flags_dataclass(
232            &format!("{name}Flags"),
233            cmd_name,
234            root_cmd_name,
235            &all_flags,
236            choice_types,
237            w,
238        );
239    }
240
241    // One alias per declared output, plus the schema and exit-code table beside it. The
242    // alias is `Any` today and a `TypedDict` later: every generated signature names it
243    // rather than `Any` directly, so filling it in is a substitution no caller sees.
244    let outputs = crate::sdk::output_methods(cmd, spec, package_name);
245    if !outputs.is_empty() {
246        w.line("");
247        for output in &outputs {
248            if let Some(help) = &output.help {
249                w.line(&format!("# {}", sanitize_py_comment(help)));
250            }
251            w.line(&format!("{} = Any", output.type_alias));
252        }
253    }
254    for output in &outputs {
255        // A string rather than a literal, so a schema that is not valid JSON cannot emit
256        // a module that fails to import. `json.loads` it to hand to a validator.
257        if let (Some(const_name), Some(schema)) = (&output.schema_const, &output.schema) {
258            w.line("");
259            w.line(&format!(
260                "{const_name}: str = \"{}\"",
261                escape_py_string(schema)
262            ));
263        }
264    }
265    let exit_codes = crate::sdk::exit_codes_for(cmd, spec);
266    if !exit_codes.is_empty() {
267        let entries = exit_codes
268            .iter()
269            .map(|e| format!("{}: \"{}\"", e.code, escape_py_string(&e.help)))
270            .collect::<Vec<_>>()
271            .join(", ");
272        let union = exit_codes
273            .iter()
274            .map(|e| e.code.to_string())
275            .collect::<Vec<_>>()
276            .join(", ");
277        let exit_name = crate::sdk::command_path_type_name(cmd, package_name);
278        w.line("");
279        w.line(&format!(
280            "{}_EXIT_CODES: dict[int, str] = {{{entries}}}",
281            crate::sdk::shouty(&exit_name)
282        ));
283        w.line(&format!("{exit_name}ExitCode = Literal[{union}]"));
284    }
285
286    for subcmd in cmd.subcommands.values() {
287        render_command_types(
288            subcmd,
289            package_name,
290            root_cmd_name,
291            choice_types,
292            has_global_flags,
293            global_flags,
294            spec,
295            w,
296        );
297    }
298}
299
300fn render_args_dataclass(
301    name: &str,
302    cmd_name: &str,
303    args: &[&SpecArg],
304    choice_types: &ChoiceTypeMap,
305    w: &mut CodeWriter,
306) {
307    w.line("");
308    w.line("@dataclass");
309    w.line(&format!("class {name}:"));
310    w.indent();
311    if args.is_empty() {
312        w.line("pass");
313    } else {
314        // Python dataclass requires non-default fields before default fields
315        let (required, optional): (Vec<_>, Vec<_>) = args
316            .iter()
317            .copied()
318            .partition(|a| a.required && a.default.is_empty());
319
320        for arg in required.iter().chain(optional.iter()) {
321            let py_type = arg_py_type(arg, cmd_name, choice_types);
322            let is_required_no_default = arg.required && arg.default.is_empty();
323            let field = if !is_required_no_default && !arg.default.is_empty() {
324                // has default value
325                let default_val = &arg.default[0];
326                format!(
327                    "{}: Optional[{}] = \"{}\"",
328                    sanitize_py_ident(&arg.name),
329                    py_type,
330                    escape_py_string(default_val)
331                )
332            } else if !is_required_no_default {
333                // optional without explicit default
334                format!(
335                    "{}: Optional[{}] = None",
336                    sanitize_py_ident(&arg.name),
337                    py_type
338                )
339            } else {
340                // required, no default
341                format!("{}: {}", sanitize_py_ident(&arg.name), py_type)
342            };
343            if let Some(help) = &arg.help {
344                w.line(&format!("# {}", sanitize_py_comment(help)));
345            }
346            w.line(&field);
347        }
348    }
349    w.dedent();
350}
351
352fn render_flags_dataclass(
353    name: &str,
354    cmd_name: &str,
355    root_cmd_name: &str,
356    flags: &[&SpecFlag],
357    choice_types: &ChoiceTypeMap,
358    w: &mut CodeWriter,
359) {
360    w.line("");
361    w.line("@dataclass");
362    w.line(&format!("class {name}:"));
363    w.indent();
364    if flags.is_empty() {
365        w.line("pass");
366    } else {
367        // Python dataclass requires non-default fields before default fields
368        let (required, optional): (Vec<_>, Vec<_>) = flags
369            .iter()
370            .copied()
371            .partition(|f| f.required && f.default.is_empty());
372
373        for flag in required.iter().chain(optional.iter()) {
374            let lookup_cmd = if flag.global { root_cmd_name } else { cmd_name };
375            let py_type = flag_py_type(flag, lookup_cmd, choice_types);
376            let prop_name = flag_property_name_py(flag);
377            let optional = !(flag.required && flag.default.is_empty());
378            let field = if !flag.default.is_empty() {
379                // has explicit default — use first value
380                let default_val = &flag.default[0];
381                if flag.count {
382                    let numeric = default_val.trim();
383                    if let Ok(n) = numeric.parse::<i64>() {
384                        format!("{prop_name}: {py_type} = {n}")
385                    } else {
386                        // invalid count default — cannot emit as valid Python int;
387                        // fall back to 0 with a comment
388                        format!(
389                            "{prop_name}: {py_type} = 0  # default: {}",
390                            sanitize_py_comment(default_val)
391                        )
392                    }
393                } else if flag.arg.is_none() {
394                    // boolean with default
395                    match default_val.as_str() {
396                        "true" | "#true" => format!("{prop_name}: {py_type} = True"),
397                        "false" | "#false" => format!("{prop_name}: {py_type} = False"),
398                        _ => {
399                            // unrecognized boolean default — cannot emit as valid Python bool;
400                            // fall back to Optional[bool] = None with a comment
401                            format!(
402                                "{prop_name}: Optional[bool] = None  # default: {}",
403                                sanitize_py_comment(default_val)
404                            )
405                        }
406                    }
407                } else if flag.var {
408                    // var flag with a default — list defaults are mutable and forbidden in dataclasses.
409                    // use None and preserve the intended default in a comment.
410                    format!(
411                        "{prop_name}: Optional[{py_type}] = None  # default: {}",
412                        sanitize_py_comment(default_val)
413                    )
414                } else {
415                    format!(
416                        "{prop_name}: Optional[{py_type}] = \"{}\"",
417                        escape_py_string(default_val)
418                    )
419                }
420            } else if optional {
421                format!("{prop_name}: Optional[{py_type}] = None")
422            } else {
423                format!("{prop_name}: {py_type}")
424            };
425            let mut doc_parts = Vec::new();
426            if let Some(help) = &flag.help {
427                doc_parts.push(help.clone());
428            }
429            if let Some(env) = &flag.env {
430                doc_parts.push(format!("Env: {env}"));
431            }
432            if let Some(deprecated) = &flag.deprecated {
433                doc_parts.push(format!("Deprecated: {deprecated}"));
434            }
435            if flag.long.len() > 1 {
436                let aliases: Vec<&str> = flag.long.iter().skip(1).map(|s| s.as_str()).collect();
437                doc_parts.push(format!("Aliases: {}", aliases.join(", ")));
438            }
439            if !flag.short.is_empty() {
440                let shorts: Vec<String> = flag.short.iter().map(|c| format!("-{c}")).collect();
441                doc_parts.push(format!("Short: {}", shorts.join(", ")));
442            }
443            if !doc_parts.is_empty() {
444                w.line(&format!("# {}", sanitize_py_comment(&doc_parts.join(". "))));
445            }
446            w.line(&field);
447        }
448    }
449    w.dedent();
450}
451
452fn arg_py_type(arg: &SpecArg, cmd_name: &str, choice_types: &ChoiceTypeMap) -> String {
453    let base = if let Some(choices) = crate::sdk::typed_choices(arg) {
454        if let Some(resolved) = choice_types.lookup(cmd_name, &arg.name) {
455            resolved.to_string()
456        } else {
457            let union = choices
458                .choices
459                .iter()
460                .map(|c| format!("\"{}\"", escape_py_string(c)))
461                .collect::<Vec<_>>()
462                .join(", ");
463            format!("Literal[{union}]")
464        }
465    } else {
466        "str".to_string()
467    };
468
469    if arg.var {
470        format!("list[{base}]")
471    } else {
472        base
473    }
474}
475
476fn flag_py_type(flag: &SpecFlag, cmd_name: &str, choice_types: &ChoiceTypeMap) -> String {
477    if flag.count {
478        return "int".to_string();
479    }
480
481    match &flag.arg {
482        Some(arg) => {
483            let base = if let Some(choices) = crate::sdk::typed_choices(arg) {
484                if let Some(resolved) = choice_types.lookup(cmd_name, &flag.name) {
485                    resolved.to_string()
486                } else {
487                    let union = choices
488                        .choices
489                        .iter()
490                        .map(|c| format!("\"{}\"", escape_py_string(c)))
491                        .collect::<Vec<_>>()
492                        .join(", ");
493                    format!("Literal[{union}]")
494                }
495            } else {
496                "str".to_string()
497            };
498
499            if flag.var {
500                format!("list[{base}]")
501            } else {
502                base
503            }
504        }
505        None => {
506            if flag.var {
507                "list[bool]".to_string()
508            } else {
509                "bool".to_string()
510            }
511        }
512    }
513}
514
515fn config_prop_type(prop: &SpecConfigProp) -> String {
516    match prop.data_type {
517        SpecDataTypes::String => "str".to_string(),
518        SpecDataTypes::Integer => "int".to_string(),
519        SpecDataTypes::Float => "float".to_string(),
520        SpecDataTypes::Boolean => "bool".to_string(),
521        SpecDataTypes::Null => "object".to_string(),
522    }
523}
524
525fn flag_property_name_py(flag: &SpecFlag) -> String {
526    // Python uses snake_case for attributes
527    if let Some(long) = flag.long.first() {
528        return sanitize_py_ident(&AsSnakeCase(long).to_string());
529    }
530    if let Some(short) = flag.short.first() {
531        return short.to_string();
532    }
533    sanitize_py_ident(&flag.name)
534}
535
536fn sanitize_py_ident(name: &str) -> String {
537    let snake = AsSnakeCase(name).to_string();
538    match snake.as_str() {
539        "async" | "await" | "nonlocal" | "class" | "def" | "return" | "import" | "from"
540        | "global" | "lambda" | "pass" | "raise" | "with" | "yield" | "del" | "try" | "except"
541        | "finally" | "while" | "for" | "if" | "elif" | "else" | "and" | "or" | "not" | "in"
542        | "is" | "as" | "break" | "continue" | "assert" | "type" | "input" | "id" | "list"
543        | "dict" | "set" | "print" | "range" | "format" | "help" | "vars" | "dir" | "exec"
544        | "exit" | "quit" | "bool" | "int" | "str" | "float" | "bytes" | "object" | "super"
545        | "property" | "static" | "true" | "false" | "none" => format!("_{snake}"),
546        _ => snake,
547    }
548}
549
550// ---------------------------------------------------------------------------
551// client.py
552// ---------------------------------------------------------------------------
553
554fn render_client(spec: &Spec, package_name: &str, source_file: &Option<String>) -> String {
555    let mut w = CodeWriter::with_indent("    ");
556
557    w.line(&generated_header("#", source_file));
558    w.line("from __future__ import annotations");
559    w.line("from typing import Optional");
560    if any_outputs(&spec.cmd, spec, package_name) {
561        w.line("from .runtime import CliJsonResult, CliResult, CliRunner, CliStream");
562    } else {
563        w.line("from .runtime import CliResult, CliRunner");
564    }
565
566    // collect imports from types
567    let choice_types = collect_choice_types(&spec.cmd);
568    let type_imports = collect_type_imports(&spec.cmd, package_name, &choice_types, spec);
569    let has_global_flags = spec.cmd.flags.iter().any(|f| f.global && !f.hide);
570    let mut all_imports = type_imports;
571    if has_global_flags {
572        all_imports.push("GlobalFlags".to_string());
573    }
574    all_imports.sort();
575    all_imports.dedup();
576    if !all_imports.is_empty() {
577        w.line(&format!("from .types import {}", all_imports.join(", ")));
578    }
579
580    w.line("");
581
582    let global_flags: Vec<&SpecFlag> = spec
583        .cmd
584        .flags
585        .iter()
586        .filter(|f| f.global && !f.hide)
587        .collect();
588
589    let class_name = AsPascalCase(package_name).to_string();
590    render_class(
591        &spec.cmd,
592        &class_name,
593        true,
594        &global_flags,
595        &spec.bin,
596        spec,
597        package_name,
598        &mut w,
599    );
600
601    w.finish()
602}
603
604#[allow(clippy::too_many_arguments)]
605fn render_class(
606    cmd: &SpecCommand,
607    class_name: &str,
608    is_root: bool,
609    global_flags: &[&SpecFlag],
610    bin_name: &str,
611    spec: &Spec,
612    package_name: &str,
613    w: &mut CodeWriter,
614) {
615    let visible_subcmds: Vec<_> = cmd.subcommands.iter().filter(|(_, c)| !c.hide).collect();
616
617    let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect();
618    let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
619    let has_args = !visible_args.is_empty();
620    let has_flags = !visible_flags.is_empty() || !global_flags.is_empty();
621
622    // docstring on class
623    let mut class_doc = Vec::new();
624    if let Some(help) = &cmd.help {
625        class_doc.push(help.clone());
626    } else if let Some(about) = &cmd.help_long {
627        class_doc.push(about.clone());
628    }
629    if let Some(deprecated) = &cmd.deprecated {
630        class_doc.push(format!("DEPRECATED: {deprecated}"));
631    }
632    if !cmd.aliases.is_empty() {
633        class_doc.push(format!("Aliases: {}", cmd.aliases.join(", ")));
634    }
635
636    w.line(&format!("class {class_name}:"));
637    w.indent();
638
639    if !class_doc.is_empty() {
640        w.line(&format!(
641            "\"\"\"{}\"\"\"",
642            escape_py_docstring(&class_doc.join(". "))
643        ));
644    }
645
646    // constructor
647    if is_root {
648        w.line(&format!(
649            "def __init__(self, bin_path: str = \"{}\") -> None:",
650            escape_py_string(bin_name)
651        ));
652    } else {
653        w.line("def __init__(self, runner: CliRunner) -> None:");
654    }
655    w.indent();
656    if is_root {
657        w.line("self._runner = CliRunner(bin_path)");
658    } else {
659        w.line("self._runner = runner");
660    }
661    for (name, _) in &visible_subcmds {
662        let sub_class = AsPascalCase(name).to_string();
663        let prop = sanitize_py_ident(name);
664        w.line(&format!("self.{prop} = {sub_class}(self._runner)"));
665    }
666    w.dedent();
667
668    // exec method — build signature with four explicit arms to avoid double comma
669    let flags_type = if !global_flags.is_empty() && !visible_flags.is_empty() {
670        format!("{class_name}Flags")
671    } else if !global_flags.is_empty() && visible_flags.is_empty() {
672        "GlobalFlags".to_string()
673    } else if !visible_flags.is_empty() {
674        format!("{class_name}Flags")
675    } else {
676        String::new()
677    };
678    let outputs = crate::sdk::output_methods(cmd, spec, package_name);
679    // A command with declared outputs builds its argv in a helper, so `exec` and each
680    // per-output method share one copy of the assembly. A command without one keeps the
681    // body inline under `exec`, which is why no existing client regenerates.
682    let (method, ret) = if outputs.is_empty() {
683        ("exec", "CliResult")
684    } else {
685        ("_cmd_args", "list[str]")
686    };
687    let omit_param = if outputs.is_empty() {
688        ""
689    } else {
690        ", _omit: str = \"\""
691    };
692    let sig = if has_args && !flags_type.is_empty() {
693        format!(
694            "def {method}(self, args: {class_name}Args, flags: Optional[{flags_type}] = None{omit_param}) -> {ret}:"
695        )
696    } else if has_args {
697        format!("def {method}(self, args: {class_name}Args{omit_param}) -> {ret}:")
698    } else if !flags_type.is_empty() {
699        format!("def {method}(self, flags: Optional[{flags_type}] = None{omit_param}) -> {ret}:")
700    } else {
701        format!("def {method}(self{omit_param}) -> {ret}:")
702    };
703
704    // docstring on exec
705    let mut exec_doc = Vec::new();
706    if !cmd.usage.is_empty() {
707        exec_doc.push(cmd.usage.clone());
708    }
709    for example in &cmd.examples {
710        let label = example.header.as_deref().unwrap_or("Example");
711        exec_doc.push(format!("{label}: {code}", code = example.code));
712    }
713    let exit_codes = crate::sdk::exit_codes_for(cmd, spec);
714    if !exit_codes.is_empty() {
715        exec_doc.push(format!(
716            "Exit codes: {}.",
717            exit_codes
718                .iter()
719                .map(|e| format!("{} — {}", e.code, e.help))
720                .collect::<Vec<_>>()
721                .join("; ")
722        ));
723    }
724    // The argv helper is internal, so the docstring moves to the methods a caller sees.
725    let caller_doc = exec_doc.clone();
726    if !outputs.is_empty() {
727        exec_doc.clear();
728    }
729
730    w.line("");
731    if !exec_doc.is_empty() {
732        w.line(&sig);
733        w.indent();
734        if exec_doc.len() == 1 {
735            w.line(&format!(
736                "\"\"\"{}\"\"\"",
737                escape_py_docstring(&exec_doc[0])
738            ));
739        } else {
740            w.line(&format!("\"\"\"{}", escape_py_docstring(&exec_doc[0])));
741            for part in exec_doc.iter().skip(1) {
742                w.line(&escape_py_docstring(part));
743            }
744            w.line("\"\"\"");
745        }
746    } else {
747        w.line(&sig);
748        w.indent();
749    }
750
751    let path: String = cmd
752        .full_cmd
753        .iter()
754        .map(|s| format!("\"{}\"", escape_py_string(s)))
755        .collect::<Vec<_>>()
756        .join(", ");
757    w.line(&format!("cmd_args: list[str] = [{path}]"));
758
759    if has_args {
760        let has_required_double_dash = visible_args
761            .iter()
762            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Required));
763        let has_automatic_double_dash = visible_args
764            .iter()
765            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Automatic));
766
767        // Args before `--`: all args without double_dash=required
768        for arg in &visible_args {
769            if matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
770                continue;
771            }
772            let ident = sanitize_py_ident(&arg.name);
773            if let Some(sigil) = &arg.sigil {
774                let sigil = escape_py_string(sigil);
775                if arg.var {
776                    w.line(&format!(
777                        "if args.{ident} is not None: cmd_args.extend(\"{sigil}\" + str(value) for value in args.{ident})"
778                    ));
779                } else {
780                    w.line(&format!(
781                        "if args.{ident} is not None: cmd_args.append(\"{sigil}\" + str(args.{ident}))"
782                    ));
783                }
784            } else if arg.var {
785                w.line(&format!(
786                    "if args.{ident} is not None: cmd_args.extend(args.{ident})"
787                ));
788            } else {
789                w.line(&format!(
790                    "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
791                ));
792            }
793        }
794
795        if has_required_double_dash {
796            w.line("cmd_args.append(\"--\")");
797            // Args after `--`: only double_dash=required args
798            for arg in &visible_args {
799                if !matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
800                    continue;
801                }
802                let ident = sanitize_py_ident(&arg.name);
803                if arg.var {
804                    w.line(&format!(
805                        "if args.{ident} is not None: cmd_args.extend(args.{ident})"
806                    ));
807                } else {
808                    w.line(&format!(
809                        "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
810                    ));
811                }
812            }
813        } else if has_automatic_double_dash {
814            w.line("# double_dash=automatic: \"--\" is implied after the first positional arg");
815        }
816    }
817
818    let omit_arg = if outputs.is_empty() { "" } else { ", _omit" };
819    if outputs.is_empty() {
820        if has_flags {
821            w.line("flag_args = self._build_flag_args(flags)");
822            w.line("return self._runner.run(cmd_args + flag_args)");
823        } else {
824            w.line("return self._runner.run(cmd_args)");
825        }
826    } else if has_flags {
827        w.line(&format!(
828            "flag_args = self._build_flag_args(flags{omit_arg})"
829        ));
830        w.line("return cmd_args + flag_args");
831    } else {
832        w.line("return cmd_args");
833    }
834    w.dedent();
835
836    // `exec` and one method per declared output, all thin delegates over `_cmd_args`.
837    if !outputs.is_empty() {
838        let call = match (has_args, flags_type.is_empty()) {
839            (true, false) => "args, flags",
840            (true, true) => "args",
841            (false, false) => "flags",
842            (false, true) => "",
843        };
844        let params = match (has_args, flags_type.is_empty()) {
845            (true, false) => {
846                format!("self, args: {class_name}Args, flags: Optional[{flags_type}] = None")
847            }
848            (true, true) => format!("self, args: {class_name}Args"),
849            (false, false) => format!("self, flags: Optional[{flags_type}] = None"),
850            (false, true) => "self".to_string(),
851        };
852        let comma = if call.is_empty() { "" } else { ", " };
853
854        let write_doc = |w: &mut CodeWriter, doc: &[String]| {
855            if doc.is_empty() {
856                return;
857            }
858            if doc.len() == 1 {
859                w.line(&format!("\"\"\"{}\"\"\"", escape_py_docstring(&doc[0])));
860            } else {
861                w.line(&format!("\"\"\"{}", escape_py_docstring(&doc[0])));
862                for part in doc.iter().skip(1) {
863                    w.line(&escape_py_docstring(part));
864                }
865                w.line("\"\"\"");
866            }
867        };
868
869        w.line("");
870        w.line(&format!("def exec({params}) -> CliResult:"));
871        w.indent();
872        write_doc(w, &caller_doc);
873        w.line(&format!("return self._runner.run(self._cmd_args({call}))"));
874        w.dedent();
875
876        for output in &outputs {
877            let (ret, runner) = match output.framing {
878                Framing::Jsonl => ("CliStream", "run_jsonl"),
879                _ => ("CliJsonResult", "run_json"),
880            };
881            let mut doc = Vec::new();
882            if let Some(help) = &output.help {
883                doc.push(help.clone());
884            }
885            doc.push(format!(
886                "Selected with `{}`; any value of it in `flags` is ignored.",
887                output.select.join(" ")
888            ));
889            if output.framing == Framing::Jsonl {
890                doc.push(
891                    "One object per line, as they arrive: iterate it rather than \
892                     collecting, and close it if you stop early."
893                        .to_string(),
894                );
895            }
896            doc.extend(caller_doc.iter().cloned());
897
898            // The property name a caller would have set, not the flag's spelling: the two
899            // differ wherever `sanitize_py_ident` had to move out of the way of a keyword.
900            let omit = output
901                .omit
902                .as_deref()
903                .and_then(|name| {
904                    global_flags
905                        .iter()
906                        .copied()
907                        .chain(visible_flags.iter().copied())
908                        .find(|f| flag_names(f, name))
909                        .map(flag_property_name_py)
910                })
911                .unwrap_or_default();
912            let selector = output
913                .select
914                .iter()
915                .map(|w| format!("\"{}\"", escape_py_string(w)))
916                .collect::<Vec<_>>()
917                .join(", ");
918            w.line("");
919            w.line(&format!("def exec_{}({params}) -> {ret}:", output.suffix));
920            w.indent();
921            write_doc(w, &doc);
922            w.line(&format!(
923                "cmd_args = self._cmd_args({call}{comma}\"{omit}\")"
924            ));
925            w.line(&format!("cmd_args.extend([{selector}])"));
926            w.line(&format!("return self._runner.{runner}(cmd_args)"));
927            w.dedent();
928        }
929    }
930
931    // _build_flag_args
932    if has_flags {
933        w.line("");
934        let omit_param = if outputs.is_empty() {
935            String::new()
936        } else {
937            ", _omit: str = \"\"".to_string()
938        };
939        w.line(&format!(
940            "def _build_flag_args(self, flags: Optional[{flags_type}]{omit_param}) -> list[str]:"
941        ));
942        w.indent();
943        w.line("result: list[str] = []");
944        w.line("if flags is None: return result");
945
946        let omit = outputs.iter().find_map(|o| o.omit.clone());
947        let render = |flag: &SpecFlag, w: &mut CodeWriter| {
948            // The selecting flag is left out when a method already picked the output, so a
949            // caller-supplied `format` cannot end up on the line twice contradicting it.
950            // Compared by property name, which is what a caller actually set.
951            let guarded = omit.as_deref().is_some_and(|name| flag_names(flag, name));
952            if guarded {
953                let prop = flag_property_name_py(flag);
954                w.line(&format!("if _omit != \"{prop}\":"));
955                w.indent();
956                render_flag_build_py(flag, w);
957                w.dedent();
958            } else {
959                render_flag_build_py(flag, w);
960            }
961        };
962        for flag in global_flags {
963            render(flag, w);
964        }
965        for flag in &visible_flags {
966            // skip global flags already rendered above
967            if !global_flags.iter().any(|gf| gf.name == flag.name) {
968                render(flag, w);
969            }
970        }
971
972        w.line("return result");
973        w.dedent();
974    }
975
976    // alias properties for subcommand aliases
977    for (name, subcmd) in &visible_subcmds {
978        for alias in &subcmd.aliases {
979            let alias_prop = sanitize_py_ident(alias);
980            let target_prop = sanitize_py_ident(name);
981            let sub_class = AsPascalCase(name).to_string();
982            w.line("");
983            w.line("@property");
984            w.line(&format!("def {alias_prop}(self) -> {sub_class}:"));
985            w.indent();
986            w.line(&format!(
987                "\"\"\"Alias for {}.\"\"\"",
988                escape_py_docstring(name)
989            ));
990            w.line(&format!("return self.{target_prop}"));
991            w.dedent();
992        }
993    }
994
995    w.dedent(); // end class
996
997    // render subcommand classes
998    for (name, subcmd) in &visible_subcmds {
999        w.line("");
1000        let sub_class = AsPascalCase(name).to_string();
1001        render_class(
1002            subcmd,
1003            &sub_class,
1004            false,
1005            global_flags,
1006            bin_name,
1007            spec,
1008            package_name,
1009            w,
1010        );
1011    }
1012}
1013
1014fn render_flag_build_py(flag: &SpecFlag, w: &mut CodeWriter) {
1015    let prop_name = flag_property_name_py(flag);
1016    let flag_arg_name = if let Some(long) = flag.long.first() {
1017        format!("--{}", escape_py_string(long))
1018    } else if let Some(short) = flag.short.first() {
1019        format!("-{short}")
1020    } else {
1021        format!("--{}", escape_py_string(&flag.name))
1022    };
1023
1024    if flag.arg.is_some() {
1025        if flag.var {
1026            w.line(&format!("if flags.{prop_name} is not None:"));
1027            w.indent();
1028            w.line(&format!(
1029                "for v in flags.{prop_name}: result.extend([\"{flag_arg_name}\", str(v)])"
1030            ));
1031            w.dedent();
1032        } else {
1033            w.line(&format!(
1034                "if flags.{prop_name} is not None: result.extend([\"{flag_arg_name}\", str(flags.{prop_name})])"
1035            ));
1036        }
1037    } else if flag.count {
1038        w.line(&format!(
1039            "if flags.{prop_name} is not None and flags.{prop_name} > 0: result.extend([\"{flag_arg_name}\"] * flags.{prop_name})"
1040        ));
1041    } else if flag.var {
1042        w.line(&format!("if flags.{prop_name} is not None:"));
1043        w.indent();
1044        w.line(&format!("for v in flags.{prop_name}:"));
1045        w.indent();
1046        w.line(&format!("if v: result.append(\"{flag_arg_name}\")"));
1047        w.dedent();
1048        w.dedent();
1049    } else {
1050        w.line(&format!(
1051            "if flags.{prop_name}: result.append(\"{flag_arg_name}\")"
1052        ));
1053        if let Some(negate) = &flag.negate {
1054            w.line(&format!(
1055                "elif flags.{prop_name} is False: result.append(\"{}\")",
1056                escape_py_string(negate)
1057            ));
1058        }
1059    }
1060}
1061
1062/// Whether anything in the tree declares an output, so the generated imports carry only
1063/// what is used.
1064fn any_outputs(cmd: &SpecCommand, spec: &Spec, package_name: &str) -> bool {
1065    !crate::sdk::output_methods(cmd, spec, package_name).is_empty()
1066        || cmd
1067            .subcommands
1068            .values()
1069            .any(|sub| any_outputs(sub, spec, package_name))
1070}
1071
1072// ---------------------------------------------------------------------------
1073// Tests
1074// ---------------------------------------------------------------------------
1075
1076#[cfg(test)]
1077mod tests {
1078    use crate::Spec;
1079    use crate::sdk::{SdkLanguage, SdkOptions};
1080    use crate::test::SPEC_KITCHEN_SINK;
1081
1082    fn make_opts() -> SdkOptions {
1083        SdkOptions {
1084            language: SdkLanguage::Python,
1085            package_name: None,
1086            source_file: Some("test.usage.kdl".to_string()),
1087        }
1088    }
1089
1090    fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str {
1091        output
1092            .files
1093            .iter()
1094            .find(|f| f.path.to_str() == Some(name))
1095            .unwrap_or_else(|| panic!("{name} should exist"))
1096            .content
1097            .as_str()
1098    }
1099
1100    #[test]
1101    fn test_python_types() {
1102        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1103        insta::assert_snapshot!(get_file(&output, "types.py"));
1104    }
1105
1106    #[test]
1107    fn test_python_client() {
1108        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1109        insta::assert_snapshot!(get_file(&output, "client.py"));
1110    }
1111
1112    #[test]
1113    fn structured_output_client_imports_its_runtime_result_types() {
1114        let spec: Spec = r#"
1115            bin "reporter"
1116            flag "--json"
1117            output "text" default=#true
1118            output "json" framing="json" select="--json"
1119        "#
1120        .parse()
1121        .unwrap();
1122        let output = crate::sdk::generate(&spec, &make_opts());
1123        let client = get_file(&output, "client.py");
1124        assert!(client.contains("CliJsonResult, CliResult, CliRunner, CliStream"));
1125    }
1126
1127    #[test]
1128    fn nested_same_named_commands_get_distinct_exit_code_exports() {
1129        let spec: Spec = r#"
1130            bin "app"
1131            cmd "one" { cmd "show" { exit_code 1 "one failed" } }
1132            cmd "two" { cmd "show" { exit_code 2 "two failed" } }
1133        "#
1134        .parse()
1135        .unwrap();
1136        let output = crate::sdk::generate(&spec, &make_opts());
1137        let types = get_file(&output, "types.py");
1138        assert!(types.contains("ONE_SHOW_EXIT_CODES"), "{types}");
1139        assert!(types.contains("TWO_SHOW_EXIT_CODES"), "{types}");
1140        assert!(types.contains("OneShowExitCode"), "{types}");
1141        assert!(types.contains("TwoShowExitCode"), "{types}");
1142    }
1143
1144    #[test]
1145    fn test_python_runtime() {
1146        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1147        insta::assert_snapshot!(get_file(&output, "runtime.py"));
1148    }
1149
1150    #[test]
1151    fn test_python_init() {
1152        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1153        insta::assert_snapshot!(get_file(&output, "__init__.py"));
1154    }
1155
1156    fn full_feature_spec() -> Spec {
1157        r##"
1158            bin "mytool"
1159            name "mytool"
1160            version "1.2.3"
1161            about "A powerful CLI tool"
1162            author "Jane Doe"
1163
1164            flag "-v --verbose" help="Verbosity level" count=#true global=#true
1165            flag "-C --config <path>" help="Config file path" global=#true env="MYTOOL_CONFIG"
1166            flag "--dry-run" help="Show what would be done" negate="--no-dry-run"
1167
1168            arg "input" help="Input file" required=#true
1169            arg "extra" var=#true help="Extra files"
1170
1171            cmd "build" help="Build the project" deprecated="Use compile instead" {
1172                alias "b"
1173                arg "target" help="Build target" {
1174                    choices "debug" "release"
1175                }
1176                arg "output" help="Output directory" double_dash="required"
1177                flag "-j --jobs <n>" help="Parallel jobs" var=#true
1178                flag "--release" help="Build in release mode"
1179            }
1180
1181            cmd "deploy" help="Deploy the project" {
1182                arg "env" help="Target environment" {
1183                    choices "staging" "production"
1184                }
1185                arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5
1186                flag "-f --force" help="Force deploy" deprecated="Use --confirm instead"
1187                flag "--confirm" help="Confirm deployment"
1188            }
1189        "##
1190        .parse()
1191        .unwrap()
1192    }
1193
1194    #[test]
1195    fn test_python_full_feature_types() {
1196        let spec = full_feature_spec();
1197        let output = crate::sdk::generate(&spec, &make_opts());
1198        insta::assert_snapshot!(get_file(&output, "types.py"));
1199    }
1200
1201    #[test]
1202    fn test_python_full_feature_client() {
1203        let spec = full_feature_spec();
1204        let output = crate::sdk::generate(&spec, &make_opts());
1205        insta::assert_snapshot!(get_file(&output, "client.py"));
1206    }
1207
1208    #[test]
1209    fn test_python_hyphenated_subcommands() {
1210        let spec: Spec = r##"
1211            bin "cli"
1212            cmd "add-remote" help="Add a remote" {
1213                arg "name"
1214                arg "url"
1215            }
1216            cmd "remove-remote" help="Remove a remote" {
1217                arg "name"
1218            }
1219        "##
1220        .parse()
1221        .unwrap();
1222        let output = crate::sdk::generate(&spec, &make_opts());
1223        insta::assert_snapshot!(get_file(&output, "client.py"));
1224    }
1225
1226    #[test]
1227    fn test_python_minimal() {
1228        let spec: Spec = r##"
1229            bin "hello"
1230        "##
1231        .parse()
1232        .unwrap();
1233        let output = crate::sdk::generate(&spec, &make_opts());
1234        insta::assert_snapshot!(get_file(&output, "client.py"));
1235    }
1236
1237    /// Flags-only subcommand (no positional args) — tests exec signature without double comma.
1238    #[test]
1239    fn test_python_flags_only_subcommand() {
1240        let spec: Spec = r##"
1241            bin "app"
1242            cmd "status" help="Show status" {
1243                flag "--verbose" help="Show detailed status"
1244                flag "--json" help="Output as JSON"
1245            }
1246        "##
1247        .parse()
1248        .unwrap();
1249        let output = crate::sdk::generate(&spec, &make_opts());
1250        let client = get_file(&output, "client.py");
1251        assert!(!client.contains("def exec(self, ,"));
1252        assert!(
1253            client.contains("def exec(self, flags: Optional[StatusFlags] = None) -> CliResult:")
1254        );
1255        insta::assert_snapshot!(client);
1256    }
1257
1258    /// Choice type collision: same arg name with different choices in different subcommands.
1259    #[test]
1260    fn test_python_choice_collision() {
1261        let spec: Spec = r##"
1262            bin "tool"
1263            cmd "build" help="Build" {
1264                arg "env" help="Build environment" {
1265                    choices "debug" "release"
1266                }
1267            }
1268            cmd "deploy" help="Deploy" {
1269                arg "env" help="Deploy environment" {
1270                    choices "staging" "production"
1271                }
1272            }
1273        "##
1274        .parse()
1275        .unwrap();
1276        let output = crate::sdk::generate(&spec, &make_opts());
1277        let types = get_file(&output, "types.py");
1278        // Must have separate choice types due to collision
1279        assert!(types.contains("BuildEnvChoice"));
1280        assert!(types.contains("DeployEnvChoice"));
1281        assert!(types.contains(r#""debug""#));
1282        assert!(types.contains(r#""staging""#));
1283        insta::assert_snapshot!(types);
1284    }
1285
1286    /// Args with default values — tests that defaults are preserved, not dropped to None.
1287    #[test]
1288    fn test_python_arg_defaults() {
1289        let spec: Spec = r##"
1290            bin "runner"
1291            arg "mode" default="fast" help="Run mode"
1292            arg "output" help="Output path" required=#true
1293        "##
1294        .parse()
1295        .unwrap();
1296        let output = crate::sdk::generate(&spec, &make_opts());
1297        let types = get_file(&output, "types.py");
1298        assert!(types.contains(r#"mode: Optional[str] = "fast""#));
1299        assert!(types.contains("output: str"));
1300        // required arg must come before optional
1301        let output_pos = types.find("output: str").unwrap();
1302        let mode_pos = types.find(r#"mode: Optional[str] = "fast""#).unwrap();
1303        assert!(
1304            output_pos < mode_pos,
1305            "required arg must precede optional arg"
1306        );
1307    }
1308
1309    /// Config props — covers config dataclass and config_prop_type.
1310    #[test]
1311    fn test_python_config_props() {
1312        let spec: Spec = r##"
1313            bin "myapp"
1314            config {
1315                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1316                prop "port" default=8080 data_type=integer
1317                prop "rate" default="1.5" data_type=float
1318                prop "host" data_type=string
1319                prop "extra" data_type="null"
1320            }
1321        "##
1322        .parse()
1323        .unwrap();
1324        let output = crate::sdk::generate(&spec, &make_opts());
1325        let types = get_file(&output, "types.py");
1326        assert!(types.contains("class MyappConfig"));
1327        insta::assert_snapshot!(types);
1328    }
1329
1330    /// A default whose text is not the type it claims to be must not become an expression.
1331    #[test]
1332    fn test_python_config_default_is_never_an_expression() {
1333        // types.py is imported, so anything written into it unquoted runs.
1334        //
1335        // The route this test was written for — `data_type=integer` beside a default of
1336        // arbitrary text — no longer parses at all; see
1337        // `a_default_the_declared_type_cannot_read_is_refused`. But a *string*-typed default
1338        // legitimately holds arbitrary text, which is the surface that still exists and the
1339        // one the generator has to quote. Two defences, and this is the second.
1340        let spec: Spec = r##"
1341            bin "myapp"
1342            config {
1343                prop "cmd" data_type=string default="__import__('os').system('touch /tmp/pwned')"
1344                prop "quoteful" data_type=string default="he said \"hi\""
1345                prop "multiline" data_type=string default="two\nlines"
1346            }
1347        "##
1348        .parse()
1349        .unwrap();
1350        let output = crate::sdk::generate(&spec, &make_opts());
1351        let types = get_file(&output, "types.py");
1352
1353        assert!(
1354            !types.contains("= __import__"),
1355            "a string default must not be emitted as an expression:\n{types}"
1356        );
1357        assert!(
1358            types.contains(r#"= "__import__('os').system('touch /tmp/pwned')""#),
1359            "it should be a quoted string:\n{types}"
1360        );
1361        // And a string containing quotes stays escaped rather than closing the literal.
1362        assert!(
1363            types.contains(r#"= "he said \"hi\"""#),
1364            "quotes inside a default should be escaped:\n{types}"
1365        );
1366        // A control character cannot be carried literally inside a Python literal at all: a
1367        // newline in a default wrote a module that fails to *import*, which is a worse failure
1368        // than one that says something wrong.
1369        assert!(
1370            types.contains(r#"= "two\nlines""#),
1371            "a newline in a default must be escaped:\n{types}"
1372        );
1373        assert!(
1374            !types.lines().any(|line| line.trim() == "lines\""),
1375            "the literal was split across lines:\n{types}"
1376        );
1377    }
1378
1379    /// Hidden command, hidden arg/flag — covers early-return and empty dataclass paths.
1380    #[test]
1381    fn test_python_hidden_command() {
1382        let spec: Spec = r##"
1383            bin "app"
1384            cmd "visible" help="A visible command" {
1385                arg "name"
1386            }
1387            cmd "secret" hide=#true help="Hidden command" {
1388                arg "name"
1389            }
1390        "##
1391        .parse()
1392        .unwrap();
1393        let output = crate::sdk::generate(&spec, &make_opts());
1394        let types = get_file(&output, "types.py");
1395        assert!(types.contains("VisibleArgs"));
1396        assert!(!types.contains("SecretArgs"));
1397    }
1398
1399    /// Flag edge cases: short-only, aliases, deprecated, count+default, required flag,
1400    /// repeatable boolean flag, non-bool value flag with default.
1401    #[test]
1402    fn test_python_flag_edge_cases() {
1403        let spec: Spec = r##"
1404            bin "tool"
1405            flag "-v" help="Short-only flag"
1406            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1407            flag "--level" count=#true default="2" help="Count flag with default"
1408            flag "--format <fmt>" default="json" help="Value flag with default"
1409            flag "--confirm" required=#true help="Required flag"
1410            flag "--verbose" var=#true help="Repeatable boolean flag"
1411        "##
1412        .parse()
1413        .unwrap();
1414        let output = crate::sdk::generate(&spec, &make_opts());
1415        let types = get_file(&output, "types.py");
1416        insta::assert_snapshot!(types);
1417        let client = get_file(&output, "client.py");
1418        // short-only flag build
1419        assert!(client.contains(r#""-v""#));
1420        // repeatable boolean flag build
1421        assert!(client.contains("for v in flags.verbose:"));
1422        insta::assert_snapshot!(client);
1423    }
1424
1425    /// double_dash=automatic, examples in exec doc, global flags with flags-only subcommand.
1426    #[test]
1427    fn test_python_exec_edge_cases() {
1428        let spec: Spec = r##"
1429            bin "runner"
1430            flag "-v --verbose" global=#true help="Verbosity"
1431            arg "input" help="Input file"
1432            arg "extra" double_dash="automatic" var=#true help="Extra files"
1433            cmd "run" help="Run a task" {
1434                example "runner run hello" header="Basic run"
1435                arg "task" help="Task to run" double_dash="automatic"
1436            }
1437            cmd "info" help="Show info" {}
1438        "##
1439        .parse()
1440        .unwrap();
1441        let output = crate::sdk::generate(&spec, &make_opts());
1442        let client = get_file(&output, "client.py");
1443        assert!(client.contains("double_dash=automatic"));
1444        assert!(client.contains("Basic run: runner run hello"));
1445        // "info" has no own flags, only global flags => GlobalFlags type
1446        assert!(client.contains("flags: Optional[GlobalFlags] = None"));
1447        insta::assert_snapshot!(client);
1448    }
1449
1450    /// Optional arg without default and empty flags dataclass.
1451    #[test]
1452    fn test_python_optional_arg_empty_flags() {
1453        let spec: Spec = r##"
1454            bin "app"
1455            arg "[name]" help="Optional arg without default"
1456            cmd "check" help="Check something" {
1457                arg "target" required=#true help="Required arg"
1458                arg "mode" default="quick" help="Optional arg with default"
1459            }
1460        "##
1461        .parse()
1462        .unwrap();
1463        let output = crate::sdk::generate(&spec, &make_opts());
1464        let types = get_file(&output, "types.py");
1465        // optional arg without default should have = None
1466        assert!(types.contains("name: Optional[str] = None"));
1467        insta::assert_snapshot!(types);
1468    }
1469
1470    /// Deeply nested subcommands — 3+ levels.
1471    #[test]
1472    fn test_python_deep_nesting() {
1473        let spec: Spec = r##"
1474            bin "app"
1475            cmd "db" help="Database operations" {
1476                cmd "migration" help="Migration management" {
1477                    cmd "create" help="Create a new migration" {
1478                        arg "name"
1479                        flag "--template <t>" help="Migration template"
1480                    }
1481                    cmd "run" help="Run pending migrations" {
1482                        flag "--step <n>" help="Number of migrations to run"
1483                    }
1484                }
1485            }
1486        "##
1487        .parse()
1488        .unwrap();
1489        let output = crate::sdk::generate(&spec, &make_opts());
1490        let client = get_file(&output, "client.py");
1491        // deeply nested class must exist
1492        assert!(client.contains("class Db:"));
1493        assert!(client.contains("class Migration:"));
1494        assert!(client.contains("class Create:"));
1495        insta::assert_snapshot!(client);
1496    }
1497
1498    /// Test package_name override.
1499    #[test]
1500    fn test_python_package_name_override() {
1501        let spec: Spec = r##"
1502            bin "original-cli"
1503        "##
1504        .parse()
1505        .unwrap();
1506        let opts = SdkOptions {
1507            language: SdkLanguage::Python,
1508            package_name: Some("my_custom_sdk".to_string()),
1509            source_file: None,
1510        };
1511        let output = crate::sdk::generate(&spec, &opts);
1512        let init = get_file(&output, "__init__.py");
1513        assert!(init.contains("MyCustomSdk"));
1514        insta::assert_snapshot!(init);
1515    }
1516
1517    /// Global flags with flags-only subcommand — covers GlobalFlags type branch.
1518    #[test]
1519    fn test_python_global_flags_flags_only() {
1520        let spec: Spec = r##"
1521            bin "app"
1522            flag "-v --verbose" global=#true help="Verbosity"
1523            cmd "status" help="Show status" {
1524                flag "--json" help="JSON output"
1525            }
1526            cmd "info" help="Show info" {}
1527        "##
1528        .parse()
1529        .unwrap();
1530        let output = crate::sdk::generate(&spec, &make_opts());
1531        let client = get_file(&output, "client.py");
1532        // "info" subcommand has no own flags, only global flags => GlobalFlags type
1533        assert!(client.contains("Optional[GlobalFlags]"));
1534        insta::assert_snapshot!(client);
1535    }
1536
1537    /// Flag with choices — flag arg with choices renders correct type.
1538    #[test]
1539    fn test_python_flag_with_choices() {
1540        let spec: Spec = r##"
1541            bin "tool"
1542            flag "--shell <shell>" help="Shell type" {
1543                choices "bash" "zsh" "fish"
1544            }
1545        "##
1546        .parse()
1547        .unwrap();
1548        let output = crate::sdk::generate(&spec, &make_opts());
1549        let types = get_file(&output, "types.py");
1550        assert!(types.contains("Literal[\"bash\", \"zsh\", \"fish\"]"));
1551        insta::assert_snapshot!(types);
1552    }
1553
1554    /// Flag with env annotation — env variable appears in comment.
1555    #[test]
1556    fn test_python_flag_with_env() {
1557        let spec: Spec = r##"
1558            bin "app"
1559            flag "--config <path>" help="Config file" env="APP_CONFIG"
1560        "##
1561        .parse()
1562        .unwrap();
1563        let output = crate::sdk::generate(&spec, &make_opts());
1564        let types = get_file(&output, "types.py");
1565        assert!(types.contains("Env: APP_CONFIG"));
1566        insta::assert_snapshot!(types);
1567    }
1568
1569    /// Hidden flag excluded from types and client.
1570    #[test]
1571    fn test_python_flag_hide() {
1572        let spec: Spec = r##"
1573            bin "app"
1574            flag "--verbose" help="Verbosity"
1575            flag "--debug" hide=#true help="Hidden debug flag"
1576        "##
1577        .parse()
1578        .unwrap();
1579        let output = crate::sdk::generate(&spec, &make_opts());
1580        let types = get_file(&output, "types.py");
1581        assert!(types.contains("verbose"));
1582        assert!(!types.contains("debug"));
1583    }
1584
1585    /// Negate flag rendered in client build method.
1586    #[test]
1587    fn test_python_negate_flag_build() {
1588        let spec: Spec = r##"
1589            bin "app"
1590            flag "--dry-run" help="Dry run" negate="--no-dry-run"
1591        "##
1592        .parse()
1593        .unwrap();
1594        let output = crate::sdk::generate(&spec, &make_opts());
1595        let client = get_file(&output, "client.py");
1596        assert!(client.contains("--dry-run"));
1597        assert!(client.contains("--no-dry-run"));
1598        insta::assert_snapshot!(client);
1599    }
1600
1601    /// Count flag rendered in client build method.
1602    #[test]
1603    fn test_python_count_flag_build() {
1604        let spec: Spec = r##"
1605            bin "app"
1606            flag "-v --verbose" count=#true help="Verbosity level"
1607        "##
1608        .parse()
1609        .unwrap();
1610        let output = crate::sdk::generate(&spec, &make_opts());
1611        let client = get_file(&output, "client.py");
1612        assert!(client.contains(r#""--verbose""#));
1613        assert!(client.contains("flags.verbose"));
1614        insta::assert_snapshot!(client);
1615    }
1616
1617    /// Repeatable value flag with default — covers var + arg + default in client build.
1618    #[test]
1619    fn test_python_var_value_flag_with_default() {
1620        let spec: Spec = r##"
1621            bin "tool"
1622            flag "--tag <t>" var=#true default="latest" help="Tags"
1623        "##
1624        .parse()
1625        .unwrap();
1626        let output = crate::sdk::generate(&spec, &make_opts());
1627        let types = get_file(&output, "types.py");
1628        assert!(types.contains(r#"list[str]"#));
1629        assert!(types.contains(r#"default: latest"#));
1630        let client = get_file(&output, "client.py");
1631        assert!(client.contains("for v in flags.tag:"));
1632        insta::assert_snapshot!(types);
1633    }
1634
1635    /// Flag with multiple long aliases — `-f --format --fmt <fmt>`.
1636    #[test]
1637    fn test_python_multiple_aliases() {
1638        let spec: Spec = r##"
1639            bin "tool"
1640            flag "-f --format --fmt <fmt>" help="Output format"
1641        "##
1642        .parse()
1643        .unwrap();
1644        let output = crate::sdk::generate(&spec, &make_opts());
1645        let types = get_file(&output, "types.py");
1646        assert!(types.contains("Aliases: fmt"));
1647        let client = get_file(&output, "client.py");
1648        // should use first long for the flag argument name
1649        assert!(client.contains("--format"));
1650        insta::assert_snapshot!(client);
1651    }
1652
1653    /// Boolean flag with default=#false — covers the "= False" branch.
1654    #[test]
1655    fn test_python_boolean_flag_default_false() {
1656        let spec: Spec = r##"
1657            bin "app"
1658            flag "--no-cache" default=#false help="Disable cache"
1659        "##
1660        .parse()
1661        .unwrap();
1662        let output = crate::sdk::generate(&spec, &make_opts());
1663        let types = get_file(&output, "types.py");
1664        assert!(types.contains("no_cache: bool = False"));
1665        insta::assert_snapshot!(types);
1666    }
1667
1668    /// Boolean config prop with default=#false — covers the "= False" branch.
1669    #[test]
1670    fn test_python_config_boolean_default_false() {
1671        let spec: Spec = r##"
1672            bin "app"
1673            config {
1674                prop "verbose" default=#false data_type=boolean help="Verbose output"
1675                prop "dry_run" default=#true data_type=boolean help="Dry run mode"
1676            }
1677        "##
1678        .parse()
1679        .unwrap();
1680        let output = crate::sdk::generate(&spec, &make_opts());
1681        let types = get_file(&output, "types.py");
1682        assert!(types.contains("verbose: bool = False"));
1683        assert!(types.contains("dry_run: bool = True"));
1684        insta::assert_snapshot!(types);
1685    }
1686
1687    /// String config prop with default — covers String/Null match arm with default.
1688    #[test]
1689    fn test_python_config_string_with_default() {
1690        let spec: Spec = r##"
1691            bin "app"
1692            config {
1693                prop "host" default="localhost" data_type=string help="Server host"
1694                prop "name" default="myapp" data_type=string
1695            }
1696        "##
1697        .parse()
1698        .unwrap();
1699        let output = crate::sdk::generate(&spec, &make_opts());
1700        let types = get_file(&output, "types.py");
1701        assert!(types.contains(r#"host: str = "localhost""#));
1702        assert!(types.contains(r#"name: str = "myapp""#));
1703        insta::assert_snapshot!(types);
1704    }
1705
1706    /// Config with all props having defaults — tests Default derive on config dataclass.
1707    #[test]
1708    fn test_python_config_all_optional() {
1709        let spec: Spec = r##"
1710            bin "app"
1711            config {
1712                prop "debug" default=#true data_type=boolean
1713                prop "port" default=8080 data_type=integer
1714            }
1715        "##
1716        .parse()
1717        .unwrap();
1718        let output = crate::sdk::generate(&spec, &make_opts());
1719        let types = get_file(&output, "types.py");
1720        assert!(types.contains("class AppConfig:"));
1721        insta::assert_snapshot!(types);
1722    }
1723
1724    /// Optional variadic arg — covers the optional + var branch in client rendering.
1725    #[test]
1726    fn test_python_optional_variadic_arg() {
1727        let spec: Spec = r##"
1728            bin "tool"
1729            arg "[files]" var=#true help="Input files"
1730        "##
1731        .parse()
1732        .unwrap();
1733        let output = crate::sdk::generate(&spec, &make_opts());
1734        let types = get_file(&output, "types.py");
1735        assert!(types.contains("list[str]"));
1736        let client = get_file(&output, "client.py");
1737        // optional variadic arg should use extend with None guard
1738        assert!(client.contains("if args.files is not None: cmd_args.extend(args.files)"));
1739        insta::assert_snapshot!(client);
1740    }
1741
1742    /// Example without lang attribute — tests single-line exec doc path.
1743    #[test]
1744    fn test_python_example_without_lang() {
1745        let spec: Spec = r##"
1746            bin "app"
1747            cmd "greet" help="Greet someone" {
1748                example "app greet hello"
1749                arg "name" help="Name to greet"
1750            }
1751        "##
1752        .parse()
1753        .unwrap();
1754        let output = crate::sdk::generate(&spec, &make_opts());
1755        let client = get_file(&output, "client.py");
1756        assert!(client.contains("app greet hello"));
1757        insta::assert_snapshot!(client);
1758    }
1759
1760    /// Required flag without default — tests non-optional flag type rendering.
1761    #[test]
1762    fn test_python_required_flag_type() {
1763        let spec: Spec = r##"
1764            bin "tool"
1765            flag "--token <t>" required=#true help="Auth token"
1766        "##
1767        .parse()
1768        .unwrap();
1769        let output = crate::sdk::generate(&spec, &make_opts());
1770        let types = get_file(&output, "types.py");
1771        // required flag without default should NOT be Optional
1772        assert!(types.contains("token: str"));
1773        assert!(!types.contains("token: Optional[str]"));
1774        insta::assert_snapshot!(types);
1775    }
1776
1777    /// Global repeatable flags — covers flag_ts_simple var branches.
1778    #[test]
1779    fn test_python_global_repeatable_flags() {
1780        let spec: Spec = r##"
1781            bin "app"
1782            flag "-v --verbose" global=#true var=#true help="Repeatable verbose"
1783            flag "--tag <t>" global=#true var=#true help="Repeatable tag"
1784            cmd "run" help="Run" {
1785                arg "target"
1786            }
1787        "##
1788        .parse()
1789        .unwrap();
1790        let output = crate::sdk::generate(&spec, &make_opts());
1791        let types = get_file(&output, "types.py");
1792        // GlobalFlags should have list[bool] and list[str] types
1793        assert!(types.contains("list[bool]"));
1794        assert!(types.contains("list[str]"));
1795        insta::assert_snapshot!(types);
1796    }
1797
1798    /// Client edge cases: double_dash=automatic, examples, global flags, repeatable boolean flag.
1799    #[test]
1800    fn test_python_client_edge_cases() {
1801        let spec: Spec = r##"
1802            bin "runner"
1803            flag "-v --verbose" global=#true help="Verbosity"
1804            flag "--debug" var=#true help="Repeatable boolean flag"
1805            arg "input" help="Input file"
1806            arg "extra" double_dash="automatic" var=#true help="Extra files"
1807            cmd "run" help="Run a task" {
1808                example "runner run hello" header="Basic run" lang="bash"
1809                arg "task" help="Task to run" double_dash="automatic"
1810            }
1811            cmd "info" help="Show info" {}
1812        "##
1813        .parse()
1814        .unwrap();
1815        let output = crate::sdk::generate(&spec, &make_opts());
1816        let client = get_file(&output, "client.py");
1817        assert!(client.contains("double_dash=automatic"));
1818        assert!(client.contains("Basic run: runner run hello"));
1819        // GlobalFlags type for info subcommand
1820        assert!(client.contains("Optional[GlobalFlags]"));
1821        // repeatable boolean flag
1822        assert!(client.contains("for v in flags.debug:"));
1823        insta::assert_snapshot!(client);
1824    }
1825
1826    /// Config and flag edge cases — config with various data types, env, deprecated, aliases.
1827    #[test]
1828    fn test_python_config_and_flag_edge_cases() {
1829        let spec: Spec = r##"
1830            bin "myapp"
1831            config {
1832                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1833                prop "port" default=8080 data_type=integer
1834                prop "rate" default="1.5" data_type=float
1835                prop "host" data_type=string
1836            }
1837            arg "input" help="Input file" env="MYAPP_INPUT"
1838            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1839            flag "-f --format --fmt <fmt>" help="Flag with short and long alias"
1840            flag "-v" help="Short-only flag"
1841        "##
1842        .parse()
1843        .unwrap();
1844        let output = crate::sdk::generate(&spec, &make_opts());
1845        let types = get_file(&output, "types.py");
1846        assert!(types.contains("MyappConfig"));
1847        insta::assert_snapshot!(types);
1848    }
1849
1850    /// double_dash=automatic — covers arg ordering and separator insertion.
1851    #[test]
1852    fn test_python_double_dash_automatic() {
1853        let spec: Spec = r##"
1854            bin "runner"
1855            arg "input" help="Input file"
1856            arg "extra" double_dash="automatic" var=#true help="Extra files"
1857            flag "--verbose" var=#true help="Repeatable boolean flag"
1858            cmd "run" help="Run a task" {
1859                example "runner run hello" header="Basic run"
1860                arg "task" help="Task to run" double_dash="automatic"
1861            }
1862        "##
1863        .parse()
1864        .unwrap();
1865        let output = crate::sdk::generate(&spec, &make_opts());
1866        let client = get_file(&output, "client.py");
1867        assert!(client.contains("double_dash=automatic"));
1868        insta::assert_snapshot!(client);
1869    }
1870}