Skip to main content

usage/sdk/python/
mod.rs

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