Coverage for yuio / cli.py: 100%
2 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8from __future__ import annotations
10__all__ = []
12# from __future__ import annotations
14# import abc
15# import re
16# import sys
17# from dataclasses import dataclass
19# import yuio
20# import yuio.complete
21# import yuio.parse
22# import yuio.term
23# from yuio import _typing as _t
25# __all__ = []
27# T = _t.TypeVar("T")
29# _PREFIX_CHARS = tuple("-+")
31# _SHORT_FLAG_RE = r"^[-+][a-zA-Z0-9]$"
32# _LONG_FLAG_RE = r"^[-+][a-zA-Z0-9_+/-]+$"
34# _NUM_RE = r"""(?x)
35# ^
36# [+-]?
37# (?:
38# (?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?
39# |0[bB][01]+
40# |0[oO][0-7]+
41# |0[xX][0-9a-fA-F]+
42# )
43# $
44# """
46# NArgs: _t.TypeAlias = int | _t.Literal["?", "*", "+"]
47# """
48# Type alias for nargs.
50# """
53# class ArgumentError(yuio.parse.ParsingError):
54# pass
57# @dataclass(kw_only=True)
58# class Option(abc.ABC):
59# """
60# Base class for a CLI option.
62# """
64# metavar: str = "<value>"
65# """
66# Option's meta variable, used for displaying help messages.
68# """
70# completer: yuio.complete.Completer | None = None
71# """
72# Option's completer, used for generating completion scripts.
74# """
76# usage: yuio.Group | bool = True
77# """
78# Specifies whether this option should be displayed in CLI usage.
80# """
82# help: str = ""
83# """
84# Help message for an option.
86# """
88# flags: list[str] | yuio.Positional
89# """
90# Flags corresponding to this option.
92# """
94# nargs: NArgs
95# """
96# How many arguments this option takes.
98# """
100# mutually_exclusive_group: None | _t.Any = None
101# """
102# Index of a mutually exclusive group.
104# """
106# dest: str
107# """
108# Key where to store parsed argument.
110# """
112# @abc.abstractmethod
113# def process(
114# self,
115# cli_parser: CliParser,
116# name: str,
117# args: str | list[str],
118# ns: _t.MutableMapping[str, _t.Any],
119# ):
120# """
121# Process this argument.
123# This method is called every time an option is encountered
124# on the command line. It should parse option's args and merge them
125# with previous values, if there are any.
127# When option's arguments are passed separately (i.e. ``--opt arg1 arg2 ...``),
128# ``args`` is given as a list. List's length is checked against ``nargs``
129# before this method is called.
131# When option's arguments are passed as an inline value (i.e. ``--long=arg``
132# or ``-Sarg``), the ``args`` is given as a string. ``nargs`` are not checked
133# in this case, giving you an opportunity to handle inline option
134# however you like.
136# :param cli_parser:
137# CLI parser instance that's doing the parsing. Not to be confused with
138# :class:`yuio.parse.Parser`.
139# :param name:
140# name of the flag that set this option. For positional options, an empty
141# string is passed.
142# :param args:
143# option arguments, see above.
144# :param ns:
145# namespace where parsed arguments should be stored.
146# :raises:
147# :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
149# """
151# def format_usage(self) -> yuio.term.ColorizedString:
152# """
153# Allows customizing how this option looks in CLI usage.
155# """
157# return yuio.term.ColorizedString()
159# def format_toc_usage(self) -> yuio.term.ColorizedString:
160# """
161# Allows customizing how this option looks in CLI help.
163# """
165# return yuio.term.ColorizedString()
168# @dataclass(kw_only=True)
169# class SubCommandOption(Option):
170# """
171# A positional option for subcommands.
173# """
175# subcommands: dict[str, Command]
176# """
177# All subcommands.
179# """
181# ns_dest: str
182# """
183# Where to save subcommand's namespace.
185# """
187# ns_ctor: _t.Callable[[], _t.MutableMapping[str, _t.Any]]
188# """
189# A constructor that will be called to create namespace for subcommand's arguments.
191# """
193# def __init__(
194# self,
195# *,
196# subcommands: dict[str, Command],
197# subcommand_required: bool,
198# ns_dest: str,
199# ns_ctor: _t.Callable[[], _t.MutableMapping[str, _t.Any]],
200# **kwargs,
201# ):
202# super().__init__(
203# metavar="<subcommand>",
204# completer=None,
205# usage=True,
206# flags=[],
207# nargs=1 if subcommand_required else "?",
208# mutually_exclusive_group=None,
209# **kwargs,
210# )
212# self.subcommands = subcommands
213# self.ns_dest = ns_dest
214# self.ns_ctor = ns_ctor
216# def process(
217# self,
218# cli_parser: CliParser,
219# name: str,
220# args: str | list[str],
221# ns: _t.MutableMapping[str, _t.Any],
222# ):
223# assert isinstance(args, list) and len(args) == 1
224# subcommand = self.subcommands.get(args[0])
225# if subcommand is None:
226# allowed_subcommands = ", ".join(sorted(self.subcommands))
227# raise ArgumentError(
228# f"unknown subcommand {args[0]}, can be one of {allowed_subcommands}"
229# )
230# ns[self.dest] = subcommand.name
231# ns[self.ns_dest] = new_ns = self.ns_ctor()
232# cli_parser._load_command(subcommand, new_ns)
235# @dataclass
236# class BoolOption(Option):
237# """
238# An option with flags to set :data:`True` and :data:`False` values.
240# """
242# parser: yuio.parse.Parser[bool]
243# """
244# A parser used to parse bools when an explicit value is provided.
246# """
248# neg_flags: list[str]
249# """
250# List of flag names that negate this boolean option.
252# """
254# def __init__(
255# self,
256# *,
257# flags: list[str],
258# neg_flags: list[str],
259# parser: yuio.parse.Parser[bool] | None = None,
260# **kwargs,
261# ):
262# self.parser = parser or yuio.parse.Bool()
263# self.neg_flags = neg_flags
265# super().__init__(
266# completer=None,
267# flags=flags + neg_flags,
268# nargs=0,
269# **kwargs,
270# )
272# def process(
273# self,
274# cli_parser: CliParser,
275# name: str,
276# args: str | list[str],
277# ns: _t.MutableMapping[str, _t.Any],
278# ):
279# if name in self.neg_flags:
280# if isinstance(args, str):
281# raise ArgumentError(f"{name} expected 0 arguments, got 1")
282# ns[self.dest] = False
283# elif isinstance(args, str):
284# ns[self.dest] = self.parser.parse(args)
285# else:
286# ns[self.dest] = True
289# @dataclass
290# class ParseOneOption(Option, _t.Generic[T]):
291# """
292# An option with a single argument that uses Yuio parser.
294# """
296# parser: yuio.parse.Parser[T]
297# """
298# A parser used to parse bools when an explicit value is provided.
300# """
302# merge: _t.Callable[[T, T], T] | None
303# """
304# Function to merge previous and new value.
306# """
308# def __init__(
309# self,
310# *,
311# parser: yuio.parse.Parser[T],
312# merge: _t.Callable[[T, T], T] | None = None,
313# **kwargs,
314# ):
315# self.parser = parser
316# self.merge = merge
318# super().__init__(
319# nargs=1,
320# **kwargs,
321# )
323# if self.completer is None:
324# self.completer = self.parser.completer()
326# def process(
327# self,
328# cli_parser: CliParser,
329# name: str,
330# args: str | list[str],
331# ns: _t.MutableMapping[str, _t.Any],
332# ):
333# if not isinstance(args, str):
334# args = args[0]
335# try:
336# value = self.parser.parse(args)
337# except yuio.parse.ParsingError as e:
338# raise e.with_prefix("Error in <c flag>%s</c>:", name) from None
339# if self.merge and self.dest in ns:
340# ns[self.dest] = self.merge(ns[self.dest], value)
341# else:
342# ns[self.dest] = value
345# @dataclass
346# class ParseManyOption(Option, _t.Generic[T]):
347# """
348# An option with a multiple arguments that uses Yuio parser.
350# """
352# parser: yuio.parse.Parser[T]
353# """
354# A parser used to parse bools when an explicit value is provided.
356# """
358# merge: _t.Callable[[T, T], T] | None
359# """
360# Function to merge previous and new value.
362# """
364# def __init__(
365# self,
366# *,
367# parser: yuio.parse.Parser[T],
368# merge: _t.Callable[[T, T], T] | None = None,
369# **kwargs,
370# ):
371# self.parser = parser
372# self.merge = merge
374# super().__init__(
375# nargs=self.parser.get_nargs(),
376# **kwargs,
377# )
379# if self.completer is None:
380# self.completer = self.parser.completer()
382# def process(
383# self,
384# cli_parser: CliParser,
385# name: str,
386# args: str | list[str],
387# ns: _t.MutableMapping[str, _t.Any],
388# ):
389# try:
390# if isinstance(args, str):
391# value = self.parser.parse(args)
392# else:
393# value = self.parser.parse_many(args)
394# except yuio.parse.ParsingError as e:
395# raise e.with_prefix("Error in <c flag>%s</c>:", name) from None
396# if self.merge and self.dest in ns:
397# ns[self.dest] = self.merge(ns[self.dest], value)
398# else:
399# ns[self.dest] = value
402# @dataclass
403# class ConstOption(Option, _t.Generic[T]):
404# """
405# An option with no arguments that stores a constant to namespace.
407# """
409# const: T
410# """
411# Constant that will be stored.
413# """
415# merge: _t.Callable[[T, T], T] | None
416# """
417# Function to merge previous and new value.
419# """
421# def __init__(
422# self,
423# *,
424# const: T,
425# merge: _t.Callable[[T, T], T] | None = None,
426# **kwargs,
427# ):
428# self.const = const
429# self.merge = merge
431# super().__init__(
432# nargs=0,
433# **kwargs,
434# )
436# def process(
437# self,
438# cli_parser: CliParser,
439# name: str,
440# args: str | list[str],
441# ns: _t.MutableMapping[str, _t.Any],
442# ):
443# if isinstance(args, str):
444# raise ArgumentError(f"{name} expected 0 arguments, got 1")
446# if self.merge and self.dest in ns:
447# ns[self.dest] = self.merge(ns[self.dest], self.const)
448# else:
449# ns[self.dest] = self.const
452# @dataclass
453# class CountOption(ConstOption[int]):
454# """
455# An option that counts number of its appearances on the command line.
457# """
459# def __init__(self, **kwargs):
460# super().__init__(**kwargs, const=1, merge=lambda l, r: l + r)
463# @dataclass
464# class StoreTrueOption(ConstOption[bool]):
465# """
466# An option that stores :data:`True` to namespace.
468# """
470# def __init__(self, **kwargs):
471# super().__init__(**kwargs, const=True)
474# @dataclass
475# class StoreFalseOption(ConstOption[bool]):
476# """
477# An option that stores :data:`False` to namespace.
479# """
481# def __init__(self, **kwargs):
482# super().__init__(**kwargs, const=False)
485# @dataclass
486# class Command:
487# """
488# Data about CLI interface of a single command or subcommand.
490# """
492# name: str
493# """
494# Name of this command.
496# """
498# desc: str
499# """
500# Long description for a command.
502# """
504# help: str
505# """
506# Help message displayed when listing subcommands.
508# """
510# options: list[Option]
511# """
512# Options for this command.
514# """
516# subcommands: dict[str, Command]
517# """
518# Last positional option can be a sub-command.
520# This is a map from subcommand's name or alias to subcommand's implementation.
522# Subcommand's implementation is either a :class:`Command` or a callable that takes
523# name/alias which invoked a subcommand, and returns a :class:`Command` instance.
525# The latter is especially useful to set up command's options and bind them
526# to a namespace.
528# """
530# subcommand_required: bool
531# """
532# Whether subcommand is required or optional.
534# """
536# def make_subcommand_option(self) -> SubCommandOption | None:
537# """
538# Turn :attr:`~Command.subcommands` and :attr:`~Command.subcommand_required`
539# into a :class:`SubCommandOption`.
541# Return :data:`None` if this command doesn't have any subcommands.
543# """
545# if not self.subcommands:
546# return None
547# else:
548# return SubCommandOption(
549# subcommands=self.subcommands,
550# subcommand_required=self.subcommand_required,
551# help=self.help,
552# dest="subcommand",
553# ns_dest="subcommand_data",
554# ns_ctor=dict,
555# )
558# @dataclass
559# class _BoundOption:
560# wrapped: Option
561# ns: _t.MutableMapping[str, _t.Any]
563# @property
564# def metavar(self):
565# return self.wrapped.metavar
567# @property
568# def completer(self):
569# return self.wrapped.completer
571# @property
572# def usage(self):
573# return self.wrapped.usage
575# @property
576# def flags(self):
577# return self.wrapped.flags
579# @property
580# def nargs(self):
581# return self.wrapped.nargs
583# @property
584# def mutually_exclusive_group(self):
585# return self.wrapped.mutually_exclusive_group
587# @property
588# def dest(self):
589# return self.wrapped.dest
592# class CliParser:
593# """
594# CLI arguments parser.
596# :param command:
597# root command.
599# """
601# def __init__(self, command: Command, ns: _t.MutableMapping[str, _t.Any]) -> None:
602# self._root_command = command
603# self._allow_abbrev = True
605# self._seen_mutex_groups: dict[_t.Any, tuple[_BoundOption, str]] = {}
607# self._known_long_args: dict[str, _BoundOption] = {}
608# self._known_short_args: dict[str, _BoundOption] = {}
610# self._current_positional: int = 0
611# self._current_positional_args: list[str] = []
613# self._current_flag: _BoundOption | None = None
614# self._current_flag_name: str = ""
615# self._current_flag_args: list[str] = []
617# self._positionals = []
618# self._current_positional = 0
620# self._load_command(command, ns)
622# def _load_command(self, command: Command, ns: _t.MutableMapping[str, _t.Any]):
623# # All pending flags and positionals should'be been flushed by now.
624# assert self._current_flag is None
625# assert self._current_positional == len(self._positionals)
627# # Update known flags and positionals.
628# self._positionals = []
629# for option in command.options:
630# if option.flags is yuio.POSITIONAL:
631# self._positionals.append(option)
632# else:
633# for flag in option.flags:
634# if _is_short(flag):
635# dest = self._known_short_args
636# else:
637# dest = self._known_short_args
638# dest[flag] = _BoundOption(option, ns)
639# if subcommand := command.make_subcommand_option():
640# self._positionals.append(subcommand)
641# self._current_positional = 0
643# def parse(self, args: list[str] | None):
644# """
645# Parse arguments and invoke their actions.
647# :param args:
648# CLI arguments, not including the program name (i.e. the first argument).
649# If :data:`None`, use :data:`sys.argv` instead.
650# :returns:
651# subcommand path, starting with the name of the root command,
652# and namespace filled with parsing results.
653# :raises:
654# :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
656# """
658# if args is None:
659# args = sys.argv[1:]
661# allow_flags = True
663# for arg in args:
664# # Handle `--`.
665# if arg == "--" and allow_flags:
666# self._flush_flag()
667# allow_flags = False
668# continue
670# # Check what we have here.
671# if allow_flags:
672# result = self._detect_flag(arg, accept_negative_numbers=False)
673# else:
674# result = None
676# if result is None:
677# # This not a flag. Can be an argument to a positional/flag option.
678# self._handle_positional(arg)
679# else:
680# # This is a flag.
681# options, inline_arg = result
682# self._handle_flags(options, inline_arg)
684# self._flush_flag()
685# self._flush_positional()
687# def _detect_flag(
688# self, arg: str, accept_negative_numbers: bool
689# ) -> tuple[list[tuple[_BoundOption, str]], str | None] | None:
690# if not arg.startswith(_PREFIX_CHARS):
691# # This is a positional.
692# return None
694# # Detect long flag.
695# if "=" in arg:
696# long_arg, long_inline_arg = arg.split("=", maxsplit=1)
697# else:
698# long_arg, long_inline_arg = arg, None
699# if long_opt := self._known_long_args.get(long_arg):
700# return [(long_opt, long_arg)], long_inline_arg
702# # This can be an abbreviated long flag or a short flag.
704# # Try detecting short flags first.
705# prefix_char = arg[0]
706# short_opts: list[tuple[_BoundOption, str]] = []
707# short_inline_arg = None
708# unknown_ch = None
709# for i, ch in enumerate(arg[1:]):
710# if ch == "=":
711# # Short flag with explicit argument.
712# short_inline_arg = arg[i + 2 :]
713# break
714# elif short_opts and short_opts[-1][0].nargs != 0:
715# # Short flag with implicit argument.
716# short_inline_arg = arg[i + 1 :]
717# break
718# elif short_opt := self._known_short_args.get(prefix_char + ch):
719# # Short flag, arguments may follow.
720# short_opts.append((short_opt, prefix_char + ch))
721# else:
722# # Unknown short flag. Will try parsing as abbreviated long flag next.
723# unknown_ch = ch
724# break
726# # Try as abbreviated long flags.
727# candidates = []
728# if self._allow_abbrev:
729# for candidate in self._known_long_args:
730# if candidate.startswith(long_arg):
731# candidates.append(candidate)
732# if len(candidates) == 1:
733# candidate = candidates[0]
734# return [(self._known_long_args[candidate], candidate)], long_inline_arg
736# # Try as signed int.
737# if re.match(_NUM_RE, arg):
738# # This is a positional.
739# return None
741# # Exhausted all options, raise an error.
742# if candidates:
743# raise ArgumentError(
744# "Unknown flag <c flag>%s</c>, can be "
745# + "".join([""] * len(candidates)),
746# long_arg,
747# *candidates,
748# )
749# elif unknown_ch:
750# raise ArgumentError(
751# "Unknown short option {prefix_char}{short_inline_arg} in argument {arg}"
752# )
753# else:
754# raise ArgumentError("Unknown flag {arg}")
756# def _handle_positional(self, arg: str):
757# if self._current_flag is not None:
758# # This is an argument for a flag option.
759# self._current_flag_args.append(arg)
760# nargs = self._current_flag.nargs
761# if nargs == "?" or (
762# isinstance(nargs, int) and len(self._current_flag_args) == nargs
763# ):
764# self._flush_flag() # This flag is full.
765# else:
766# # This is an argument for a positional option.
767# if self._current_positional >= len(self._positionals):
768# raise ArgumentError(f"unexpected positional argument {arg}")
769# self._current_positional_args.append(arg)
770# nargs = self._positionals[self._current_positional].nargs
771# if nargs == "?" or (
772# isinstance(nargs, int) and len(self._current_positional_args) == nargs
773# ):
774# self._flush_positional() # This positional is full.
776# def _handle_flags(
777# self, options: list[tuple[_BoundOption, str]], inline_arg: str | None
778# ):
779# # If we've seen another flag before this one, and we were waiting
780# # for that flag's arguments, flush them now.
781# self._flush_flag()
783# # Handle short flags in multi-arg sequence, i.e. `-li` -> `-l -c`
784# for opt, name in options[:-1]:
785# self._eval_option(opt, name, [])
787# # Handle the last short flag in multi-arg sequence.
788# opt, name = options[-1]
789# if inline_arg is not None:
790# # Flag with an inline argument, i.e. `-Xfoo`/`-X=foo` -> `-X foo`
791# self._eval_option(opt, name, inline_arg)
792# else:
793# self._push_flag(opt, name)
795# def _flush_positional(self):
796# if self._current_positional >= len(self._positionals):
797# return
798# opt, args = (
799# self._positionals[self._current_positional],
800# self._current_positional_args,
801# )
803# self._current_positional += 1
804# self._current_positional_args = []
806# self._eval_option(opt, "", args)
808# def _flush_flag(self):
809# if self._current_flag is None:
810# return
812# opt, name, args = (
813# self._current_flag,
814# self._current_flag_name,
815# self._current_flag_args,
816# )
818# self._current_flag = None
819# self._current_flag_name = ""
820# self._current_flag_args = []
822# self._eval_option(opt, name, args)
824# def _push_flag(self, opt: _BoundOption, name: str):
825# assert self._current_flag is None
827# if opt.nargs == 0:
828# # Flag without arguments, handle it right now.
829# self._eval_option(opt, name, [])
830# else:
831# # Flag with possible arguments, save it. If we see a non-flag later,
832# # it will be added to this flag's arguments.
833# self._current_flag = opt
834# self._current_flag_name = name
835# self._current_flag_args = []
837# def _eval_option(self, opt: _BoundOption, name: str, args: str | list[str]):
838# metavar = name or opt.metavar
839# if opt.mutually_exclusive_group is not None:
840# if seen := self._seen_mutex_groups.get(opt.mutually_exclusive_group):
841# prev_opt, prev_name = seen
842# prev_name = prev_name or prev_opt.metavar
843# raise ArgumentError(f"{metavar} can't be given with option {prev_name}")
844# self._seen_mutex_groups[opt.mutually_exclusive_group] = opt, name
846# if isinstance(args, list):
847# if opt.nargs == "?":
848# if len(args) > 1:
849# raise ArgumentError(
850# f"{metavar} expected at most 1 argument, got {len(args)}"
851# )
852# elif opt.nargs == "+":
853# if not args:
854# raise ArgumentError(
855# f"{metavar} requires at least one argument, got 0"
856# )
857# elif opt.nargs != "*":
858# if len(args) != opt.nargs:
859# s = "" if opt.nargs == 1 else "s"
860# raise ArgumentError(
861# f"{metavar} expected {opt.nargs} argument{s}, got {len(args)}"
862# )
864# opt.wrapped.process(self, name, args, opt.ns)
867# def _is_short(flag: str):
868# if not flag.startswith(_PREFIX_CHARS):
869# pchars = " or ".join(map(repr, _PREFIX_CHARS))
870# raise TypeError(f"flag {flag!r} should start with {pchars}")
871# if len(flag) == 2:
872# if not re.match(_SHORT_FLAG_RE, flag):
873# raise TypeError(f"invalid short flag {flag!r}")
874# return True
875# elif len(flag) == 1:
876# raise TypeError(f"flag {flag!r} is too short")
877# else:
878# if not re.match(_LONG_FLAG_RE, flag):
879# raise TypeError(f"invalid long flag {flag!r}")
880# return False