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