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