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