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