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, generated_header, ChoiceTypeMap, CodeWriter, SdkFile, SdkOptions, SdkOutput,
8};
9use crate::spec::arg::SpecDoubleDashChoices;
10use crate::spec::cmd::SpecCommand;
11use crate::spec::config::SpecConfigProp;
12use crate::spec::data_types::SpecDataTypes;
13use crate::{Spec, SpecArg, SpecFlag};
14
15fn sanitize_py_comment(text: &str) -> String {
16    text.replace(['\n', '\r'], " ")
17}
18
19mod runtime;
20
21pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput {
22    let package_name = opts
23        .package_name
24        .clone()
25        .unwrap_or_else(|| spec.bin.clone());
26
27    SdkOutput {
28        files: vec![
29            SdkFile {
30                path: PathBuf::from("types.py"),
31                content: render_types(spec, &package_name, &opts.source_file),
32            },
33            SdkFile {
34                path: PathBuf::from("client.py"),
35                content: render_client(spec, &package_name, &opts.source_file),
36            },
37            SdkFile {
38                path: PathBuf::from("runtime.py"),
39                content: runtime::RUNTIME_PY.to_string(),
40            },
41            SdkFile {
42                path: PathBuf::from("__init__.py"),
43                content: render_init(&package_name),
44            },
45        ],
46    }
47}
48
49fn render_init(package_name: &str) -> String {
50    let class_name = AsPascalCase(package_name).to_string();
51    format!("from .client import {class_name}\nfrom .runtime import CliResult, CliRunner\nfrom .types import *\n")
52}
53
54// ---------------------------------------------------------------------------
55// types.py
56// ---------------------------------------------------------------------------
57
58fn render_types(spec: &Spec, package_name: &str, source_file: &Option<String>) -> String {
59    let mut w = CodeWriter::with_indent("    ");
60
61    w.line(&generated_header("#", source_file));
62    w.line("from __future__ import annotations");
63    w.line("from dataclasses import dataclass");
64    w.line("from typing import Literal, Optional");
65    w.line("");
66
67    // spec metadata
68    if let Some(version) = &spec.version {
69        w.line(&format!("VERSION = \"{}\"", escape_py_string(version)));
70    }
71    if let Some(about) = &spec.about {
72        w.line(&format!("ABOUT = \"{}\"", escape_py_string(about)));
73    }
74    if let Some(author) = &spec.author {
75        w.line(&format!("AUTHOR = \"{}\"", escape_py_string(author)));
76    }
77
78    let choice_types = collect_choice_types(&spec.cmd);
79    let root_global_flags: Vec<&SpecFlag> = spec
80        .cmd
81        .flags
82        .iter()
83        .filter(|f| f.global && !f.hide)
84        .collect();
85    let has_global_flags = !root_global_flags.is_empty();
86
87    if !choice_types.is_empty() {
88        w.line("");
89        for (name, choices) in choice_types.iter() {
90            let union = choices
91                .iter()
92                .map(|c| format!("\"{}\"", escape_py_string(c)))
93                .collect::<Vec<_>>()
94                .join(", ");
95            w.line(&format!("{name} = Literal[{union}]"));
96        }
97    }
98
99    if has_global_flags {
100        w.line("");
101        render_flags_dataclass(
102            "GlobalFlags",
103            &spec.cmd.name,
104            &spec.cmd.name,
105            &root_global_flags,
106            &choice_types,
107            &mut w,
108        );
109    }
110
111    render_command_types(
112        &spec.cmd,
113        package_name,
114        &spec.cmd.name,
115        &choice_types,
116        has_global_flags,
117        &root_global_flags,
118        &mut w,
119    );
120
121    if !spec.config.props.is_empty() {
122        w.line("");
123        let config_name = format!("{}Config", AsPascalCase(package_name));
124        w.line("");
125        w.line("@dataclass");
126        w.line(&format!("class {config_name}:"));
127        w.indent();
128        // Python dataclass requires non-default fields before default fields
129        let (required, optional): (Vec<_>, Vec<_>) = spec
130            .config
131            .props
132            .iter()
133            .partition(|(_, p)| p.default.is_none());
134
135        for (name, prop) in required.iter().chain(optional.iter()) {
136            let py_type = config_prop_type(prop);
137            let default = if let Some(d) = &prop.default {
138                match prop.data_type {
139                    SpecDataTypes::Boolean => match d.as_str() {
140                        "#true" | "true" => " = True".to_string(),
141                        "#false" | "false" => " = False".to_string(),
142                        other => format!(" = {other}"),
143                    },
144                    SpecDataTypes::Integer | SpecDataTypes::Float => {
145                        let numeric = d.trim_matches('"');
146                        format!(" = {numeric}")
147                    }
148                    _ => format!(" = \"{}\"", escape_py_string(d)),
149                }
150            } else {
151                String::new()
152            };
153            if let Some(help) = &prop.help {
154                w.line(&format!("# {}", sanitize_py_comment(help)));
155            }
156            w.line(&format!("{name}: {py_type}{default}"));
157        }
158        w.dedent();
159    }
160
161    w.finish()
162}
163
164fn render_command_types(
165    cmd: &SpecCommand,
166    package_name: &str,
167    root_cmd_name: &str,
168    choice_types: &ChoiceTypeMap,
169    has_global_flags: bool,
170    global_flags: &[&SpecFlag],
171    w: &mut CodeWriter,
172) {
173    if cmd.hide {
174        return;
175    }
176
177    let name = command_type_name(cmd, package_name);
178    let cmd_name = &cmd.name;
179    let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect();
180    let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
181
182    if !visible_args.is_empty() {
183        w.line("");
184        render_args_dataclass(
185            &format!("{name}Args"),
186            cmd_name,
187            &visible_args,
188            choice_types,
189            w,
190        );
191    }
192
193    if !visible_flags.is_empty() {
194        w.line("");
195        let all_flags: Vec<&SpecFlag> = if has_global_flags {
196            global_flags
197                .iter()
198                .copied()
199                .chain(
200                    visible_flags
201                        .iter()
202                        .filter(|f| !global_flags.iter().any(|gf| gf.name == f.name))
203                        .copied(),
204                )
205                .collect()
206        } else {
207            visible_flags
208        };
209        render_flags_dataclass(
210            &format!("{name}Flags"),
211            cmd_name,
212            root_cmd_name,
213            &all_flags,
214            choice_types,
215            w,
216        );
217    }
218
219    for subcmd in cmd.subcommands.values() {
220        render_command_types(
221            subcmd,
222            package_name,
223            root_cmd_name,
224            choice_types,
225            has_global_flags,
226            global_flags,
227            w,
228        );
229    }
230}
231
232fn render_args_dataclass(
233    name: &str,
234    cmd_name: &str,
235    args: &[&SpecArg],
236    choice_types: &ChoiceTypeMap,
237    w: &mut CodeWriter,
238) {
239    w.line("");
240    w.line("@dataclass");
241    w.line(&format!("class {name}:"));
242    w.indent();
243    if args.is_empty() {
244        w.line("pass");
245    } else {
246        // Python dataclass requires non-default fields before default fields
247        let (required, optional): (Vec<_>, Vec<_>) = args
248            .iter()
249            .copied()
250            .partition(|a| a.required && a.default.is_empty());
251
252        for arg in required.iter().chain(optional.iter()) {
253            let py_type = arg_py_type(arg, cmd_name, choice_types);
254            let is_required_no_default = arg.required && arg.default.is_empty();
255            let field = if !is_required_no_default && !arg.default.is_empty() {
256                // has default value
257                let default_val = &arg.default[0];
258                format!(
259                    "{}: Optional[{}] = \"{}\"",
260                    sanitize_py_ident(&arg.name),
261                    py_type,
262                    escape_py_string(default_val)
263                )
264            } else if !is_required_no_default {
265                // optional without explicit default
266                format!(
267                    "{}: Optional[{}] = None",
268                    sanitize_py_ident(&arg.name),
269                    py_type
270                )
271            } else {
272                // required, no default
273                format!("{}: {}", sanitize_py_ident(&arg.name), py_type)
274            };
275            if let Some(help) = &arg.help {
276                w.line(&format!("# {}", sanitize_py_comment(help)));
277            }
278            w.line(&field);
279        }
280    }
281    w.dedent();
282}
283
284fn render_flags_dataclass(
285    name: &str,
286    cmd_name: &str,
287    root_cmd_name: &str,
288    flags: &[&SpecFlag],
289    choice_types: &ChoiceTypeMap,
290    w: &mut CodeWriter,
291) {
292    w.line("");
293    w.line("@dataclass");
294    w.line(&format!("class {name}:"));
295    w.indent();
296    if flags.is_empty() {
297        w.line("pass");
298    } else {
299        // Python dataclass requires non-default fields before default fields
300        let (required, optional): (Vec<_>, Vec<_>) = flags
301            .iter()
302            .copied()
303            .partition(|f| f.required && f.default.is_empty());
304
305        for flag in required.iter().chain(optional.iter()) {
306            let lookup_cmd = if flag.global { root_cmd_name } else { cmd_name };
307            let py_type = flag_py_type(flag, lookup_cmd, choice_types);
308            let prop_name = flag_property_name_py(flag);
309            let optional = !(flag.required && flag.default.is_empty());
310            let field = if !flag.default.is_empty() {
311                // has explicit default — use first value
312                let default_val = &flag.default[0];
313                if flag.count {
314                    let numeric = default_val.trim();
315                    if let Ok(n) = numeric.parse::<i64>() {
316                        format!("{prop_name}: {py_type} = {n}")
317                    } else {
318                        // invalid count default — cannot emit as valid Python int;
319                        // fall back to 0 with a comment
320                        format!(
321                            "{prop_name}: {py_type} = 0  # default: {}",
322                            sanitize_py_comment(default_val)
323                        )
324                    }
325                } else if flag.arg.is_none() {
326                    // boolean with default
327                    match default_val.as_str() {
328                        "true" | "#true" => format!("{prop_name}: {py_type} = True"),
329                        "false" | "#false" => format!("{prop_name}: {py_type} = False"),
330                        _ => {
331                            // unrecognized boolean default — cannot emit as valid Python bool;
332                            // fall back to Optional[bool] = None with a comment
333                            format!(
334                                "{prop_name}: Optional[bool] = None  # default: {}",
335                                sanitize_py_comment(default_val)
336                            )
337                        }
338                    }
339                } else if flag.var {
340                    // var flag with a default — list defaults are mutable and forbidden in dataclasses.
341                    // use None and preserve the intended default in a comment.
342                    format!(
343                        "{prop_name}: Optional[{py_type}] = None  # default: {}",
344                        sanitize_py_comment(default_val)
345                    )
346                } else {
347                    format!(
348                        "{prop_name}: Optional[{py_type}] = \"{}\"",
349                        escape_py_string(default_val)
350                    )
351                }
352            } else if optional {
353                format!("{prop_name}: Optional[{py_type}] = None")
354            } else {
355                format!("{prop_name}: {py_type}")
356            };
357            let mut doc_parts = Vec::new();
358            if let Some(help) = &flag.help {
359                doc_parts.push(help.clone());
360            }
361            if let Some(env) = &flag.env {
362                doc_parts.push(format!("Env: {env}"));
363            }
364            if let Some(deprecated) = &flag.deprecated {
365                doc_parts.push(format!("Deprecated: {deprecated}"));
366            }
367            if flag.long.len() > 1 {
368                let aliases: Vec<&str> = flag.long.iter().skip(1).map(|s| s.as_str()).collect();
369                doc_parts.push(format!("Aliases: {}", aliases.join(", ")));
370            }
371            if !flag.short.is_empty() {
372                let shorts: Vec<String> = flag.short.iter().map(|c| format!("-{c}")).collect();
373                doc_parts.push(format!("Short: {}", shorts.join(", ")));
374            }
375            if !doc_parts.is_empty() {
376                w.line(&format!("# {}", sanitize_py_comment(&doc_parts.join(". "))));
377            }
378            w.line(&field);
379        }
380    }
381    w.dedent();
382}
383
384fn arg_py_type(arg: &SpecArg, cmd_name: &str, choice_types: &ChoiceTypeMap) -> String {
385    let base = if let Some(choices) = &arg.choices {
386        if let Some(resolved) = choice_types.lookup(cmd_name, &arg.name) {
387            resolved.to_string()
388        } else {
389            let union = choices
390                .choices
391                .iter()
392                .map(|c| format!("\"{}\"", escape_py_string(c)))
393                .collect::<Vec<_>>()
394                .join(", ");
395            format!("Literal[{union}]")
396        }
397    } else {
398        "str".to_string()
399    };
400
401    if arg.var {
402        format!("list[{base}]")
403    } else {
404        base
405    }
406}
407
408fn flag_py_type(flag: &SpecFlag, cmd_name: &str, choice_types: &ChoiceTypeMap) -> String {
409    if flag.count {
410        return "int".to_string();
411    }
412
413    match &flag.arg {
414        Some(arg) => {
415            let base = if let Some(choices) = &arg.choices {
416                if let Some(resolved) = choice_types.lookup(cmd_name, &flag.name) {
417                    resolved.to_string()
418                } else {
419                    let union = choices
420                        .choices
421                        .iter()
422                        .map(|c| format!("\"{}\"", escape_py_string(c)))
423                        .collect::<Vec<_>>()
424                        .join(", ");
425                    format!("Literal[{union}]")
426                }
427            } else {
428                "str".to_string()
429            };
430
431            if flag.var {
432                format!("list[{base}]")
433            } else {
434                base
435            }
436        }
437        None => {
438            if flag.var {
439                "list[bool]".to_string()
440            } else {
441                "bool".to_string()
442            }
443        }
444    }
445}
446
447fn config_prop_type(prop: &SpecConfigProp) -> String {
448    match prop.data_type {
449        SpecDataTypes::String => "str".to_string(),
450        SpecDataTypes::Integer => "int".to_string(),
451        SpecDataTypes::Float => "float".to_string(),
452        SpecDataTypes::Boolean => "bool".to_string(),
453        SpecDataTypes::Null => "object".to_string(),
454    }
455}
456
457fn flag_property_name_py(flag: &SpecFlag) -> String {
458    // Python uses snake_case for attributes
459    if let Some(long) = flag.long.first() {
460        return sanitize_py_ident(&heck::AsSnakeCase(long).to_string());
461    }
462    if let Some(short) = flag.short.first() {
463        return short.to_string();
464    }
465    sanitize_py_ident(&flag.name)
466}
467
468fn sanitize_py_ident(name: &str) -> String {
469    let snake = heck::AsSnakeCase(name).to_string();
470    match snake.as_str() {
471        "async" | "await" | "nonlocal" | "class" | "def" | "return" | "import" | "from"
472        | "global" | "lambda" | "pass" | "raise" | "with" | "yield" | "del" | "try" | "except"
473        | "finally" | "while" | "for" | "if" | "elif" | "else" | "and" | "or" | "not" | "in"
474        | "is" | "as" | "break" | "continue" | "assert" | "type" | "input" | "id" | "list"
475        | "dict" | "set" | "print" | "range" | "format" | "help" | "vars" | "dir" | "exec"
476        | "exit" | "quit" | "bool" | "int" | "str" | "float" | "bytes" | "object" | "super"
477        | "property" | "static" | "true" | "false" | "none" => format!("_{snake}"),
478        _ => snake,
479    }
480}
481
482// ---------------------------------------------------------------------------
483// client.py
484// ---------------------------------------------------------------------------
485
486fn render_client(spec: &Spec, package_name: &str, source_file: &Option<String>) -> String {
487    let mut w = CodeWriter::with_indent("    ");
488
489    w.line(&generated_header("#", source_file));
490    w.line("from __future__ import annotations");
491    w.line("from typing import Optional");
492    w.line("from .runtime import CliResult, CliRunner");
493
494    // collect imports from types
495    let choice_types = collect_choice_types(&spec.cmd);
496    let type_imports = collect_type_imports(&spec.cmd, package_name, &choice_types);
497    let has_global_flags = spec.cmd.flags.iter().any(|f| f.global && !f.hide);
498    let mut all_imports = type_imports;
499    if has_global_flags {
500        all_imports.push("GlobalFlags".to_string());
501    }
502    all_imports.sort();
503    all_imports.dedup();
504    if !all_imports.is_empty() {
505        w.line(&format!("from .types import {}", all_imports.join(", ")));
506    }
507
508    w.line("");
509
510    let global_flags: Vec<&SpecFlag> = spec
511        .cmd
512        .flags
513        .iter()
514        .filter(|f| f.global && !f.hide)
515        .collect();
516
517    let class_name = AsPascalCase(package_name).to_string();
518    render_class(
519        &spec.cmd,
520        &class_name,
521        true,
522        &global_flags,
523        &spec.bin,
524        &mut w,
525    );
526
527    w.finish()
528}
529
530fn render_class(
531    cmd: &SpecCommand,
532    class_name: &str,
533    is_root: bool,
534    global_flags: &[&SpecFlag],
535    bin_name: &str,
536    w: &mut CodeWriter,
537) {
538    let visible_subcmds: Vec<_> = cmd.subcommands.iter().filter(|(_, c)| !c.hide).collect();
539
540    let visible_args: Vec<&SpecArg> = cmd.args.iter().filter(|a| !a.hide).collect();
541    let visible_flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
542    let has_args = !visible_args.is_empty();
543    let has_flags = !visible_flags.is_empty() || !global_flags.is_empty();
544
545    // docstring on class
546    let mut class_doc = Vec::new();
547    if let Some(help) = &cmd.help {
548        class_doc.push(help.clone());
549    } else if let Some(about) = &cmd.help_long {
550        class_doc.push(about.clone());
551    }
552    if let Some(deprecated) = &cmd.deprecated {
553        class_doc.push(format!("DEPRECATED: {deprecated}"));
554    }
555    if !cmd.aliases.is_empty() {
556        class_doc.push(format!("Aliases: {}", cmd.aliases.join(", ")));
557    }
558
559    w.line(&format!("class {class_name}:"));
560    w.indent();
561
562    if !class_doc.is_empty() {
563        w.line(&format!(
564            "\"\"\"{}\"\"\"",
565            escape_py_docstring(&class_doc.join(". "))
566        ));
567    }
568
569    // constructor
570    if is_root {
571        w.line(&format!(
572            "def __init__(self, bin_path: str = \"{}\") -> None:",
573            escape_py_string(bin_name)
574        ));
575    } else {
576        w.line("def __init__(self, runner: CliRunner) -> None:");
577    }
578    w.indent();
579    if is_root {
580        w.line("self._runner = CliRunner(bin_path)");
581    } else {
582        w.line("self._runner = runner");
583    }
584    for (name, _) in &visible_subcmds {
585        let sub_class = AsPascalCase(name).to_string();
586        let prop = sanitize_py_ident(name);
587        w.line(&format!("self.{prop} = {sub_class}(self._runner)"));
588    }
589    w.dedent();
590
591    // exec method — build signature with four explicit arms to avoid double comma
592    let flags_type = if !global_flags.is_empty() && !visible_flags.is_empty() {
593        format!("{class_name}Flags")
594    } else if !global_flags.is_empty() && visible_flags.is_empty() {
595        "GlobalFlags".to_string()
596    } else if !visible_flags.is_empty() {
597        format!("{class_name}Flags")
598    } else {
599        String::new()
600    };
601    let sig = if has_args && !flags_type.is_empty() {
602        format!("def exec(self, args: {class_name}Args, flags: Optional[{flags_type}] = None) -> CliResult:")
603    } else if has_args {
604        format!("def exec(self, args: {class_name}Args) -> CliResult:")
605    } else if !flags_type.is_empty() {
606        format!("def exec(self, flags: Optional[{flags_type}] = None) -> CliResult:")
607    } else {
608        "def exec(self) -> CliResult:".to_string()
609    };
610
611    // docstring on exec
612    let mut exec_doc = Vec::new();
613    if !cmd.usage.is_empty() {
614        exec_doc.push(cmd.usage.clone());
615    }
616    for example in &cmd.examples {
617        let label = example.header.as_deref().unwrap_or("Example");
618        exec_doc.push(format!("{label}: {code}", code = example.code));
619    }
620
621    w.line("");
622    if !exec_doc.is_empty() {
623        w.line(&sig);
624        w.indent();
625        if exec_doc.len() == 1 {
626            w.line(&format!(
627                "\"\"\"{}\"\"\"",
628                escape_py_docstring(&exec_doc[0])
629            ));
630        } else {
631            w.line(&format!("\"\"\"{}", escape_py_docstring(&exec_doc[0])));
632            for part in exec_doc.iter().skip(1) {
633                w.line(&escape_py_docstring(part));
634            }
635            w.line("\"\"\"");
636        }
637    } else {
638        w.line(&sig);
639        w.indent();
640    }
641
642    let path: String = cmd
643        .full_cmd
644        .iter()
645        .map(|s| format!("\"{}\"", escape_py_string(s)))
646        .collect::<Vec<_>>()
647        .join(", ");
648    w.line(&format!("cmd_args: list[str] = [{path}]"));
649
650    if has_args {
651        let has_required_double_dash = visible_args
652            .iter()
653            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Required));
654        let has_automatic_double_dash = visible_args
655            .iter()
656            .any(|a| matches!(a.double_dash, SpecDoubleDashChoices::Automatic));
657
658        // Args before `--`: all args without double_dash=required
659        for arg in &visible_args {
660            if matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
661                continue;
662            }
663            let ident = sanitize_py_ident(&arg.name);
664            if arg.var {
665                w.line(&format!(
666                    "if args.{ident} is not None: cmd_args.extend(args.{ident})"
667                ));
668            } else {
669                w.line(&format!(
670                    "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
671                ));
672            }
673        }
674
675        if has_required_double_dash {
676            w.line("cmd_args.append(\"--\")");
677            // Args after `--`: only double_dash=required args
678            for arg in &visible_args {
679                if !matches!(arg.double_dash, SpecDoubleDashChoices::Required) {
680                    continue;
681                }
682                let ident = sanitize_py_ident(&arg.name);
683                if arg.var {
684                    w.line(&format!(
685                        "if args.{ident} is not None: cmd_args.extend(args.{ident})"
686                    ));
687                } else {
688                    w.line(&format!(
689                        "if args.{ident} is not None: cmd_args.append(str(args.{ident}))"
690                    ));
691                }
692            }
693        } else if has_automatic_double_dash {
694            w.line("# double_dash=automatic: \"--\" is implied after the first positional arg");
695        }
696    }
697
698    if has_flags {
699        w.line("flag_args = self._build_flag_args(flags)");
700        w.line("return self._runner.run(cmd_args + flag_args)");
701    } else {
702        w.line("return self._runner.run(cmd_args)");
703    }
704
705    w.dedent();
706
707    // _build_flag_args
708    if has_flags {
709        w.line("");
710        w.line(&format!(
711            "def _build_flag_args(self, flags: Optional[{flags_type}]) -> list[str]:"
712        ));
713        w.indent();
714        w.line("result: list[str] = []");
715        w.line("if flags is None: return result");
716
717        for flag in global_flags {
718            render_flag_build_py(flag, w);
719        }
720        for flag in &visible_flags {
721            // skip global flags already rendered above
722            if !global_flags.iter().any(|gf| gf.name == flag.name) {
723                render_flag_build_py(flag, w);
724            }
725        }
726
727        w.line("return result");
728        w.dedent();
729    }
730
731    // alias properties for subcommand aliases
732    for (name, subcmd) in &visible_subcmds {
733        for alias in &subcmd.aliases {
734            let alias_prop = sanitize_py_ident(alias);
735            let target_prop = sanitize_py_ident(name);
736            let sub_class = AsPascalCase(name).to_string();
737            w.line("");
738            w.line("@property");
739            w.line(&format!("def {alias_prop}(self) -> {sub_class}:"));
740            w.indent();
741            w.line(&format!(
742                "\"\"\"Alias for {}.\"\"\"",
743                escape_py_docstring(name)
744            ));
745            w.line(&format!("return self.{target_prop}"));
746            w.dedent();
747        }
748    }
749
750    w.dedent(); // end class
751
752    // render subcommand classes
753    for (name, subcmd) in &visible_subcmds {
754        w.line("");
755        let sub_class = AsPascalCase(name).to_string();
756        render_class(subcmd, &sub_class, false, global_flags, bin_name, w);
757    }
758}
759
760fn render_flag_build_py(flag: &SpecFlag, w: &mut CodeWriter) {
761    let prop_name = flag_property_name_py(flag);
762    let flag_arg_name = if let Some(long) = flag.long.first() {
763        format!("--{}", escape_py_string(long))
764    } else if let Some(short) = flag.short.first() {
765        format!("-{short}")
766    } else {
767        format!("--{}", escape_py_string(&flag.name))
768    };
769
770    if flag.arg.is_some() {
771        if flag.var {
772            w.line(&format!("if flags.{prop_name} is not None:"));
773            w.indent();
774            w.line(&format!(
775                "for v in flags.{prop_name}: result.extend([\"{flag_arg_name}\", str(v)])"
776            ));
777            w.dedent();
778        } else {
779            w.line(&format!(
780                "if flags.{prop_name} is not None: result.extend([\"{flag_arg_name}\", str(flags.{prop_name})])"
781            ));
782        }
783    } else if flag.count {
784        w.line(&format!(
785            "if flags.{prop_name} is not None and flags.{prop_name} > 0: result.extend([\"{flag_arg_name}\"] * flags.{prop_name})"
786        ));
787    } else if flag.var {
788        w.line(&format!("if flags.{prop_name} is not None:"));
789        w.indent();
790        w.line(&format!("for v in flags.{prop_name}:"));
791        w.indent();
792        w.line(&format!("if v: result.append(\"{flag_arg_name}\")"));
793        w.dedent();
794        w.dedent();
795    } else {
796        w.line(&format!(
797            "if flags.{prop_name}: result.append(\"{flag_arg_name}\")"
798        ));
799        if let Some(negate) = &flag.negate {
800            w.line(&format!(
801                "elif flags.{prop_name} is False: result.append(\"{}\")",
802                escape_py_string(negate)
803            ));
804        }
805    }
806}
807
808// ---------------------------------------------------------------------------
809// Tests
810// ---------------------------------------------------------------------------
811
812#[cfg(test)]
813mod tests {
814    use crate::sdk::{SdkLanguage, SdkOptions};
815    use crate::test::SPEC_KITCHEN_SINK;
816    use crate::Spec;
817
818    fn make_opts() -> SdkOptions {
819        SdkOptions {
820            language: SdkLanguage::Python,
821            package_name: None,
822            source_file: Some("test.usage.kdl".to_string()),
823        }
824    }
825
826    fn get_file<'a>(output: &'a crate::sdk::SdkOutput, name: &str) -> &'a str {
827        output
828            .files
829            .iter()
830            .find(|f| f.path.to_str() == Some(name))
831            .unwrap_or_else(|| panic!("{name} should exist"))
832            .content
833            .as_str()
834    }
835
836    #[test]
837    fn test_python_types() {
838        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
839        insta::assert_snapshot!(get_file(&output, "types.py"));
840    }
841
842    #[test]
843    fn test_python_client() {
844        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
845        insta::assert_snapshot!(get_file(&output, "client.py"));
846    }
847
848    #[test]
849    fn test_python_runtime() {
850        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
851        insta::assert_snapshot!(get_file(&output, "runtime.py"));
852    }
853
854    #[test]
855    fn test_python_init() {
856        let output = crate::sdk::generate(&SPEC_KITCHEN_SINK, &make_opts());
857        insta::assert_snapshot!(get_file(&output, "__init__.py"));
858    }
859
860    fn full_feature_spec() -> Spec {
861        r##"
862            bin "mytool"
863            name "mytool"
864            version "1.2.3"
865            about "A powerful CLI tool"
866            author "Jane Doe"
867
868            flag "-v --verbose" help="Verbosity level" count=#true global=#true
869            flag "-C --config <path>" help="Config file path" global=#true env="MYTOOL_CONFIG"
870            flag "--dry-run" help="Show what would be done" negate="--no-dry-run"
871
872            arg "input" help="Input file" required=#true
873            arg "extra" var=#true help="Extra files"
874
875            cmd "build" help="Build the project" deprecated="Use compile instead" {
876                alias "b"
877                arg "target" help="Build target" {
878                    choices "debug" "release"
879                }
880                arg "output" help="Output directory" double_dash="required"
881                flag "-j --jobs <n>" help="Parallel jobs" var=#true
882                flag "--release" help="Build in release mode"
883            }
884
885            cmd "deploy" help="Deploy the project" {
886                arg "env" help="Target environment" {
887                    choices "staging" "production"
888                }
889                arg "tags" var=#true help="Deployment tags" var_min=1 var_max=5
890                flag "-f --force" help="Force deploy" deprecated="Use --confirm instead"
891                flag "--confirm" help="Confirm deployment"
892            }
893        "##
894        .parse()
895        .unwrap()
896    }
897
898    #[test]
899    fn test_python_full_feature_types() {
900        let spec = full_feature_spec();
901        let output = crate::sdk::generate(&spec, &make_opts());
902        insta::assert_snapshot!(get_file(&output, "types.py"));
903    }
904
905    #[test]
906    fn test_python_full_feature_client() {
907        let spec = full_feature_spec();
908        let output = crate::sdk::generate(&spec, &make_opts());
909        insta::assert_snapshot!(get_file(&output, "client.py"));
910    }
911
912    #[test]
913    fn test_python_hyphenated_subcommands() {
914        let spec: Spec = r##"
915            bin "cli"
916            cmd "add-remote" help="Add a remote" {
917                arg "name"
918                arg "url"
919            }
920            cmd "remove-remote" help="Remove a remote" {
921                arg "name"
922            }
923        "##
924        .parse()
925        .unwrap();
926        let output = crate::sdk::generate(&spec, &make_opts());
927        insta::assert_snapshot!(get_file(&output, "client.py"));
928    }
929
930    #[test]
931    fn test_python_minimal() {
932        let spec: Spec = r##"
933            bin "hello"
934        "##
935        .parse()
936        .unwrap();
937        let output = crate::sdk::generate(&spec, &make_opts());
938        insta::assert_snapshot!(get_file(&output, "client.py"));
939    }
940
941    /// Flags-only subcommand (no positional args) — tests exec signature without double comma.
942    #[test]
943    fn test_python_flags_only_subcommand() {
944        let spec: Spec = r##"
945            bin "app"
946            cmd "status" help="Show status" {
947                flag "--verbose" help="Show detailed status"
948                flag "--json" help="Output as JSON"
949            }
950        "##
951        .parse()
952        .unwrap();
953        let output = crate::sdk::generate(&spec, &make_opts());
954        let client = get_file(&output, "client.py");
955        assert!(!client.contains("def exec(self, ,"));
956        assert!(
957            client.contains("def exec(self, flags: Optional[StatusFlags] = None) -> CliResult:")
958        );
959        insta::assert_snapshot!(client);
960    }
961
962    /// Choice type collision: same arg name with different choices in different subcommands.
963    #[test]
964    fn test_python_choice_collision() {
965        let spec: Spec = r##"
966            bin "tool"
967            cmd "build" help="Build" {
968                arg "env" help="Build environment" {
969                    choices "debug" "release"
970                }
971            }
972            cmd "deploy" help="Deploy" {
973                arg "env" help="Deploy environment" {
974                    choices "staging" "production"
975                }
976            }
977        "##
978        .parse()
979        .unwrap();
980        let output = crate::sdk::generate(&spec, &make_opts());
981        let types = get_file(&output, "types.py");
982        // Must have separate choice types due to collision
983        assert!(types.contains("BuildEnvChoice"));
984        assert!(types.contains("DeployEnvChoice"));
985        assert!(types.contains(r#""debug""#));
986        assert!(types.contains(r#""staging""#));
987        insta::assert_snapshot!(types);
988    }
989
990    /// Args with default values — tests that defaults are preserved, not dropped to None.
991    #[test]
992    fn test_python_arg_defaults() {
993        let spec: Spec = r##"
994            bin "runner"
995            arg "mode" default="fast" help="Run mode"
996            arg "output" help="Output path" required=#true
997        "##
998        .parse()
999        .unwrap();
1000        let output = crate::sdk::generate(&spec, &make_opts());
1001        let types = get_file(&output, "types.py");
1002        assert!(types.contains(r#"mode: Optional[str] = "fast""#));
1003        assert!(types.contains("output: str"));
1004        // required arg must come before optional
1005        let output_pos = types.find("output: str").unwrap();
1006        let mode_pos = types.find(r#"mode: Optional[str] = "fast""#).unwrap();
1007        assert!(
1008            output_pos < mode_pos,
1009            "required arg must precede optional arg"
1010        );
1011    }
1012
1013    /// Config props — covers config dataclass and config_prop_type.
1014    #[test]
1015    fn test_python_config_props() {
1016        let spec: Spec = r##"
1017            bin "myapp"
1018            config {
1019                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1020                prop "port" default=8080 data_type=integer
1021                prop "rate" default="1.5" data_type=float
1022                prop "host" data_type=string
1023                prop "extra" data_type="null"
1024            }
1025        "##
1026        .parse()
1027        .unwrap();
1028        let output = crate::sdk::generate(&spec, &make_opts());
1029        let types = get_file(&output, "types.py");
1030        assert!(types.contains("class MyappConfig"));
1031        insta::assert_snapshot!(types);
1032    }
1033
1034    /// Hidden command, hidden arg/flag — covers early-return and empty dataclass paths.
1035    #[test]
1036    fn test_python_hidden_command() {
1037        let spec: Spec = r##"
1038            bin "app"
1039            cmd "visible" help="A visible command" {
1040                arg "name"
1041            }
1042            cmd "secret" hide=#true help="Hidden command" {
1043                arg "name"
1044            }
1045        "##
1046        .parse()
1047        .unwrap();
1048        let output = crate::sdk::generate(&spec, &make_opts());
1049        let types = get_file(&output, "types.py");
1050        assert!(types.contains("VisibleArgs"));
1051        assert!(!types.contains("SecretArgs"));
1052    }
1053
1054    /// Flag edge cases: short-only, aliases, deprecated, count+default, required flag,
1055    /// repeatable boolean flag, non-bool value flag with default.
1056    #[test]
1057    fn test_python_flag_edge_cases() {
1058        let spec: Spec = r##"
1059            bin "tool"
1060            flag "-v" help="Short-only flag"
1061            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1062            flag "--level" count=#true default="2" help="Count flag with default"
1063            flag "--format <fmt>" default="json" help="Value flag with default"
1064            flag "--confirm" required=#true help="Required flag"
1065            flag "--verbose" var=#true help="Repeatable boolean flag"
1066        "##
1067        .parse()
1068        .unwrap();
1069        let output = crate::sdk::generate(&spec, &make_opts());
1070        let types = get_file(&output, "types.py");
1071        insta::assert_snapshot!(types);
1072        let client = get_file(&output, "client.py");
1073        // short-only flag build
1074        assert!(client.contains(r#""-v""#));
1075        // repeatable boolean flag build
1076        assert!(client.contains("for v in flags.verbose:"));
1077        insta::assert_snapshot!(client);
1078    }
1079
1080    /// double_dash=automatic, examples in exec doc, global flags with flags-only subcommand.
1081    #[test]
1082    fn test_python_exec_edge_cases() {
1083        let spec: Spec = r##"
1084            bin "runner"
1085            flag "-v --verbose" global=#true help="Verbosity"
1086            arg "input" help="Input file"
1087            arg "extra" double_dash="automatic" var=#true help="Extra files"
1088            cmd "run" help="Run a task" {
1089                example "runner run hello" header="Basic run"
1090                arg "task" help="Task to run" double_dash="automatic"
1091            }
1092            cmd "info" help="Show info" {}
1093        "##
1094        .parse()
1095        .unwrap();
1096        let output = crate::sdk::generate(&spec, &make_opts());
1097        let client = get_file(&output, "client.py");
1098        assert!(client.contains("double_dash=automatic"));
1099        assert!(client.contains("Basic run: runner run hello"));
1100        // "info" has no own flags, only global flags => GlobalFlags type
1101        assert!(client.contains("flags: Optional[GlobalFlags] = None"));
1102        insta::assert_snapshot!(client);
1103    }
1104
1105    /// Optional arg without default and empty flags dataclass.
1106    #[test]
1107    fn test_python_optional_arg_empty_flags() {
1108        let spec: Spec = r##"
1109            bin "app"
1110            arg "[name]" help="Optional arg without default"
1111            cmd "check" help="Check something" {
1112                arg "target" required=#true help="Required arg"
1113                arg "mode" default="quick" help="Optional arg with default"
1114            }
1115        "##
1116        .parse()
1117        .unwrap();
1118        let output = crate::sdk::generate(&spec, &make_opts());
1119        let types = get_file(&output, "types.py");
1120        // optional arg without default should have = None
1121        assert!(types.contains("name: Optional[str] = None"));
1122        insta::assert_snapshot!(types);
1123    }
1124
1125    /// Deeply nested subcommands — 3+ levels.
1126    #[test]
1127    fn test_python_deep_nesting() {
1128        let spec: Spec = r##"
1129            bin "app"
1130            cmd "db" help="Database operations" {
1131                cmd "migration" help="Migration management" {
1132                    cmd "create" help="Create a new migration" {
1133                        arg "name"
1134                        flag "--template <t>" help="Migration template"
1135                    }
1136                    cmd "run" help="Run pending migrations" {
1137                        flag "--step <n>" help="Number of migrations to run"
1138                    }
1139                }
1140            }
1141        "##
1142        .parse()
1143        .unwrap();
1144        let output = crate::sdk::generate(&spec, &make_opts());
1145        let client = get_file(&output, "client.py");
1146        // deeply nested class must exist
1147        assert!(client.contains("class Db:"));
1148        assert!(client.contains("class Migration:"));
1149        assert!(client.contains("class Create:"));
1150        insta::assert_snapshot!(client);
1151    }
1152
1153    /// Test package_name override.
1154    #[test]
1155    fn test_python_package_name_override() {
1156        let spec: Spec = r##"
1157            bin "original-cli"
1158        "##
1159        .parse()
1160        .unwrap();
1161        let opts = SdkOptions {
1162            language: SdkLanguage::Python,
1163            package_name: Some("my_custom_sdk".to_string()),
1164            source_file: None,
1165        };
1166        let output = crate::sdk::generate(&spec, &opts);
1167        let init = get_file(&output, "__init__.py");
1168        assert!(init.contains("MyCustomSdk"));
1169        insta::assert_snapshot!(init);
1170    }
1171
1172    /// Global flags with flags-only subcommand — covers GlobalFlags type branch.
1173    #[test]
1174    fn test_python_global_flags_flags_only() {
1175        let spec: Spec = r##"
1176            bin "app"
1177            flag "-v --verbose" global=#true help="Verbosity"
1178            cmd "status" help="Show status" {
1179                flag "--json" help="JSON output"
1180            }
1181            cmd "info" help="Show info" {}
1182        "##
1183        .parse()
1184        .unwrap();
1185        let output = crate::sdk::generate(&spec, &make_opts());
1186        let client = get_file(&output, "client.py");
1187        // "info" subcommand has no own flags, only global flags => GlobalFlags type
1188        assert!(client.contains("Optional[GlobalFlags]"));
1189        insta::assert_snapshot!(client);
1190    }
1191
1192    /// Flag with choices — flag arg with choices renders correct type.
1193    #[test]
1194    fn test_python_flag_with_choices() {
1195        let spec: Spec = r##"
1196            bin "tool"
1197            flag "--shell <shell>" help="Shell type" {
1198                choices "bash" "zsh" "fish"
1199            }
1200        "##
1201        .parse()
1202        .unwrap();
1203        let output = crate::sdk::generate(&spec, &make_opts());
1204        let types = get_file(&output, "types.py");
1205        assert!(types.contains("Literal[\"bash\", \"zsh\", \"fish\"]"));
1206        insta::assert_snapshot!(types);
1207    }
1208
1209    /// Flag with env annotation — env variable appears in comment.
1210    #[test]
1211    fn test_python_flag_with_env() {
1212        let spec: Spec = r##"
1213            bin "app"
1214            flag "--config <path>" help="Config file" env="APP_CONFIG"
1215        "##
1216        .parse()
1217        .unwrap();
1218        let output = crate::sdk::generate(&spec, &make_opts());
1219        let types = get_file(&output, "types.py");
1220        assert!(types.contains("Env: APP_CONFIG"));
1221        insta::assert_snapshot!(types);
1222    }
1223
1224    /// Hidden flag excluded from types and client.
1225    #[test]
1226    fn test_python_flag_hide() {
1227        let spec: Spec = r##"
1228            bin "app"
1229            flag "--verbose" help="Verbosity"
1230            flag "--debug" hide=#true help="Hidden debug flag"
1231        "##
1232        .parse()
1233        .unwrap();
1234        let output = crate::sdk::generate(&spec, &make_opts());
1235        let types = get_file(&output, "types.py");
1236        assert!(types.contains("verbose"));
1237        assert!(!types.contains("debug"));
1238    }
1239
1240    /// Negate flag rendered in client build method.
1241    #[test]
1242    fn test_python_negate_flag_build() {
1243        let spec: Spec = r##"
1244            bin "app"
1245            flag "--dry-run" help="Dry run" negate="--no-dry-run"
1246        "##
1247        .parse()
1248        .unwrap();
1249        let output = crate::sdk::generate(&spec, &make_opts());
1250        let client = get_file(&output, "client.py");
1251        assert!(client.contains("--dry-run"));
1252        assert!(client.contains("--no-dry-run"));
1253        insta::assert_snapshot!(client);
1254    }
1255
1256    /// Count flag rendered in client build method.
1257    #[test]
1258    fn test_python_count_flag_build() {
1259        let spec: Spec = r##"
1260            bin "app"
1261            flag "-v --verbose" count=#true help="Verbosity level"
1262        "##
1263        .parse()
1264        .unwrap();
1265        let output = crate::sdk::generate(&spec, &make_opts());
1266        let client = get_file(&output, "client.py");
1267        assert!(client.contains(r#""--verbose""#));
1268        assert!(client.contains("flags.verbose"));
1269        insta::assert_snapshot!(client);
1270    }
1271
1272    /// Repeatable value flag with default — covers var + arg + default in client build.
1273    #[test]
1274    fn test_python_var_value_flag_with_default() {
1275        let spec: Spec = r##"
1276            bin "tool"
1277            flag "--tag <t>" var=#true default="latest" help="Tags"
1278        "##
1279        .parse()
1280        .unwrap();
1281        let output = crate::sdk::generate(&spec, &make_opts());
1282        let types = get_file(&output, "types.py");
1283        assert!(types.contains(r#"list[str]"#));
1284        assert!(types.contains(r#"default: latest"#));
1285        let client = get_file(&output, "client.py");
1286        assert!(client.contains("for v in flags.tag:"));
1287        insta::assert_snapshot!(types);
1288    }
1289
1290    /// Flag with multiple long aliases — `-f --format --fmt <fmt>`.
1291    #[test]
1292    fn test_python_multiple_aliases() {
1293        let spec: Spec = r##"
1294            bin "tool"
1295            flag "-f --format --fmt <fmt>" help="Output format"
1296        "##
1297        .parse()
1298        .unwrap();
1299        let output = crate::sdk::generate(&spec, &make_opts());
1300        let types = get_file(&output, "types.py");
1301        assert!(types.contains("Aliases: fmt"));
1302        let client = get_file(&output, "client.py");
1303        // should use first long for the flag argument name
1304        assert!(client.contains("--format"));
1305        insta::assert_snapshot!(client);
1306    }
1307
1308    /// Boolean flag with default=#false — covers the "= False" branch.
1309    #[test]
1310    fn test_python_boolean_flag_default_false() {
1311        let spec: Spec = r##"
1312            bin "app"
1313            flag "--no-cache" default=#false help="Disable cache"
1314        "##
1315        .parse()
1316        .unwrap();
1317        let output = crate::sdk::generate(&spec, &make_opts());
1318        let types = get_file(&output, "types.py");
1319        assert!(types.contains("no_cache: bool = False"));
1320        insta::assert_snapshot!(types);
1321    }
1322
1323    /// Boolean config prop with default=#false — covers the "= False" branch.
1324    #[test]
1325    fn test_python_config_boolean_default_false() {
1326        let spec: Spec = r##"
1327            bin "app"
1328            config {
1329                prop "verbose" default=#false data_type=boolean help="Verbose output"
1330                prop "dry_run" default=#true data_type=boolean help="Dry run mode"
1331            }
1332        "##
1333        .parse()
1334        .unwrap();
1335        let output = crate::sdk::generate(&spec, &make_opts());
1336        let types = get_file(&output, "types.py");
1337        assert!(types.contains("verbose: bool = False"));
1338        assert!(types.contains("dry_run: bool = True"));
1339        insta::assert_snapshot!(types);
1340    }
1341
1342    /// String config prop with default — covers String/Null match arm with default.
1343    #[test]
1344    fn test_python_config_string_with_default() {
1345        let spec: Spec = r##"
1346            bin "app"
1347            config {
1348                prop "host" default="localhost" data_type=string help="Server host"
1349                prop "name" default="myapp" data_type=string
1350            }
1351        "##
1352        .parse()
1353        .unwrap();
1354        let output = crate::sdk::generate(&spec, &make_opts());
1355        let types = get_file(&output, "types.py");
1356        assert!(types.contains(r#"host: str = "localhost""#));
1357        assert!(types.contains(r#"name: str = "myapp""#));
1358        insta::assert_snapshot!(types);
1359    }
1360
1361    /// Config with all props having defaults — tests Default derive on config dataclass.
1362    #[test]
1363    fn test_python_config_all_optional() {
1364        let spec: Spec = r##"
1365            bin "app"
1366            config {
1367                prop "debug" default=#true data_type=boolean
1368                prop "port" default=8080 data_type=integer
1369            }
1370        "##
1371        .parse()
1372        .unwrap();
1373        let output = crate::sdk::generate(&spec, &make_opts());
1374        let types = get_file(&output, "types.py");
1375        assert!(types.contains("class AppConfig:"));
1376        insta::assert_snapshot!(types);
1377    }
1378
1379    /// Optional variadic arg — covers the optional + var branch in client rendering.
1380    #[test]
1381    fn test_python_optional_variadic_arg() {
1382        let spec: Spec = r##"
1383            bin "tool"
1384            arg "[files]" var=#true help="Input files"
1385        "##
1386        .parse()
1387        .unwrap();
1388        let output = crate::sdk::generate(&spec, &make_opts());
1389        let types = get_file(&output, "types.py");
1390        assert!(types.contains("list[str]"));
1391        let client = get_file(&output, "client.py");
1392        // optional variadic arg should use extend with None guard
1393        assert!(client.contains("if args.files is not None: cmd_args.extend(args.files)"));
1394        insta::assert_snapshot!(client);
1395    }
1396
1397    /// Example without lang attribute — tests single-line exec doc path.
1398    #[test]
1399    fn test_python_example_without_lang() {
1400        let spec: Spec = r##"
1401            bin "app"
1402            cmd "greet" help="Greet someone" {
1403                example "app greet hello"
1404                arg "name" help="Name to greet"
1405            }
1406        "##
1407        .parse()
1408        .unwrap();
1409        let output = crate::sdk::generate(&spec, &make_opts());
1410        let client = get_file(&output, "client.py");
1411        assert!(client.contains("app greet hello"));
1412        insta::assert_snapshot!(client);
1413    }
1414
1415    /// Required flag without default — tests non-optional flag type rendering.
1416    #[test]
1417    fn test_python_required_flag_type() {
1418        let spec: Spec = r##"
1419            bin "tool"
1420            flag "--token <t>" required=#true help="Auth token"
1421        "##
1422        .parse()
1423        .unwrap();
1424        let output = crate::sdk::generate(&spec, &make_opts());
1425        let types = get_file(&output, "types.py");
1426        // required flag without default should NOT be Optional
1427        assert!(types.contains("token: str"));
1428        assert!(!types.contains("token: Optional[str]"));
1429        insta::assert_snapshot!(types);
1430    }
1431
1432    /// Global repeatable flags — covers flag_ts_simple var branches.
1433    #[test]
1434    fn test_python_global_repeatable_flags() {
1435        let spec: Spec = r##"
1436            bin "app"
1437            flag "-v --verbose" global=#true var=#true help="Repeatable verbose"
1438            flag "--tag <t>" global=#true var=#true help="Repeatable tag"
1439            cmd "run" help="Run" {
1440                arg "target"
1441            }
1442        "##
1443        .parse()
1444        .unwrap();
1445        let output = crate::sdk::generate(&spec, &make_opts());
1446        let types = get_file(&output, "types.py");
1447        // GlobalFlags should have list[bool] and list[str] types
1448        assert!(types.contains("list[bool]"));
1449        assert!(types.contains("list[str]"));
1450        insta::assert_snapshot!(types);
1451    }
1452
1453    /// Client edge cases: double_dash=automatic, examples, global flags, repeatable boolean flag.
1454    #[test]
1455    fn test_python_client_edge_cases() {
1456        let spec: Spec = r##"
1457            bin "runner"
1458            flag "-v --verbose" global=#true help="Verbosity"
1459            flag "--debug" var=#true help="Repeatable boolean flag"
1460            arg "input" help="Input file"
1461            arg "extra" double_dash="automatic" var=#true help="Extra files"
1462            cmd "run" help="Run a task" {
1463                example "runner run hello" header="Basic run" lang="bash"
1464                arg "task" help="Task to run" double_dash="automatic"
1465            }
1466            cmd "info" help="Show info" {}
1467        "##
1468        .parse()
1469        .unwrap();
1470        let output = crate::sdk::generate(&spec, &make_opts());
1471        let client = get_file(&output, "client.py");
1472        assert!(client.contains("double_dash=automatic"));
1473        assert!(client.contains("Basic run: runner run hello"));
1474        // GlobalFlags type for info subcommand
1475        assert!(client.contains("Optional[GlobalFlags]"));
1476        // repeatable boolean flag
1477        assert!(client.contains("for v in flags.debug:"));
1478        insta::assert_snapshot!(client);
1479    }
1480
1481    /// Config and flag edge cases — config with various data types, env, deprecated, aliases.
1482    #[test]
1483    fn test_python_config_and_flag_edge_cases() {
1484        let spec: Spec = r##"
1485            bin "myapp"
1486            config {
1487                prop "debug" default=#true data_type=boolean help="Enable debug mode"
1488                prop "port" default=8080 data_type=integer
1489                prop "rate" default="1.5" data_type=float
1490                prop "host" data_type=string
1491            }
1492            arg "input" help="Input file" env="MYAPP_INPUT"
1493            flag "--type" help="Reserved keyword" deprecated="Use --kind"
1494            flag "-f --format --fmt <fmt>" help="Flag with short and long alias"
1495            flag "-v" help="Short-only flag"
1496        "##
1497        .parse()
1498        .unwrap();
1499        let output = crate::sdk::generate(&spec, &make_opts());
1500        let types = get_file(&output, "types.py");
1501        assert!(types.contains("MyappConfig"));
1502        insta::assert_snapshot!(types);
1503    }
1504
1505    /// double_dash=automatic — covers arg ordering and separator insertion.
1506    #[test]
1507    fn test_python_double_dash_automatic() {
1508        let spec: Spec = r##"
1509            bin "runner"
1510            arg "input" help="Input file"
1511            arg "extra" double_dash="automatic" var=#true help="Extra files"
1512            flag "--verbose" var=#true help="Repeatable boolean flag"
1513            cmd "run" help="Run a task" {
1514                example "runner run hello" header="Basic run"
1515                arg "task" help="Task to run" double_dash="automatic"
1516            }
1517        "##
1518        .parse()
1519        .unwrap();
1520        let output = crate::sdk::generate(&spec, &make_opts());
1521        let client = get_file(&output, "client.py");
1522        assert!(client.contains("double_dash=automatic"));
1523        insta::assert_snapshot!(client);
1524    }
1525}