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