clyops
clyops — declarative CLI parsing for Python. Behavior follows spec/SPEC.md.
from clyops import Cli
cli = Cli() cli.arg("input", "Input file", rule="path") cli.opt("PORT", "port", "p", "8080", "Server port", group="Network", rule="port") cli.opt("VERBOSE", "verbose", "v", "flag", "Verbose output") args = cli.run() print(args.input, args.PORT, args.VERBOSE)
1"""clyops — declarative CLI parsing for Python. Behavior follows spec/SPEC.md. 2 3 from clyops import Cli 4 5 cli = Cli() 6 cli.arg("input", "Input file", rule="path") 7 cli.opt("PORT", "port", "p", "8080", "Server port", group="Network", rule="port") 8 cli.opt("VERBOSE", "verbose", "v", "flag", "Verbose output") 9 args = cli.run() 10 print(args.input, args.PORT, args.VERBOSE) 11""" 12from __future__ import annotations 13 14import json 15import os 16import re 17import shutil 18import sys 19import time 20from dataclasses import dataclass, field 21from typing import Dict, List, Mapping, NoReturn, Optional, Sequence, Union 22 23from ._completions import SCRIPTS as _COMPLETION_SCRIPTS 24 25__all__ = [ 26 "Cli", "Values", "ParseResult", "ValidationError", "validate", "resolve_path", "describe_rule", "wrap_text", 27 "info", "warn", "error", "success", "die", "set_silent", 28] 29__version__ = "0.3.0" 30 31Scalar = Union[str, int, float, bool] 32Value = Union[Scalar, List[Scalar], None] 33 34# --------------------------------------------------------------------------- 35# Logging 36# --------------------------------------------------------------------------- 37 38_COLORS = {"info": "\033[1;37m", "warning": "\033[0;33m", "error": "\033[0;31m", "success": "\033[0;32m"} 39_silent = os.environ.get("CLYOPS_SILENT") == "true" 40 41 42def set_silent(value: bool) -> None: 43 """Suppress info/warn/error/success output (die and parse errors still print).""" 44 global _silent 45 _silent = value 46 47 48def _emit(level: str, msg: str, force: bool = False) -> None: 49 if _silent and not force: 50 return 51 tag = level 52 if sys.stderr.isatty() and not os.environ.get("NO_COLOR"): 53 tag = f"{_COLORS[level]}{level}\033[0m" 54 sys.stderr.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{tag}] {msg}\n") 55 56 57def info(msg: str) -> None: 58 """Write a timestamped informational message to stderr.""" 59 _emit("info", msg) 60 61 62def warn(msg: str) -> None: 63 """Write a timestamped warning to stderr.""" 64 _emit("warning", msg) 65 66 67def error(msg: str) -> None: 68 """Write a timestamped error to stderr.""" 69 _emit("error", msg) 70 71 72def success(msg: str) -> None: 73 """Write a timestamped success message to stderr.""" 74 _emit("success", msg) 75 76 77def die(code: int, msg: str) -> NoReturn: 78 """Print an error and exit with `code`. Never suppressed.""" 79 _emit("error", msg, force=True) 80 sys.exit(code) 81 82 83# --------------------------------------------------------------------------- 84# Rules 85# --------------------------------------------------------------------------- 86 87_TRUE = ("true", "yes", "1", "on") 88_FALSE = ("false", "no", "0", "off") 89_FIXED_RULES = { 90 "int", "float", "string", "path", "ip", "hostname", "url", "port", "email", "uuid", "bool", 91 "date:YYYY-MM-DD", "file:exists", "file:readable", "file:writable", "dir:exists", "dir:writable", 92} 93_HOSTNAME = r"[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" 94 95 96_EFFECTS = ("read-only", "idempotent", "destructive", "network") 97 98 99def _bool_word(value: str) -> Optional[bool]: 100 lower = value.lower() 101 if lower in _TRUE: 102 return True 103 if lower in _FALSE: 104 return False 105 return None 106 107 108def _known_rule(rule: str) -> bool: 109 if not rule or rule in _FIXED_RULES: 110 return True 111 if re.fullmatch(r"int:(\d+-\d*|-\d+)", rule) or re.fullmatch(r"float:(\d*\.?\d+-(\d*\.?\d+)?|-\d*\.?\d+)", rule): 112 return True 113 if re.fullmatch(r"string:(\d+|\d+-\d*|-\d+)", rule) or re.fullmatch(r"choice:.+", rule): 114 return True 115 if rule.startswith("regex:") and len(rule) > 6: 116 try: 117 re.compile(rule[6:]) 118 return True 119 except re.error: 120 return False 121 return False 122 123 124def _bounds(rule: str): 125 rng = rule.split(":", 1)[1] 126 lo, _, hi = rng.partition("-") 127 return lo, hi 128 129 130def _is_path_rule(rule: str) -> bool: 131 return rule == "path" or rule.startswith(("file:", "dir:")) 132 133 134def describe_rule(rule: str) -> str: 135 """Help text for a validation rule (spec section 5).""" 136 fixed = { 137 "int": "integer", "float": "number", "string": "text", "path": "path", "ip": "IP address", 138 "hostname": "hostname", "url": "URL", "port": "port: 1-65535", "email": "email address", "uuid": "UUID", 139 "bool": "true/false, yes/no, 1/0, on/off", "date:YYYY-MM-DD": "date: YYYY-MM-DD", 140 "file:exists": "existing file", "file:readable": "readable file", "file:writable": "writable file", 141 "dir:exists": "existing directory", "dir:writable": "writable directory", 142 } 143 if rule in fixed: 144 return fixed[rule] 145 for prefix, noun, suffix in (("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")): 146 if rule.startswith(prefix): 147 if "-" not in rule: 148 return f"{noun}: {rule[len(prefix):]}{suffix}" 149 lo, hi = _bounds(rule) 150 if lo and hi: 151 return f"{noun}: {lo}-{hi}{suffix}" 152 return f"{noun}: >={lo}{suffix}" if lo else f"{noun}: <={hi}{suffix}" 153 if rule.startswith("choice:"): 154 return "choices: " + ", ".join(rule[7:].split(",")) 155 if rule.startswith("regex:"): 156 return "pattern: " + rule[6:] 157 return rule 158 159 160class ValidationError(ValueError): 161 """An invalid value reported with the validation rule and option or argument name.""" 162 pass 163 164 165def validate(value: str, rule: str, name: str) -> Scalar: 166 """Validate `value` against `rule` and return its typed form. 167 168 Raises ValidationError with the spec's error text. 169 """ 170 def fail(msg: str): 171 raise ValidationError(f"{name} {msg}") 172 173 def check_bounds(num: float, lo: str, hi: str): 174 if lo and num < float(lo): 175 fail(f"must be >= {lo}, got {value}") 176 if hi and num > float(hi): 177 fail(f"must be <= {hi}, got {value}") 178 179 if rule == "int" or rule.startswith("int:"): 180 if not re.fullmatch(r"-?[0-9]+", value): 181 fail(f"must be an integer, got '{value}'") 182 num: Union[int, float] = int(value) 183 if rule != "int": 184 check_bounds(num, *_bounds(rule)) 185 return num 186 if rule == "float" or rule.startswith("float:"): 187 if not re.fullmatch(r"-?[0-9]*\.?[0-9]+", value): 188 fail(f"must be a number, got '{value}'") 189 num = float(value) 190 if rule != "float": 191 check_bounds(num, *_bounds(rule)) 192 return num 193 if rule.startswith("string:"): 194 length = len(value) 195 if "-" not in rule: 196 exact = rule[7:] 197 if length != int(exact): 198 fail(f"must be exactly {exact} characters, got {length}") 199 else: 200 lo, hi = _bounds(rule) 201 if lo and length < int(lo): 202 fail(f"must be at least {lo} characters, got {length}") 203 if hi and length > int(hi): 204 fail(f"must be at most {hi} characters, got {length}") 205 return value 206 if rule.startswith("choice:"): 207 choices = rule[7:].split(",") 208 if value not in choices: 209 fail(f"must be one of: {', '.join(choices)}, got '{value}'") 210 return value 211 if rule.startswith("regex:"): 212 if not re.search(rule[6:], value): 213 fail(f"does not match required pattern, got '{value}'") 214 return value 215 216 if rule == "bool": 217 b = _bool_word(value) 218 if b is None: 219 fail(f"must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'") 220 return bool(b) 221 if rule == "port": 222 if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535: 223 fail(f"must be a valid port (1-65535), got '{value}'") 224 return int(value) 225 if rule == "ip": 226 if not (re.fullmatch(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value) 227 or re.fullmatch(r"([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value)): 228 fail(f"must be a valid IP address, got '{value}'") 229 elif rule == "hostname": 230 if not re.fullmatch(_HOSTNAME, value): 231 fail(f"must be a valid hostname, got '{value}'") 232 elif rule == "url": 233 if not re.fullmatch(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?", value, re.S): 234 fail(f"must be a valid URL, got '{value}'") 235 elif rule == "email": 236 if not re.fullmatch(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value): 237 fail(f"must be a valid email address, got '{value}'") 238 elif rule == "uuid": 239 if not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value): 240 fail(f"must be a valid UUID, got '{value}'") 241 elif rule == "date:YYYY-MM-DD": 242 if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value): 243 fail(f"must be in YYYY-MM-DD format, got '{value}'") 244 elif rule == "file:exists": 245 if not os.path.isfile(value): 246 fail(f"file does not exist: {value}") 247 elif rule == "file:readable": 248 if not os.access(value, os.R_OK): 249 fail(f"file is not readable: {value}") 250 elif rule == "file:writable": 251 if os.path.lexists(value): 252 if not os.access(value, os.W_OK): 253 fail(f"file is not writable: {value}") 254 else: 255 directory = os.path.dirname(value) 256 if not (os.path.isdir(directory) and os.access(directory, os.W_OK)): 257 fail(f"directory is not writable: {directory}") 258 elif rule == "dir:exists": 259 if not os.path.isdir(value): 260 fail(f"directory does not exist: {value}") 261 elif rule == "dir:writable": 262 if not (os.path.isdir(value) and os.access(value, os.W_OK)): 263 fail(f"directory does not exist or is not writable: {value}") 264 return value 265 266 267def resolve_path(value: str, base: str, search_dirs: Sequence[str] = ()) -> str: 268 """Resolve a path value against `base` (spec section 6).""" 269 if not value or value in ("-", "disabled", "optional"): 270 return value 271 if os.path.isabs(value) or re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", value): 272 return value 273 from_base = os.path.normpath(os.path.join(base, value)) 274 bare = not re.match(r"\.\.?(/|$)", value) 275 if bare and search_dirs and not os.path.exists(from_base): 276 for directory in search_dirs: 277 candidate = os.path.normpath(os.path.join(directory, value)) 278 if os.path.exists(candidate): 279 return candidate 280 return from_base 281 282 283def wrap_text(text: str, width: int) -> List[str]: 284 """Greedy word wrap that keeps existing line breaks (spec section 7).""" 285 out: List[str] = [] 286 for original in re.split(r"\r?\n", text): 287 words = original.split() 288 if not words: 289 out.append("") 290 continue 291 line = "" 292 for word in words: 293 if not line: 294 line = word 295 elif len(line) + 1 + len(word) <= width: 296 line += " " + word 297 else: 298 out.append(line) 299 line = word 300 out.append(line) 301 return out 302 303 304def _completion_kind(rule: str, search_dirs: Sequence[str]): 305 if rule == "path" or rule.startswith("file:"): 306 return "file", ":".join(search_dirs) 307 if rule.startswith("dir:"): 308 return "dir", ":".join(search_dirs) 309 if rule.startswith("choice:"): 310 return "choice", rule[7:] 311 if rule == "bool": 312 return "choice", "true,false" 313 if rule in ("hostname", "ip"): 314 return "host", "" 315 return ("none", "") if rule else ("default", "") 316 317 318# --------------------------------------------------------------------------- 319# Cli 320# --------------------------------------------------------------------------- 321 322@dataclass 323class _Option: 324 var: str 325 long: str 326 short: str 327 kind: str # flag | value | array 328 default: str 329 required: bool 330 description: str 331 group: str 332 rule: str 333 search_dirs: List[str] = field(default_factory=list) 334 secret: bool = False 335 336 @property 337 def label(self) -> str: 338 head = f"-{self.short}, --{self.long}" if self.short else f" --{self.long}" 339 return head if self.kind == "flag" else head + "=<value>" 340 341 @property 342 def bool_like(self) -> bool: 343 return self.kind == "flag" or self.rule in ("bool", "choice:true,false", "choice:false,true") 344 345 346@dataclass 347class _Arg: 348 name: str 349 description: str 350 default: str 351 rule: str 352 variadic: bool 353 354 355@dataclass 356class ParseResult: 357 """Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help.""" 358 status: str # ok | help | error 359 error: str = "" 360 show_usage: bool = True 361 detail: List[str] = field(default_factory=list) 362 363 364class Values(Dict[str, Value]): 365 """Resolved values; keys are option vars and argument names, also readable as attributes.""" 366 367 def __getattr__(self, name: str) -> Value: 368 """Read a resolved key as an attribute; missing keys raise AttributeError.""" 369 try: 370 return self[name] 371 except KeyError: 372 raise AttributeError(name) from None 373 374 375class _ParseError(Exception): 376 pass 377 378 379class Cli: 380 """A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys.""" 381 def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None, 382 env: Optional[Mapping[str, str]] = None): 383 """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ.""" 384 self.name = name or os.path.basename(sys.argv[0] or "cli") 385 self.cwd = cwd or os.getcwd() 386 self.root = os.path.normpath(os.path.join(self.cwd, root or ".")) 387 self.env = os.environ if env is None else env 388 self.description = "" 389 self.epilog = "" 390 self.values = Values() 391 self._options: List[_Option] = [] 392 self._by_long: Dict[str, _Option] = {} 393 self._by_short: Dict[str, _Option] = {} 394 self._args: List[_Arg] = [] 395 self._commands: List[tuple] = [] 396 self._config_option = "" 397 self._config_prefixes: List[str] = [] 398 self._raw: Dict[str, Union[str, List[str]]] = {} 399 self._arg_raw: Dict[str, Union[str, List[str]]] = {} 400 self._sources: Dict[str, str] = {} 401 self._config: Dict[str, tuple] = {} # key -> (value, dir) 402 self._effects: List[str] = [] 403 self._stdin: Optional[Dict[str, str]] = None 404 self._stdout: Optional[Dict[str, str]] = None 405 self._constraints: List[Dict[str, object]] = [] 406 self._children: List["Cli"] = [] 407 self._parent: Optional["Cli"] = None 408 self._word = "" 409 # The command selected by the last parse (this one when it has no commands). 410 self._selected: "Cli" = self 411 412 # -- registration --------------------------------------------------------- 413 414 def set_description(self, text: str) -> "Cli": 415 """Set the help description and return this CLI.""" 416 self.description = text 417 return self 418 419 def set_epilog(self, text: str) -> "Cli": 420 """Set the final help text and return this CLI.""" 421 self.epilog = text 422 return self 423 424 def set_effects(self, *effects: str) -> "Cli": 425 """What running the program does: read-only, idempotent, destructive, network.""" 426 for e in effects: 427 if e not in _EFFECTS: 428 raise ValueError(f"Unknown effect '{e}'") 429 self._effects = list(effects) 430 return self 431 432 def set_stdin(self, description: str, content_type: str = "") -> "Cli": 433 """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list.""" 434 self._stdin = {"description": description, "contentType": content_type} 435 return self 436 437 def set_stdout(self, description: str, content_type: str = "") -> "Cli": 438 """What the program writes on stdout; undeclared means text.""" 439 self._stdout = {"description": description, "contentType": content_type} 440 return self 441 442 def exclusive(self, *longs: str) -> "Cli": 443 """At most one of these options may be given.""" 444 return self._constraint("exclusive", longs) 445 446 def requires(self, long: str, *longs: str) -> "Cli": 447 """When the first option is given, the others must be too.""" 448 return self._constraint("requires", (long,) + longs) 449 450 def one_of(self, *longs: str) -> "Cli": 451 """At least one of these options must be given.""" 452 return self._constraint("oneOf", longs) 453 454 def _constraint(self, kind: str, longs: Sequence[str]) -> "Cli": 455 for long in longs: 456 if not self._find(long, False): 457 raise ValueError(f"Unknown option --{long} in constraint") 458 self._constraints.append({"type": kind, "options": list(longs)}) 459 return self 460 461 def command(self, name: str, description: str = "") -> "Cli": 462 """Register a command (spec section 1.7) and return it, to register its options and arguments on.""" 463 if self._args: 464 raise ValueError("Cannot mix commands and positional arguments") 465 if any(c._word == name for c in self._children): 466 raise ValueError(f"Duplicate command {name}") 467 child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env) 468 child.description = description 469 child._parent = self 470 child._word = name 471 self._children.append(child) 472 return child 473 474 @property 475 def command_path(self) -> List[str]: 476 """The command words selected by the last parse, e.g. ["db", "migrate"].""" 477 words: List[str] = [] 478 node: Optional[Cli] = self._selected 479 while node is not None and node is not self: 480 words.insert(0, node._word) 481 node = node._parent 482 return words 483 484 def _chain(self) -> List["Cli"]: 485 """The selected command, its parent, ... up to this one.""" 486 out: List[Cli] = [] 487 node: Optional[Cli] = self._selected 488 while node is not None: 489 out.append(node) 490 node = None if node is self else node._parent 491 return out 492 493 def _chain_options(self) -> List[_Option]: 494 return [o for n in self._chain() for o in n._options] 495 496 def _find(self, name: str, short: bool) -> Optional[_Option]: 497 """An option by long (or short) name, in this level and its ancestors.""" 498 node: Optional[Cli] = self 499 while node is not None: 500 opt = (node._by_short if short else node._by_long).get(name) 501 if opt: 502 return opt 503 node = node._parent 504 return None 505 506 def set_config(self, option: str, prefixes: str) -> "Cli": 507 """`option` holds the config file path; `prefixes` is comma-separated.""" 508 self._config_option = option 509 self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()] 510 return self 511 512 def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli": 513 """Require an executable on PATH when parsing; description and install_hint explain how to install it.""" 514 self._commands.append((command, description, install_hint)) 515 return self 516 517 def set_path_search(self, long: str, dirs: Union[str, Sequence[str]]) -> "Cli": 518 """Fallback dirs (relative to root) for bare relative values of a path option.""" 519 opt = self._by_long[long] 520 items = dirs.split(":") if isinstance(dirs, str) else list(dirs) 521 opt.search_dirs = [os.path.normpath(os.path.join(self.root, d)) for d in items if d] 522 if not opt.rule: 523 opt.rule = "path" 524 return self 525 526 def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "", 527 group: str = "Options", rule: str = "") -> "Cli": 528 """Register an option. `default` is a value, "flag", "optional", or "" (required).""" 529 kind = "flag" if default == "flag" else "value" 530 value = "" if default in ("flag", "optional") else default 531 return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule)) 532 533 def opt_array(self, var: str, long: str, short: str = "", description: str = "", 534 group: str = "Options", rule: str = "") -> "Cli": 535 """Register a repeatable option whose values accumulate into a list.""" 536 return self._add(_Option(var, long, short, "array", "", False, description, group, rule)) 537 538 def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli": 539 """Register a positional argument. An empty default makes it required.""" 540 return self._add_arg(_Arg(name, description, default, rule, False)) 541 542 def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli": 543 """Register a final positional argument that collects all remaining tokens.""" 544 return self._add_arg(_Arg(name, description, "", rule, True)) 545 546 def _add(self, opt: _Option) -> "Cli": 547 if opt.long in self._by_long: 548 raise ValueError(f"Duplicate option --{opt.long}") 549 if opt.short and (len(opt.short) != 1 or opt.short in self._by_short): 550 raise ValueError(f"Invalid or duplicate short option -{opt.short}") 551 if opt.rule == "secret" or opt.rule.startswith("secret:"): 552 opt.secret, opt.rule = True, opt.rule[7:] 553 if not _known_rule(opt.rule): 554 raise ValueError(f"Unknown validation rule '{opt.rule}' for --{opt.long}") 555 self._options.append(opt) 556 self._by_long[opt.long] = opt 557 if opt.short: 558 self._by_short[opt.short] = opt 559 return self 560 561 def _add_arg(self, arg: _Arg) -> "Cli": 562 if self._children: 563 raise ValueError("Cannot mix commands and positional arguments") 564 if any(a.variadic for a in self._args): 565 raise ValueError(f"Argument {arg.name} registered after a variadic argument") 566 if not _known_rule(arg.rule): 567 raise ValueError(f"Unknown validation rule '{arg.rule}' for {arg.name}") 568 self._args.append(arg) 569 return self 570 571 def _ensure_help(self) -> None: 572 if "help" not in self._by_long: 573 short = "" if "h" in self._by_short else "h" 574 self._add(_Option("HELP", "help", short, "flag", "", False, "Show this help message and exit", "Global", "")) 575 576 # -- parsing -------------------------------------------------------------- 577 578 def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult: 579 """Parse without exiting. Values are in `self.values` when status is "ok".""" 580 argv = list(sys.argv[1:] if argv is None else argv) 581 self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {} 582 self.values = Values() 583 self._selected = self 584 self._ensure_help() 585 586 try: 587 self._scan(argv) 588 scan_error = None 589 except _ParseError as exc: 590 scan_error = str(exc) 591 if scan_error is None and self._config_option: 592 try: 593 self._load_config() 594 except _ParseError as exc: 595 scan_error = str(exc) 596 if self._raw.get("help") == "true": 597 return ParseResult("help") 598 if scan_error is not None: 599 return ParseResult("error", scan_error) 600 601 try: 602 self._resolve() 603 except (_ParseError, ValidationError) as exc: 604 return ParseResult("error", str(exc)) 605 606 missing_cmds = [c for n in reversed(self._chain()) for c in n._commands 607 if not shutil.which(c[0], path=self.env.get("PATH", ""))] 608 if missing_cmds: 609 detail = [] 610 for cmd, desc, hint in missing_cmds: 611 detail.append(f" {cmd} - {desc}") 612 if hint: 613 detail.append(f" Install: {hint}") 614 return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds), 615 False, detail) 616 617 missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)] 618 if missing: 619 return ParseResult("error", "Missing required argument(s): " + " ".join(missing)) 620 conflict = self._check_constraints() 621 if conflict: 622 return ParseResult("error", conflict) 623 return ParseResult("ok") 624 625 def _check_constraints(self) -> Optional[str]: 626 """Spec section 1.6: the first relationship that fails, from the program down.""" 627 def given(long: str) -> bool: 628 opt = self._selected._find(long, False) 629 v = self.values.get(opt.var) if opt else None 630 return self.source(long) in ("cli", "config", "env") and v is not False and v != [] 631 632 for node in reversed(self._chain()): 633 for c in node._constraints: 634 longs: List[str] = c["options"] # type: ignore[assignment] 635 on = [o for o in longs if given(o)] 636 if c["type"] == "exclusive" and len(on) > 1: 637 return f"Options --{on[0]} and --{on[1]} cannot be used together" 638 if c["type"] == "requires" and given(longs[0]): 639 absent = next((o for o in longs[1:] if not given(o)), None) 640 if absent: 641 return f"Option --{longs[0]} requires --{absent}" 642 if c["type"] == "oneOf" and not on: 643 return "One of " + ", ".join(f"--{o}" for o in longs) + " is required" 644 return None 645 646 def _set_cli(self, opt: _Option, value: str) -> None: 647 if opt.kind == "array": 648 current = self._raw[opt.long] if self._sources.get(opt.long) == "cli" else [] 649 self._raw[opt.long] = list(current) + [value] 650 else: 651 self._raw[opt.long] = value 652 self._sources[opt.long] = "cli" 653 654 def _scan(self, argv: List[str]) -> None: 655 pos = 0 656 rest: Optional[List[str]] = None 657 end_of_options = False 658 i = 0 659 while i < len(argv): 660 token = argv[i] 661 i += 1 662 if end_of_options or token == "-" or not token.startswith("-"): 663 node = self._selected 664 if rest is not None: 665 rest.append(token) 666 elif node._children: 667 child = next((c for c in node._children if c._word == token), None) 668 if child is None: 669 raise _ParseError(f"Unknown command: {token}") 670 self._selected = child 671 elif pos >= len(node._args): 672 raise _ParseError(f"Unexpected argument: {token}") 673 else: 674 arg = node._args[pos] 675 pos += 1 676 if arg.variadic: 677 rest = [token] 678 self._arg_raw[arg.name] = rest 679 else: 680 self._arg_raw[arg.name] = token 681 elif token == "--": 682 end_of_options = True 683 elif token.startswith("--"): 684 name, eq, value = token[2:].partition("=") 685 opt = self._selected._find(name, False) 686 if opt and eq: 687 if opt.kind == "flag": 688 b = _bool_word(value) 689 if b is None: 690 raise _ParseError(f"Option --{name} expects a boolean value, got '{value}'") 691 value = "true" if b else "false" 692 self._set_cli(opt, value) 693 elif opt: 694 if opt.kind == "flag": 695 self._set_cli(opt, "true") 696 else: 697 if i >= len(argv) or argv[i].startswith("--"): 698 raise _ParseError(f"Option --{name} requires an argument") 699 self._set_cli(opt, argv[i]) 700 i += 1 701 elif name.startswith("no-") and not eq and self._selected._find(name[3:], False): 702 target = self._selected._find(name[3:], False) 703 assert target is not None 704 if not target.bool_like: 705 raise _ParseError(f"Option --{name} can only be used with flag/boolean options") 706 self._set_cli(target, "false") 707 else: 708 raise _ParseError(f"Unknown option: --{name}") 709 else: 710 cluster = token[1:] 711 for j, ch in enumerate(cluster): 712 opt = self._selected._find(ch, True) 713 if not opt: 714 raise _ParseError(f"Unknown option: -{ch}") 715 if opt.kind == "flag": 716 self._set_cli(opt, "true") 717 continue 718 if j + 1 < len(cluster): 719 self._set_cli(opt, cluster[j + 1:]) 720 break 721 if i >= len(argv) or argv[i].startswith("-"): 722 raise _ParseError(f"Option -{ch} requires an argument") 723 self._set_cli(opt, argv[i]) 724 i += 1 725 break 726 727 def _load_config(self) -> None: 728 opt = self._selected._find(self._config_option, False) 729 if not opt: 730 return 731 path, source = self._raw.get(opt.long), "cli" 732 assert not isinstance(path, list) 733 if path is None and self.env.get(opt.var): 734 path, source = self.env[opt.var], "env" 735 if path is None and opt.default: 736 path, source = opt.default, "default" 737 if not path or path == "disabled": 738 return 739 resolved = resolve_path(path, self.cwd, opt.search_dirs) 740 self._raw[opt.long] = resolved 741 self._sources[opt.long] = source 742 self._read_config(resolved, 0, set()) 743 744 for key, (value, _) in self._config.items(): 745 target = self._selected._find(key, False) 746 if not target or target is opt or self._sources.get(key) == "cli": 747 continue 748 if target.kind == "flag": 749 b = _bool_word(value) 750 if b is None: 751 raise _ParseError(f"Config value for --{key} must be a boolean, got '{value}'") 752 self._raw[key] = "true" if b else "false" 753 else: 754 self._raw[key] = [value] if target.kind == "array" else value 755 self._sources[key] = "config" 756 757 def _read_config(self, path: str, depth: int, stack: set) -> None: 758 if depth > 10: 759 raise _ParseError(f"Config include depth exceeded (10) while processing: {path}") 760 if not os.path.isfile(path): 761 raise _ParseError(f"Config file not found: {path}") 762 if path in stack: 763 raise _ParseError(f"Circular config include detected: {path}") 764 stack.add(path) 765 directory = os.path.dirname(path) 766 with open(path, encoding="utf-8", newline="") as fh: 767 lines = fh.read().split("\n") 768 for line in lines: 769 line = line[:-1] if line.endswith("\r") else line 770 trimmed = line.strip() 771 if not trimmed or trimmed.startswith("#"): 772 continue 773 include = re.match(r"\s*@include\s+(.+)$", line) 774 if include: 775 target = include.group(1).strip() 776 quoted = re.fullmatch(r"(['\"])(.*)\1", target) 777 if quoted: 778 target = quoted.group(2) 779 self._read_config(os.path.normpath(os.path.join(directory, target)), depth + 1, stack) 780 continue 781 if self._config_prefixes: 782 prefix = next((p for p in self._config_prefixes if line.startswith(p)), None) 783 if prefix is None: 784 continue 785 body = line[len(prefix):] 786 else: 787 body = line 788 key, eq, value = body.partition("=") 789 key = key.strip() 790 if key.startswith("--"): 791 key = key[2:] 792 if not eq or not key: 793 continue 794 self._config[key] = (value.strip(), directory) 795 stack.discard(path) 796 797 def _resolve(self) -> None: 798 if self._selected._children: 799 raise _ParseError("Missing command") 800 options = self._chain_options() 801 args = self._selected._args 802 for arg in args: 803 if arg.name in self._arg_raw: 804 continue 805 if arg.variadic: 806 self._arg_raw[arg.name] = [] 807 elif not arg.default: 808 raise _ParseError(f"Missing required positional argument: {arg.name}") 809 else: 810 self._arg_raw[arg.name] = arg.default 811 812 for opt in options: 813 if opt.long in self._sources: 814 continue 815 env_value = None if opt.kind == "array" else self.env.get(opt.var) 816 if env_value: 817 if opt.kind == "flag": 818 b = _bool_word(env_value) 819 if b is None: 820 raise _ParseError(f"Environment variable {opt.var} must be a boolean, got '{env_value}'") 821 env_value = "true" if b else "false" 822 self._raw[opt.long] = env_value 823 self._sources[opt.long] = "env" 824 elif opt.kind == "flag": 825 self._raw[opt.long] = "false" 826 self._sources[opt.long] = "default" 827 elif opt.default: 828 self._raw[opt.long] = opt.default 829 self._sources[opt.long] = "default" 830 831 # Path resolution: the base depends on where the value came from. 832 for opt in options: 833 if not _is_path_rule(opt.rule) or opt.long not in self._raw or opt.long == self._config_option: 834 continue 835 source = self._sources[opt.long] 836 base = self.cwd if source == "cli" else self._config[opt.long][1] if source == "config" else self.root 837 value = self._raw[opt.long] 838 if isinstance(value, list): 839 self._raw[opt.long] = [resolve_path(v, base, opt.search_dirs) for v in value] 840 else: 841 self._raw[opt.long] = resolve_path(value, base, opt.search_dirs) 842 for arg in args: 843 if _is_path_rule(arg.rule): 844 value = self._arg_raw[arg.name] 845 if isinstance(value, list): 846 self._arg_raw[arg.name] = [resolve_path(v, self.cwd) for v in value] 847 else: 848 self._arg_raw[arg.name] = resolve_path(value, self.cwd) 849 850 def convert(v: str, rule: str, name: str) -> Scalar: 851 return validate(v, rule, name) if v and rule else v 852 853 for opt in options: 854 raw = self._raw.get(opt.long) 855 if raw is None: 856 self.values[opt.var] = [] if opt.kind == "array" else None 857 elif opt.kind == "flag": 858 self.values[opt.var] = raw == "true" 859 elif isinstance(raw, list): 860 self.values[opt.var] = [convert(v, opt.rule, f"--{opt.long}") for v in raw] 861 else: 862 self.values[opt.var] = convert(raw, opt.rule, f"--{opt.long}") 863 for arg in args: 864 value = self._arg_raw[arg.name] 865 if isinstance(value, list): 866 self.values[arg.name] = [convert(v, arg.rule, arg.name) for v in value] 867 else: 868 self.values[arg.name] = convert(value, arg.rule, arg.name) 869 if self._children: 870 self.values["command"] = self.command_path # type: ignore[assignment] 871 872 def run(self, argv: Optional[Sequence[str]] = None) -> Values: 873 """Parse like a CLI: handles --help, --help-json-schema and --bash-completion, 874 prints errors and exits on failure. Returns the values.""" 875 argv = list(sys.argv[1:] if argv is None else argv) 876 head = argv[:argv.index("--")] if "--" in argv else argv 877 if "--help-json-schema" in head: 878 sys.stdout.write(self.json_schema() + "\n") 879 sys.exit(0) 880 if "--bash-completion" in head: 881 sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else [])) 882 sys.exit(0) 883 if "--completion" in head: 884 shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else "" 885 script = self.completion_script(shell) 886 if script is None: 887 die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)") 888 sys.stdout.write(script) 889 sys.exit(0) 890 891 result = self.parse(argv) 892 if result.status == "help": 893 sys.stdout.write(self.usage()) 894 sys.exit(0) 895 if result.status == "error": 896 _emit("error", result.error, force=True) 897 for line in result.detail: 898 sys.stderr.write(line + "\n") 899 if result.show_usage: 900 sys.stderr.write(self.usage()) 901 sys.exit(1) 902 return self.values 903 904 # -- accessors ------------------------------------------------------------ 905 906 def get(self, name: str) -> Value: 907 """Return the resolved value for a variable or argument name, or None when absent.""" 908 return self.values.get(name) 909 910 def source(self, long: str) -> str: 911 """Where an option's value came from: cli, config, env, default or unset.""" 912 return self._sources.get(long[2:] if long.startswith("--") else long, "unset") 913 914 def is_set(self, long: str) -> bool: 915 """Return whether the option was supplied on the command line (long name, optionally prefixed with --).""" 916 return self.source(long) == "cli" 917 918 def is_explicitly_set(self, long: str) -> bool: 919 """Return whether the option came from CLI, config, or environment rather than a default.""" 920 return self.source(long) in ("cli", "config", "env") 921 922 def values_json(self) -> str: 923 """Resolved values as JSON (spec section 10).""" 924 out: Dict[str, object] = {} 925 for o in self._chain_options(): 926 v = self.values.get(o.var) 927 out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v 928 out.update({a.name: self.values.get(a.name) for a in self._selected._args}) 929 if self._children: 930 out["command"] = self.command_path 931 return json.dumps(out, indent=2, ensure_ascii=False) 932 933 # -- output --------------------------------------------------------------- 934 935 def usage(self) -> str: 936 """Help text (spec section 7), for the selected command.""" 937 self._ensure_help() 938 node = self._selected 939 chain = self._chain() 940 options = self._chain_options() 941 width = self.env.get("CLYOPS_MAX_WIDTH", "") 942 max_width = int(width) if width.isdigit() and int(width) > 0 else 100 943 longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0) 944 indent = min(50, max(32, longest + 4)) 945 text_width = max(20, max_width - indent) 946 947 def row(label: str, text: str) -> List[str]: 948 left = " " + label 949 left = left.ljust(indent) if len(left) < indent else left + " " 950 lines = wrap_text(text, text_width) 951 return [left + lines[0]] + [" " * indent + line for line in lines[1:]] 952 953 def annotate(text: str, notes: List[str]) -> str: 954 return f"{text} ({', '.join(notes)})" if notes else text 955 956 sections: List[List[str]] = [] 957 usage = f"Usage: {node.name}" 958 if node._children: 959 usage += " <command>" 960 for arg in node._args: 961 usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>" 962 sections.append([usage + " [OPTIONS]"]) 963 964 if node.description: 965 sections.append(wrap_text(node.description, max_width)) 966 967 io = [] 968 for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)): 969 if decl is not None: 970 ctype = decl["contentType"] 971 io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x)) 972 if io: 973 sections.append(io) 974 975 if node._children: 976 sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)]) 977 978 if node._args: 979 lines = ["Positional Arguments:"] 980 for arg in node._args: 981 notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else []) 982 if arg.rule: 983 notes.append(f"accepts: {describe_rule(arg.rule)}") 984 lines += row(arg.name, annotate(arg.description, notes)) 985 sections.append(lines) 986 987 commands = [c for n in reversed(chain) for c in n._commands] 988 if commands: 989 lines = ["Required Commands:"] 990 for cmd, desc, hint in commands: 991 status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found" 992 lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc) 993 sections.append(lines) 994 995 constraints = [c for n in reversed(chain) for c in n._constraints] 996 groups = list(dict.fromkeys(o.group for o in options)) 997 for group in groups: 998 lines = [f"{group}:"] 999 for opt in (o for o in options if o.group == group): 1000 notes = [] 1001 if opt.required: 1002 notes.append("required") 1003 if opt.kind == "array": 1004 notes.append("multiple") 1005 if opt.secret: 1006 notes.append("secret") 1007 if opt.long in self._config: 1008 notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}") 1009 if opt.default: 1010 notes.append(f"default: {opt.default}") 1011 if opt.rule: 1012 notes.append(f"accepts: {describe_rule(opt.rule)}") 1013 for c in constraints: 1014 longs: List[str] = c["options"] # type: ignore[assignment] 1015 if opt.long not in longs: 1016 continue 1017 if c["type"] == "exclusive": 1018 notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long)) 1019 elif c["type"] == "requires" and longs[0] == opt.long: 1020 notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:])) 1021 elif c["type"] == "oneOf": 1022 notes.append("one of: " + ", ".join(f"--{o}" for o in longs)) 1023 lines += row(opt.label, annotate(opt.description, notes)) 1024 sections.append(lines) 1025 1026 if node.epilog: 1027 sections.append(node.epilog.rstrip("\n").split("\n")) 1028 1029 text = "\n\n".join("\n".join(s) for s in sections) 1030 return "\n".join(line.rstrip() for line in text.split("\n")) + "\n" 1031 1032 def json_schema(self) -> str: 1033 """JSON description of the CLI (spec section 8).""" 1034 self._ensure_help() 1035 return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False) 1036 1037 def _schema_node(self) -> Dict[str, object]: 1038 def type_of(o: _Option) -> str: 1039 r = o.rule 1040 if o.kind == "flag" or r == "bool": 1041 return "boolean" 1042 if r.startswith("int") or r == "port": 1043 return "integer" 1044 if r.startswith("float"): 1045 return "number" 1046 if r.startswith("choice:"): 1047 return "choice" 1048 return "path" if _is_path_rule(r) else "string" 1049 1050 return { 1051 **({"name": self._word} if self._parent else {}), 1052 "description": self.description, 1053 "epilog": self.epilog, 1054 "arguments": [{ 1055 "name": a.name, "description": a.description, "required": not a.variadic and not a.default, 1056 "isVariadic": a.variadic, "default": a.default, "validation": a.rule, 1057 } for a in self._args], 1058 "options": [{ 1059 "name": o.long, "shortName": o.short, "variableName": o.var, "description": o.description, 1060 "default": "false" if o.kind == "flag" else o.default, "group": o.group, "type": type_of(o), 1061 "isFlag": o.kind == "flag", "isArray": o.kind == "array", "required": o.required, 1062 "validation": o.rule, "choices": o.rule[7:].split(",") if o.rule.startswith("choice:") else [], 1063 "secret": o.secret, 1064 } for o in self._options], 1065 "requiredCommands": [{"command": c, "description": d, "installHint": h} for c, d, h in self._commands], 1066 "effects": self._effects, 1067 "constraints": self._constraints, 1068 "stdin": self._stdin, 1069 "stdout": self._stdout, 1070 "commands": [c._schema_node() for c in self._children], 1071 } 1072 1073 def completion_script(self, shell: str) -> Optional[str]: 1074 """Shell script that enables completion for this program (spec section 9): 1075 eval "$(prog --completion bash)". None for an unknown shell.""" 1076 template = _COMPLETION_SCRIPTS.get(shell) 1077 if template is None: 1078 return None 1079 func = re.sub(r"[^A-Za-z0-9_]", "_", self.name) 1080 return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name) 1081 1082 def completion_data(self, words: Sequence[str] = ()) -> str: 1083 """Tab-separated completion records (spec section 9). `words` are the words typed 1084 after the program name; a program with commands follows them.""" 1085 self._ensure_help() 1086 1087 def clean(s: str) -> str: 1088 return s.replace("\t", " ").replace("\n", " ") 1089 1090 lines = ["#clyops-completion 1"] 1091 node: Cli = self 1092 if self._children: 1093 skip = 0 1094 for w in words: 1095 child = next((c for c in node._children if c._word == w), None) 1096 if child is None: 1097 break 1098 node, skip = child, skip + 1 1099 if node._children and skip < len(words) and not words[skip].startswith("-"): 1100 return lines[0] + "\n" 1101 lines.append(f"skip\t{skip}") 1102 lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children] 1103 options: List[_Option] = [] 1104 current: Optional[Cli] = node 1105 while current is not None: 1106 options += current._options 1107 current = current._parent 1108 for o in options: 1109 short = f"-{o.short}" if o.short else "-" 1110 if o.kind == "flag": 1111 lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}") 1112 else: 1113 kind, values = _completion_kind(o.rule, o.search_dirs) 1114 lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}") 1115 if o.bool_like: 1116 lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}") 1117 for a in node._args: 1118 kind, values = _completion_kind(a.rule, []) 1119 arity = "variadic" if a.variadic else "single" 1120 lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}") 1121 return "\n".join(lines) + "\n"
380class Cli: 381 """A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys.""" 382 def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None, 383 env: Optional[Mapping[str, str]] = None): 384 """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ.""" 385 self.name = name or os.path.basename(sys.argv[0] or "cli") 386 self.cwd = cwd or os.getcwd() 387 self.root = os.path.normpath(os.path.join(self.cwd, root or ".")) 388 self.env = os.environ if env is None else env 389 self.description = "" 390 self.epilog = "" 391 self.values = Values() 392 self._options: List[_Option] = [] 393 self._by_long: Dict[str, _Option] = {} 394 self._by_short: Dict[str, _Option] = {} 395 self._args: List[_Arg] = [] 396 self._commands: List[tuple] = [] 397 self._config_option = "" 398 self._config_prefixes: List[str] = [] 399 self._raw: Dict[str, Union[str, List[str]]] = {} 400 self._arg_raw: Dict[str, Union[str, List[str]]] = {} 401 self._sources: Dict[str, str] = {} 402 self._config: Dict[str, tuple] = {} # key -> (value, dir) 403 self._effects: List[str] = [] 404 self._stdin: Optional[Dict[str, str]] = None 405 self._stdout: Optional[Dict[str, str]] = None 406 self._constraints: List[Dict[str, object]] = [] 407 self._children: List["Cli"] = [] 408 self._parent: Optional["Cli"] = None 409 self._word = "" 410 # The command selected by the last parse (this one when it has no commands). 411 self._selected: "Cli" = self 412 413 # -- registration --------------------------------------------------------- 414 415 def set_description(self, text: str) -> "Cli": 416 """Set the help description and return this CLI.""" 417 self.description = text 418 return self 419 420 def set_epilog(self, text: str) -> "Cli": 421 """Set the final help text and return this CLI.""" 422 self.epilog = text 423 return self 424 425 def set_effects(self, *effects: str) -> "Cli": 426 """What running the program does: read-only, idempotent, destructive, network.""" 427 for e in effects: 428 if e not in _EFFECTS: 429 raise ValueError(f"Unknown effect '{e}'") 430 self._effects = list(effects) 431 return self 432 433 def set_stdin(self, description: str, content_type: str = "") -> "Cli": 434 """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list.""" 435 self._stdin = {"description": description, "contentType": content_type} 436 return self 437 438 def set_stdout(self, description: str, content_type: str = "") -> "Cli": 439 """What the program writes on stdout; undeclared means text.""" 440 self._stdout = {"description": description, "contentType": content_type} 441 return self 442 443 def exclusive(self, *longs: str) -> "Cli": 444 """At most one of these options may be given.""" 445 return self._constraint("exclusive", longs) 446 447 def requires(self, long: str, *longs: str) -> "Cli": 448 """When the first option is given, the others must be too.""" 449 return self._constraint("requires", (long,) + longs) 450 451 def one_of(self, *longs: str) -> "Cli": 452 """At least one of these options must be given.""" 453 return self._constraint("oneOf", longs) 454 455 def _constraint(self, kind: str, longs: Sequence[str]) -> "Cli": 456 for long in longs: 457 if not self._find(long, False): 458 raise ValueError(f"Unknown option --{long} in constraint") 459 self._constraints.append({"type": kind, "options": list(longs)}) 460 return self 461 462 def command(self, name: str, description: str = "") -> "Cli": 463 """Register a command (spec section 1.7) and return it, to register its options and arguments on.""" 464 if self._args: 465 raise ValueError("Cannot mix commands and positional arguments") 466 if any(c._word == name for c in self._children): 467 raise ValueError(f"Duplicate command {name}") 468 child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env) 469 child.description = description 470 child._parent = self 471 child._word = name 472 self._children.append(child) 473 return child 474 475 @property 476 def command_path(self) -> List[str]: 477 """The command words selected by the last parse, e.g. ["db", "migrate"].""" 478 words: List[str] = [] 479 node: Optional[Cli] = self._selected 480 while node is not None and node is not self: 481 words.insert(0, node._word) 482 node = node._parent 483 return words 484 485 def _chain(self) -> List["Cli"]: 486 """The selected command, its parent, ... up to this one.""" 487 out: List[Cli] = [] 488 node: Optional[Cli] = self._selected 489 while node is not None: 490 out.append(node) 491 node = None if node is self else node._parent 492 return out 493 494 def _chain_options(self) -> List[_Option]: 495 return [o for n in self._chain() for o in n._options] 496 497 def _find(self, name: str, short: bool) -> Optional[_Option]: 498 """An option by long (or short) name, in this level and its ancestors.""" 499 node: Optional[Cli] = self 500 while node is not None: 501 opt = (node._by_short if short else node._by_long).get(name) 502 if opt: 503 return opt 504 node = node._parent 505 return None 506 507 def set_config(self, option: str, prefixes: str) -> "Cli": 508 """`option` holds the config file path; `prefixes` is comma-separated.""" 509 self._config_option = option 510 self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()] 511 return self 512 513 def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli": 514 """Require an executable on PATH when parsing; description and install_hint explain how to install it.""" 515 self._commands.append((command, description, install_hint)) 516 return self 517 518 def set_path_search(self, long: str, dirs: Union[str, Sequence[str]]) -> "Cli": 519 """Fallback dirs (relative to root) for bare relative values of a path option.""" 520 opt = self._by_long[long] 521 items = dirs.split(":") if isinstance(dirs, str) else list(dirs) 522 opt.search_dirs = [os.path.normpath(os.path.join(self.root, d)) for d in items if d] 523 if not opt.rule: 524 opt.rule = "path" 525 return self 526 527 def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "", 528 group: str = "Options", rule: str = "") -> "Cli": 529 """Register an option. `default` is a value, "flag", "optional", or "" (required).""" 530 kind = "flag" if default == "flag" else "value" 531 value = "" if default in ("flag", "optional") else default 532 return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule)) 533 534 def opt_array(self, var: str, long: str, short: str = "", description: str = "", 535 group: str = "Options", rule: str = "") -> "Cli": 536 """Register a repeatable option whose values accumulate into a list.""" 537 return self._add(_Option(var, long, short, "array", "", False, description, group, rule)) 538 539 def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli": 540 """Register a positional argument. An empty default makes it required.""" 541 return self._add_arg(_Arg(name, description, default, rule, False)) 542 543 def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli": 544 """Register a final positional argument that collects all remaining tokens.""" 545 return self._add_arg(_Arg(name, description, "", rule, True)) 546 547 def _add(self, opt: _Option) -> "Cli": 548 if opt.long in self._by_long: 549 raise ValueError(f"Duplicate option --{opt.long}") 550 if opt.short and (len(opt.short) != 1 or opt.short in self._by_short): 551 raise ValueError(f"Invalid or duplicate short option -{opt.short}") 552 if opt.rule == "secret" or opt.rule.startswith("secret:"): 553 opt.secret, opt.rule = True, opt.rule[7:] 554 if not _known_rule(opt.rule): 555 raise ValueError(f"Unknown validation rule '{opt.rule}' for --{opt.long}") 556 self._options.append(opt) 557 self._by_long[opt.long] = opt 558 if opt.short: 559 self._by_short[opt.short] = opt 560 return self 561 562 def _add_arg(self, arg: _Arg) -> "Cli": 563 if self._children: 564 raise ValueError("Cannot mix commands and positional arguments") 565 if any(a.variadic for a in self._args): 566 raise ValueError(f"Argument {arg.name} registered after a variadic argument") 567 if not _known_rule(arg.rule): 568 raise ValueError(f"Unknown validation rule '{arg.rule}' for {arg.name}") 569 self._args.append(arg) 570 return self 571 572 def _ensure_help(self) -> None: 573 if "help" not in self._by_long: 574 short = "" if "h" in self._by_short else "h" 575 self._add(_Option("HELP", "help", short, "flag", "", False, "Show this help message and exit", "Global", "")) 576 577 # -- parsing -------------------------------------------------------------- 578 579 def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult: 580 """Parse without exiting. Values are in `self.values` when status is "ok".""" 581 argv = list(sys.argv[1:] if argv is None else argv) 582 self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {} 583 self.values = Values() 584 self._selected = self 585 self._ensure_help() 586 587 try: 588 self._scan(argv) 589 scan_error = None 590 except _ParseError as exc: 591 scan_error = str(exc) 592 if scan_error is None and self._config_option: 593 try: 594 self._load_config() 595 except _ParseError as exc: 596 scan_error = str(exc) 597 if self._raw.get("help") == "true": 598 return ParseResult("help") 599 if scan_error is not None: 600 return ParseResult("error", scan_error) 601 602 try: 603 self._resolve() 604 except (_ParseError, ValidationError) as exc: 605 return ParseResult("error", str(exc)) 606 607 missing_cmds = [c for n in reversed(self._chain()) for c in n._commands 608 if not shutil.which(c[0], path=self.env.get("PATH", ""))] 609 if missing_cmds: 610 detail = [] 611 for cmd, desc, hint in missing_cmds: 612 detail.append(f" {cmd} - {desc}") 613 if hint: 614 detail.append(f" Install: {hint}") 615 return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds), 616 False, detail) 617 618 missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)] 619 if missing: 620 return ParseResult("error", "Missing required argument(s): " + " ".join(missing)) 621 conflict = self._check_constraints() 622 if conflict: 623 return ParseResult("error", conflict) 624 return ParseResult("ok") 625 626 def _check_constraints(self) -> Optional[str]: 627 """Spec section 1.6: the first relationship that fails, from the program down.""" 628 def given(long: str) -> bool: 629 opt = self._selected._find(long, False) 630 v = self.values.get(opt.var) if opt else None 631 return self.source(long) in ("cli", "config", "env") and v is not False and v != [] 632 633 for node in reversed(self._chain()): 634 for c in node._constraints: 635 longs: List[str] = c["options"] # type: ignore[assignment] 636 on = [o for o in longs if given(o)] 637 if c["type"] == "exclusive" and len(on) > 1: 638 return f"Options --{on[0]} and --{on[1]} cannot be used together" 639 if c["type"] == "requires" and given(longs[0]): 640 absent = next((o for o in longs[1:] if not given(o)), None) 641 if absent: 642 return f"Option --{longs[0]} requires --{absent}" 643 if c["type"] == "oneOf" and not on: 644 return "One of " + ", ".join(f"--{o}" for o in longs) + " is required" 645 return None 646 647 def _set_cli(self, opt: _Option, value: str) -> None: 648 if opt.kind == "array": 649 current = self._raw[opt.long] if self._sources.get(opt.long) == "cli" else [] 650 self._raw[opt.long] = list(current) + [value] 651 else: 652 self._raw[opt.long] = value 653 self._sources[opt.long] = "cli" 654 655 def _scan(self, argv: List[str]) -> None: 656 pos = 0 657 rest: Optional[List[str]] = None 658 end_of_options = False 659 i = 0 660 while i < len(argv): 661 token = argv[i] 662 i += 1 663 if end_of_options or token == "-" or not token.startswith("-"): 664 node = self._selected 665 if rest is not None: 666 rest.append(token) 667 elif node._children: 668 child = next((c for c in node._children if c._word == token), None) 669 if child is None: 670 raise _ParseError(f"Unknown command: {token}") 671 self._selected = child 672 elif pos >= len(node._args): 673 raise _ParseError(f"Unexpected argument: {token}") 674 else: 675 arg = node._args[pos] 676 pos += 1 677 if arg.variadic: 678 rest = [token] 679 self._arg_raw[arg.name] = rest 680 else: 681 self._arg_raw[arg.name] = token 682 elif token == "--": 683 end_of_options = True 684 elif token.startswith("--"): 685 name, eq, value = token[2:].partition("=") 686 opt = self._selected._find(name, False) 687 if opt and eq: 688 if opt.kind == "flag": 689 b = _bool_word(value) 690 if b is None: 691 raise _ParseError(f"Option --{name} expects a boolean value, got '{value}'") 692 value = "true" if b else "false" 693 self._set_cli(opt, value) 694 elif opt: 695 if opt.kind == "flag": 696 self._set_cli(opt, "true") 697 else: 698 if i >= len(argv) or argv[i].startswith("--"): 699 raise _ParseError(f"Option --{name} requires an argument") 700 self._set_cli(opt, argv[i]) 701 i += 1 702 elif name.startswith("no-") and not eq and self._selected._find(name[3:], False): 703 target = self._selected._find(name[3:], False) 704 assert target is not None 705 if not target.bool_like: 706 raise _ParseError(f"Option --{name} can only be used with flag/boolean options") 707 self._set_cli(target, "false") 708 else: 709 raise _ParseError(f"Unknown option: --{name}") 710 else: 711 cluster = token[1:] 712 for j, ch in enumerate(cluster): 713 opt = self._selected._find(ch, True) 714 if not opt: 715 raise _ParseError(f"Unknown option: -{ch}") 716 if opt.kind == "flag": 717 self._set_cli(opt, "true") 718 continue 719 if j + 1 < len(cluster): 720 self._set_cli(opt, cluster[j + 1:]) 721 break 722 if i >= len(argv) or argv[i].startswith("-"): 723 raise _ParseError(f"Option -{ch} requires an argument") 724 self._set_cli(opt, argv[i]) 725 i += 1 726 break 727 728 def _load_config(self) -> None: 729 opt = self._selected._find(self._config_option, False) 730 if not opt: 731 return 732 path, source = self._raw.get(opt.long), "cli" 733 assert not isinstance(path, list) 734 if path is None and self.env.get(opt.var): 735 path, source = self.env[opt.var], "env" 736 if path is None and opt.default: 737 path, source = opt.default, "default" 738 if not path or path == "disabled": 739 return 740 resolved = resolve_path(path, self.cwd, opt.search_dirs) 741 self._raw[opt.long] = resolved 742 self._sources[opt.long] = source 743 self._read_config(resolved, 0, set()) 744 745 for key, (value, _) in self._config.items(): 746 target = self._selected._find(key, False) 747 if not target or target is opt or self._sources.get(key) == "cli": 748 continue 749 if target.kind == "flag": 750 b = _bool_word(value) 751 if b is None: 752 raise _ParseError(f"Config value for --{key} must be a boolean, got '{value}'") 753 self._raw[key] = "true" if b else "false" 754 else: 755 self._raw[key] = [value] if target.kind == "array" else value 756 self._sources[key] = "config" 757 758 def _read_config(self, path: str, depth: int, stack: set) -> None: 759 if depth > 10: 760 raise _ParseError(f"Config include depth exceeded (10) while processing: {path}") 761 if not os.path.isfile(path): 762 raise _ParseError(f"Config file not found: {path}") 763 if path in stack: 764 raise _ParseError(f"Circular config include detected: {path}") 765 stack.add(path) 766 directory = os.path.dirname(path) 767 with open(path, encoding="utf-8", newline="") as fh: 768 lines = fh.read().split("\n") 769 for line in lines: 770 line = line[:-1] if line.endswith("\r") else line 771 trimmed = line.strip() 772 if not trimmed or trimmed.startswith("#"): 773 continue 774 include = re.match(r"\s*@include\s+(.+)$", line) 775 if include: 776 target = include.group(1).strip() 777 quoted = re.fullmatch(r"(['\"])(.*)\1", target) 778 if quoted: 779 target = quoted.group(2) 780 self._read_config(os.path.normpath(os.path.join(directory, target)), depth + 1, stack) 781 continue 782 if self._config_prefixes: 783 prefix = next((p for p in self._config_prefixes if line.startswith(p)), None) 784 if prefix is None: 785 continue 786 body = line[len(prefix):] 787 else: 788 body = line 789 key, eq, value = body.partition("=") 790 key = key.strip() 791 if key.startswith("--"): 792 key = key[2:] 793 if not eq or not key: 794 continue 795 self._config[key] = (value.strip(), directory) 796 stack.discard(path) 797 798 def _resolve(self) -> None: 799 if self._selected._children: 800 raise _ParseError("Missing command") 801 options = self._chain_options() 802 args = self._selected._args 803 for arg in args: 804 if arg.name in self._arg_raw: 805 continue 806 if arg.variadic: 807 self._arg_raw[arg.name] = [] 808 elif not arg.default: 809 raise _ParseError(f"Missing required positional argument: {arg.name}") 810 else: 811 self._arg_raw[arg.name] = arg.default 812 813 for opt in options: 814 if opt.long in self._sources: 815 continue 816 env_value = None if opt.kind == "array" else self.env.get(opt.var) 817 if env_value: 818 if opt.kind == "flag": 819 b = _bool_word(env_value) 820 if b is None: 821 raise _ParseError(f"Environment variable {opt.var} must be a boolean, got '{env_value}'") 822 env_value = "true" if b else "false" 823 self._raw[opt.long] = env_value 824 self._sources[opt.long] = "env" 825 elif opt.kind == "flag": 826 self._raw[opt.long] = "false" 827 self._sources[opt.long] = "default" 828 elif opt.default: 829 self._raw[opt.long] = opt.default 830 self._sources[opt.long] = "default" 831 832 # Path resolution: the base depends on where the value came from. 833 for opt in options: 834 if not _is_path_rule(opt.rule) or opt.long not in self._raw or opt.long == self._config_option: 835 continue 836 source = self._sources[opt.long] 837 base = self.cwd if source == "cli" else self._config[opt.long][1] if source == "config" else self.root 838 value = self._raw[opt.long] 839 if isinstance(value, list): 840 self._raw[opt.long] = [resolve_path(v, base, opt.search_dirs) for v in value] 841 else: 842 self._raw[opt.long] = resolve_path(value, base, opt.search_dirs) 843 for arg in args: 844 if _is_path_rule(arg.rule): 845 value = self._arg_raw[arg.name] 846 if isinstance(value, list): 847 self._arg_raw[arg.name] = [resolve_path(v, self.cwd) for v in value] 848 else: 849 self._arg_raw[arg.name] = resolve_path(value, self.cwd) 850 851 def convert(v: str, rule: str, name: str) -> Scalar: 852 return validate(v, rule, name) if v and rule else v 853 854 for opt in options: 855 raw = self._raw.get(opt.long) 856 if raw is None: 857 self.values[opt.var] = [] if opt.kind == "array" else None 858 elif opt.kind == "flag": 859 self.values[opt.var] = raw == "true" 860 elif isinstance(raw, list): 861 self.values[opt.var] = [convert(v, opt.rule, f"--{opt.long}") for v in raw] 862 else: 863 self.values[opt.var] = convert(raw, opt.rule, f"--{opt.long}") 864 for arg in args: 865 value = self._arg_raw[arg.name] 866 if isinstance(value, list): 867 self.values[arg.name] = [convert(v, arg.rule, arg.name) for v in value] 868 else: 869 self.values[arg.name] = convert(value, arg.rule, arg.name) 870 if self._children: 871 self.values["command"] = self.command_path # type: ignore[assignment] 872 873 def run(self, argv: Optional[Sequence[str]] = None) -> Values: 874 """Parse like a CLI: handles --help, --help-json-schema and --bash-completion, 875 prints errors and exits on failure. Returns the values.""" 876 argv = list(sys.argv[1:] if argv is None else argv) 877 head = argv[:argv.index("--")] if "--" in argv else argv 878 if "--help-json-schema" in head: 879 sys.stdout.write(self.json_schema() + "\n") 880 sys.exit(0) 881 if "--bash-completion" in head: 882 sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else [])) 883 sys.exit(0) 884 if "--completion" in head: 885 shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else "" 886 script = self.completion_script(shell) 887 if script is None: 888 die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)") 889 sys.stdout.write(script) 890 sys.exit(0) 891 892 result = self.parse(argv) 893 if result.status == "help": 894 sys.stdout.write(self.usage()) 895 sys.exit(0) 896 if result.status == "error": 897 _emit("error", result.error, force=True) 898 for line in result.detail: 899 sys.stderr.write(line + "\n") 900 if result.show_usage: 901 sys.stderr.write(self.usage()) 902 sys.exit(1) 903 return self.values 904 905 # -- accessors ------------------------------------------------------------ 906 907 def get(self, name: str) -> Value: 908 """Return the resolved value for a variable or argument name, or None when absent.""" 909 return self.values.get(name) 910 911 def source(self, long: str) -> str: 912 """Where an option's value came from: cli, config, env, default or unset.""" 913 return self._sources.get(long[2:] if long.startswith("--") else long, "unset") 914 915 def is_set(self, long: str) -> bool: 916 """Return whether the option was supplied on the command line (long name, optionally prefixed with --).""" 917 return self.source(long) == "cli" 918 919 def is_explicitly_set(self, long: str) -> bool: 920 """Return whether the option came from CLI, config, or environment rather than a default.""" 921 return self.source(long) in ("cli", "config", "env") 922 923 def values_json(self) -> str: 924 """Resolved values as JSON (spec section 10).""" 925 out: Dict[str, object] = {} 926 for o in self._chain_options(): 927 v = self.values.get(o.var) 928 out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v 929 out.update({a.name: self.values.get(a.name) for a in self._selected._args}) 930 if self._children: 931 out["command"] = self.command_path 932 return json.dumps(out, indent=2, ensure_ascii=False) 933 934 # -- output --------------------------------------------------------------- 935 936 def usage(self) -> str: 937 """Help text (spec section 7), for the selected command.""" 938 self._ensure_help() 939 node = self._selected 940 chain = self._chain() 941 options = self._chain_options() 942 width = self.env.get("CLYOPS_MAX_WIDTH", "") 943 max_width = int(width) if width.isdigit() and int(width) > 0 else 100 944 longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0) 945 indent = min(50, max(32, longest + 4)) 946 text_width = max(20, max_width - indent) 947 948 def row(label: str, text: str) -> List[str]: 949 left = " " + label 950 left = left.ljust(indent) if len(left) < indent else left + " " 951 lines = wrap_text(text, text_width) 952 return [left + lines[0]] + [" " * indent + line for line in lines[1:]] 953 954 def annotate(text: str, notes: List[str]) -> str: 955 return f"{text} ({', '.join(notes)})" if notes else text 956 957 sections: List[List[str]] = [] 958 usage = f"Usage: {node.name}" 959 if node._children: 960 usage += " <command>" 961 for arg in node._args: 962 usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>" 963 sections.append([usage + " [OPTIONS]"]) 964 965 if node.description: 966 sections.append(wrap_text(node.description, max_width)) 967 968 io = [] 969 for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)): 970 if decl is not None: 971 ctype = decl["contentType"] 972 io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x)) 973 if io: 974 sections.append(io) 975 976 if node._children: 977 sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)]) 978 979 if node._args: 980 lines = ["Positional Arguments:"] 981 for arg in node._args: 982 notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else []) 983 if arg.rule: 984 notes.append(f"accepts: {describe_rule(arg.rule)}") 985 lines += row(arg.name, annotate(arg.description, notes)) 986 sections.append(lines) 987 988 commands = [c for n in reversed(chain) for c in n._commands] 989 if commands: 990 lines = ["Required Commands:"] 991 for cmd, desc, hint in commands: 992 status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found" 993 lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc) 994 sections.append(lines) 995 996 constraints = [c for n in reversed(chain) for c in n._constraints] 997 groups = list(dict.fromkeys(o.group for o in options)) 998 for group in groups: 999 lines = [f"{group}:"] 1000 for opt in (o for o in options if o.group == group): 1001 notes = [] 1002 if opt.required: 1003 notes.append("required") 1004 if opt.kind == "array": 1005 notes.append("multiple") 1006 if opt.secret: 1007 notes.append("secret") 1008 if opt.long in self._config: 1009 notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}") 1010 if opt.default: 1011 notes.append(f"default: {opt.default}") 1012 if opt.rule: 1013 notes.append(f"accepts: {describe_rule(opt.rule)}") 1014 for c in constraints: 1015 longs: List[str] = c["options"] # type: ignore[assignment] 1016 if opt.long not in longs: 1017 continue 1018 if c["type"] == "exclusive": 1019 notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long)) 1020 elif c["type"] == "requires" and longs[0] == opt.long: 1021 notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:])) 1022 elif c["type"] == "oneOf": 1023 notes.append("one of: " + ", ".join(f"--{o}" for o in longs)) 1024 lines += row(opt.label, annotate(opt.description, notes)) 1025 sections.append(lines) 1026 1027 if node.epilog: 1028 sections.append(node.epilog.rstrip("\n").split("\n")) 1029 1030 text = "\n\n".join("\n".join(s) for s in sections) 1031 return "\n".join(line.rstrip() for line in text.split("\n")) + "\n" 1032 1033 def json_schema(self) -> str: 1034 """JSON description of the CLI (spec section 8).""" 1035 self._ensure_help() 1036 return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False) 1037 1038 def _schema_node(self) -> Dict[str, object]: 1039 def type_of(o: _Option) -> str: 1040 r = o.rule 1041 if o.kind == "flag" or r == "bool": 1042 return "boolean" 1043 if r.startswith("int") or r == "port": 1044 return "integer" 1045 if r.startswith("float"): 1046 return "number" 1047 if r.startswith("choice:"): 1048 return "choice" 1049 return "path" if _is_path_rule(r) else "string" 1050 1051 return { 1052 **({"name": self._word} if self._parent else {}), 1053 "description": self.description, 1054 "epilog": self.epilog, 1055 "arguments": [{ 1056 "name": a.name, "description": a.description, "required": not a.variadic and not a.default, 1057 "isVariadic": a.variadic, "default": a.default, "validation": a.rule, 1058 } for a in self._args], 1059 "options": [{ 1060 "name": o.long, "shortName": o.short, "variableName": o.var, "description": o.description, 1061 "default": "false" if o.kind == "flag" else o.default, "group": o.group, "type": type_of(o), 1062 "isFlag": o.kind == "flag", "isArray": o.kind == "array", "required": o.required, 1063 "validation": o.rule, "choices": o.rule[7:].split(",") if o.rule.startswith("choice:") else [], 1064 "secret": o.secret, 1065 } for o in self._options], 1066 "requiredCommands": [{"command": c, "description": d, "installHint": h} for c, d, h in self._commands], 1067 "effects": self._effects, 1068 "constraints": self._constraints, 1069 "stdin": self._stdin, 1070 "stdout": self._stdout, 1071 "commands": [c._schema_node() for c in self._children], 1072 } 1073 1074 def completion_script(self, shell: str) -> Optional[str]: 1075 """Shell script that enables completion for this program (spec section 9): 1076 eval "$(prog --completion bash)". None for an unknown shell.""" 1077 template = _COMPLETION_SCRIPTS.get(shell) 1078 if template is None: 1079 return None 1080 func = re.sub(r"[^A-Za-z0-9_]", "_", self.name) 1081 return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name) 1082 1083 def completion_data(self, words: Sequence[str] = ()) -> str: 1084 """Tab-separated completion records (spec section 9). `words` are the words typed 1085 after the program name; a program with commands follows them.""" 1086 self._ensure_help() 1087 1088 def clean(s: str) -> str: 1089 return s.replace("\t", " ").replace("\n", " ") 1090 1091 lines = ["#clyops-completion 1"] 1092 node: Cli = self 1093 if self._children: 1094 skip = 0 1095 for w in words: 1096 child = next((c for c in node._children if c._word == w), None) 1097 if child is None: 1098 break 1099 node, skip = child, skip + 1 1100 if node._children and skip < len(words) and not words[skip].startswith("-"): 1101 return lines[0] + "\n" 1102 lines.append(f"skip\t{skip}") 1103 lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children] 1104 options: List[_Option] = [] 1105 current: Optional[Cli] = node 1106 while current is not None: 1107 options += current._options 1108 current = current._parent 1109 for o in options: 1110 short = f"-{o.short}" if o.short else "-" 1111 if o.kind == "flag": 1112 lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}") 1113 else: 1114 kind, values = _completion_kind(o.rule, o.search_dirs) 1115 lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}") 1116 if o.bool_like: 1117 lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}") 1118 for a in node._args: 1119 kind, values = _completion_kind(a.rule, []) 1120 arity = "variadic" if a.variadic else "single" 1121 lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}") 1122 return "\n".join(lines) + "\n"
A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys.
382 def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None, 383 env: Optional[Mapping[str, str]] = None): 384 """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ.""" 385 self.name = name or os.path.basename(sys.argv[0] or "cli") 386 self.cwd = cwd or os.getcwd() 387 self.root = os.path.normpath(os.path.join(self.cwd, root or ".")) 388 self.env = os.environ if env is None else env 389 self.description = "" 390 self.epilog = "" 391 self.values = Values() 392 self._options: List[_Option] = [] 393 self._by_long: Dict[str, _Option] = {} 394 self._by_short: Dict[str, _Option] = {} 395 self._args: List[_Arg] = [] 396 self._commands: List[tuple] = [] 397 self._config_option = "" 398 self._config_prefixes: List[str] = [] 399 self._raw: Dict[str, Union[str, List[str]]] = {} 400 self._arg_raw: Dict[str, Union[str, List[str]]] = {} 401 self._sources: Dict[str, str] = {} 402 self._config: Dict[str, tuple] = {} # key -> (value, dir) 403 self._effects: List[str] = [] 404 self._stdin: Optional[Dict[str, str]] = None 405 self._stdout: Optional[Dict[str, str]] = None 406 self._constraints: List[Dict[str, object]] = [] 407 self._children: List["Cli"] = [] 408 self._parent: Optional["Cli"] = None 409 self._word = "" 410 # The command selected by the last parse (this one when it has no commands). 411 self._selected: "Cli" = self
Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ.
415 def set_description(self, text: str) -> "Cli": 416 """Set the help description and return this CLI.""" 417 self.description = text 418 return self
Set the help description and return this CLI.
420 def set_epilog(self, text: str) -> "Cli": 421 """Set the final help text and return this CLI.""" 422 self.epilog = text 423 return self
Set the final help text and return this CLI.
425 def set_effects(self, *effects: str) -> "Cli": 426 """What running the program does: read-only, idempotent, destructive, network.""" 427 for e in effects: 428 if e not in _EFFECTS: 429 raise ValueError(f"Unknown effect '{e}'") 430 self._effects = list(effects) 431 return self
What running the program does: read-only, idempotent, destructive, network.
433 def set_stdin(self, description: str, content_type: str = "") -> "Cli": 434 """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list.""" 435 self._stdin = {"description": description, "contentType": content_type} 436 return self
What the program reads on stdin; content_type is a MIME type or a comma-separated list.
438 def set_stdout(self, description: str, content_type: str = "") -> "Cli": 439 """What the program writes on stdout; undeclared means text.""" 440 self._stdout = {"description": description, "contentType": content_type} 441 return self
What the program writes on stdout; undeclared means text.
443 def exclusive(self, *longs: str) -> "Cli": 444 """At most one of these options may be given.""" 445 return self._constraint("exclusive", longs)
At most one of these options may be given.
447 def requires(self, long: str, *longs: str) -> "Cli": 448 """When the first option is given, the others must be too.""" 449 return self._constraint("requires", (long,) + longs)
When the first option is given, the others must be too.
451 def one_of(self, *longs: str) -> "Cli": 452 """At least one of these options must be given.""" 453 return self._constraint("oneOf", longs)
At least one of these options must be given.
462 def command(self, name: str, description: str = "") -> "Cli": 463 """Register a command (spec section 1.7) and return it, to register its options and arguments on.""" 464 if self._args: 465 raise ValueError("Cannot mix commands and positional arguments") 466 if any(c._word == name for c in self._children): 467 raise ValueError(f"Duplicate command {name}") 468 child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env) 469 child.description = description 470 child._parent = self 471 child._word = name 472 self._children.append(child) 473 return child
Register a command (spec section 1.7) and return it, to register its options and arguments on.
475 @property 476 def command_path(self) -> List[str]: 477 """The command words selected by the last parse, e.g. ["db", "migrate"].""" 478 words: List[str] = [] 479 node: Optional[Cli] = self._selected 480 while node is not None and node is not self: 481 words.insert(0, node._word) 482 node = node._parent 483 return words
The command words selected by the last parse, e.g. ["db", "migrate"].
507 def set_config(self, option: str, prefixes: str) -> "Cli": 508 """`option` holds the config file path; `prefixes` is comma-separated.""" 509 self._config_option = option 510 self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()] 511 return self
option holds the config file path; prefixes is comma-separated.
513 def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli": 514 """Require an executable on PATH when parsing; description and install_hint explain how to install it.""" 515 self._commands.append((command, description, install_hint)) 516 return self
Require an executable on PATH when parsing; description and install_hint explain how to install it.
518 def set_path_search(self, long: str, dirs: Union[str, Sequence[str]]) -> "Cli": 519 """Fallback dirs (relative to root) for bare relative values of a path option.""" 520 opt = self._by_long[long] 521 items = dirs.split(":") if isinstance(dirs, str) else list(dirs) 522 opt.search_dirs = [os.path.normpath(os.path.join(self.root, d)) for d in items if d] 523 if not opt.rule: 524 opt.rule = "path" 525 return self
Fallback dirs (relative to root) for bare relative values of a path option.
527 def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "", 528 group: str = "Options", rule: str = "") -> "Cli": 529 """Register an option. `default` is a value, "flag", "optional", or "" (required).""" 530 kind = "flag" if default == "flag" else "value" 531 value = "" if default in ("flag", "optional") else default 532 return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule))
Register an option. default is a value, "flag", "optional", or "" (required).
534 def opt_array(self, var: str, long: str, short: str = "", description: str = "", 535 group: str = "Options", rule: str = "") -> "Cli": 536 """Register a repeatable option whose values accumulate into a list.""" 537 return self._add(_Option(var, long, short, "array", "", False, description, group, rule))
Register a repeatable option whose values accumulate into a list.
539 def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli": 540 """Register a positional argument. An empty default makes it required.""" 541 return self._add_arg(_Arg(name, description, default, rule, False))
Register a positional argument. An empty default makes it required.
543 def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli": 544 """Register a final positional argument that collects all remaining tokens.""" 545 return self._add_arg(_Arg(name, description, "", rule, True))
Register a final positional argument that collects all remaining tokens.
579 def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult: 580 """Parse without exiting. Values are in `self.values` when status is "ok".""" 581 argv = list(sys.argv[1:] if argv is None else argv) 582 self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {} 583 self.values = Values() 584 self._selected = self 585 self._ensure_help() 586 587 try: 588 self._scan(argv) 589 scan_error = None 590 except _ParseError as exc: 591 scan_error = str(exc) 592 if scan_error is None and self._config_option: 593 try: 594 self._load_config() 595 except _ParseError as exc: 596 scan_error = str(exc) 597 if self._raw.get("help") == "true": 598 return ParseResult("help") 599 if scan_error is not None: 600 return ParseResult("error", scan_error) 601 602 try: 603 self._resolve() 604 except (_ParseError, ValidationError) as exc: 605 return ParseResult("error", str(exc)) 606 607 missing_cmds = [c for n in reversed(self._chain()) for c in n._commands 608 if not shutil.which(c[0], path=self.env.get("PATH", ""))] 609 if missing_cmds: 610 detail = [] 611 for cmd, desc, hint in missing_cmds: 612 detail.append(f" {cmd} - {desc}") 613 if hint: 614 detail.append(f" Install: {hint}") 615 return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds), 616 False, detail) 617 618 missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)] 619 if missing: 620 return ParseResult("error", "Missing required argument(s): " + " ".join(missing)) 621 conflict = self._check_constraints() 622 if conflict: 623 return ParseResult("error", conflict) 624 return ParseResult("ok")
Parse without exiting. Values are in self.values when status is "ok".
873 def run(self, argv: Optional[Sequence[str]] = None) -> Values: 874 """Parse like a CLI: handles --help, --help-json-schema and --bash-completion, 875 prints errors and exits on failure. Returns the values.""" 876 argv = list(sys.argv[1:] if argv is None else argv) 877 head = argv[:argv.index("--")] if "--" in argv else argv 878 if "--help-json-schema" in head: 879 sys.stdout.write(self.json_schema() + "\n") 880 sys.exit(0) 881 if "--bash-completion" in head: 882 sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else [])) 883 sys.exit(0) 884 if "--completion" in head: 885 shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else "" 886 script = self.completion_script(shell) 887 if script is None: 888 die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)") 889 sys.stdout.write(script) 890 sys.exit(0) 891 892 result = self.parse(argv) 893 if result.status == "help": 894 sys.stdout.write(self.usage()) 895 sys.exit(0) 896 if result.status == "error": 897 _emit("error", result.error, force=True) 898 for line in result.detail: 899 sys.stderr.write(line + "\n") 900 if result.show_usage: 901 sys.stderr.write(self.usage()) 902 sys.exit(1) 903 return self.values
Parse like a CLI: handles --help, --help-json-schema and --bash-completion, prints errors and exits on failure. Returns the values.
907 def get(self, name: str) -> Value: 908 """Return the resolved value for a variable or argument name, or None when absent.""" 909 return self.values.get(name)
Return the resolved value for a variable or argument name, or None when absent.
911 def source(self, long: str) -> str: 912 """Where an option's value came from: cli, config, env, default or unset.""" 913 return self._sources.get(long[2:] if long.startswith("--") else long, "unset")
Where an option's value came from: cli, config, env, default or unset.
915 def is_set(self, long: str) -> bool: 916 """Return whether the option was supplied on the command line (long name, optionally prefixed with --).""" 917 return self.source(long) == "cli"
Return whether the option was supplied on the command line (long name, optionally prefixed with --).
919 def is_explicitly_set(self, long: str) -> bool: 920 """Return whether the option came from CLI, config, or environment rather than a default.""" 921 return self.source(long) in ("cli", "config", "env")
Return whether the option came from CLI, config, or environment rather than a default.
923 def values_json(self) -> str: 924 """Resolved values as JSON (spec section 10).""" 925 out: Dict[str, object] = {} 926 for o in self._chain_options(): 927 v = self.values.get(o.var) 928 out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v 929 out.update({a.name: self.values.get(a.name) for a in self._selected._args}) 930 if self._children: 931 out["command"] = self.command_path 932 return json.dumps(out, indent=2, ensure_ascii=False)
Resolved values as JSON (spec section 10).
936 def usage(self) -> str: 937 """Help text (spec section 7), for the selected command.""" 938 self._ensure_help() 939 node = self._selected 940 chain = self._chain() 941 options = self._chain_options() 942 width = self.env.get("CLYOPS_MAX_WIDTH", "") 943 max_width = int(width) if width.isdigit() and int(width) > 0 else 100 944 longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0) 945 indent = min(50, max(32, longest + 4)) 946 text_width = max(20, max_width - indent) 947 948 def row(label: str, text: str) -> List[str]: 949 left = " " + label 950 left = left.ljust(indent) if len(left) < indent else left + " " 951 lines = wrap_text(text, text_width) 952 return [left + lines[0]] + [" " * indent + line for line in lines[1:]] 953 954 def annotate(text: str, notes: List[str]) -> str: 955 return f"{text} ({', '.join(notes)})" if notes else text 956 957 sections: List[List[str]] = [] 958 usage = f"Usage: {node.name}" 959 if node._children: 960 usage += " <command>" 961 for arg in node._args: 962 usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>" 963 sections.append([usage + " [OPTIONS]"]) 964 965 if node.description: 966 sections.append(wrap_text(node.description, max_width)) 967 968 io = [] 969 for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)): 970 if decl is not None: 971 ctype = decl["contentType"] 972 io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x)) 973 if io: 974 sections.append(io) 975 976 if node._children: 977 sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)]) 978 979 if node._args: 980 lines = ["Positional Arguments:"] 981 for arg in node._args: 982 notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else []) 983 if arg.rule: 984 notes.append(f"accepts: {describe_rule(arg.rule)}") 985 lines += row(arg.name, annotate(arg.description, notes)) 986 sections.append(lines) 987 988 commands = [c for n in reversed(chain) for c in n._commands] 989 if commands: 990 lines = ["Required Commands:"] 991 for cmd, desc, hint in commands: 992 status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found" 993 lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc) 994 sections.append(lines) 995 996 constraints = [c for n in reversed(chain) for c in n._constraints] 997 groups = list(dict.fromkeys(o.group for o in options)) 998 for group in groups: 999 lines = [f"{group}:"] 1000 for opt in (o for o in options if o.group == group): 1001 notes = [] 1002 if opt.required: 1003 notes.append("required") 1004 if opt.kind == "array": 1005 notes.append("multiple") 1006 if opt.secret: 1007 notes.append("secret") 1008 if opt.long in self._config: 1009 notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}") 1010 if opt.default: 1011 notes.append(f"default: {opt.default}") 1012 if opt.rule: 1013 notes.append(f"accepts: {describe_rule(opt.rule)}") 1014 for c in constraints: 1015 longs: List[str] = c["options"] # type: ignore[assignment] 1016 if opt.long not in longs: 1017 continue 1018 if c["type"] == "exclusive": 1019 notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long)) 1020 elif c["type"] == "requires" and longs[0] == opt.long: 1021 notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:])) 1022 elif c["type"] == "oneOf": 1023 notes.append("one of: " + ", ".join(f"--{o}" for o in longs)) 1024 lines += row(opt.label, annotate(opt.description, notes)) 1025 sections.append(lines) 1026 1027 if node.epilog: 1028 sections.append(node.epilog.rstrip("\n").split("\n")) 1029 1030 text = "\n\n".join("\n".join(s) for s in sections) 1031 return "\n".join(line.rstrip() for line in text.split("\n")) + "\n"
Help text (spec section 7), for the selected command.
1033 def json_schema(self) -> str: 1034 """JSON description of the CLI (spec section 8).""" 1035 self._ensure_help() 1036 return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False)
JSON description of the CLI (spec section 8).
1074 def completion_script(self, shell: str) -> Optional[str]: 1075 """Shell script that enables completion for this program (spec section 9): 1076 eval "$(prog --completion bash)". None for an unknown shell.""" 1077 template = _COMPLETION_SCRIPTS.get(shell) 1078 if template is None: 1079 return None 1080 func = re.sub(r"[^A-Za-z0-9_]", "_", self.name) 1081 return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name)
Shell script that enables completion for this program (spec section 9): eval "$(prog --completion bash)". None for an unknown shell.
1083 def completion_data(self, words: Sequence[str] = ()) -> str: 1084 """Tab-separated completion records (spec section 9). `words` are the words typed 1085 after the program name; a program with commands follows them.""" 1086 self._ensure_help() 1087 1088 def clean(s: str) -> str: 1089 return s.replace("\t", " ").replace("\n", " ") 1090 1091 lines = ["#clyops-completion 1"] 1092 node: Cli = self 1093 if self._children: 1094 skip = 0 1095 for w in words: 1096 child = next((c for c in node._children if c._word == w), None) 1097 if child is None: 1098 break 1099 node, skip = child, skip + 1 1100 if node._children and skip < len(words) and not words[skip].startswith("-"): 1101 return lines[0] + "\n" 1102 lines.append(f"skip\t{skip}") 1103 lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children] 1104 options: List[_Option] = [] 1105 current: Optional[Cli] = node 1106 while current is not None: 1107 options += current._options 1108 current = current._parent 1109 for o in options: 1110 short = f"-{o.short}" if o.short else "-" 1111 if o.kind == "flag": 1112 lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}") 1113 else: 1114 kind, values = _completion_kind(o.rule, o.search_dirs) 1115 lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}") 1116 if o.bool_like: 1117 lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}") 1118 for a in node._args: 1119 kind, values = _completion_kind(a.rule, []) 1120 arity = "variadic" if a.variadic else "single" 1121 lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}") 1122 return "\n".join(lines) + "\n"
Tab-separated completion records (spec section 9). words are the words typed
after the program name; a program with commands follows them.
365class Values(Dict[str, Value]): 366 """Resolved values; keys are option vars and argument names, also readable as attributes.""" 367 368 def __getattr__(self, name: str) -> Value: 369 """Read a resolved key as an attribute; missing keys raise AttributeError.""" 370 try: 371 return self[name] 372 except KeyError: 373 raise AttributeError(name) from None
Resolved values; keys are option vars and argument names, also readable as attributes.
356@dataclass 357class ParseResult: 358 """Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help.""" 359 status: str # ok | help | error 360 error: str = "" 361 show_usage: bool = True 362 detail: List[str] = field(default_factory=list)
Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help.
161class ValidationError(ValueError): 162 """An invalid value reported with the validation rule and option or argument name.""" 163 pass
An invalid value reported with the validation rule and option or argument name.
166def validate(value: str, rule: str, name: str) -> Scalar: 167 """Validate `value` against `rule` and return its typed form. 168 169 Raises ValidationError with the spec's error text. 170 """ 171 def fail(msg: str): 172 raise ValidationError(f"{name} {msg}") 173 174 def check_bounds(num: float, lo: str, hi: str): 175 if lo and num < float(lo): 176 fail(f"must be >= {lo}, got {value}") 177 if hi and num > float(hi): 178 fail(f"must be <= {hi}, got {value}") 179 180 if rule == "int" or rule.startswith("int:"): 181 if not re.fullmatch(r"-?[0-9]+", value): 182 fail(f"must be an integer, got '{value}'") 183 num: Union[int, float] = int(value) 184 if rule != "int": 185 check_bounds(num, *_bounds(rule)) 186 return num 187 if rule == "float" or rule.startswith("float:"): 188 if not re.fullmatch(r"-?[0-9]*\.?[0-9]+", value): 189 fail(f"must be a number, got '{value}'") 190 num = float(value) 191 if rule != "float": 192 check_bounds(num, *_bounds(rule)) 193 return num 194 if rule.startswith("string:"): 195 length = len(value) 196 if "-" not in rule: 197 exact = rule[7:] 198 if length != int(exact): 199 fail(f"must be exactly {exact} characters, got {length}") 200 else: 201 lo, hi = _bounds(rule) 202 if lo and length < int(lo): 203 fail(f"must be at least {lo} characters, got {length}") 204 if hi and length > int(hi): 205 fail(f"must be at most {hi} characters, got {length}") 206 return value 207 if rule.startswith("choice:"): 208 choices = rule[7:].split(",") 209 if value not in choices: 210 fail(f"must be one of: {', '.join(choices)}, got '{value}'") 211 return value 212 if rule.startswith("regex:"): 213 if not re.search(rule[6:], value): 214 fail(f"does not match required pattern, got '{value}'") 215 return value 216 217 if rule == "bool": 218 b = _bool_word(value) 219 if b is None: 220 fail(f"must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'") 221 return bool(b) 222 if rule == "port": 223 if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535: 224 fail(f"must be a valid port (1-65535), got '{value}'") 225 return int(value) 226 if rule == "ip": 227 if not (re.fullmatch(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value) 228 or re.fullmatch(r"([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value)): 229 fail(f"must be a valid IP address, got '{value}'") 230 elif rule == "hostname": 231 if not re.fullmatch(_HOSTNAME, value): 232 fail(f"must be a valid hostname, got '{value}'") 233 elif rule == "url": 234 if not re.fullmatch(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?", value, re.S): 235 fail(f"must be a valid URL, got '{value}'") 236 elif rule == "email": 237 if not re.fullmatch(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value): 238 fail(f"must be a valid email address, got '{value}'") 239 elif rule == "uuid": 240 if not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value): 241 fail(f"must be a valid UUID, got '{value}'") 242 elif rule == "date:YYYY-MM-DD": 243 if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value): 244 fail(f"must be in YYYY-MM-DD format, got '{value}'") 245 elif rule == "file:exists": 246 if not os.path.isfile(value): 247 fail(f"file does not exist: {value}") 248 elif rule == "file:readable": 249 if not os.access(value, os.R_OK): 250 fail(f"file is not readable: {value}") 251 elif rule == "file:writable": 252 if os.path.lexists(value): 253 if not os.access(value, os.W_OK): 254 fail(f"file is not writable: {value}") 255 else: 256 directory = os.path.dirname(value) 257 if not (os.path.isdir(directory) and os.access(directory, os.W_OK)): 258 fail(f"directory is not writable: {directory}") 259 elif rule == "dir:exists": 260 if not os.path.isdir(value): 261 fail(f"directory does not exist: {value}") 262 elif rule == "dir:writable": 263 if not (os.path.isdir(value) and os.access(value, os.W_OK)): 264 fail(f"directory does not exist or is not writable: {value}") 265 return value
Validate value against rule and return its typed form.
Raises ValidationError with the spec's error text.
268def resolve_path(value: str, base: str, search_dirs: Sequence[str] = ()) -> str: 269 """Resolve a path value against `base` (spec section 6).""" 270 if not value or value in ("-", "disabled", "optional"): 271 return value 272 if os.path.isabs(value) or re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", value): 273 return value 274 from_base = os.path.normpath(os.path.join(base, value)) 275 bare = not re.match(r"\.\.?(/|$)", value) 276 if bare and search_dirs and not os.path.exists(from_base): 277 for directory in search_dirs: 278 candidate = os.path.normpath(os.path.join(directory, value)) 279 if os.path.exists(candidate): 280 return candidate 281 return from_base
Resolve a path value against base (spec section 6).
135def describe_rule(rule: str) -> str: 136 """Help text for a validation rule (spec section 5).""" 137 fixed = { 138 "int": "integer", "float": "number", "string": "text", "path": "path", "ip": "IP address", 139 "hostname": "hostname", "url": "URL", "port": "port: 1-65535", "email": "email address", "uuid": "UUID", 140 "bool": "true/false, yes/no, 1/0, on/off", "date:YYYY-MM-DD": "date: YYYY-MM-DD", 141 "file:exists": "existing file", "file:readable": "readable file", "file:writable": "writable file", 142 "dir:exists": "existing directory", "dir:writable": "writable directory", 143 } 144 if rule in fixed: 145 return fixed[rule] 146 for prefix, noun, suffix in (("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")): 147 if rule.startswith(prefix): 148 if "-" not in rule: 149 return f"{noun}: {rule[len(prefix):]}{suffix}" 150 lo, hi = _bounds(rule) 151 if lo and hi: 152 return f"{noun}: {lo}-{hi}{suffix}" 153 return f"{noun}: >={lo}{suffix}" if lo else f"{noun}: <={hi}{suffix}" 154 if rule.startswith("choice:"): 155 return "choices: " + ", ".join(rule[7:].split(",")) 156 if rule.startswith("regex:"): 157 return "pattern: " + rule[6:] 158 return rule
Help text for a validation rule (spec section 5).
284def wrap_text(text: str, width: int) -> List[str]: 285 """Greedy word wrap that keeps existing line breaks (spec section 7).""" 286 out: List[str] = [] 287 for original in re.split(r"\r?\n", text): 288 words = original.split() 289 if not words: 290 out.append("") 291 continue 292 line = "" 293 for word in words: 294 if not line: 295 line = word 296 elif len(line) + 1 + len(word) <= width: 297 line += " " + word 298 else: 299 out.append(line) 300 line = word 301 out.append(line) 302 return out
Greedy word wrap that keeps existing line breaks (spec section 7).
58def info(msg: str) -> None: 59 """Write a timestamped informational message to stderr.""" 60 _emit("info", msg)
Write a timestamped informational message to stderr.
63def warn(msg: str) -> None: 64 """Write a timestamped warning to stderr.""" 65 _emit("warning", msg)
Write a timestamped warning to stderr.
Write a timestamped error to stderr.
73def success(msg: str) -> None: 74 """Write a timestamped success message to stderr.""" 75 _emit("success", msg)
Write a timestamped success message to stderr.
78def die(code: int, msg: str) -> NoReturn: 79 """Print an error and exit with `code`. Never suppressed.""" 80 _emit("error", msg, force=True) 81 sys.exit(code)
Print an error and exit with code. Never suppressed.
43def set_silent(value: bool) -> None: 44 """Suppress info/warn/error/success output (die and parse errors still print).""" 45 global _silent 46 _silent = value
Suppress info/warn/error/success output (die and parse errors still print).