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