Skip to main content

usage/sdk/python/
mod.rs

1use std::path::PathBuf;
2
3use heck::AsPascalCase;
4
5use crate::sdk::{
6    collect_choice_types, collect_type_imports, command_type_name, escape_py_docstring,
7    escape_py_string, flag_names, generated_header, ChoiceTypeMap, CodeWriter, SdkFile, SdkOptions,
8    SdkOutput,
9};
10use crate::spec::arg::SpecDoubleDashChoices;
11use crate::spec::cmd::SpecCommand;
12use crate::spec::config::{SpecConfigProp, SpecConfigValue};
13use crate::spec::data_types::SpecDataTypes;
14use crate::Framing;
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) = &arg.choices {
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) = &arg.choices {
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(&heck::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 = heck::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!("def {method}(self, args: {class_name}Args, flags: Optional[{flags_type}] = None{omit_param}) -> {ret}:")
694    } else if has_args {
695        format!("def {method}(self, args: {class_name}Args{omit_param}) -> {ret}:")
696    } else if !flags_type.is_empty() {
697        format!("def {method}(self, flags: Optional[{flags_type}] = None{omit_param}) -> {ret}:")
698    } else {
699        format!("def {method}(self{omit_param}) -> {ret}:")
700    };
701
702    // docstring on exec
703    let mut exec_doc = Vec::new();
704    if !cmd.usage.is_empty() {
705        exec_doc.push(cmd.usage.clone());
706    }
707    for example in &cmd.examples {
708        let label = example.header.as_deref().unwrap_or("Example");
709        exec_doc.push(format!("{label}: {code}", code = example.code));
710    }
711    let exit_codes = crate::sdk::exit_codes_for(cmd, spec);
712    if !exit_codes.is_empty() {
713        exec_doc.push(format!(
714            "Exit codes: {}.",
715            exit_codes
716                .iter()
717                .map(|e| format!("{} — {}", e.code, e.help))
718                .collect::<Vec<_>>()
719                .join("; ")
720        ));
721    }
722    // The argv helper is internal, so the docstring moves to the methods a caller sees.
723    let caller_doc = exec_doc.clone();
724    if !outputs.is_empty() {
725        exec_doc.clear();
726    }
727
728    w.line("");
729    if !exec_doc.is_empty() {
730        w.line(&sig);
731        w.indent();
732        if exec_doc.len() == 1 {
733            w.line(&format!(
734                "\"\"\"{}\"\"\"",
735                escape_py_docstring(&exec_doc[0])
736            ));
737        } else {
738            w.line(&format!("\"\"\"{}", escape_py_docstring(&exec_doc[0])));
739            for part in exec_doc.iter().skip(1) {
740                w.line(&escape_py_docstring(part));
741            }
742            w.line("\"\"\"");
743        }
744    } else {
745        w.line(&sig);
746        w.indent();
747    }
748
749    let path: String = cmd
750        .full_cmd
751        .iter()
752        .map(|s| format!("\"{}\"", escape_py_string(s)))
753        .collect::<Vec<_>>()
754        .join(", ");
755    w.line(&format!("cmd_args: list[str] = [{path}]"));
756
757    if has_args {
758        let has_required_double_dash = visible_args
759            .iter()
760            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Required));
761        let has_automatic_double_dash = visible_args
762            .iter()
763            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Automatic));
764
765        // Args before `--`: all args without double_dash=required
766        for arg in &visible_args {
767            if matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
768                continue;
769            }
770            let ident = sanitize_py_ident(&arg.name);
771            if arg.var {
772                w.line(&format!(
773                    "if args.{ident} is not None: cmd_args.extend(args.{ident})"
774                ));
775            } else {
776                w.line(&format!(
777                    "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
778                ));
779            }
780        }
781
782        if has_required_double_dash {
783            w.line("cmd_args.append(\"--\")");
784            // Args after `--`: only double_dash=required args
785            for arg in &visible_args {
786                if !matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
787                    continue;
788                }
789                let ident = sanitize_py_ident(&arg.name);
790                if arg.var {
791                    w.line(&format!(
792                        "if args.{ident} is not None: cmd_args.extend(args.{ident})"
793                    ));
794                } else {
795                    w.line(&format!(
796                        "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
797                    ));
798                }
799            }
800        } else if has_automatic_double_dash {
801            w.line("# double_dash=automatic: \"--\" is implied after the first positional arg");
802        }
803    }
804
805    let omit_arg = if outputs.is_empty() { "" } else { ", _omit" };
806    if outputs.is_empty() {
807        if has_flags {
808            w.line("flag_args = self._build_flag_args(flags)");
809            w.line("return self._runner.run(cmd_args + flag_args)");
810        } else {
811            w.line("return self._runner.run(cmd_args)");
812        }
813    } else if has_flags {
814        w.line(&format!(
815            "flag_args = self._build_flag_args(flags{omit_arg})"
816        ));
817        w.line("return cmd_args + flag_args");
818    } else {
819        w.line("return cmd_args");
820    }
821    w.dedent();
822
823    // `exec` and one method per declared output, all thin delegates over `_cmd_args`.
824    if !outputs.is_empty() {
825        let call = match (has_args, flags_type.is_empty()) {
826            (true, false) => "args, flags",
827            (true, true) => "args",
828            (false, false) => "flags",
829            (false, true) => "",
830        };
831        let params = match (has_args, flags_type.is_empty()) {
832            (true, false) => {
833                format!("self, args: {class_name}Args, flags: Optional[{flags_type}] = None")
834            }
835            (true, true) => format!("self, args: {class_name}Args"),
836            (false, false) => format!("self, flags: Optional[{flags_type}] = None"),
837            (false, true) => "self".to_string(),
838        };
839        let comma = if call.is_empty() { "" } else { ", " };
840
841        let write_doc = |w: &mut CodeWriter, doc: &[String]| {
842            if doc.is_empty() {
843                return;
844            }
845            if doc.len() == 1 {
846                w.line(&format!("\"\"\"{}\"\"\"", escape_py_docstring(&doc[0])));
847            } else {
848                w.line(&format!("\"\"\"{}", escape_py_docstring(&doc[0])));
849                for part in doc.iter().skip(1) {
850                    w.line(&escape_py_docstring(part));
851                }
852                w.line("\"\"\"");
853            }
854        };
855
856        w.line("");
857        w.line(&format!("def exec({params}) -> CliResult:"));
858        w.indent();
859        write_doc(w, &caller_doc);
860        w.line(&format!("return self._runner.run(self._cmd_args({call}))"));
861        w.dedent();
862
863        for output in &outputs {
864            let (ret, runner) = match output.framing {
865                Framing::Jsonl => ("CliStream", "run_jsonl"),
866                _ => ("CliJsonResult", "run_json"),
867            };
868            let mut doc = Vec::new();
869            if let Some(help) = &output.help {
870                doc.push(help.clone());
871            }
872            doc.push(format!(
873                "Selected with `{}`; any value of it in `flags` is ignored.",
874                output.select.join(" ")
875            ));
876            if output.framing == Framing::Jsonl {
877                doc.push(
878                    "One object per line, as they arrive: iterate it rather than \
879                     collecting, and close it if you stop early."
880                        .to_string(),
881                );
882            }
883            doc.extend(caller_doc.iter().cloned());
884
885            // The property name a caller would have set, not the flag's spelling: the two
886            // differ wherever `sanitize_py_ident` had to move out of the way of a keyword.
887            let omit = output
888                .omit
889                .as_deref()
890                .and_then(|name| {
891                    global_flags
892                        .iter()
893                        .copied()
894                        .chain(visible_flags.iter().copied())
895                        .find(|f| flag_names(f, name))
896                        .map(flag_property_name_py)
897                })
898                .unwrap_or_default();
899            let selector = output
900                .select
901                .iter()
902                .map(|w| format!("\"{}\"", escape_py_string(w)))
903                .collect::<Vec<_>>()
904                .join(", ");
905            w.line("");
906            w.line(&format!("def exec_{}({params}) -> {ret}:", output.suffix));
907            w.indent();
908            write_doc(w, &doc);
909            w.line(&format!(
910                "cmd_args = self._cmd_args({call}{comma}\"{omit}\")"
911            ));
912            w.line(&format!("cmd_args.extend([{selector}])"));
913            w.line(&format!("return self._runner.{runner}(cmd_args)"));
914            w.dedent();
915        }
916    }
917
918    // _build_flag_args
919    if has_flags {
920        w.line("");
921        let omit_param = if outputs.is_empty() {
922            String::new()
923        } else {
924            ", _omit: str = \"\"".to_string()
925        };
926        w.line(&format!(
927            "def _build_flag_args(self, flags: Optional[{flags_type}]{omit_param}) -> list[str]:"
928        ));
929        w.indent();
930        w.line("result: list[str] = []");
931        w.line("if flags is None: return result");
932
933        let omit = outputs.iter().find_map(|o| o.omit.clone());
934        let render = |flag: &SpecFlag, w: &mut CodeWriter| {
935            // The selecting flag is left out when a method already picked the output, so a
936            // caller-supplied `format` cannot end up on the line twice contradicting it.
937            // Compared by property name, which is what a caller actually set.
938            let guarded = omit.as_deref().is_some_and(|name| flag_names(flag, name));
939            if guarded {
940                let prop = flag_property_name_py(flag);
941                w.line(&format!("if _omit != \"{prop}\":"));
942                w.indent();
943                render_flag_build_py(flag, w);
944                w.dedent();
945            } else {
946                render_flag_build_py(flag, w);
947            }
948        };
949        for flag in global_flags {
950            render(flag, w);
951        }
952        for flag in &visible_flags {
953            // skip global flags already rendered above
954            if !global_flags.iter().any(|gf| gf.name == flag.name) {
955                render(flag, w);
956            }
957        }
958
959        w.line("return result");
960        w.dedent();
961    }
962
963    // alias properties for subcommand aliases
964    for (name, subcmd) in &visible_subcmds {
965        for alias in &subcmd.aliases {
966            let alias_prop = sanitize_py_ident(alias);
967            let target_prop = sanitize_py_ident(name);
968            let sub_class = AsPascalCase(name).to_string();
969            w.line("");
970            w.line("@property");
971            w.line(&format!("def {alias_prop}(self) -> {sub_class}:"));
972            w.indent();
973            w.line(&format!(
974                "\"\"\"Alias for {}.\"\"\"",
975                escape_py_docstring(name)
976            ));
977            w.line(&format!("return self.{target_prop}"));
978            w.dedent();
979        }
980    }
981
982    w.dedent(); // end class
983
984    // render subcommand classes
985    for (name, subcmd) in &visible_subcmds {
986        w.line("");
987        let sub_class = AsPascalCase(name).to_string();
988        render_class(
989            subcmd,
990            &sub_class,
991            false,
992            global_flags,
993            bin_name,
994            spec,
995            package_name,
996            w,
997        );
998    }
999}
1000
1001fn render_flag_build_py(flag: &SpecFlag, w: &mut CodeWriter) {
1002    let prop_name = flag_property_name_py(flag);
1003    let flag_arg_name = if let Some(long) = flag.long.first() {
1004        format!("--{}", escape_py_string(long))
1005    } else if let Some(short) = flag.short.first() {
1006        format!("-{short}")
1007    } else {
1008        format!("--{}", escape_py_string(&flag.name))
1009    };
1010
1011    if flag.arg.is_some() {
1012        if flag.var {
1013            w.line(&format!("if flags.{prop_name} is not None:"));
1014            w.indent();
1015            w.line(&format!(
1016                "for v in flags.{prop_name}: result.extend([\"{flag_arg_name}\", str(v)])"
1017            ));
1018            w.dedent();
1019        } else {
1020            w.line(&format!(
1021                "if flags.{prop_name} is not None: result.extend([\"{flag_arg_name}\", str(flags.{prop_name})])"
1022            ));
1023        }
1024    } else if flag.count {
1025        w.line(&format!(
1026            "if flags.{prop_name} is not None and flags.{prop_name} > 0: result.extend([\"{flag_arg_name}\"] * flags.{prop_name})"
1027        ));
1028    } else if flag.var {
1029        w.line(&format!("if flags.{prop_name} is not None:"));
1030        w.indent();
1031        w.line(&format!("for v in flags.{prop_name}:"));
1032        w.indent();
1033        w.line(&format!("if v: result.append(\"{flag_arg_name}\")"));
1034        w.dedent();
1035        w.dedent();
1036    } else {
1037        w.line(&format!(
1038            "if flags.{prop_name}: result.append(\"{flag_arg_name}\")"
1039        ));
1040        if let Some(negate) = &flag.negate {
1041            w.line(&format!(
1042                "elif flags.{prop_name} is False: result.append(\"{}\")",
1043                escape_py_string(negate)
1044            ));
1045        }
1046    }
1047}
1048
1049/// Whether anything in the tree declares an output, so the generated imports carry only
1050/// what is used.
1051fn any_outputs(cmd: &SpecCommand, spec: &Spec, package_name: &str) -> bool {
1052    !crate::sdk::output_methods(cmd, spec, package_name).is_empty()
1053        || cmd
1054            .subcommands
1055            .values()
1056            .any(|sub| any_outputs(sub, spec, package_name))
1057}
1058
1059// ---------------------------------------------------------------------------
1060// Tests
1061// ---------------------------------------------------------------------------
1062
1063#[cfg(test)]
1064mod tests {
1065    use crate::sdk::{SdkLanguage, SdkOptions};
1066    use crate::test::SPEC_KITCHEN_SINK;
1067    use crate::Spec;
1068
1069    fn make_opts() -> SdkOptions {
1070        SdkOptions {
1071            language: SdkLanguage::Python,
1072            package_name: None,
1073            source_file: Some("test.usage.kdl".to_string()),
1074        }
1075    }
1076
1077    fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str {
1078        output
1079            .files
1080            .iter()
1081            .find(|f| f.path.to_str() == Some(name))
1082            .unwrap_or_else(|| panic!("{name} should exist"))
1083            .content
1084            .as_str()
1085    }
1086
1087    #[test]
1088    fn test_python_types() {
1089        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1090        insta::assert_snapshot!(get_file(&output, "types.py"));
1091    }
1092
1093    #[test]
1094    fn test_python_client() {
1095        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1096        insta::assert_snapshot!(get_file(&output, "client.py"));
1097    }
1098
1099    #[test]
1100    fn structured_output_client_imports_its_runtime_result_types() {
1101        let spec: Spec = r#"
1102            bin "reporter"
1103            flag "--json"
1104            output "text" default=#true
1105            output "json" framing="json" select="--json"
1106        "#
1107        .parse()
1108        .unwrap();
1109        let output = crate::sdk::generate(&spec, &make_opts());
1110        let client = get_file(&output, "client.py");
1111        assert!(client.contains("CliJsonResult, CliResult, CliRunner, CliStream"));
1112    }
1113
1114    #[test]
1115    fn nested_same_named_commands_get_distinct_exit_code_exports() {
1116        let spec: Spec = r#"
1117            bin "app"
1118            cmd "one" { cmd "show" { exit_code 1 "one failed" } }
1119            cmd "two" { cmd "show" { exit_code 2 "two failed" } }
1120        "#
1121        .parse()
1122        .unwrap();
1123        let output = crate::sdk::generate(&spec, &make_opts());
1124        let types = get_file(&output, "types.py");
1125        assert!(types.contains("ONE_SHOW_EXIT_CODES"), "{types}");
1126        assert!(types.contains("TWO_SHOW_EXIT_CODES"), "{types}");
1127        assert!(types.contains("OneShowExitCode"), "{types}");
1128        assert!(types.contains("TwoShowExitCode"), "{types}");
1129    }
1130
1131    #[test]
1132    fn test_python_runtime() {
1133        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1134        insta::assert_snapshot!(get_file(&output, "runtime.py"));
1135    }
1136
1137    #[test]
1138    fn test_python_init() {
1139        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
1140        insta::assert_snapshot!(get_file(&output, "__init__.py"));
1141    }
1142
1143    fn full_feature_spec() -> Spec {
1144        r##"
1145            bin "mytool"
1146            name "mytool"
1147            version "1.2.3"
1148            about "A powerful CLI tool"
1149            author "Jane Doe"
1150
1151            flag "-v --verbose" help="Verbosity level" count=#true global=#true
1152            flag "-C --config <path>" help="Config file path" global=#true env="MYTOOL_CONFIG"
1153            flag "--dry-run" help="Show what would be done" negate="--no-dry-run"
1154
1155            arg "input" help="Input file" required=#true
1156            arg "extra" var=#true help="Extra files"
1157
1158            cmd "build" help="Build the project" deprecated="Use compile instead" {
1159                alias "b"
1160                arg "target" help="Build target" {
1161                    choices "debug" "release"
1162                }
1163                arg "output" help="Output directory" double_dash="required"
1164                flag "-j --jobs <n>" help="Parallel jobs" var=#true
1165                flag "--release" help="Build in release mode"
1166            }
1167
1168            cmd "deploy" help="Deploy the project" {
1169                arg "env" help="Target environment" {
1170                    choices "staging" "production"
1171                }
1172                arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5
1173                flag "-f --force" help="Force deploy" deprecated="Use --confirm instead"
1174                flag "--confirm" help="Confirm deployment"
1175            }
1176        "##
1177        .parse()
1178        .unwrap()
1179    }
1180
1181    #[test]
1182    fn test_python_full_feature_types() {
1183        let spec = full_feature_spec();
1184        let output = crate::sdk::generate(&spec, &make_opts());
1185        insta::assert_snapshot!(get_file(&output, "types.py"));
1186    }
1187
1188    #[test]
1189    fn test_python_full_feature_client() {
1190        let spec = full_feature_spec();
1191        let output = crate::sdk::generate(&spec, &make_opts());
1192        insta::assert_snapshot!(get_file(&output, "client.py"));
1193    }
1194
1195    #[test]
1196    fn test_python_hyphenated_subcommands() {
1197        let spec: Spec = r##"
1198            bin "cli"
1199            cmd "add-remote" help="Add a remote" {
1200                arg "name"
1201                arg "url"
1202            }
1203            cmd "remove-remote" help="Remove a remote" {
1204                arg "name"
1205            }
1206        "##
1207        .parse()
1208        .unwrap();
1209        let output = crate::sdk::generate(&spec, &make_opts());
1210        insta::assert_snapshot!(get_file(&output, "client.py"));
1211    }
1212
1213    #[test]
1214    fn test_python_minimal() {
1215        let spec: Spec = r##"
1216            bin "hello"
1217        "##
1218        .parse()
1219        .unwrap();
1220        let output = crate::sdk::generate(&spec, &make_opts());
1221        insta::assert_snapshot!(get_file(&output, "client.py"));
1222    }
1223
1224    /// Flags-only subcommand (no positional args) — tests exec signature without double comma.
1225    #[test]
1226    fn test_python_flags_only_subcommand() {
1227        let spec: Spec = r##"
1228            bin "app"
1229            cmd "status" help="Show status" {
1230                flag "--verbose" help="Show detailed status"
1231                flag "--json" help="Output as JSON"
1232            }
1233        "##
1234        .parse()
1235        .unwrap();
1236        let output = crate::sdk::generate(&spec, &make_opts());
1237        let client = get_file(&output, "client.py");
1238        assert!(!client.contains("def exec(self, ,"));
1239        assert!(
1240            client.contains("def exec(self, flags: Optional[StatusFlags] = None) -> CliResult:")
1241        );
1242        insta::assert_snapshot!(client);
1243    }
1244
1245    /// Choice type collision: same arg name with different choices in different subcommands.
1246    #[test]
1247    fn test_python_choice_collision() {
1248        let spec: Spec = r##"
1249            bin "tool"
1250            cmd "build" help="Build" {
1251                arg "env" help="Build environment" {
1252                    choices "debug" "release"
1253                }
1254            }
1255            cmd "deploy" help="Deploy" {
1256                arg "env" help="Deploy environment" {
1257                    choices "staging" "production"
1258                }
1259            }
1260        "##
1261        .parse()
1262        .unwrap();
1263        let output = crate::sdk::generate(&spec, &make_opts());
1264        let types = get_file(&output, "types.py");
1265        // Must have separate choice types due to collision
1266        assert!(types.contains("BuildEnvChoice"));
1267        assert!(types.contains("DeployEnvChoice"));
1268        assert!(types.contains(r#""debug""#));
1269        assert!(types.contains(r#""staging""#));
1270        insta::assert_snapshot!(types);
1271    }
1272
1273    /// Args with default values — tests that defaults are preserved, not dropped to None.
1274    #[test]
1275    fn test_python_arg_defaults() {
1276        let spec: Spec = r##"
1277            bin "runner"
1278            arg "mode" default="fast" help="Run mode"
1279            arg "output" help="Output path" required=#true
1280        "##
1281        .parse()
1282        .unwrap();
1283        let output = crate::sdk::generate(&spec, &make_opts());
1284        let types = get_file(&output, "types.py");
1285        assert!(types.contains(r#"mode: Optional[str] = "fast""#));
1286        assert!(types.contains("output: str"));
1287        // required arg must come before optional
1288        let output_pos = types.find("output: str").unwrap();
1289        let mode_pos = types.find(r#"mode: Optional[str] = "fast""#).unwrap();
1290        assert!(
1291            output_pos < mode_pos,
1292            "required arg must precede optional arg"
1293        );
1294    }
1295
1296    /// Config props — covers config dataclass and config_prop_type.
1297    #[test]
1298    fn test_python_config_props() {
1299        let spec: Spec = r##"
1300            bin "myapp"
1301            config {
1302                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1303                prop "port" default=8080 data_type=integer
1304                prop "rate" default="1.5" data_type=float
1305                prop "host" data_type=string
1306                prop "extra" data_type="null"
1307            }
1308        "##
1309        .parse()
1310        .unwrap();
1311        let output = crate::sdk::generate(&spec, &make_opts());
1312        let types = get_file(&output, "types.py");
1313        assert!(types.contains("class MyappConfig"));
1314        insta::assert_snapshot!(types);
1315    }
1316
1317    /// A default whose text is not the type it claims to be must not become an expression.
1318    #[test]
1319    fn test_python_config_default_is_never_an_expression() {
1320        // types.py is imported, so anything written into it unquoted runs.
1321        //
1322        // The route this test was written for — `data_type=integer` beside a default of
1323        // arbitrary text — no longer parses at all; see
1324        // `a_default_the_declared_type_cannot_read_is_refused`. But a *string*-typed default
1325        // legitimately holds arbitrary text, which is the surface that still exists and the
1326        // one the generator has to quote. Two defences, and this is the second.
1327        let spec: Spec = r##"
1328            bin "myapp"
1329            config {
1330                prop "cmd" data_type=string default="__import__('os').system('touch /tmp/pwned')"
1331                prop "quoteful" data_type=string default="he said \"hi\""
1332                prop "multiline" data_type=string default="two\nlines"
1333            }
1334        "##
1335        .parse()
1336        .unwrap();
1337        let output = crate::sdk::generate(&spec, &make_opts());
1338        let types = get_file(&output, "types.py");
1339
1340        assert!(
1341            !types.contains("= __import__"),
1342            "a string default must not be emitted as an expression:\n{types}"
1343        );
1344        assert!(
1345            types.contains(r#"= "__import__('os').system('touch /tmp/pwned')""#),
1346            "it should be a quoted string:\n{types}"
1347        );
1348        // And a string containing quotes stays escaped rather than closing the literal.
1349        assert!(
1350            types.contains(r#"= "he said \"hi\"""#),
1351            "quotes inside a default should be escaped:\n{types}"
1352        );
1353        // A control character cannot be carried literally inside a Python literal at all: a
1354        // newline in a default wrote a module that fails to *import*, which is a worse failure
1355        // than one that says something wrong.
1356        assert!(
1357            types.contains(r#"= "two\nlines""#),
1358            "a newline in a default must be escaped:\n{types}"
1359        );
1360        assert!(
1361            !types.lines().any(|line| line.trim() == "lines\""),
1362            "the literal was split across lines:\n{types}"
1363        );
1364    }
1365
1366    /// Hidden command, hidden arg/flag — covers early-return and empty dataclass paths.
1367    #[test]
1368    fn test_python_hidden_command() {
1369        let spec: Spec = r##"
1370            bin "app"
1371            cmd "visible" help="A visible command" {
1372                arg "name"
1373            }
1374            cmd "secret" hide=#true help="Hidden command" {
1375                arg "name"
1376            }
1377        "##
1378        .parse()
1379        .unwrap();
1380        let output = crate::sdk::generate(&spec, &make_opts());
1381        let types = get_file(&output, "types.py");
1382        assert!(types.contains("VisibleArgs"));
1383        assert!(!types.contains("SecretArgs"));
1384    }
1385
1386    /// Flag edge cases: short-only, aliases, deprecated, count+default, required flag,
1387    /// repeatable boolean flag, non-bool value flag with default.
1388    #[test]
1389    fn test_python_flag_edge_cases() {
1390        let spec: Spec = r##"
1391            bin "tool"
1392            flag "-v" help="Short-only flag"
1393            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1394            flag "--level" count=#true default="2" help="Count flag with default"
1395            flag "--format <fmt>" default="json" help="Value flag with default"
1396            flag "--confirm" required=#true help="Required flag"
1397            flag "--verbose" var=#true help="Repeatable boolean flag"
1398        "##
1399        .parse()
1400        .unwrap();
1401        let output = crate::sdk::generate(&spec, &make_opts());
1402        let types = get_file(&output, "types.py");
1403        insta::assert_snapshot!(types);
1404        let client = get_file(&output, "client.py");
1405        // short-only flag build
1406        assert!(client.contains(r#""-v""#));
1407        // repeatable boolean flag build
1408        assert!(client.contains("for v in flags.verbose:"));
1409        insta::assert_snapshot!(client);
1410    }
1411
1412    /// double_dash=automatic, examples in exec doc, global flags with flags-only subcommand.
1413    #[test]
1414    fn test_python_exec_edge_cases() {
1415        let spec: Spec = r##"
1416            bin "runner"
1417            flag "-v --verbose" global=#true help="Verbosity"
1418            arg "input" help="Input file"
1419            arg "extra" double_dash="automatic" var=#true help="Extra files"
1420            cmd "run" help="Run a task" {
1421                example "runner run hello" header="Basic run"
1422                arg "task" help="Task to run" double_dash="automatic"
1423            }
1424            cmd "info" help="Show info" {}
1425        "##
1426        .parse()
1427        .unwrap();
1428        let output = crate::sdk::generate(&spec, &make_opts());
1429        let client = get_file(&output, "client.py");
1430        assert!(client.contains("double_dash=automatic"));
1431        assert!(client.contains("Basic run: runner run hello"));
1432        // "info" has no own flags, only global flags => GlobalFlags type
1433        assert!(client.contains("flags: Optional[GlobalFlags] = None"));
1434        insta::assert_snapshot!(client);
1435    }
1436
1437    /// Optional arg without default and empty flags dataclass.
1438    #[test]
1439    fn test_python_optional_arg_empty_flags() {
1440        let spec: Spec = r##"
1441            bin "app"
1442            arg "[name]" help="Optional arg without default"
1443            cmd "check" help="Check something" {
1444                arg "target" required=#true help="Required arg"
1445                arg "mode" default="quick" help="Optional arg with default"
1446            }
1447        "##
1448        .parse()
1449        .unwrap();
1450        let output = crate::sdk::generate(&spec, &make_opts());
1451        let types = get_file(&output, "types.py");
1452        // optional arg without default should have = None
1453        assert!(types.contains("name: Optional[str] = None"));
1454        insta::assert_snapshot!(types);
1455    }
1456
1457    /// Deeply nested subcommands — 3+ levels.
1458    #[test]
1459    fn test_python_deep_nesting() {
1460        let spec: Spec = r##"
1461            bin "app"
1462            cmd "db" help="Database operations" {
1463                cmd "migration" help="Migration management" {
1464                    cmd "create" help="Create a new migration" {
1465                        arg "name"
1466                        flag "--template <t>" help="Migration template"
1467                    }
1468                    cmd "run" help="Run pending migrations" {
1469                        flag "--step <n>" help="Number of migrations to run"
1470                    }
1471                }
1472            }
1473        "##
1474        .parse()
1475        .unwrap();
1476        let output = crate::sdk::generate(&spec, &make_opts());
1477        let client = get_file(&output, "client.py");
1478        // deeply nested class must exist
1479        assert!(client.contains("class Db:"));
1480        assert!(client.contains("class Migration:"));
1481        assert!(client.contains("class Create:"));
1482        insta::assert_snapshot!(client);
1483    }
1484
1485    /// Test package_name override.
1486    #[test]
1487    fn test_python_package_name_override() {
1488        let spec: Spec = r##"
1489            bin "original-cli"
1490        "##
1491        .parse()
1492        .unwrap();
1493        let opts = SdkOptions {
1494            language: SdkLanguage::Python,
1495            package_name: Some("my_custom_sdk".to_string()),
1496            source_file: None,
1497        };
1498        let output = crate::sdk::generate(&spec, &opts);
1499        let init = get_file(&output, "__init__.py");
1500        assert!(init.contains("MyCustomSdk"));
1501        insta::assert_snapshot!(init);
1502    }
1503
1504    /// Global flags with flags-only subcommand — covers GlobalFlags type branch.
1505    #[test]
1506    fn test_python_global_flags_flags_only() {
1507        let spec: Spec = r##"
1508            bin "app"
1509            flag "-v --verbose" global=#true help="Verbosity"
1510            cmd "status" help="Show status" {
1511                flag "--json" help="JSON output"
1512            }
1513            cmd "info" help="Show info" {}
1514        "##
1515        .parse()
1516        .unwrap();
1517        let output = crate::sdk::generate(&spec, &make_opts());
1518        let client = get_file(&output, "client.py");
1519        // "info" subcommand has no own flags, only global flags => GlobalFlags type
1520        assert!(client.contains("Optional[GlobalFlags]"));
1521        insta::assert_snapshot!(client);
1522    }
1523
1524    /// Flag with choices — flag arg with choices renders correct type.
1525    #[test]
1526    fn test_python_flag_with_choices() {
1527        let spec: Spec = r##"
1528            bin "tool"
1529            flag "--shell <shell>" help="Shell type" {
1530                choices "bash" "zsh" "fish"
1531            }
1532        "##
1533        .parse()
1534        .unwrap();
1535        let output = crate::sdk::generate(&spec, &make_opts());
1536        let types = get_file(&output, "types.py");
1537        assert!(types.contains("Literal[\"bash\", \"zsh\", \"fish\"]"));
1538        insta::assert_snapshot!(types);
1539    }
1540
1541    /// Flag with env annotation — env variable appears in comment.
1542    #[test]
1543    fn test_python_flag_with_env() {
1544        let spec: Spec = r##"
1545            bin "app"
1546            flag "--config <path>" help="Config file" env="APP_CONFIG"
1547        "##
1548        .parse()
1549        .unwrap();
1550        let output = crate::sdk::generate(&spec, &make_opts());
1551        let types = get_file(&output, "types.py");
1552        assert!(types.contains("Env: APP_CONFIG"));
1553        insta::assert_snapshot!(types);
1554    }
1555
1556    /// Hidden flag excluded from types and client.
1557    #[test]
1558    fn test_python_flag_hide() {
1559        let spec: Spec = r##"
1560            bin "app"
1561            flag "--verbose" help="Verbosity"
1562            flag "--debug" hide=#true help="Hidden debug flag"
1563        "##
1564        .parse()
1565        .unwrap();
1566        let output = crate::sdk::generate(&spec, &make_opts());
1567        let types = get_file(&output, "types.py");
1568        assert!(types.contains("verbose"));
1569        assert!(!types.contains("debug"));
1570    }
1571
1572    /// Negate flag rendered in client build method.
1573    #[test]
1574    fn test_python_negate_flag_build() {
1575        let spec: Spec = r##"
1576            bin "app"
1577            flag "--dry-run" help="Dry run" negate="--no-dry-run"
1578        "##
1579        .parse()
1580        .unwrap();
1581        let output = crate::sdk::generate(&spec, &make_opts());
1582        let client = get_file(&output, "client.py");
1583        assert!(client.contains("--dry-run"));
1584        assert!(client.contains("--no-dry-run"));
1585        insta::assert_snapshot!(client);
1586    }
1587
1588    /// Count flag rendered in client build method.
1589    #[test]
1590    fn test_python_count_flag_build() {
1591        let spec: Spec = r##"
1592            bin "app"
1593            flag "-v --verbose" count=#true help="Verbosity level"
1594        "##
1595        .parse()
1596        .unwrap();
1597        let output = crate::sdk::generate(&spec, &make_opts());
1598        let client = get_file(&output, "client.py");
1599        assert!(client.contains(r#""--verbose""#));
1600        assert!(client.contains("flags.verbose"));
1601        insta::assert_snapshot!(client);
1602    }
1603
1604    /// Repeatable value flag with default — covers var + arg + default in client build.
1605    #[test]
1606    fn test_python_var_value_flag_with_default() {
1607        let spec: Spec = r##"
1608            bin "tool"
1609            flag "--tag <t>" var=#true default="latest" help="Tags"
1610        "##
1611        .parse()
1612        .unwrap();
1613        let output = crate::sdk::generate(&spec, &make_opts());
1614        let types = get_file(&output, "types.py");
1615        assert!(types.contains(r#"list[str]"#));
1616        assert!(types.contains(r#"default: latest"#));
1617        let client = get_file(&output, "client.py");
1618        assert!(client.contains("for v in flags.tag:"));
1619        insta::assert_snapshot!(types);
1620    }
1621
1622    /// Flag with multiple long aliases — `-f --format --fmt <fmt>`.
1623    #[test]
1624    fn test_python_multiple_aliases() {
1625        let spec: Spec = r##"
1626            bin "tool"
1627            flag "-f --format --fmt <fmt>" help="Output format"
1628        "##
1629        .parse()
1630        .unwrap();
1631        let output = crate::sdk::generate(&spec, &make_opts());
1632        let types = get_file(&output, "types.py");
1633        assert!(types.contains("Aliases: fmt"));
1634        let client = get_file(&output, "client.py");
1635        // should use first long for the flag argument name
1636        assert!(client.contains("--format"));
1637        insta::assert_snapshot!(client);
1638    }
1639
1640    /// Boolean flag with default=#false — covers the "= False" branch.
1641    #[test]
1642    fn test_python_boolean_flag_default_false() {
1643        let spec: Spec = r##"
1644            bin "app"
1645            flag "--no-cache" default=#false help="Disable cache"
1646        "##
1647        .parse()
1648        .unwrap();
1649        let output = crate::sdk::generate(&spec, &make_opts());
1650        let types = get_file(&output, "types.py");
1651        assert!(types.contains("no_cache: bool = False"));
1652        insta::assert_snapshot!(types);
1653    }
1654
1655    /// Boolean config prop with default=#false — covers the "= False" branch.
1656    #[test]
1657    fn test_python_config_boolean_default_false() {
1658        let spec: Spec = r##"
1659            bin "app"
1660            config {
1661                prop "verbose" default=#false data_type=boolean help="Verbose output"
1662                prop "dry_run" default=#true data_type=boolean help="Dry run mode"
1663            }
1664        "##
1665        .parse()
1666        .unwrap();
1667        let output = crate::sdk::generate(&spec, &make_opts());
1668        let types = get_file(&output, "types.py");
1669        assert!(types.contains("verbose: bool = False"));
1670        assert!(types.contains("dry_run: bool = True"));
1671        insta::assert_snapshot!(types);
1672    }
1673
1674    /// String config prop with default — covers String/Null match arm with default.
1675    #[test]
1676    fn test_python_config_string_with_default() {
1677        let spec: Spec = r##"
1678            bin "app"
1679            config {
1680                prop "host" default="localhost" data_type=string help="Server host"
1681                prop "name" default="myapp" data_type=string
1682            }
1683        "##
1684        .parse()
1685        .unwrap();
1686        let output = crate::sdk::generate(&spec, &make_opts());
1687        let types = get_file(&output, "types.py");
1688        assert!(types.contains(r#"host: str = "localhost""#));
1689        assert!(types.contains(r#"name: str = "myapp""#));
1690        insta::assert_snapshot!(types);
1691    }
1692
1693    /// Config with all props having defaults — tests Default derive on config dataclass.
1694    #[test]
1695    fn test_python_config_all_optional() {
1696        let spec: Spec = r##"
1697            bin "app"
1698            config {
1699                prop "debug" default=#true data_type=boolean
1700                prop "port" default=8080 data_type=integer
1701            }
1702        "##
1703        .parse()
1704        .unwrap();
1705        let output = crate::sdk::generate(&spec, &make_opts());
1706        let types = get_file(&output, "types.py");
1707        assert!(types.contains("class AppConfig:"));
1708        insta::assert_snapshot!(types);
1709    }
1710
1711    /// Optional variadic arg — covers the optional + var branch in client rendering.
1712    #[test]
1713    fn test_python_optional_variadic_arg() {
1714        let spec: Spec = r##"
1715            bin "tool"
1716            arg "[files]" var=#true help="Input files"
1717        "##
1718        .parse()
1719        .unwrap();
1720        let output = crate::sdk::generate(&spec, &make_opts());
1721        let types = get_file(&output, "types.py");
1722        assert!(types.contains("list[str]"));
1723        let client = get_file(&output, "client.py");
1724        // optional variadic arg should use extend with None guard
1725        assert!(client.contains("if args.files is not None: cmd_args.extend(args.files)"));
1726        insta::assert_snapshot!(client);
1727    }
1728
1729    /// Example without lang attribute — tests single-line exec doc path.
1730    #[test]
1731    fn test_python_example_without_lang() {
1732        let spec: Spec = r##"
1733            bin "app"
1734            cmd "greet" help="Greet someone" {
1735                example "app greet hello"
1736                arg "name" help="Name to greet"
1737            }
1738        "##
1739        .parse()
1740        .unwrap();
1741        let output = crate::sdk::generate(&spec, &make_opts());
1742        let client = get_file(&output, "client.py");
1743        assert!(client.contains("app greet hello"));
1744        insta::assert_snapshot!(client);
1745    }
1746
1747    /// Required flag without default — tests non-optional flag type rendering.
1748    #[test]
1749    fn test_python_required_flag_type() {
1750        let spec: Spec = r##"
1751            bin "tool"
1752            flag "--token <t>" required=#true help="Auth token"
1753        "##
1754        .parse()
1755        .unwrap();
1756        let output = crate::sdk::generate(&spec, &make_opts());
1757        let types = get_file(&output, "types.py");
1758        // required flag without default should NOT be Optional
1759        assert!(types.contains("token: str"));
1760        assert!(!types.contains("token: Optional[str]"));
1761        insta::assert_snapshot!(types);
1762    }
1763
1764    /// Global repeatable flags — covers flag_ts_simple var branches.
1765    #[test]
1766    fn test_python_global_repeatable_flags() {
1767        let spec: Spec = r##"
1768            bin "app"
1769            flag "-v --verbose" global=#true var=#true help="Repeatable verbose"
1770            flag "--tag <t>" global=#true var=#true help="Repeatable tag"
1771            cmd "run" help="Run" {
1772                arg "target"
1773            }
1774        "##
1775        .parse()
1776        .unwrap();
1777        let output = crate::sdk::generate(&spec, &make_opts());
1778        let types = get_file(&output, "types.py");
1779        // GlobalFlags should have list[bool] and list[str] types
1780        assert!(types.contains("list[bool]"));
1781        assert!(types.contains("list[str]"));
1782        insta::assert_snapshot!(types);
1783    }
1784
1785    /// Client edge cases: double_dash=automatic, examples, global flags, repeatable boolean flag.
1786    #[test]
1787    fn test_python_client_edge_cases() {
1788        let spec: Spec = r##"
1789            bin "runner"
1790            flag "-v --verbose" global=#true help="Verbosity"
1791            flag "--debug" var=#true help="Repeatable boolean flag"
1792            arg "input" help="Input file"
1793            arg "extra" double_dash="automatic" var=#true help="Extra files"
1794            cmd "run" help="Run a task" {
1795                example "runner run hello" header="Basic run" lang="bash"
1796                arg "task" help="Task to run" double_dash="automatic"
1797            }
1798            cmd "info" help="Show info" {}
1799        "##
1800        .parse()
1801        .unwrap();
1802        let output = crate::sdk::generate(&spec, &make_opts());
1803        let client = get_file(&output, "client.py");
1804        assert!(client.contains("double_dash=automatic"));
1805        assert!(client.contains("Basic run: runner run hello"));
1806        // GlobalFlags type for info subcommand
1807        assert!(client.contains("Optional[GlobalFlags]"));
1808        // repeatable boolean flag
1809        assert!(client.contains("for v in flags.debug:"));
1810        insta::assert_snapshot!(client);
1811    }
1812
1813    /// Config and flag edge cases — config with various data types, env, deprecated, aliases.
1814    #[test]
1815    fn test_python_config_and_flag_edge_cases() {
1816        let spec: Spec = r##"
1817            bin "myapp"
1818            config {
1819                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1820                prop "port" default=8080 data_type=integer
1821                prop "rate" default="1.5" data_type=float
1822                prop "host" data_type=string
1823            }
1824            arg "input" help="Input file" env="MYAPP_INPUT"
1825            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1826            flag "-f --format --fmt <fmt>" help="Flag with short and long alias"
1827            flag "-v" help="Short-only flag"
1828        "##
1829        .parse()
1830        .unwrap();
1831        let output = crate::sdk::generate(&spec, &make_opts());
1832        let types = get_file(&output, "types.py");
1833        assert!(types.contains("MyappConfig"));
1834        insta::assert_snapshot!(types);
1835    }
1836
1837    /// double_dash=automatic — covers arg ordering and separator insertion.
1838    #[test]
1839    fn test_python_double_dash_automatic() {
1840        let spec: Spec = r##"
1841            bin "runner"
1842            arg "input" help="Input file"
1843            arg "extra" double_dash="automatic" var=#true help="Extra files"
1844            flag "--verbose" var=#true help="Repeatable boolean flag"
1845            cmd "run" help="Run a task" {
1846                example "runner run hello" header="Basic run"
1847                arg "task" help="Task to run" double_dash="automatic"
1848            }
1849        "##
1850        .parse()
1851        .unwrap();
1852        let output = crate::sdk::generate(&spec, &make_opts());
1853        let client = get_file(&output, "client.py");
1854        assert!(client.contains("double_dash=automatic"));
1855        insta::assert_snapshot!(client);
1856    }
1857}