summaryrefslogtreecommitdiff
path: root/src
diff options
authorEmmanuel Arias <eamanu@yaerobi.com>2023-01-05 22:48:42 -0300
committergit-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com>2023-01-07 10:34:05 +0000
commit86a409f1178707fbcb4e098be902fc0e3907c5bb (patch)
tree554d6ec6f4b743d24b34c8be6c0149d410b2fbd1 /src
parent8a4826ea4b65099cce43cd0c72de43b0e56f1e29 (diff)
2.0.1-1 (patches unapplied)import/2.0.1-1
Imported using git-ubuntu import.
Notes
Notes: * New upstream version. * d/copyright: Add copyright year for upstream files according to LICENSE. * d/control: Bump Standards-Version to 4.6.2 (from 4.6.1.1; no further changes). * d/patches: Remove 0001-change_regex_string_to_less_permissive_one.patch. This security issue was fixed in upstream. * d/patches: Add patch to replace the use of rapidfuzz in favor of pylev. This is a temporal patch until rapidfuzz be in Debian. * d/tests/control: Add python3-pylev as Dependency.
Diffstat (limited to 'src')
-rw-r--r--src/cleo/__init__.py4
-rw-r--r--src/cleo/_compat.py15
-rw-r--r--src/cleo/_utils.py107
-rw-r--r--src/cleo/application.py648
-rw-r--r--src/cleo/color.py150
-rw-r--r--src/cleo/commands/__init__.py0
-rw-r--r--src/cleo/commands/base_command.py146
-rw-r--r--src/cleo/commands/command.py354
-rw-r--r--src/cleo/commands/completions/__init__.py0
-rw-r--r--src/cleo/commands/completions/templates.py125
-rw-r--r--src/cleo/commands/completions_command.py305
-rw-r--r--src/cleo/commands/help_command.py52
-rw-r--r--src/cleo/commands/list_command.py34
-rw-r--r--src/cleo/cursor.py108
-rw-r--r--src/cleo/descriptors/__init__.py0
-rw-r--r--src/cleo/descriptors/application_description.py88
-rw-r--r--src/cleo/descriptors/descriptor.py51
-rw-r--r--src/cleo/descriptors/text_descriptor.py287
-rw-r--r--src/cleo/events/__init__.py0
-rw-r--r--src/cleo/events/console_command_event.py34
-rw-r--r--src/cleo/events/console_error_event.py43
-rw-r--r--src/cleo/events/console_event.py30
-rw-r--r--src/cleo/events/console_events.py21
-rw-r--r--src/cleo/events/console_signal_event.py28
-rw-r--r--src/cleo/events/console_terminate_event.py28
-rw-r--r--src/cleo/events/event.py16
-rw-r--r--src/cleo/events/event_dispatcher.py101
-rw-r--r--src/cleo/exceptions/__init__.py92
-rw-r--r--src/cleo/formatters/__init__.py0
-rw-r--r--src/cleo/formatters/formatter.py212
-rw-r--r--src/cleo/formatters/style.py70
-rw-r--r--src/cleo/formatters/style_stack.py41
-rw-r--r--src/cleo/helpers.py42
-rw-r--r--src/cleo/io/__init__.py0
-rw-r--r--src/cleo/io/buffered_io.py55
-rw-r--r--src/cleo/io/inputs/__init__.py0
-rw-r--r--src/cleo/io/inputs/argument.py68
-rw-r--r--src/cleo/io/inputs/argv_input.py303
-rw-r--r--src/cleo/io/inputs/definition.py228
-rw-r--r--src/cleo/io/inputs/input.py179
-rw-r--r--src/cleo/io/inputs/option.py97
-rw-r--r--src/cleo/io/inputs/string_input.py18
-rw-r--r--src/cleo/io/inputs/token_parser.py114
-rw-r--r--src/cleo/io/io.py140
-rw-r--r--src/cleo/io/null_io.py19
-rw-r--r--src/cleo/io/outputs/__init__.py0
-rw-r--r--src/cleo/io/outputs/buffered_output.py62
-rw-r--r--src/cleo/io/outputs/null_output.py60
-rw-r--r--src/cleo/io/outputs/output.py121
-rw-r--r--src/cleo/io/outputs/section_output.py106
-rw-r--r--src/cleo/io/outputs/stream_output.py152
-rw-r--r--src/cleo/loaders/__init__.py0
-rw-r--r--src/cleo/loaders/command_loader.py28
-rw-r--r--src/cleo/loaders/factory_command_loader.py34
-rw-r--r--src/cleo/py.typed0
-rw-r--r--src/cleo/testers/__init__.py0
-rw-r--r--src/cleo/testers/application_tester.py76
-rw-r--r--src/cleo/testers/command_tester.py90
-rw-r--r--src/cleo/ui/__init__.py0
-rw-r--r--src/cleo/ui/choice_question.py148
-rw-r--r--src/cleo/ui/component.py6
-rw-r--r--src/cleo/ui/confirmation_question.py46
-rw-r--r--src/cleo/ui/exception_trace.py439
-rw-r--r--src/cleo/ui/progress_bar.py453
-rw-r--r--src/cleo/ui/progress_indicator.py218
-rw-r--r--src/cleo/ui/question.py273
-rw-r--r--src/cleo/ui/table.py738
-rw-r--r--src/cleo/ui/table_cell.py41
-rw-r--r--src/cleo/ui/table_cell_style.py55
-rw-r--r--src/cleo/ui/table_separator.py8
-rw-r--r--src/cleo/ui/table_style.py276
-rw-r--r--src/cleo/ui/ui.py32
72 files changed, 7915 insertions, 0 deletions
diff --git a/src/cleo/__init__.py b/src/cleo/__init__.py
new file mode 100644
index 0000000..2d2070a
--- /dev/null
+++ b/src/cleo/__init__.py
@@ -0,0 +1,4 @@
+from __future__ import annotations
+
+
+__version__ = "2.0.1"
diff --git a/src/cleo/_compat.py b/src/cleo/_compat.py
new file mode 100644
index 0000000..8263612
--- /dev/null
+++ b/src/cleo/_compat.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+import shlex
+import subprocess
+import sys
+
+
+WINDOWS = sys.platform == "win32"
+
+
+def shell_quote(token: str) -> str:
+ if WINDOWS:
+ return subprocess.list2cmdline([token])
+
+ return shlex.quote(token)
diff --git a/src/cleo/_utils.py b/src/cleo/_utils.py
new file mode 100644
index 0000000..e096099
--- /dev/null
+++ b/src/cleo/_utils.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import math
+
+from dataclasses import dataclass
+from html.parser import HTMLParser
+
+from rapidfuzz.distance import Levenshtein
+
+
+class TagStripper(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=False)
+
+ self.reset()
+ self.fed: list[str] = []
+
+ def handle_data(self, d: str) -> None:
+ self.fed.append(d)
+
+ def handle_entityref(self, name: str) -> None:
+ self.fed.append(f"&{name};")
+
+ def handle_charref(self, name: str) -> None:
+ self.fed.append(f"&#{name};")
+
+ def get_data(self) -> str:
+ return "".join(self.fed)
+
+
+def _strip(value: str) -> str:
+ s = TagStripper()
+ s.feed(value)
+ s.close()
+
+ return s.get_data()
+
+
+def strip_tags(value: str) -> str:
+ while "<" in value and ">" in value:
+ new_value = _strip(value)
+ if value.count("<") == new_value.count("<"):
+ break
+
+ value = new_value
+
+ return value
+
+
+def find_similar_names(name: str, names: list[str]) -> list[str]:
+ """
+ Finds names similar to a given command name.
+ """
+ threshold = 1e3
+ distance_by_name = {}
+
+ for actual_name in names:
+ # Get Levenshtein distance between the input and each command name
+ distance = Levenshtein.distance(name, actual_name)
+
+ is_similar = distance <= len(name) / 3
+ is_sub_string = actual_name.find(name) != -1
+
+ if is_similar or is_sub_string:
+ distance_by_name[actual_name] = (
+ distance,
+ actual_name.find(name) if is_sub_string else float("inf"),
+ )
+
+ # Only keep results with a distance below the threshold
+ distance_by_name = {
+ k: v for k, v in distance_by_name.items() if v[0] < 2 * threshold
+ }
+ # Display results with shortest distance first
+ return sorted(distance_by_name, key=lambda x: distance_by_name[x])
+
+
+@dataclass
+class TimeFormat:
+ threshold: int
+ alias: str
+ divisor: int | None = None
+
+ def apply(self, secs: float) -> str:
+ if self.divisor:
+ return f"{math.ceil(secs / self.divisor)} {self.alias}"
+ return self.alias
+
+
+_TIME_FORMATS: list[TimeFormat] = [
+ TimeFormat(1, "< 1 sec"),
+ TimeFormat(2, "1 sec"),
+ TimeFormat(60, "secs", 1),
+ TimeFormat(61, "1 min"),
+ TimeFormat(3600, "mins", 60),
+ TimeFormat(5401, "1 hr"),
+ TimeFormat(86400, "hrs", 3600),
+ TimeFormat(129601, "1 day"),
+ TimeFormat(604801, "days", 86400),
+]
+
+
+def format_time(secs: float) -> str:
+ format = next(
+ (fmt for fmt in _TIME_FORMATS if secs < fmt.threshold), _TIME_FORMATS[-1]
+ )
+ return format.apply(secs)
diff --git a/src/cleo/application.py b/src/cleo/application.py
new file mode 100644
index 0000000..3374999
--- /dev/null
+++ b/src/cleo/application.py
@@ -0,0 +1,648 @@
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import sys
+
+from contextlib import suppress
+from typing import TYPE_CHECKING
+from typing import cast
+
+from cleo.commands.completions_command import CompletionsCommand
+from cleo.commands.help_command import HelpCommand
+from cleo.commands.list_command import ListCommand
+from cleo.events.console_command_event import ConsoleCommandEvent
+from cleo.events.console_error_event import ConsoleErrorEvent
+from cleo.events.console_events import COMMAND
+from cleo.events.console_events import ERROR
+from cleo.events.console_events import TERMINATE
+from cleo.events.console_terminate_event import ConsoleTerminateEvent
+from cleo.exceptions import CleoCommandNotFoundError
+from cleo.exceptions import CleoError
+from cleo.exceptions import CleoLogicError
+from cleo.exceptions import CleoNamespaceNotFoundError
+from cleo.exceptions import CleoUserError
+from cleo.io.inputs.argument import Argument
+from cleo.io.inputs.argv_input import ArgvInput
+from cleo.io.inputs.definition import Definition
+from cleo.io.inputs.option import Option
+from cleo.io.io import IO
+from cleo.io.outputs.output import Verbosity
+from cleo.io.outputs.stream_output import StreamOutput
+from cleo.ui.ui import UI
+
+
+if TYPE_CHECKING:
+ from crashtest.solution_providers.solution_provider_repository import (
+ SolutionProviderRepository,
+ )
+
+ from cleo.commands.command import Command
+ from cleo.events.event_dispatcher import EventDispatcher
+ from cleo.io.inputs.input import Input
+ from cleo.io.outputs.output import Output
+ from cleo.loaders.command_loader import CommandLoader
+
+
+class Application:
+ """
+ An Application is the container for a collection of commands.
+
+ This class is optimized for a standard CLI environment.
+
+ Usage:
+ >>> app = Application('myapp', '1.0 (stable)')
+ >>> app.add(Command())
+ >>> app.run()
+ """
+
+ def __init__(self, name: str = "console", version: str = "") -> None:
+ self._name = name
+ self._version = version
+ self._display_name: str | None = None
+ self._terminal = shutil.get_terminal_size()
+ self._default_command = "list"
+ self._single_command = False
+ self._commands: dict[str, Command] = {}
+ self._running_command: Command | None = None
+ self._want_helps = False
+ self._definition: Definition | None = None
+ self._catch_exceptions = True
+ self._auto_exit = True
+ self._initialized = False
+ self._ui: UI | None = None
+
+ # TODO: signals support
+ self._event_dispatcher: EventDispatcher | None = None
+
+ self._command_loader: CommandLoader | None = None
+
+ self._solution_provider_repository: SolutionProviderRepository | None = None
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def display_name(self) -> str:
+ if self._display_name is None:
+ return re.sub(r"[\s\-_]+", " ", self._name).title()
+
+ return self._display_name
+
+ @property
+ def version(self) -> str:
+ return self._version
+
+ @property
+ def long_version(self) -> str:
+ if self._name:
+ if self._version:
+ return f"<b>{self.display_name}</b> (version <c1>{self._version}</c1>)"
+
+ return f"<b>{self.display_name}</b>"
+
+ return "<b>Console</b> application"
+
+ @property
+ def definition(self) -> Definition:
+ if self._definition is None:
+ self._definition = self._default_definition
+
+ if self._single_command:
+ definition = self._definition
+ definition.set_arguments([])
+
+ return definition
+
+ return self._definition
+
+ @property
+ def default_commands(self) -> list[Command]:
+ return [HelpCommand(), ListCommand(), CompletionsCommand()]
+
+ @property
+ def help(self) -> str:
+ return self.long_version
+
+ @property
+ def ui(self) -> UI:
+ if self._ui is None:
+ self._ui = self._get_default_ui()
+
+ return self._ui
+
+ @property
+ def event_dispatcher(self) -> EventDispatcher | None:
+ return self._event_dispatcher
+
+ def set_event_dispatcher(self, event_dispatcher: EventDispatcher) -> None:
+ self._event_dispatcher = event_dispatcher
+
+ def set_name(self, name: str) -> None:
+ self._name = name
+
+ def set_display_name(self, display_name: str) -> None:
+ self._display_name = display_name
+
+ def set_version(self, version: str) -> None:
+ self._version = version
+
+ def set_ui(self, ui: UI) -> None:
+ self._ui = ui
+
+ def set_command_loader(self, command_loader: CommandLoader) -> None:
+ self._command_loader = command_loader
+
+ def auto_exits(self, auto_exits: bool = True) -> None:
+ self._auto_exit = auto_exits
+
+ def is_auto_exit_enabled(self) -> bool:
+ return self._auto_exit
+
+ def are_exceptions_caught(self) -> bool:
+ return self._catch_exceptions
+
+ def catch_exceptions(self, catch_exceptions: bool = True) -> None:
+ self._catch_exceptions = catch_exceptions
+
+ def is_single_command(self) -> bool:
+ return self._single_command
+
+ def set_solution_provider_repository(
+ self, solution_provider_repository: SolutionProviderRepository
+ ) -> None:
+ self._solution_provider_repository = solution_provider_repository
+
+ def add(self, command: Command) -> Command | None:
+ self._init()
+
+ command.set_application(self)
+
+ if not command.enabled:
+ command.set_application()
+
+ return None
+
+ if not command.name:
+ raise CleoLogicError(
+ f'The command "{command.__class__.__name__}" cannot have an empty name'
+ )
+
+ self._commands[command.name] = command
+
+ for alias in command.aliases:
+ self._commands[alias] = command
+
+ return command
+
+ def get(self, name: str) -> Command:
+ self._init()
+
+ if not self.has(name):
+ raise CleoCommandNotFoundError(name)
+
+ if name not in self._commands:
+ # The command was registered in a different name in the command loader
+ raise CleoCommandNotFoundError(name)
+
+ command = self._commands[name]
+
+ if self._want_helps:
+ self._want_helps = False
+
+ help_command: HelpCommand = cast(HelpCommand, self.get("help"))
+ help_command.set_command(command)
+
+ return help_command
+
+ return command
+
+ def has(self, name: str) -> bool:
+ self._init()
+
+ if name in self._commands:
+ return True
+
+ if not self._command_loader:
+ return False
+
+ return bool(
+ self._command_loader.has(name) and self.add(self._command_loader.get(name))
+ )
+
+ def get_namespaces(self) -> list[str]:
+ namespaces = []
+ seen = set()
+
+ for command in self.all().values():
+ if command.hidden or not command.name:
+ continue
+
+ for namespace in self._extract_all_namespaces(command.name):
+ if namespace in seen:
+ continue
+
+ namespaces.append(namespace)
+ seen.add(namespace)
+
+ for alias in command.aliases:
+ for namespace in self._extract_all_namespaces(alias):
+ if namespace in seen:
+ continue
+
+ namespaces.append(namespace)
+ seen.add(namespace)
+
+ return namespaces
+
+ def find_namespace(self, namespace: str) -> str:
+ all_namespaces = self.get_namespaces()
+
+ if namespace not in all_namespaces:
+ raise CleoNamespaceNotFoundError(namespace, all_namespaces)
+
+ return namespace
+
+ def find(self, name: str) -> Command:
+ self._init()
+
+ if self.has(name):
+ return self.get(name)
+
+ all_commands = []
+ if self._command_loader:
+ all_commands += self._command_loader.names
+
+ all_commands += [
+ name for name, command in self._commands.items() if not command.hidden
+ ]
+
+ raise CleoCommandNotFoundError(name, all_commands)
+
+ def all(self, namespace: str | None = None) -> dict[str, Command]:
+ self._init()
+
+ if namespace is None:
+ commands = self._commands.copy()
+ if not self._command_loader:
+ return commands
+
+ for name in self._command_loader.names:
+ if name not in commands and self.has(name):
+ commands[name] = self.get(name)
+
+ return commands
+
+ commands = {}
+
+ for name, command in self._commands.items():
+ if namespace == self.extract_namespace(name, name.count(" ") + 1):
+ commands[name] = command
+
+ if self._command_loader:
+ for name in self._command_loader.names:
+ if (
+ name not in commands
+ and namespace == self.extract_namespace(name, name.count(" ") + 1)
+ and self.has(name)
+ ):
+ commands[name] = self.get(name)
+
+ return commands
+
+ def run(
+ self,
+ input: Input | None = None,
+ output: Output | None = None,
+ error_output: Output | None = None,
+ ) -> int:
+ try:
+ io = self.create_io(input, output, error_output)
+
+ self._configure_io(io)
+
+ try:
+ exit_code = self._run(io)
+ except BrokenPipeError:
+ # If we are piped to another process, it may close early and send a
+ # SIGPIPE: https://docs.python.org/3/library/signal.html#note-on-sigpipe
+ devnull = os.open(os.devnull, os.O_WRONLY)
+ os.dup2(devnull, sys.stdout.fileno())
+ exit_code = 0
+ except Exception as e:
+ if not self._catch_exceptions:
+ raise
+
+ self.render_error(e, io)
+
+ exit_code = 1
+ # TODO: Custom error exit codes
+ except KeyboardInterrupt:
+ exit_code = 1
+
+ if self._auto_exit:
+ sys.exit(exit_code)
+
+ return exit_code
+
+ def _run(self, io: IO) -> int:
+ if io.input.has_parameter_option(["--version", "-V"], True):
+ io.write_line(self.long_version)
+
+ return 0
+
+ definition = self.definition
+ input_definition = Definition()
+ for argument in definition.arguments:
+ if argument.name == "command":
+ argument = Argument(
+ "command",
+ required=True,
+ is_list=True,
+ description=definition.argument("command").description,
+ )
+
+ input_definition.add_argument(argument)
+
+ input_definition.set_options(definition.options)
+
+ # Errors must be ignored, full binding/validation
+ # happens later when the command is known.
+ with suppress(CleoError):
+ # Makes ArgvInput.first_argument() able to
+ # distinguish an option from an argument.
+ io.input.bind(input_definition)
+
+ name = self._get_command_name(io)
+ if io.input.has_parameter_option(["--help", "-h"], True):
+ if not name:
+ name = "help"
+ io.set_input(ArgvInput(["console", "help", self._default_command]))
+ else:
+ self._want_helps = True
+
+ if not name:
+ name = self._default_command
+ definition = self.definition
+ arguments = definition.arguments
+ if not definition.has_argument("command"):
+ arguments.append(
+ Argument(
+ "command",
+ required=False,
+ description=definition.argument("command").description,
+ default=name,
+ )
+ )
+ definition.set_arguments(arguments)
+
+ self._running_command = None
+ command = self.find(name)
+
+ self._running_command = command
+
+ if " " in name and isinstance(io.input, ArgvInput):
+ # If the command is namespaced we rearrange
+ # the input to parse it as a single argument
+ argv = io.input._tokens[:]
+
+ if io.input.script_name is not None:
+ argv.insert(0, io.input.script_name)
+
+ namespace = name.split(" ")[0]
+ index = None
+ for i, arg in enumerate(argv):
+ if arg == namespace and i > 0:
+ argv[i] = name
+ index = i
+ break
+
+ if index is not None:
+ del argv[index + 1 : index + 1 + (len(name.split(" ")) - 1)]
+
+ stream = io.input.stream
+ interactive = io.input.is_interactive()
+ io.set_input(ArgvInput(argv))
+ io.input.set_stream(stream)
+ io.input.interactive(interactive)
+
+ exit_code = self._run_command(command, io)
+ self._running_command = None
+
+ return exit_code
+
+ def _run_command(self, command: Command, io: IO) -> int:
+ if self._event_dispatcher is None:
+ return command.run(io)
+
+ # Bind before the console.command event,
+ # so the listeners have access to the arguments and options
+ try:
+ command.merge_application_definition()
+ io.input.bind(command.definition)
+ except CleoError:
+ # Ignore invalid option/arguments for now,
+ # to allow the listeners to customize the definition
+ pass
+
+ command_event = ConsoleCommandEvent(command, io)
+ error = None
+
+ try:
+ self._event_dispatcher.dispatch(command_event, COMMAND)
+
+ if command_event.command_should_run():
+ exit_code = command.run(io)
+ else:
+ exit_code = ConsoleCommandEvent.RETURN_CODE_DISABLED
+ except Exception as e:
+ error_event = ConsoleErrorEvent(command, io, e)
+ self._event_dispatcher.dispatch(error_event, ERROR)
+ error = error_event.error
+ exit_code = error_event.exit_code
+
+ if exit_code == 0:
+ error = None
+
+ terminate_event = ConsoleTerminateEvent(command, io, exit_code)
+ self._event_dispatcher.dispatch(terminate_event, TERMINATE)
+
+ if error is not None:
+ raise error
+
+ return terminate_event.exit_code
+
+ def create_io(
+ self,
+ input: Input | None = None,
+ output: Output | None = None,
+ error_output: Output | None = None,
+ ) -> IO:
+ if input is None:
+ input = ArgvInput()
+ input.set_stream(sys.stdin)
+
+ if output is None:
+ output = StreamOutput(sys.stdout)
+
+ if error_output is None:
+ error_output = StreamOutput(sys.stderr)
+
+ return IO(input, output, error_output)
+
+ def render_error(self, error: Exception, io: IO) -> None:
+ from cleo.ui.exception_trace import ExceptionTrace
+
+ trace = ExceptionTrace(
+ error, solution_provider_repository=self._solution_provider_repository
+ )
+ simple = not io.is_verbose() or isinstance(error, CleoUserError)
+ trace.render(io.error_output, simple)
+
+ def _configure_io(self, io: IO) -> None:
+ if io.input.has_parameter_option("--ansi", True):
+ io.decorated(True)
+ elif io.input.has_parameter_option("--no-ansi", True):
+ io.decorated(False)
+
+ if io.input.has_parameter_option(["--no-interaction", "-n"], True) or (
+ io.input._interactive is None
+ and io.input.stream
+ and not io.input.stream.isatty()
+ ):
+ io.interactive(False)
+
+ shell_verbosity = int(os.getenv("SHELL_VERBOSITY", 0))
+ if shell_verbosity == -1:
+ io.set_verbosity(Verbosity.QUIET)
+ elif shell_verbosity == 1:
+ io.set_verbosity(Verbosity.VERBOSE)
+ elif shell_verbosity == 2:
+ io.set_verbosity(Verbosity.VERY_VERBOSE)
+ elif shell_verbosity == 3:
+ io.set_verbosity(Verbosity.DEBUG)
+ else:
+ shell_verbosity = 0
+
+ if io.input.has_parameter_option(["--quiet", "-q"], True):
+ io.set_verbosity(Verbosity.QUIET)
+ shell_verbosity = -1
+ else:
+ if io.input.has_parameter_option("-vvv", True):
+ io.set_verbosity(Verbosity.DEBUG)
+ shell_verbosity = 3
+ elif io.input.has_parameter_option("-vv", True):
+ io.set_verbosity(Verbosity.VERY_VERBOSE)
+ shell_verbosity = 2
+ elif io.input.has_parameter_option(
+ "-v", True
+ ) or io.input.has_parameter_option("--verbose", only_params=True):
+ io.set_verbosity(Verbosity.VERBOSE)
+ shell_verbosity = 1
+
+ if shell_verbosity == -1:
+ io.interactive(False)
+
+ @property
+ def _default_definition(self) -> Definition:
+ return Definition(
+ [
+ Argument(
+ "command",
+ required=True,
+ description="The command to execute.",
+ ),
+ Option(
+ "--help",
+ "-h",
+ flag=True,
+ description=(
+ "Display help for the given command. "
+ "When no command is given display help for "
+ f"the <info>{self._default_command}</info> command."
+ ),
+ ),
+ Option(
+ "--quiet", "-q", flag=True, description="Do not output any message."
+ ),
+ Option(
+ "--verbose",
+ "-v|vv|vvv",
+ flag=True,
+ description=(
+ "Increase the verbosity of messages: "
+ "1 for normal output, 2 for more verbose "
+ "output and 3 for debug."
+ ),
+ ),
+ Option(
+ "--version",
+ "-V",
+ flag=True,
+ description="Display this application version.",
+ ),
+ Option("--ansi", flag=True, description="Force ANSI output."),
+ Option("--no-ansi", flag=True, description="Disable ANSI output."),
+ Option(
+ "--no-interaction",
+ "-n",
+ flag=True,
+ description="Do not ask any interactive question.",
+ ),
+ ]
+ )
+
+ def _get_command_name(self, io: IO) -> str | None:
+ if self._single_command:
+ return self._default_command
+
+ if "command" in io.input.arguments and io.input.argument("command"):
+ candidates: list[str] = []
+ for command_part in io.input.argument("command"):
+ if candidates:
+ candidates.append(candidates[-1] + " " + command_part)
+ else:
+ candidates.append(command_part)
+
+ for candidate in reversed(candidates):
+ if self.has(candidate):
+ return candidate
+
+ return io.input.first_argument
+
+ def extract_namespace(self, name: str, limit: int | None = None) -> str:
+ parts = name.split(" ")[:-1]
+
+ if limit is not None:
+ return " ".join(parts[:limit])
+
+ return " ".join(parts)
+
+ def _get_default_ui(self) -> UI:
+ from cleo.ui.progress_bar import ProgressBar
+
+ io = self.create_io()
+ return UI([ProgressBar(io)])
+
+ def _extract_all_namespaces(self, name: str) -> list[str]:
+ parts = name.split(" ")[:-1]
+ namespaces: list[str] = []
+
+ for part in parts:
+ if namespaces:
+ namespaces.append(namespaces[-1] + " " + part)
+ else:
+ namespaces.append(part)
+
+ return namespaces
+
+ def _init(self) -> None:
+ if self._initialized:
+ return
+
+ self._initialized = True
+
+ for command in self.default_commands:
+ self.add(command)
diff --git a/src/cleo/color.py b/src/cleo/color.py
new file mode 100644
index 0000000..6ddf6a7
--- /dev/null
+++ b/src/cleo/color.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import os
+
+from cleo.exceptions import CleoValueError
+
+
+class Color:
+
+ COLORS = {
+ "black": (30, 40),
+ "red": (31, 41),
+ "green": (32, 42),
+ "yellow": (33, 43),
+ "blue": (34, 44),
+ "magenta": (35, 45),
+ "cyan": (36, 46),
+ "light_gray": (37, 47),
+ "default": (39, 49),
+ "dark_gray": (90, 100),
+ "light_red": (91, 101),
+ "light_green": (92, 102),
+ "light_yellow": (93, 103),
+ "light_blue": (94, 104),
+ "light_magenta": (95, 105),
+ "light_cyan": (96, 106),
+ "white": (97, 107),
+ }
+
+ AVAILABLE_OPTIONS = {
+ "bold": {"set": 1, "unset": 22},
+ "dark": {"set": 2, "unset": 22},
+ "italic": {"set": 3, "unset": 23},
+ "underline": {"set": 4, "unset": 24},
+ "blink": {"set": 5, "unset": 25},
+ "reverse": {"set": 7, "unset": 27},
+ "conceal": {"set": 8, "unset": 28},
+ }
+
+ def __init__(
+ self,
+ foreground: str = "",
+ background: str = "",
+ options: list[str] | None = None,
+ ) -> None:
+ self._foreground = self._parse_color(foreground, False)
+ self._background = self._parse_color(background, True)
+
+ if options is None:
+ options = []
+
+ self._options = {}
+ for option in options:
+ if option not in self.AVAILABLE_OPTIONS:
+ raise ValueError(
+ f'"{option}" is not a valid color option. '
+ f'It must be one of {", ".join(self.AVAILABLE_OPTIONS.keys())}'
+ )
+
+ self._options[option] = self.AVAILABLE_OPTIONS[option]
+
+ def apply(self, text: str) -> str:
+ return self.set() + text + self.unset()
+
+ def set(self) -> str:
+ codes = []
+
+ if self._foreground:
+ codes.append(self._foreground)
+
+ if self._background:
+ codes.append(self._background)
+
+ for option in self._options.values():
+ codes.append(str(option["set"]))
+
+ if not codes:
+ return ""
+
+ return f'\033[{";".join(codes)}m'
+
+ def unset(self) -> str:
+ codes = []
+
+ if self._foreground:
+ codes.append("39")
+
+ if self._background:
+ codes.append("49")
+
+ for option in self._options.values():
+ codes.append(str(option["unset"]))
+
+ if not codes:
+ return ""
+
+ return f'\033[{";".join(codes)}m'
+
+ def _parse_color(self, color: str, background: bool) -> str:
+ if not color:
+ return ""
+
+ if color.startswith("#"):
+ color = color[1:]
+
+ if len(color) == 3:
+ color = color[0] * 2 + color[1] * 2 + color[2] * 2
+
+ if len(color) != 6:
+ raise CleoValueError(f'"{color}" is an invalid color')
+
+ return ("4" if background else "3") + self._convert_hex_color_to_ansi(
+ int(color, 16)
+ )
+
+ if color not in self.COLORS:
+ raise CleoValueError(
+ f'"{color}" is an invalid color.'
+ f' It must be one of {", ".join(self.COLORS.keys())}'
+ )
+
+ return str(self.COLORS[color][int(background)])
+
+ def _convert_hex_color_to_ansi(self, color: int) -> str:
+ r = (color >> 16) & 255
+ g = (color >> 8) & 255
+ b = color & 255
+
+ if os.getenv("COLORTERM") != "truecolor":
+ return str(self._degrade_hex_color_to_ansi(r, g, b))
+
+ return f"8;2;{r};{g};{b}"
+
+ def _degrade_hex_color_to_ansi(self, r: int, g: int, b: int) -> int:
+ if round(self._get_saturation(r, g, b) / 50) == 0:
+ return 0
+
+ return (round(b / 255) << 2) | (round(g / 255) << 1) | round(r / 255)
+
+ def _get_saturation(self, r: int, g: int, b: int) -> int:
+ r_float = r / 255
+ g_float = g / 255
+ b_float = b / 255
+ v = max(r_float, g_float, b_float)
+
+ diff = v - min(r_float, g_float, b_float)
+ if diff == 0:
+ return 0
+
+ return int(diff * 100 / v)
diff --git a/src/cleo/commands/__init__.py b/src/cleo/commands/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/commands/__init__.py
diff --git a/src/cleo/commands/base_command.py b/src/cleo/commands/base_command.py
new file mode 100644
index 0000000..29cf21a
--- /dev/null
+++ b/src/cleo/commands/base_command.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import inspect
+
+from typing import TYPE_CHECKING
+
+from cleo.exceptions import CleoError
+from cleo.io.inputs.definition import Definition
+
+
+if TYPE_CHECKING:
+ from cleo.application import Application
+ from cleo.io.io import IO
+
+
+class BaseCommand:
+
+ name: str | None = None
+
+ description = ""
+
+ help = ""
+
+ enabled = True
+ hidden = False
+
+ usages: list[str] = []
+
+ def __init__(self) -> None:
+ self._definition = Definition()
+ self._full_definition: Definition | None = None
+ self._application: Application | None = None
+ self._ignore_validation_errors = False
+ self._synopsis: dict[str, str] = {}
+
+ self.configure()
+
+ for i, usage in enumerate(self.usages):
+ if self.name and usage.find(self.name) != 0:
+ self.usages[i] = f"{self.name} {usage}"
+
+ @property
+ def application(self) -> Application | None:
+ return self._application
+
+ @property
+ def definition(self) -> Definition:
+ if self._full_definition is not None:
+ return self._full_definition
+
+ return self._definition
+
+ @property
+ def processed_help(self) -> str:
+ help_text = self.help
+ if not self.help:
+ help_text = self.description
+
+ is_single_command = self._application and self._application.is_single_command()
+
+ if self._application:
+ current_script = self._application.name
+ else:
+ current_script = inspect.stack()[-1][1]
+
+ return help_text.format(
+ command_name=self.name,
+ command_full_name=current_script
+ if is_single_command
+ else f"{current_script} {self.name}",
+ script_name=current_script,
+ )
+
+ def ignore_validation_errors(self) -> None:
+ self._ignore_validation_errors = True
+
+ def set_application(self, application: Application | None = None) -> None:
+ self._application = application
+
+ self._full_definition = None
+
+ def configure(self) -> None:
+ """
+ Configures the current command.
+ """
+ pass
+
+ def execute(self, io: IO) -> int:
+ raise NotImplementedError()
+
+ def interact(self, io: IO) -> None:
+ """
+ Interacts with the user.
+ """
+ pass
+
+ def initialize(self, io: IO) -> None:
+ pass
+
+ def run(self, io: IO) -> int:
+ self.merge_application_definition()
+
+ try:
+ io.input.bind(self.definition)
+ except CleoError:
+ if not self._ignore_validation_errors:
+ raise
+
+ self.initialize(io)
+
+ if io.is_interactive():
+ self.interact(io)
+
+ if io.input.has_argument("command") and io.input.argument("command") is None:
+ io.input.set_argument("command", self.name)
+
+ io.input.validate()
+
+ status_code = self.execute(io)
+
+ if status_code is None:
+ status_code = 0
+
+ return status_code
+
+ def merge_application_definition(self, merge_args: bool = True) -> None:
+ if self._application is None:
+ return
+
+ self._full_definition = Definition()
+ self._full_definition.add_options(self._definition.options)
+ self._full_definition.add_options(self._application.definition.options)
+
+ if merge_args:
+ self._full_definition.set_arguments(self._application.definition.arguments)
+ self._full_definition.add_arguments(self._definition.arguments)
+ else:
+ self._full_definition.set_arguments(self._definition.arguments)
+
+ def synopsis(self, short: bool = False) -> str:
+ key = "short" if short else "long"
+
+ if key not in self._synopsis:
+ self._synopsis[key] = f"{self.name} {self.definition.synopsis(short)}"
+
+ return self._synopsis[key]
diff --git a/src/cleo/commands/command.py b/src/cleo/commands/command.py
new file mode 100644
index 0000000..e83656f
--- /dev/null
+++ b/src/cleo/commands/command.py
@@ -0,0 +1,354 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import ContextManager
+from typing import cast
+
+from cleo.commands.base_command import BaseCommand
+from cleo.formatters.style import Style
+from cleo.io.inputs.string_input import StringInput
+from cleo.io.null_io import NullIO
+from cleo.io.outputs.output import Verbosity
+from cleo.ui.table_separator import TableSeparator
+
+
+if TYPE_CHECKING:
+ import sys
+
+ if sys.version_info >= (3, 8):
+ from typing import Literal
+ else:
+ from typing_extensions import Literal
+
+ from cleo.io.inputs.argument import Argument
+ from cleo.io.inputs.option import Option
+ from cleo.io.io import IO
+ from cleo.ui.progress_bar import ProgressBar
+ from cleo.ui.progress_indicator import ProgressIndicator
+ from cleo.ui.question import Question
+ from cleo.ui.table import Rows
+ from cleo.ui.table import Table
+
+
+class Command(BaseCommand):
+
+ arguments: list[Argument] = []
+ options: list[Option] = []
+ aliases: list[str] = []
+ usages: list[str] = []
+ commands: list[BaseCommand] = []
+
+ def __init__(self) -> None:
+ self._io: IO = None # type: ignore[assignment]
+ super().__init__()
+
+ @property
+ def io(self) -> IO:
+ return self._io
+
+ def configure(self) -> None:
+
+ for argument in self.arguments:
+ self._definition.add_argument(argument)
+
+ for option in self.options:
+ self._definition.add_option(option)
+
+ def execute(self, io: IO) -> int:
+ self._io = io
+
+ try:
+ return self.handle()
+ except KeyboardInterrupt:
+ return 1
+
+ def handle(self) -> int:
+ """
+ Executes the command.
+ """
+ raise NotImplementedError()
+
+ def call(self, name: str, args: str | None = None) -> int:
+ """
+ Call another command.
+ """
+ if args is None:
+ args = ""
+
+ input = StringInput(args)
+ assert self.application is not None
+ command = self.application.get(name)
+
+ return self.application._run_command(command, self._io.with_input(input))
+
+ def call_silent(self, name: str, args: str | None = None) -> int:
+ """
+ Call another command silently.
+ """
+ if args is None:
+ args = ""
+
+ input = StringInput(args)
+ assert self.application is not None
+ command = self.application.get(name)
+
+ return self.application._run_command(command, NullIO(input))
+
+ def argument(self, name: str) -> Any:
+ """
+ Get the value of a command argument.
+ """
+ return self._io.input.argument(name)
+
+ def option(self, name: str) -> Any:
+ """
+ Get the value of a command option.
+ """
+ return self._io.input.option(name)
+
+ def confirm(
+ self, question: str, default: bool = False, true_answer_regex: str = "(?i)^y"
+ ) -> bool:
+ """
+ Confirm a question with the user.
+ """
+ from cleo.ui.confirmation_question import ConfirmationQuestion
+
+ confirmation = ConfirmationQuestion(
+ question, default=default, true_answer_regex=true_answer_regex
+ )
+ return cast(bool, confirmation.ask(self._io))
+
+ def ask(self, question: str | Question, default: Any | None = None) -> Any:
+ """
+ Prompt the user for input.
+ """
+ from cleo.ui.question import Question
+
+ if not isinstance(question, Question):
+ question = Question(question, default=default)
+
+ return question.ask(self._io)
+
+ def secret(self, question: str | Question, default: Any | None = None) -> Any:
+ """
+ Prompt the user for input but hide the answer from the console.
+ """
+ from cleo.ui.question import Question
+
+ if not isinstance(question, Question):
+ question = Question(question, default=default)
+
+ question.hide()
+
+ return question.ask(self._io)
+
+ def choice(
+ self,
+ question: str,
+ choices: list[str],
+ default: Any | None = None,
+ attempts: int | None = None,
+ multiple: bool = False,
+ ) -> Any:
+ """
+ Give the user a single choice from an list of answers.
+ """
+ from cleo.ui.choice_question import ChoiceQuestion
+
+ choice = ChoiceQuestion(question, choices, default)
+
+ choice.set_max_attempts(attempts)
+ choice.set_multi_select(multiple)
+
+ return choice.ask(self._io)
+
+ def create_question(
+ self,
+ question: str,
+ type: Literal["choice"] | Literal["confirmation"] | None = None,
+ **kwargs: Any,
+ ) -> Question:
+ """
+ Returns a Question of specified type.
+ """
+ from cleo.ui.choice_question import ChoiceQuestion
+ from cleo.ui.confirmation_question import ConfirmationQuestion
+ from cleo.ui.question import Question
+
+ if not type:
+ return Question(question, **kwargs)
+
+ if type == "choice":
+ return ChoiceQuestion(question, **kwargs)
+
+ if type == "confirmation":
+ return ConfirmationQuestion(question, **kwargs)
+
+ def table(
+ self,
+ header: str | None = None,
+ rows: Rows | None = None,
+ style: str | None = None,
+ ) -> Table:
+ """
+ Return a Table instance.
+ """
+ from cleo.ui.table import Table
+
+ table = Table(self._io, style=style)
+
+ if header:
+ table.set_headers([header])
+
+ if rows:
+ table.set_rows(rows)
+
+ return table
+
+ def table_separator(self) -> TableSeparator:
+ """
+ Return a TableSeparator instance.
+ """
+ from cleo.ui.table_separator import TableSeparator
+
+ return TableSeparator()
+
+ def render_table(self, headers: str, rows: Rows, style: str | None = None) -> None:
+ """
+ Format input to textual table.
+ """
+ table = self.table(headers, rows, style)
+
+ table.render()
+
+ def write(self, text: str, style: str | None = None) -> None:
+ """
+ Writes a string without a new line.
+ Useful if you want to use overwrite().
+ """
+ if style:
+ styled = f"<{style}>{text}</>"
+ else:
+ styled = text
+
+ self._io.write(styled)
+
+ def line(
+ self,
+ text: str,
+ style: str | None = None,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ ) -> None:
+ """
+ Write a string as information output.
+ """
+ if style:
+ styled = f"<{style}>{text}</>"
+ else:
+ styled = text
+
+ self._io.write_line(styled, verbosity=verbosity)
+
+ def line_error(
+ self,
+ text: str,
+ style: str | None = None,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ ) -> None:
+ """
+ Write a string as information output to stderr.
+ """
+ if style:
+ styled = f"<{style}>{text}</>"
+ else:
+ styled = text
+
+ self._io.write_error_line(styled, verbosity)
+
+ def info(self, text: str) -> None:
+ """
+ Write a string as information output.
+
+ :param text: The line to write
+ :type text: str
+ """
+ self.line(text, "info")
+
+ def comment(self, text: str) -> None:
+ """
+ Write a string as comment output.
+
+ :param text: The line to write
+ :type text: str
+ """
+ self.line(text, "comment")
+
+ def question(self, text: str) -> None:
+ """
+ Write a string as question output.
+
+ :param text: The line to write
+ :type text: str
+ """
+ self.line(text, "question")
+
+ def progress_bar(self, max: int = 0) -> ProgressBar:
+ """
+ Creates a new progress bar
+ """
+ from cleo.ui.progress_bar import ProgressBar
+
+ return ProgressBar(self._io, max=max)
+
+ def progress_indicator(
+ self,
+ fmt: str | None = None,
+ interval: int = 100,
+ values: list[str] | None = None,
+ ) -> ProgressIndicator:
+ """
+ Creates a new progress indicator.
+ """
+ from cleo.ui.progress_indicator import ProgressIndicator
+
+ return ProgressIndicator(self.io, fmt, interval, values)
+
+ def spin(
+ self,
+ start_message: str,
+ end_message: str,
+ fmt: str | None = None,
+ interval: int = 100,
+ values: list[str] | None = None,
+ ) -> ContextManager[ProgressIndicator]:
+ """
+ Automatically spin a progress indicator.
+ """
+ spinner = self.progress_indicator(fmt, interval, values)
+
+ return spinner.auto(start_message, end_message)
+
+ def add_style(
+ self,
+ name: str,
+ fg: str | None = None,
+ bg: str | None = None,
+ options: list[str] | None = None,
+ ) -> None:
+ """
+ Adds a new style
+ """
+ style = Style(fg, bg, options)
+ self._io.output.formatter.set_style(name, style)
+ self._io.error_output.formatter.set_style(name, style)
+
+ def overwrite(self, text: str) -> None:
+ """
+ Overwrites the current line.
+
+ It will not add a new line so use line('')
+ if necessary.
+ """
+ self._io.overwrite(text)
diff --git a/src/cleo/commands/completions/__init__.py b/src/cleo/commands/completions/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/commands/completions/__init__.py
diff --git a/src/cleo/commands/completions/templates.py b/src/cleo/commands/completions/templates.py
new file mode 100644
index 0000000..7cf7c54
--- /dev/null
+++ b/src/cleo/commands/completions/templates.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+
+BASH_TEMPLATE = """\
+%(function)s()
+{
+ local cur script coms opts com
+ COMPREPLY=()
+ _get_comp_words_by_ref -n : cur words
+
+ # for an alias, get the real script behind it
+ if [[ $(type -t ${words[0]}) == "alias" ]]; then
+ script=$(alias ${words[0]} | sed -E "s/alias ${words[0]}='(.*)'/\\1/")
+ else
+ script=${words[0]}
+ fi
+
+ # lookup for command
+ for word in ${words[@]:1}; do
+ if [[ $word != -* ]]; then
+ com=$word
+ break
+ fi
+ done
+
+ # completing for an option
+ if [[ ${cur} == --* ]] ; then
+ opts="%(opts)s"
+
+ case "$com" in
+
+%(cmds_opts)s
+
+ esac
+
+ COMPREPLY=($(compgen -W "${opts}" -- ${cur}))
+ __ltrim_colon_completions "$cur"
+
+ return 0;
+ fi
+
+ # completing for a command
+ if [[ $cur == $com ]]; then
+ coms="%(cmds)s"
+
+ COMPREPLY=($(compgen -W "${coms}" -- ${cur}))
+ __ltrim_colon_completions "$cur"
+
+ return 0
+ fi
+}
+
+%(compdefs)s"""
+
+ZSH_TEMPLATE = """\
+#compdef %(script_name)s
+
+%(function)s()
+{
+ local state com cur
+ local -a opts
+ local -a coms
+
+ cur=${words[${#words[@]}]}
+
+ # lookup for command
+ for word in ${words[@]:1}; do
+ if [[ $word != -* ]]; then
+ com=$word
+ break
+ fi
+ done
+
+ if [[ ${cur} == --* ]]; then
+ state="option"
+ opts+=(%(opts)s)
+ elif [[ $cur == $com ]]; then
+ state="command"
+ coms+=(%(cmds)s)
+ fi
+
+ case $state in
+ (command)
+ _describe 'command' coms
+ ;;
+ (option)
+ case "$com" in
+
+%(cmds_opts)s
+
+ esac
+
+ _describe 'option' opts
+ ;;
+ *)
+ # fallback to file completion
+ _arguments '*:file:_files'
+ esac
+}
+
+%(function)s "$@"
+%(compdefs)s"""
+
+FISH_TEMPLATE = """\
+function __fish%(function)s_no_subcommand
+ for i in (commandline -opc)
+ if contains -- $i %(cmds_names)s
+ return 1
+ end
+ end
+ return 0
+end
+
+# global options
+%(opts)s
+
+# commands
+%(cmds)s
+
+# command options
+
+%(cmds_opts)s"""
+
+
+TEMPLATES = {"bash": BASH_TEMPLATE, "zsh": ZSH_TEMPLATE, "fish": FISH_TEMPLATE}
diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py
new file mode 100644
index 0000000..7d67fa0
--- /dev/null
+++ b/src/cleo/commands/completions_command.py
@@ -0,0 +1,305 @@
+from __future__ import annotations
+
+import hashlib
+import inspect
+import os
+import posixpath
+import re
+import subprocess
+
+from cleo import helpers
+from cleo._compat import shell_quote
+from cleo.commands.command import Command
+from cleo.commands.completions.templates import TEMPLATES
+
+
+class CompletionsCommand(Command):
+
+ name = "completions"
+ description = "Generate completion scripts for your shell."
+
+ arguments = [
+ helpers.argument(
+ "shell", "The shell to generate the scripts for.", optional=True
+ )
+ ]
+ options = [
+ helpers.option(
+ "alias", None, "Alias for the current command.", flag=False, multiple=True
+ )
+ ]
+
+ SUPPORTED_SHELLS = ("bash", "zsh", "fish")
+
+ hidden = True
+
+ help = """
+One can generate a completion script for `<options=bold>{script_name}</>` \
+that is compatible with a given shell. The script is output on \
+`<options=bold>stdout</>` allowing one to re-direct \
+the output to the file of their choosing. Where you place the file will \
+depend on which shell, and which operating system you are using. Your \
+particular configuration may also determine where these scripts need \
+to be placed.
+
+Here are some common set ups for the three supported shells under \
+Unix and similar operating systems (such as GNU/Linux).
+
+<options=bold>BASH</>:
+
+Completion files are commonly stored in `<options=bold>/etc/bash_completion.d/</>`
+
+Run the command:
+
+`<options=bold>{script_name} {command_name} bash >\
+ /etc/bash_completion.d/{script_name}.bash-completion</>`
+
+This installs the completion script. You may have to log out and log \
+back in to your shell session for the changes to take effect.
+
+<options=bold>FISH</>:
+
+Fish completion files are commonly stored in\
+`<options=bold>$HOME/.config/fish/completions</>`
+
+Run the command:
+
+`<options=bold>{script_name} {command_name} fish > \
+~/.config/fish/completions/{script_name}.fish</>`
+
+This installs the completion script. You may have to log out and log \
+back in to your shell session for the changes to take effect.
+
+<options=bold>ZSH</>:
+
+ZSH completions are commonly stored in any directory listed in your \
+`<options=bold>$fpath</>` variable. To use these completions, you must either add the \
+generated script to one of those directories, or add your own \
+to this list.
+
+Adding a custom directory is often the safest best if you're unsure \
+of which directory to use. First create the directory, for this \
+example we'll create a hidden directory inside our `<options=bold>$HOME</>` directory
+
+`<options=bold>mkdir ~/.zfunc</>`
+
+Then add the following lines to your `<options=bold>.zshrc</>` \
+just before `<options=bold>compinit</>`
+
+`<options=bold>fpath+=~/.zfunc</>`
+
+Now you can install the completions script using the following command
+
+`<options=bold>{script_name} {command_name} zsh > ~/.zfunc/_{script_name}</>`
+
+You must then either log out and log back in, or simply run
+
+`<options=bold>exec zsh</>`
+
+For the new completions to take affect.
+
+<options=bold>CUSTOM LOCATIONS</>:
+
+Alternatively, you could save these files to the place of your choosing, \
+such as a custom directory inside your $HOME. Doing so will require you \
+to add the proper directives, such as `source`ing inside your login \
+script. Consult your shells documentation for how to add such directives.
+"""
+
+ def handle(self) -> int:
+ shell = self.argument("shell")
+ if not shell:
+ shell = self.get_shell_type()
+
+ if shell not in self.SUPPORTED_SHELLS:
+ raise ValueError(
+ f'[shell] argument must be one of {", ".join(self.SUPPORTED_SHELLS)}'
+ )
+
+ self.line(self.render(shell))
+
+ return 0
+
+ def render(self, shell: str) -> str:
+ if shell == "bash":
+ return self.render_bash()
+ if shell == "zsh":
+ return self.render_zsh()
+ if shell == "fish":
+ return self.render_fish()
+
+ raise RuntimeError(f"Unrecognized shell: {shell}")
+
+ def _get_script_name_and_path(self) -> tuple[str, str]:
+ # FIXME: when generating completions via `python -m script completions`,
+ # we incorrectly infer `script_name` as `__main__.py`
+ script_name = self._io.input.script_name or inspect.stack()[-1][1]
+ script_path = posixpath.realpath(script_name)
+ script_name = os.path.basename(script_path)
+
+ return script_name, script_path
+
+ def render_bash(self) -> str:
+ script_name, script_path = self._get_script_name_and_path()
+ aliases = [script_name, script_path, *self.option("alias")]
+ function = self._generate_function_name(script_name, script_path)
+
+ # Global options
+ assert self.application
+ opts = [
+ f"--{opt.name}"
+ for opt in sorted(self.application.definition.options, key=lambda o: o.name)
+ ]
+
+ # Commands + options
+ cmds = []
+ cmds_opts = []
+ for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""):
+ if cmd.hidden or not cmd.enabled or not cmd.name:
+ continue
+ command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name
+ cmds.append(command_name)
+ options = " ".join(
+ f"--{opt.name}".replace(":", "\\:")
+ for opt in sorted(cmd.definition.options, key=lambda o: o.name)
+ )
+ cmds_opts += [
+ f" ({command_name})",
+ f' opts="${{opts}} {options}"',
+ " ;;",
+ "", # newline
+ ]
+
+ return TEMPLATES["bash"] % {
+ "script_name": script_name,
+ "function": function,
+ "opts": " ".join(opts),
+ "cmds": " ".join(cmds),
+ "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline
+ "compdefs": "\n".join(
+ f"complete -o default -F {function} {alias}" for alias in aliases
+ ),
+ }
+
+ def render_zsh(self) -> str:
+ script_name, script_path = self._get_script_name_and_path()
+ aliases = [script_path, *self.option("alias")]
+ function = self._generate_function_name(script_name, script_path)
+
+ def sanitize(s: str) -> str:
+ return self._io.output.formatter.remove_format(s)
+
+ # Global options
+ assert self.application
+ opts = [
+ self._zsh_describe(f"--{opt.name}", sanitize(opt.description))
+ for opt in sorted(self.application.definition.options, key=lambda o: o.name)
+ ]
+
+ # Commands + options
+ cmds = []
+ cmds_opts = []
+ for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""):
+ if cmd.hidden or not cmd.enabled or not cmd.name:
+ continue
+ command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name
+ cmds.append(self._zsh_describe(command_name, sanitize(cmd.description)))
+ options = " ".join(
+ self._zsh_describe(f"--{opt.name}", sanitize(opt.description))
+ for opt in sorted(cmd.definition.options, key=lambda o: o.name)
+ )
+ cmds_opts += [
+ f" ({command_name})",
+ f" opts+=({options})",
+ " ;;",
+ "", # newline
+ ]
+
+ return TEMPLATES["zsh"] % {
+ "script_name": script_name,
+ "function": function,
+ "opts": " ".join(opts),
+ "cmds": " ".join(cmds),
+ "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline
+ "compdefs": "\n".join(f"compdef {function} {alias}" for alias in aliases),
+ }
+
+ def render_fish(self) -> str:
+ script_name, script_path = self._get_script_name_and_path()
+ function = self._generate_function_name(script_name, script_path)
+
+ def sanitize(s: str) -> str:
+ return self._io.output.formatter.remove_format(s).replace("'", "\\'")
+
+ # Global options
+ assert self.application
+ opts = [
+ f"complete -c {script_name} -n '__fish{function}_no_subcommand' "
+ f"-l {opt.name} -d '{sanitize(opt.description)}'"
+ for opt in sorted(self.application.definition.options, key=lambda o: o.name)
+ ]
+
+ # Commands + options
+ cmds = []
+ cmds_opts = []
+ cmds_names = []
+ for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""):
+ if cmd.hidden or not cmd.enabled or not cmd.name:
+ continue
+ command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name
+ cmds.append(
+ f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' "
+ f"-a {command_name} -d '{sanitize(cmd.description)}'"
+ )
+ cmds_opts += [
+ f"# {command_name}",
+ *[
+ f"complete -c {script_name} -A "
+ f"-n '__fish_seen_subcommand_from {command_name}' "
+ f"-l {opt.name} -d '{sanitize(opt.description)}'"
+ for opt in sorted(cmd.definition.options, key=lambda o: o.name)
+ ],
+ "", # newline
+ ]
+ cmds_names.append(command_name)
+
+ return TEMPLATES["fish"] % {
+ "script_name": script_name,
+ "function": function,
+ "opts": "\n".join(opts),
+ "cmds": "\n".join(cmds),
+ "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline
+ "cmds_names": " ".join(cmds_names),
+ }
+
+ def get_shell_type(self) -> str:
+ shell = os.getenv("SHELL")
+ if not shell:
+ raise RuntimeError(
+ "Could not read SHELL environment variable. "
+ "Please specify your shell type by passing it as the first argument."
+ )
+
+ return os.path.basename(shell)
+
+ def _generate_function_name(self, script_name: str, script_path: str) -> str:
+ sanitized_name = self._sanitize_for_function_name(script_name)
+ md5_hash = hashlib.md5(script_path.encode()).hexdigest()[0:16]
+ return f"_{sanitized_name}_{md5_hash}_complete"
+
+ def _sanitize_for_function_name(self, name: str) -> str:
+ name = name.replace("-", "_")
+
+ return re.sub("[^A-Za-z0-9_]+", "", name)
+
+ def _zsh_describe(self, value: str, description: str | None = None) -> str:
+ value = '"' + value.replace(":", "\\:")
+ if description:
+ description = re.sub(
+ r'(["\'#&;`|*?~<>^()\[\]{}$\\\x0A\xFF])', r"\\\1", description
+ )
+ value += ":" + subprocess.list2cmdline([description]).strip('"')
+
+ value += '"'
+
+ return value
diff --git a/src/cleo/commands/help_command.py b/src/cleo/commands/help_command.py
new file mode 100644
index 0000000..c01df5d
--- /dev/null
+++ b/src/cleo/commands/help_command.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from cleo.commands.command import Command
+from cleo.io.inputs.argument import Argument
+
+
+class HelpCommand(Command):
+
+ name = "help"
+
+ description = "Displays help for a command."
+
+ arguments = [
+ Argument(
+ "command_name",
+ required=False,
+ description="The command name",
+ default="help",
+ )
+ ]
+
+ help = """\
+The <info>{command_name}</info> command displays help for a given command:
+
+ <info>{command_full_name} list</info>
+
+To display the list of available commands, please use the <info>list</info> command.
+"""
+
+ _command = None
+
+ def set_command(self, command: Command) -> None:
+ self._command = command
+
+ def configure(self) -> None:
+ self.ignore_validation_errors()
+
+ super().configure()
+
+ def handle(self) -> int:
+ from cleo.descriptors.text_descriptor import TextDescriptor
+
+ if self._command is None:
+ assert self._application is not None
+ self._command = self._application.find(self.argument("command_name"))
+
+ self.line("")
+ TextDescriptor().describe(self._io, self._command)
+
+ self._command = None
+
+ return 0
diff --git a/src/cleo/commands/list_command.py b/src/cleo/commands/list_command.py
new file mode 100644
index 0000000..528e4ec
--- /dev/null
+++ b/src/cleo/commands/list_command.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from cleo.commands.command import Command
+from cleo.io.inputs.argument import Argument
+
+
+class ListCommand(Command):
+
+ name = "list"
+
+ description = "Lists commands."
+
+ help = """\
+The <info>{command_name}</info> command lists all commands:
+
+ <info>{command_full_name}</info>
+
+You can also display the commands for a specific namespace:
+
+ <info>{command_full_name} test</info>
+"""
+
+ arguments = [
+ Argument("namespace", required=False, description="The namespace name")
+ ]
+
+ def handle(self) -> int:
+ from cleo.descriptors.text_descriptor import TextDescriptor
+
+ TextDescriptor().describe(
+ self._io, self.application, namespace=self.argument("namespace")
+ )
+
+ return 0
diff --git a/src/cleo/cursor.py b/src/cleo/cursor.py
new file mode 100644
index 0000000..dddd1d1
--- /dev/null
+++ b/src/cleo/cursor.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import sys
+
+from typing import TYPE_CHECKING
+from typing import TextIO
+
+from cleo.io.io import IO
+
+
+if TYPE_CHECKING:
+ from cleo.io.outputs.output import Output
+
+
+class Cursor:
+ def __init__(self, io: IO | Output, input: TextIO | None = None) -> None:
+ if isinstance(io, IO):
+ io = io.output
+
+ self._output = io
+
+ if input is None:
+ input = sys.stdin
+
+ self._input = input
+
+ def move_up(self, lines: int = 1) -> Cursor:
+ self._output.write(f"\x1b[{lines}A")
+
+ return self
+
+ def move_down(self, lines: int = 1) -> Cursor:
+ self._output.write(f"\x1b[{lines}B")
+
+ return self
+
+ def move_right(self, columns: int = 1) -> Cursor:
+ self._output.write(f"\x1b[{columns}C")
+
+ return self
+
+ def move_left(self, columns: int = 1) -> Cursor:
+ self._output.write(f"\x1b[{columns}D")
+
+ return self
+
+ def move_to_column(self, column: int) -> Cursor:
+ self._output.write(f"\x1b[{column}G")
+
+ return self
+
+ def move_to_position(self, column: int, row: int) -> Cursor:
+ self._output.write(f"\x1b[{row + 1};{column}H")
+
+ return self
+
+ def save_position(self) -> Cursor:
+ self._output.write("\x1b7")
+
+ return self
+
+ def restore_position(self) -> Cursor:
+ self._output.write("\x1b8")
+
+ return self
+
+ def hide(self) -> Cursor:
+ self._output.write("\x1b[?25l")
+
+ return self
+
+ def show(self) -> Cursor:
+ self._output.write("\x1b[?25h\x1b[?0c")
+
+ return self
+
+ def clear_line(self) -> Cursor:
+ """
+ Clears all the output from the current line.
+ """
+ self._output.write("\x1b[2K")
+
+ return self
+
+ def clear_line_after(self) -> Cursor:
+ """
+ Clears all the output from the current line after the current position.
+ """
+ self._output.write("\x1b[K")
+
+ return self
+
+ def clear_output(self) -> Cursor:
+ """
+ Clears all the output from the cursors' current position
+ to the end of the screen.
+ """
+ self._output.write("\x1b[0J")
+
+ return self
+
+ def clear_screen(self) -> Cursor:
+ """
+ Clears the entire screen.
+ """
+ self._output.write("\x1b[2J")
+
+ return self
diff --git a/src/cleo/descriptors/__init__.py b/src/cleo/descriptors/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/descriptors/__init__.py
diff --git a/src/cleo/descriptors/application_description.py b/src/cleo/descriptors/application_description.py
new file mode 100644
index 0000000..a40e4f1
--- /dev/null
+++ b/src/cleo/descriptors/application_description.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+from cleo.exceptions import CleoCommandNotFoundError
+
+
+if TYPE_CHECKING:
+ from cleo.application import Application
+ from cleo.commands.command import Command
+
+
+class ApplicationDescription:
+
+ GLOBAL_NAMESPACE = "_global"
+
+ def __init__(
+ self,
+ application: Application,
+ namespace: str | None = None,
+ show_hidden: bool = False,
+ ) -> None:
+ self._application: Application = application
+ self._namespace = namespace
+ self._show_hidden = show_hidden
+ self._namespaces: dict[str, dict[str, str | list[str]]] = {}
+ self._commands: dict[str, Command] = {}
+ self._aliases: dict[str, Command] = {}
+
+ self._inspect_application()
+
+ @property
+ def namespaces(self) -> dict[str, dict[str, str | list[str]]]:
+ return self._namespaces
+
+ @property
+ def commands(self) -> dict[str, Command]:
+ return self._commands
+
+ def command(self, name: str) -> Command:
+ if name in self._commands:
+ return self._commands[name]
+ if name in self._aliases:
+ return self._aliases[name]
+ raise CleoCommandNotFoundError(name)
+
+ def _inspect_application(self) -> None:
+ namespace = None
+ if self._namespace:
+ namespace = self._application.find_namespace(self._namespace)
+
+ all_commands = self._application.all(namespace)
+
+ for namespace, commands in self._sort_commands(all_commands):
+ names = []
+
+ for name, command in commands:
+ if not command.name or command.hidden:
+ continue
+
+ if command.name == name:
+ self._commands[name] = command
+ else:
+ self._aliases[name] = command
+
+ names.append(name)
+
+ self._namespaces[namespace] = {"id": namespace, "commands": names}
+
+ def _sort_commands(
+ self, commands: dict[str, Command]
+ ) -> list[tuple[str, list[tuple[str, Command]]]]:
+ """
+ Sorts command in alphabetical order
+ """
+ namespaced_commands: dict[str, dict[str, Command]] = defaultdict(dict)
+ for name, command in commands.items():
+ key = self._application.extract_namespace(name, 1) or "_global"
+ namespaced_commands[key][name] = command
+
+ namespaced_commands_lst: dict[str, list[tuple[str, Command]]] = {}
+ for namespace, commands in namespaced_commands.items():
+ namespaced_commands_lst[namespace] = sorted(
+ commands.items(), key=lambda x: x[0]
+ )
+
+ return sorted(namespaced_commands_lst.items(), key=lambda x: x[0])
diff --git a/src/cleo/descriptors/descriptor.py b/src/cleo/descriptors/descriptor.py
new file mode 100644
index 0000000..8522036
--- /dev/null
+++ b/src/cleo/descriptors/descriptor.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from cleo.application import Application
+from cleo.commands.command import Command
+from cleo.io.inputs.argument import Argument
+from cleo.io.inputs.definition import Definition
+from cleo.io.inputs.option import Option
+from cleo.io.outputs.output import Type
+
+
+if TYPE_CHECKING:
+ from cleo.io.io import IO
+
+
+class Descriptor:
+ def describe(self, io: IO, obj: Any, **options: Any) -> None:
+ self._io = io
+
+ if isinstance(obj, Argument):
+ self._describe_argument(obj, **options)
+ elif isinstance(obj, Option):
+ self._describe_option(obj, **options)
+ elif isinstance(obj, Definition):
+ self._describe_definition(obj, **options)
+ elif isinstance(obj, Command):
+ self._describe_command(obj, **options)
+ elif isinstance(obj, Application):
+ self._describe_application(obj, **options)
+
+ def _write(self, content: str, decorated: bool = True) -> None:
+ self._io.write(
+ content, new_line=False, type=Type.NORMAL if decorated else Type.RAW
+ )
+
+ def _describe_argument(self, argument: Argument, **options: Any) -> None:
+ raise NotImplementedError()
+
+ def _describe_option(self, option: Option, **options: Any) -> None:
+ raise NotImplementedError()
+
+ def _describe_definition(self, definition: Definition, **options: Any) -> None:
+ raise NotImplementedError()
+
+ def _describe_command(self, command: Command, **options: Any) -> None:
+ raise NotImplementedError()
+
+ def _describe_application(self, application: Application, **options: Any) -> None:
+ raise NotImplementedError()
diff --git a/src/cleo/descriptors/text_descriptor.py b/src/cleo/descriptors/text_descriptor.py
new file mode 100644
index 0000000..dff23ef
--- /dev/null
+++ b/src/cleo/descriptors/text_descriptor.py
@@ -0,0 +1,287 @@
+from __future__ import annotations
+
+import json
+import re
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Sequence
+
+from cleo.commands.command import Command
+from cleo.descriptors.descriptor import Descriptor
+from cleo.formatters.formatter import Formatter
+from cleo.io.inputs.definition import Definition
+
+
+if TYPE_CHECKING:
+ from cleo.application import Application
+ from cleo.io.inputs.argument import Argument
+ from cleo.io.inputs.option import Option
+
+
+class TextDescriptor(Descriptor):
+ def _describe_argument(self, argument: Argument, **options: Any) -> None:
+ if argument.default is not None and (
+ not isinstance(argument.default, list) or argument.default
+ ):
+ default = (
+ f"<comment> [default: {self._format_default_value(argument.default)}]"
+ "</comment>"
+ )
+ else:
+ default = ""
+
+ total_width = options.get("total_width", len(argument.name))
+
+ spacing_width = total_width - len(argument.name)
+ sub_argument_description = re.sub(
+ r"\s*[\r\n]\s*",
+ "\n" + " " * (total_width + 4),
+ argument.description,
+ )
+ self._write(
+ f' <c1>{argument.name}</c1> {" " * spacing_width}'
+ f"{sub_argument_description}{default}"
+ )
+
+ def _describe_option(self, option: Option, **options: Any) -> None:
+ if (
+ option.accepts_value()
+ and option.default is not None
+ and (not isinstance(option.default, list) or option.default)
+ ):
+ default = (
+ "<comment> [default: "
+ f"{self._format_default_value(option.default)}]</comment>"
+ )
+ else:
+ default = ""
+
+ value = ""
+ if option.accepts_value():
+ value = "=" + option.name.upper()
+
+ if not option.requires_value():
+ value = "[" + value + "]"
+
+ total_width = options.get(
+ "total_width", self._calculate_total_width_for_options([option])
+ )
+
+ option_shortcut = f"-{option.shortcut}, " if option.shortcut else " "
+ synopsis = f"{option_shortcut}--{option.name}{value}"
+
+ spacing_width = total_width - len(synopsis)
+ sub_option_description = re.sub(
+ r"\s*[\r\n]\s*",
+ "\n" + " " * (total_width + 4),
+ option.description,
+ )
+ are_multiple_values_allowed = (
+ "<comment> (multiple values allowed)</comment>" if option.is_list() else ""
+ )
+ self._write(
+ f" <c1>{synopsis}</c1> "
+ f'{" " * spacing_width}{sub_option_description}'
+ f"{default}"
+ f"{are_multiple_values_allowed}"
+ )
+
+ def _describe_definition(self, definition: Definition, **options: Any) -> None:
+ arguments = definition.arguments
+ definition_options = definition.options
+ total_width = self._calculate_total_width_for_options(definition_options)
+
+ for argument in arguments:
+ total_width = max(total_width, len(argument.name))
+
+ if arguments:
+ self._write("<b>Arguments:</b>")
+ self._write("\n")
+
+ for argument in arguments:
+ self._describe_argument(argument, total_width=total_width)
+ self._write("\n")
+
+ if arguments and definition_options:
+ self._write("\n")
+
+ if definition_options:
+ later_options = []
+
+ self._write("<b>Options:</b>")
+
+ for option in definition_options:
+ if option.shortcut and len(option.shortcut) > 1:
+ later_options.append(option)
+ continue
+
+ self._write("\n")
+ self._describe_option(option, total_width=total_width)
+
+ for option in later_options:
+ self._write("\n")
+ self._describe_option(option, total_width=total_width)
+
+ def _describe_command(self, command: Command, **options: Any) -> None:
+ command.merge_application_definition(False)
+
+ description = command.description
+ if description:
+ self._write("<b>Description:</b>")
+ self._write("\n")
+ self._write(" " + description)
+ self._write("\n\n")
+
+ self._write("<b>Usage:</b>")
+ for usage in [command.synopsis(True)] + command.aliases + command.usages:
+ self._write("\n")
+ self._write(" " + Formatter.escape(usage))
+
+ self._write("\n")
+
+ definition = command.definition
+ if definition.options or definition.arguments:
+ self._write("\n")
+ self._describe_definition(definition, **options)
+ self._write("\n")
+
+ help_text = command.processed_help
+ if help_text and help_text != description:
+ self._write("\n")
+ self._write("<b>Help:</b>")
+ self._write("\n")
+ self._write(" " + help_text.replace("\n", "\n "))
+ self._write("\n")
+
+ def _describe_application(self, application: Application, **options: Any) -> None:
+ from cleo.descriptors.application_description import ApplicationDescription
+
+ described_namespace = options.get("namespace")
+ description = ApplicationDescription(application, namespace=described_namespace)
+
+ help_text = application.help
+ if help_text:
+ self._write(f"{help_text}\n\n")
+
+ self._write("<b>Usage:</b>\n")
+ self._write(" command [options] [arguments]\n\n")
+
+ self._describe_definition(Definition(application.definition.options), **options)
+
+ self._write("\n")
+ self._write("\n")
+
+ commands = description.commands
+ namespaces = description.namespaces
+
+ if described_namespace and namespaces:
+ described_namespace_info = list(namespaces.values())[0]
+ for name in described_namespace_info["commands"]:
+ commands[name] = description.command(name)
+
+ # calculate max width based on available commands per namespace
+ all_commands = list(commands.keys())
+ for namespace in namespaces.values():
+ all_commands += namespace["commands"]
+
+ width = self._get_column_width(all_commands)
+ if described_namespace:
+ self._write(
+ f'<b>Available commands for the "{described_namespace}" namespace:</b>'
+ )
+ else:
+ self._write("<b>Available commands:</b>")
+
+ for namespace in namespaces.values():
+ namespace["commands"] = [c for c in namespace["commands"] if c in commands]
+
+ if not namespace["commands"]:
+ continue
+
+ if (
+ not described_namespace
+ and namespace["id"] != ApplicationDescription.GLOBAL_NAMESPACE
+ ):
+ self._write("\n")
+ self._write(f' <comment>{namespace["id"]}</comment>')
+
+ for name in namespace["commands"]:
+ self._write("\n")
+ spacing_width = width - len(name)
+ command = commands[name]
+ command_aliases = (
+ self._get_command_aliases_text(command)
+ if command.name == name
+ else ""
+ )
+ self._write(
+ f' <c1>{name}</c1>{" " * spacing_width}'
+ f"{command_aliases + command.description}"
+ )
+
+ self._write("\n")
+
+ def _format_default_value(self, default: Any) -> str:
+ new_default: Any
+ if isinstance(default, str):
+ default = Formatter.escape(default)
+ elif isinstance(default, list):
+ new_default = []
+ for value in default:
+ if isinstance(value, str):
+ new_default.append(Formatter.escape(value))
+
+ default = new_default
+ elif isinstance(default, dict):
+ new_default = {}
+ for key, value in default.items():
+ if isinstance(value, str):
+ new_default[key] = Formatter.escape(value)
+
+ default = new_default
+
+ return json.dumps(default).replace("\\\\", "\\")
+
+ def _calculate_total_width_for_options(self, options: list[Option]) -> int:
+ total_width = 0
+
+ for option in options:
+ name_length = 1 + max(len(option.shortcut or ""), 1) + 4 + len(option.name)
+
+ if option.accepts_value():
+ value_length = 1 + len(option.name)
+ if not option.requires_value():
+ value_length += 2
+
+ name_length += value_length
+
+ total_width = max(total_width, name_length)
+
+ return total_width
+
+ def _get_column_width(self, commands: Sequence[Command | str]) -> int:
+ widths = []
+
+ for command in commands:
+ if isinstance(command, Command):
+ assert command.name is not None
+ widths.append(len(command.name))
+ for alias in command.aliases:
+ widths.append(len(alias))
+ else:
+ widths.append(len(command))
+
+ if not widths:
+ return 0
+
+ return max(widths) + 2
+
+ def _get_command_aliases_text(self, command: Command) -> str:
+ text = ""
+ aliases = command.aliases
+
+ if aliases:
+ text = f'[{ "|".join(aliases) }] '
+
+ return text
diff --git a/src/cleo/events/__init__.py b/src/cleo/events/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/events/__init__.py
diff --git a/src/cleo/events/console_command_event.py b/src/cleo/events/console_command_event.py
new file mode 100644
index 0000000..a3b9b26
--- /dev/null
+++ b/src/cleo/events/console_command_event.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.events.console_event import ConsoleEvent
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+ from cleo.io.io import IO
+
+
+class ConsoleCommandEvent(ConsoleEvent):
+ """
+ An event triggered before the command is executed.
+
+ It allows to do things like skipping the command or changing the input.
+ """
+
+ RETURN_CODE_DISABLED: int = 113
+
+ def __init__(self, command: Command, io: IO) -> None:
+ super().__init__(command, io)
+
+ self._command_should_run = True
+
+ def disable_command(self) -> None:
+ self._command_should_run = False
+
+ def enable_command(self) -> None:
+ self._command_should_run = True
+
+ def command_should_run(self) -> bool:
+ return self._command_should_run
diff --git a/src/cleo/events/console_error_event.py b/src/cleo/events/console_error_event.py
new file mode 100644
index 0000000..c267d87
--- /dev/null
+++ b/src/cleo/events/console_error_event.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.events.console_event import ConsoleEvent
+from cleo.exceptions import CleoError
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+ from cleo.io.io import IO
+
+
+class ConsoleErrorEvent(ConsoleEvent):
+ """
+ An event triggered when an exception is raised during the execution of a command.
+ """
+
+ def __init__(self, command: Command, io: IO, error: Exception) -> None:
+ super().__init__(command, io)
+
+ self._error = error
+ self._exit_code: int | None = None
+
+ @property
+ def error(self) -> Exception:
+ return self._error
+
+ @property
+ def exit_code(self) -> int:
+ if self._exit_code is not None:
+ return self._exit_code
+
+ if isinstance(self._error, CleoError) and self._error.exit_code is not None:
+ return self._error.exit_code
+
+ return 1
+
+ def set_error(self, error: Exception) -> None:
+ self._error = error
+
+ def set_exit_code(self, exit_code: int) -> None:
+ self._exit_code = exit_code
diff --git a/src/cleo/events/console_event.py b/src/cleo/events/console_event.py
new file mode 100644
index 0000000..5e947cf
--- /dev/null
+++ b/src/cleo/events/console_event.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.events.event import Event
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+ from cleo.io.io import IO
+
+
+class ConsoleEvent(Event):
+ """
+ An event that gives access to the IO of a command.
+ """
+
+ def __init__(self, command: Command, io: IO) -> None:
+ super().__init__()
+
+ self._command = command
+ self._io = io
+
+ @property
+ def command(self) -> Command:
+ return self._command
+
+ @property
+ def io(self) -> IO:
+ return self._io
diff --git a/src/cleo/events/console_events.py b/src/cleo/events/console_events.py
new file mode 100644
index 0000000..e9f1f7c
--- /dev/null
+++ b/src/cleo/events/console_events.py
@@ -0,0 +1,21 @@
+# The COMMAND event allows to attach listeners before any command
+# is executed. It also allows the modification of the command and IO
+# before it's handed to the command.
+from __future__ import annotations
+
+
+COMMAND = "console.command"
+
+# The SIGNAL event allows some actions to be performed after
+# the command execution is interrupted.
+SIGNAL = "console.signal"
+
+# The TERMINATE event allows listeners to be attached after the command
+# is executed by the console.
+TERMINATE = "console.terminate"
+
+# The ERROR event occurs when an uncaught exception is raised.
+#
+# This event gives the ability to deal with the exception or to modify
+# the raised exception.
+ERROR = "console.error"
diff --git a/src/cleo/events/console_signal_event.py b/src/cleo/events/console_signal_event.py
new file mode 100644
index 0000000..3c79265
--- /dev/null
+++ b/src/cleo/events/console_signal_event.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.events.console_event import ConsoleEvent
+
+
+if TYPE_CHECKING:
+ import signal
+
+ from cleo.commands.command import Command
+ from cleo.io.io import IO
+
+
+class ConsoleSignalEvent(ConsoleEvent):
+ """
+ An event triggered by a system signal.
+ """
+
+ def __init__(
+ self, command: Command, io: IO, handling_signal: signal.Signals
+ ) -> None:
+ super().__init__(command, io)
+ self._handling_signal = handling_signal
+
+ @property
+ def handling_signal(self) -> signal.Signals:
+ return self._handling_signal
diff --git a/src/cleo/events/console_terminate_event.py b/src/cleo/events/console_terminate_event.py
new file mode 100644
index 0000000..1fdb669
--- /dev/null
+++ b/src/cleo/events/console_terminate_event.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.events.console_event import ConsoleEvent
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+ from cleo.io.io import IO
+
+
+class ConsoleTerminateEvent(ConsoleEvent):
+ """
+ An event triggered by after the execution of a command.
+ """
+
+ def __init__(self, command: Command, io: IO, exit_code: int) -> None:
+ super().__init__(command, io)
+
+ self._exit_code = exit_code
+
+ @property
+ def exit_code(self) -> int:
+ return self._exit_code
+
+ def set_exit_code(self, exit_code: int) -> None:
+ self._exit_code = exit_code
diff --git a/src/cleo/events/event.py b/src/cleo/events/event.py
new file mode 100644
index 0000000..282d6ce
--- /dev/null
+++ b/src/cleo/events/event.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+
+class Event:
+ """
+ Event
+ """
+
+ def __init__(self) -> None:
+ self._propagation_stopped = False
+
+ def is_propagation_stopped(self) -> bool:
+ return self._propagation_stopped
+
+ def stop_propagation(self) -> None:
+ self._propagation_stopped = True
diff --git a/src/cleo/events/event_dispatcher.py b/src/cleo/events/event_dispatcher.py
new file mode 100644
index 0000000..b8e0ab4
--- /dev/null
+++ b/src/cleo/events/event_dispatcher.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Callable
+from typing import cast
+
+
+if TYPE_CHECKING:
+ from cleo.events.event import Event
+
+ Listener = Callable[[Event, str, "EventDispatcher"], None]
+
+
+class EventDispatcher:
+ def __init__(self) -> None:
+ self._listeners: dict[str, dict[int, list[Listener]]] = {}
+ self._sorted: dict[str, list[Listener]] = {}
+
+ def dispatch(self, event: Event, event_name: str | None = None) -> Event:
+ if event_name is None:
+ event_name = event.__class__.__name__
+
+ listeners = cast("list[Listener]", self.get_listeners(event_name))
+
+ if listeners:
+ self._do_dispatch(listeners, event_name, event)
+
+ return event
+
+ def get_listeners(
+ self, event_name: str | None = None
+ ) -> list[Listener] | dict[str, list[Listener]]:
+ if event_name is not None:
+ if event_name not in self._listeners:
+ return []
+
+ if event_name not in self._sorted:
+ self._sort_listeners(event_name)
+
+ return self._sorted[event_name]
+
+ for event_name in self._listeners.keys():
+ if event_name not in self._sorted:
+ self._sort_listeners(event_name)
+
+ return self._sorted
+
+ def get_listener_priority(self, event_name: str, listener: Listener) -> int | None:
+ if event_name not in self._listeners:
+ return None
+
+ for priority, listeners in self._listeners[event_name].items():
+ for v in listeners:
+ if v == listener:
+ return priority
+
+ return None
+
+ def has_listeners(self, event_name: str | None = None) -> bool:
+ if event_name is not None:
+ if event_name not in self._listeners:
+ return False
+
+ return len(self._listeners[event_name]) > 0
+
+ return any(self._listeners.values())
+
+ def add_listener(
+ self, event_name: str, listener: Listener, priority: int = 0
+ ) -> None:
+ if event_name not in self._listeners:
+ self._listeners[event_name] = {}
+
+ if priority not in self._listeners[event_name]:
+ self._listeners[event_name][priority] = []
+
+ self._listeners[event_name][priority].append(listener)
+
+ if event_name in self._sorted:
+ del self._sorted[event_name]
+
+ def _do_dispatch(
+ self, listeners: list[Listener], event_name: str, event: Event
+ ) -> None:
+ for listener in listeners:
+ if event.is_propagation_stopped():
+ break
+
+ listener(event, event_name, self)
+
+ def _sort_listeners(self, event_name: str) -> None:
+ """
+ Sorts the internal list of listeners for the given event by priority.
+ """
+ self._sorted[event_name] = []
+
+ for _, listeners in sorted(
+ self._listeners[event_name].items(), key=lambda t: -t[0]
+ ):
+ for listener in listeners:
+ self._sorted[event_name].append(listener)
diff --git a/src/cleo/exceptions/__init__.py b/src/cleo/exceptions/__init__.py
new file mode 100644
index 0000000..40677c4
--- /dev/null
+++ b/src/cleo/exceptions/__init__.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from cleo._utils import find_similar_names
+
+
+class CleoError(Exception):
+ """
+ Base Cleo exception.
+ """
+
+ exit_code: int | None = None
+
+
+class CleoLogicError(CleoError):
+ """
+ Raised when there is error in command arguments
+ and/or options configuration logic.
+ """
+
+
+class CleoRuntimeError(CleoError):
+ """
+ Raised when command is called with invalid options or arguments.
+ """
+
+
+class CleoValueError(CleoError):
+ """
+ Raised when wrong value was given to Cleo components.
+ """
+
+
+class CleoNoSuchOptionError(CleoError):
+ """
+ Raised when command does not have given option.
+ """
+
+
+class CleoUserError(CleoError):
+ """
+ Base exception for user errors.
+ """
+
+
+class CleoMissingArgumentsError(CleoUserError):
+ """
+ Raised when called command was not given required arguments.
+ """
+
+
+class CleoCommandNotFoundError(CleoUserError):
+ """
+ Raised when called command does not exist.
+ """
+
+ def __init__(self, name: str, commands: list[str] | None = None) -> None:
+ message = f'The command "{name}" does not exist.'
+
+ if commands:
+ suggested_names = find_similar_names(name, commands)
+
+ if suggested_names:
+ if len(suggested_names) == 1:
+ message += "\n\nDid you mean this?\n "
+ else:
+ message += "\n\nDid you mean one of these?\n "
+
+ message += "\n ".join(suggested_names)
+
+ super().__init__(message)
+
+
+class CleoNamespaceNotFoundError(CleoUserError):
+ """
+ Raised when called namespace has no commands.
+ """
+
+ def __init__(self, name: str, namespaces: list[str] | None = None) -> None:
+ message = f'There are no commands in the "{name}" namespace.'
+
+ if namespaces:
+ suggested_names = find_similar_names(name, namespaces)
+
+ if suggested_names:
+ if len(suggested_names) == 1:
+ message += "\n\nDid you mean this?\n "
+ else:
+ message += "\n\nDid you mean one of these?\n "
+
+ message += "\n ".join(suggested_names)
+
+ super().__init__(message)
diff --git a/src/cleo/formatters/__init__.py b/src/cleo/formatters/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/formatters/__init__.py
diff --git a/src/cleo/formatters/formatter.py b/src/cleo/formatters/formatter.py
new file mode 100644
index 0000000..18a8105
--- /dev/null
+++ b/src/cleo/formatters/formatter.py
@@ -0,0 +1,212 @@
+from __future__ import annotations
+
+import re
+
+from cleo.exceptions import CleoValueError
+from cleo.formatters.style import Style
+from cleo.formatters.style_stack import StyleStack
+
+
+class Formatter:
+
+ TAG_REGEX = re.compile(r"(?ix)<(([a-z](?:[^<>]*)) | /([a-z](?:[^<>]*))?)>")
+
+ _inline_styles_cache: dict[str, Style] = {}
+
+ def __init__(
+ self, decorated: bool = False, styles: dict[str, Style] | None = None
+ ) -> None:
+ self._decorated = decorated
+ self._styles: dict[str, Style] = {}
+
+ self.set_style("error", Style("red", options=["bold"]))
+ self.set_style("info", Style("blue"))
+ self.set_style("comment", Style("green"))
+ self.set_style("question", Style("cyan"))
+ self.set_style("c1", Style("cyan"))
+ self.set_style("c2", Style("default", options=["bold"]))
+ self.set_style("b", Style("default", options=["bold"]))
+
+ if styles is None:
+ styles = {}
+
+ for name, style in styles.items():
+ self.set_style(name, style)
+
+ self._style_stack = StyleStack()
+
+ @classmethod
+ def escape(cls, text: str) -> str:
+ """
+ Escapes "<" special char in given text.
+ """
+ text = re.sub(r"([^\\]?)<", "\\1\\<", text)
+
+ return cls.escape_trailing_backslash(text)
+
+ @classmethod
+ def escape_trailing_backslash(cls, text: str) -> str:
+ """
+ Escapes trailing "\" in given text.
+ """
+ if text and text[-1] == "\\":
+ length = len(text)
+ text = text.rstrip("\\")
+ text = text.replace("\0", "")
+ text += "\0" * (length - len(text))
+
+ return text
+
+ def decorated(self, decorated: bool = True) -> None:
+ self._decorated = decorated
+
+ def is_decorated(self) -> bool:
+ return self._decorated
+
+ def set_style(self, name: str, style: Style) -> None:
+ self._styles[name] = style
+
+ def has_style(self, name: str) -> bool:
+ return name in self._styles
+
+ def style(self, name: str) -> Style:
+ if not self.has_style(name):
+ raise CleoValueError(f'Undefined style: "{name}"')
+
+ return self._styles[name]
+
+ def format(self, message: str) -> str:
+ return self.format_and_wrap(message, 0)
+
+ def format_and_wrap(self, message: str, width: int) -> str:
+ offset = 0
+ output = ""
+ current_line_length = 0
+ for match in self.TAG_REGEX.finditer(message):
+ pos = match.start()
+ text = match.group(0)
+
+ if pos != 0 and message[pos - 1] == "\\":
+ continue
+
+ # add the text up to the next tag
+ formatted, current_line_length = self._apply_current_style(
+ message[offset:pos], output, width, current_line_length
+ )
+ output += formatted
+ offset = pos + len(text)
+
+ # Opening tag
+ open = text[1] != "/"
+ if open:
+ tag = match.group(1)
+ else:
+ tag = match.group(2)
+
+ style = None
+ if tag:
+ style = self._create_style_from_string(tag)
+
+ if not open and not tag:
+ # </>
+ self._style_stack.pop()
+ elif style is None:
+ formatted, current_line_length = self._apply_current_style(
+ text, output, width, current_line_length
+ )
+ output += formatted
+ elif open:
+ self._style_stack.push(style)
+ else:
+ self._style_stack.pop(style)
+
+ formatted, current_line_length = self._apply_current_style(
+ message[offset:], output, width, current_line_length
+ )
+ output += formatted
+
+ if output.find("\0") != -1:
+ return output.replace("\0", "\\").replace("\\<", "<")
+
+ return output.replace("\\<", "<")
+
+ def remove_format(self, text: str) -> str:
+ decorated = self._decorated
+ self._decorated = False
+
+ text = self.format(text)
+ text = re.sub(r"\033\[[^m]*m", "", text)
+
+ self._decorated = decorated
+
+ return text
+
+ def _create_style_from_string(self, string: str) -> Style | None:
+ if string in self._styles:
+ return self._styles[string]
+
+ if string in self._inline_styles_cache:
+ return self._inline_styles_cache[string]
+
+ matches = re.findall("([^=]+)=([^;]+)(;|$)", string.lower())
+ if not matches:
+ return None
+
+ style = Style()
+
+ for match in matches:
+ if match[0] == "fg":
+ style.foreground(match[1])
+ elif match[0] == "bg":
+ style.background(match[1])
+ else:
+ try:
+ for option in match[1].split(","):
+ style.set_option(option.strip())
+ except ValueError:
+ return None
+
+ self._inline_styles_cache[string] = style
+
+ return style
+
+ def _apply_current_style(
+ self, text: str, current: str, width: int, current_line_length: int
+ ) -> tuple[str, int]:
+ if not text:
+ return "", current_line_length
+
+ if not width:
+ if self.is_decorated():
+ return self._style_stack.current.apply(text), current_line_length
+
+ return text, current_line_length
+
+ if not current_line_length and current:
+ text = text.lstrip()
+
+ if current_line_length:
+ i = width - current_line_length
+ prefix = text[:i] + "\n"
+ text = text[i:]
+ else:
+ prefix = ""
+
+ m = re.match(r"(\n)$", text)
+ text = prefix + re.sub(rf"([^\n]{{{width}}})\ *", "\\1\n", text)
+ text = text.rstrip("\n") + (m.group(1) if m else "")
+
+ if not current_line_length and current and current[-1] != "\n":
+ text = "\n" + text
+
+ lines = text.split("\n")
+ for line in lines:
+ current_line_length += len(line)
+ if current_line_length >= width:
+ current_line_length = 0
+
+ if self.is_decorated():
+ for i, line in enumerate(lines):
+ lines[i] = self._style_stack.current.apply(line)
+
+ return "\n".join(lines), current_line_length
diff --git a/src/cleo/formatters/style.py b/src/cleo/formatters/style.py
new file mode 100644
index 0000000..e11292d
--- /dev/null
+++ b/src/cleo/formatters/style.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from cleo.color import Color
+
+
+class Style:
+ def __init__(
+ self,
+ foreground: str | None = None,
+ background: str | None = None,
+ options: list[str] | None = None,
+ ) -> None:
+ self._foreground = foreground or ""
+ self._background = background or ""
+ self._options = options or []
+
+ self._color = Color(self._foreground, self._background, self._options)
+
+ def foreground(self, foreground: str) -> Style:
+ self._color = Color(foreground, self._background, self._options)
+ self._foreground = foreground
+
+ return self
+
+ def background(self, background: str) -> Style:
+ self._color = Color(self._foreground, background, self._options)
+ self._background = background
+
+ return self
+
+ def bold(self, bold: bool = True) -> Style:
+ return self.set_option("bold") if bold else self.unset_option("bold")
+
+ def dark(self, dark: bool = True) -> Style:
+ return self.set_option("dark") if dark else self.unset_option("dark")
+
+ def underlines(self, underlined: bool = True) -> Style:
+ return (
+ self.set_option("underline")
+ if underlined
+ else self.unset_option("underline")
+ )
+
+ def italic(self, italic: bool = True) -> Style:
+ return self.set_option("italic") if italic else self.unset_option("italic")
+
+ def blinking(self, blinking: bool = True) -> Style:
+ return self.set_option("blink") if blinking else self.unset_option("blink")
+
+ def inverse(self, inverse: bool = True) -> Style:
+ return self.set_option("reverse") if inverse else self.unset_option("reverse")
+
+ def hidden(self, hidden: bool = True) -> Style:
+ return self.set_option("conceal") if hidden else self.unset_option("conceal")
+
+ def set_option(self, option: str) -> Style:
+ self._options.append(option)
+ self._color = Color(self._foreground, self._background, self._options)
+
+ return self
+
+ def unset_option(self, option: str) -> Style:
+ if option in self._options:
+ index = self._options.index(option)
+ del self._options[index]
+ self._color = Color(self._foreground, self._background, self._options)
+ return self
+
+ def apply(self, text: str) -> str:
+ return self._color.apply(text)
diff --git a/src/cleo/formatters/style_stack.py b/src/cleo/formatters/style_stack.py
new file mode 100644
index 0000000..1628f34
--- /dev/null
+++ b/src/cleo/formatters/style_stack.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from cleo.exceptions import CleoValueError
+from cleo.formatters.style import Style
+
+
+class StyleStack:
+ def __init__(self, empty_style: Style | None = None):
+ if empty_style is None:
+ empty_style = Style()
+
+ self._empty_style = empty_style
+ self._styles: list[Style] = []
+
+ @property
+ def current(self) -> Style:
+ if not self._styles:
+ return self._empty_style
+
+ return self._styles[-1]
+
+ def reset(self) -> None:
+ self._styles = []
+
+ def push(self, style: Style) -> None:
+ self._styles.append(style)
+
+ def pop(self, style: Style | None = None) -> Style:
+ if not self._styles:
+ return self._empty_style
+
+ if style is None:
+ return self._styles.pop()
+
+ for i, stacked_style in reversed(list(enumerate(self._styles))):
+ if style.apply("") == stacked_style.apply(""):
+ self._styles = self._styles[:i]
+
+ return stacked_style
+
+ raise CleoValueError("Invalid nested tag found")
diff --git a/src/cleo/helpers.py b/src/cleo/helpers.py
new file mode 100644
index 0000000..a922aa7
--- /dev/null
+++ b/src/cleo/helpers.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from typing import Any
+
+from cleo.io.inputs.argument import Argument
+from cleo.io.inputs.option import Option
+
+
+def argument(
+ name: str,
+ description: str | None = None,
+ optional: bool = False,
+ multiple: bool = False,
+ default: Any | None = None,
+) -> Argument:
+ return Argument(
+ name,
+ required=not optional,
+ is_list=multiple,
+ description=description,
+ default=default,
+ )
+
+
+def option(
+ long_name: str,
+ short_name: str | None = None,
+ description: str | None = None,
+ flag: bool = True,
+ value_required: bool = True,
+ multiple: bool = False,
+ default: Any | None = None,
+) -> Option:
+ return Option(
+ long_name,
+ short_name,
+ flag=flag,
+ requires_value=value_required,
+ is_list=multiple,
+ description=description,
+ default=default,
+ )
diff --git a/src/cleo/io/__init__.py b/src/cleo/io/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/io/__init__.py
diff --git a/src/cleo/io/buffered_io.py b/src/cleo/io/buffered_io.py
new file mode 100644
index 0000000..ed605b5
--- /dev/null
+++ b/src/cleo/io/buffered_io.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import cast
+
+from cleo.io.inputs.string_input import StringInput
+from cleo.io.io import IO
+from cleo.io.outputs.buffered_output import BufferedOutput
+
+
+if TYPE_CHECKING:
+ from cleo.io.inputs.input import Input
+
+
+class BufferedIO(IO):
+ def __init__(
+ self,
+ input: Input | None = None,
+ decorated: bool = False,
+ supports_utf8: bool = True,
+ ) -> None:
+ super().__init__(
+ input or StringInput(""),
+ BufferedOutput(decorated=decorated, supports_utf8=supports_utf8),
+ BufferedOutput(decorated=decorated, supports_utf8=supports_utf8),
+ )
+
+ def fetch_output(self) -> str:
+ return cast(BufferedOutput, self._output).fetch()
+
+ def fetch_error(self) -> str:
+ return cast(BufferedOutput, self._error_output).fetch()
+
+ def clear(self) -> None:
+ cast(BufferedOutput, self._output).clear()
+ cast(BufferedOutput, self._error_output).clear()
+
+ def clear_output(self) -> None:
+ cast(BufferedOutput, self._output).clear()
+
+ def clear_error(self) -> None:
+ cast(BufferedOutput, self._error_output).clear()
+
+ def supports_utf8(self) -> bool:
+ return cast(BufferedOutput, self._output).supports_utf8()
+
+ def clear_user_input(self) -> None:
+ self._input.stream.truncate(0)
+ self._input.stream.seek(0)
+
+ def set_user_input(self, user_input: str) -> None:
+ self.clear_user_input()
+
+ self._input.stream.write(user_input)
+ self._input.stream.seek(0)
diff --git a/src/cleo/io/inputs/__init__.py b/src/cleo/io/inputs/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/io/inputs/__init__.py
diff --git a/src/cleo/io/inputs/argument.py b/src/cleo/io/inputs/argument.py
new file mode 100644
index 0000000..237bca9
--- /dev/null
+++ b/src/cleo/io/inputs/argument.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from typing import Any
+
+from cleo.exceptions import CleoLogicError
+
+
+class Argument:
+ """
+ A command line argument.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ required: bool = True,
+ is_list: bool = False,
+ description: str | None = None,
+ default: Any | None = None,
+ ) -> None:
+ self._name = name
+ self._required = required
+ self._is_list = is_list
+ self._description = description or ""
+ self._default: str | list[str] | None = None
+
+ self.set_default(default)
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def default(self) -> str | list[str] | None:
+ return self._default
+
+ @property
+ def description(self) -> str:
+ return self._description
+
+ def is_required(self) -> bool:
+ return self._required
+
+ def is_list(self) -> bool:
+ return self._is_list
+
+ def set_default(self, default: Any | None = None) -> None:
+ if self._required and default is not None:
+ raise CleoLogicError("Cannot set a default value for required arguments")
+
+ if self._is_list:
+ if default is None:
+ default = []
+ elif not isinstance(default, list):
+ raise CleoLogicError(
+ "A default value for a list argument must be a list"
+ )
+
+ self._default = default
+
+ def __repr__(self) -> str:
+ return (
+ f"Argument({repr(self._name)}, "
+ f"required={self._required}, "
+ f"is_list={self._is_list}, "
+ f"description={repr(self._description)}, "
+ f"default={repr(self._default)})"
+ )
diff --git a/src/cleo/io/inputs/argv_input.py b/src/cleo/io/inputs/argv_input.py
new file mode 100644
index 0000000..3705f72
--- /dev/null
+++ b/src/cleo/io/inputs/argv_input.py
@@ -0,0 +1,303 @@
+from __future__ import annotations
+
+import sys
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from cleo.exceptions import CleoNoSuchOptionError
+from cleo.exceptions import CleoRuntimeError
+from cleo.io.inputs.input import Input
+
+
+if TYPE_CHECKING:
+ from cleo.io.inputs.definition import Definition
+
+
+class ArgvInput(Input):
+ """
+ Represents an input coming from the command line.
+ """
+
+ def __init__(
+ self, argv: list[str] | None = None, definition: Definition | None = None
+ ) -> None:
+ if argv is None:
+ argv = sys.argv
+
+ argv = argv[:]
+
+ # Strip the application name
+ try:
+ self._script_name: str | None = argv.pop(0)
+ except IndexError:
+ self._script_name = None
+
+ self._tokens = argv
+ self._parsed: list[str] = []
+
+ super().__init__(definition=definition)
+
+ @property
+ def first_argument(self) -> str | None:
+ is_option = False
+
+ for i, token in enumerate(self._tokens):
+ if token and token[0] == "-":
+ if token.find("=") != -1 or len(self._tokens) <= (i + 1):
+ continue
+
+ # If it's a long option, consider that
+ # everything after "--" is the option name.
+ # Otherwise, use the last character
+ # (if it's a short option set, only the last one
+ # can take a value with space separator).
+ if len(token) > 1 and token[1] == "-":
+ name = token[2:]
+ else:
+ name = token[-1]
+
+ if name not in self._options and not self._definition.has_shortcut(
+ name
+ ):
+ # noop
+ continue
+
+ if name not in self._options:
+ name = self._definition.shortcut_to_name(name)
+
+ if name in self._options and self._tokens[i + 1] == self._options[name]:
+ is_option = True
+
+ continue
+
+ if is_option:
+ is_option = False
+ continue
+
+ return token
+
+ return None
+
+ @property
+ def script_name(self) -> str | None:
+ return self._script_name
+
+ def has_parameter_option(
+ self, values: str | list[str], only_params: bool = False
+ ) -> bool:
+ """
+ Returns true if the raw parameters (not parsed) contain a value.
+ """
+ if not isinstance(values, list):
+ values = [values]
+
+ for token in self._tokens:
+ if only_params and token == "--":
+ return False
+
+ for value in values:
+ # Options with values:
+ # For long options, test for '--option=' at beginning
+ # For short options, test for '-o' at beginning
+ if value.find("--") == 0:
+ leading = value + "="
+ else:
+ leading = value
+
+ if token == value or leading != "" and token.find(leading) == 0:
+ return True
+
+ return False
+
+ def parameter_option(
+ self,
+ values: str | list[str],
+ default: Any = False,
+ only_params: bool = False,
+ ) -> Any:
+ if not isinstance(values, list):
+ values = [values]
+
+ tokens = self._tokens[:]
+ while len(tokens) > 0:
+ token = tokens.pop(0)
+ if only_params and token == "--":
+ return default
+
+ for value in values:
+ if token == value:
+ try:
+ return tokens.pop(0)
+ except IndexError:
+ return
+
+ # Options with values:
+ # For long options, test for '--option=' at beginning
+ # For short options, test for '-o' at beginning
+ if value.find("--") == 0:
+ leading = value + "="
+ else:
+ leading = value
+
+ if token == value or leading != "" and token.find(leading) == 0:
+ return token[len(leading)]
+
+ return False
+
+ def _set_tokens(self, tokens: list[str]) -> None:
+ self._tokens = tokens
+
+ def _parse(self) -> None:
+ parse_options = True
+ self._parsed = self._tokens[:]
+
+ try:
+ token = self._parsed.pop(0)
+ except IndexError:
+ token = None
+
+ while token is not None:
+ if parse_options and token == "":
+ self._parse_argument(token)
+ elif parse_options and token == "--":
+ parse_options = False
+ elif parse_options and token.find("--") == 0:
+ self._parse_long_option(token)
+ elif parse_options and token[0] == "-" and token != "-":
+ self._parse_short_option(token)
+ else:
+ self._parse_argument(token)
+
+ try:
+ token = self._parsed.pop(0)
+ except IndexError:
+ token = None
+
+ def _parse_short_option(self, token: str) -> None:
+ name = token[1:]
+
+ if len(name) > 1:
+ if (
+ self._definition.has_shortcut(name[0])
+ and self._definition.option_for_shortcut(name[0]).accepts_value()
+ ):
+ # An option with a value and no space
+ self._add_short_option(name[0], name[1:])
+ else:
+ self._parse_short_option_set(name)
+ else:
+ self._add_short_option(name, None)
+
+ def _parse_short_option_set(self, name: str) -> None:
+ length = len(name)
+ for i in range(length):
+ if not self._definition.has_shortcut(name[i]):
+ raise CleoRuntimeError(f'The option "{name[i]}" does not exist')
+
+ option = self._definition.option_for_shortcut(name[i])
+ if option.accepts_value():
+ self._add_long_option(
+ option.name, None if i == length - 1 else name[i + 1 :]
+ )
+
+ break
+
+ self._add_long_option(option.name, None)
+
+ def _parse_long_option(self, token: str) -> None:
+ name = token[2:]
+
+ pos = name.find("=")
+ if pos != -1:
+ value = name[pos + 1 :]
+ if not value:
+ self._parsed.insert(0, value)
+
+ self._add_long_option(name[0:pos], value)
+ else:
+ self._add_long_option(name, None)
+
+ def _parse_argument(self, token: str) -> None:
+ count = len(self._arguments)
+
+ # If the input is expecting another argument, add it
+ if self._definition.has_argument(count):
+ argument = self._definition.argument(count)
+ self._arguments[argument.name] = [token] if argument.is_list() else token
+ # If the last argument is a list, append the token to it
+ elif (
+ self._definition.has_argument(count - 1)
+ and self._definition.argument(count - 1).is_list()
+ ):
+ argument = self._definition.argument(count - 1)
+ self._arguments[argument.name].append(token)
+ # Unexpected argument
+ else:
+ all = self._definition.arguments.copy()
+ command_name = None
+ argument = all[0]
+ if argument and argument.name == "command":
+ command_name = self._arguments.get("command")
+ del all[0]
+
+ if all:
+ all_names = '" "'.join([a.name for a in all])
+ if command_name:
+
+ message = (
+ f'Too many arguments to "{command_name}" command, '
+ f'expected arguments "{all_names}"'
+ )
+ else:
+ message = f'Too many arguments, expected arguments "{all_names}"'
+ elif command_name:
+ message = (
+ f'No arguments expected for "{command_name}" command, '
+ f'got "{token}"'
+ )
+ else:
+ message = f'No arguments expected, got "{token}"'
+
+ raise CleoRuntimeError(message)
+
+ def _add_short_option(self, shortcut: str, value: Any) -> None:
+ if not self._definition.has_shortcut(shortcut):
+ raise CleoNoSuchOptionError(f'The option "-{shortcut}" does not exist')
+
+ self._add_long_option(
+ self._definition.option_for_shortcut(shortcut).name, value
+ )
+
+ def _add_long_option(self, name: str, value: Any) -> None:
+ if not self._definition.has_option(name):
+ raise CleoNoSuchOptionError(f'The option "--{name}" does not exist')
+
+ option = self._definition.option(name)
+
+ if value is not None and not option.accepts_value():
+ raise CleoRuntimeError(f'The "--{name}" option does not accept a value')
+
+ if value in ["", None] and option.accepts_value() and self._parsed:
+ # If the option accepts a value, either required or optional,
+ # we check if there is one
+ next_token = self._parsed.pop(0)
+ if (next_token and next_token[0] != "-") or next_token in ["", None]:
+ value = next_token
+ else:
+ self._parsed.insert(0, next_token)
+
+ if value is None:
+ if option.requires_value():
+ raise CleoRuntimeError(f'The "--{name}" option requires a value')
+
+ if not option.is_list() and option.is_flag():
+ value = True
+
+ if option.is_list():
+ if name not in self._options:
+ self._options[name] = []
+
+ self._options[name].append(value)
+ else:
+ self._options[name] = value
diff --git a/src/cleo/io/inputs/definition.py b/src/cleo/io/inputs/definition.py
new file mode 100644
index 0000000..5ef07cc
--- /dev/null
+++ b/src/cleo/io/inputs/definition.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import sys
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Sequence
+
+from cleo.exceptions import CleoLogicError
+from cleo.io.inputs.option import Option
+
+
+if TYPE_CHECKING:
+ from cleo.io.inputs.argument import Argument
+
+
+class Definition:
+ """
+ A Definition represents a set of command line arguments and options.
+ """
+
+ def __init__(self, definition: Sequence[Argument | Option] | None = None) -> None:
+ self._arguments: dict[str, Argument] = {}
+ self._required_count = 0
+ self._has_a_list_argument = False
+ self._has_optional = False
+ self._options: dict[str, Option] = {}
+ self._shortcuts: dict[str, str] = {}
+
+ if definition is None:
+ definition = []
+
+ self.set_definition(definition)
+
+ @property
+ def arguments(self) -> list[Argument]:
+ return list(self._arguments.values())
+
+ @property
+ def argument_count(self) -> int:
+ if self._has_a_list_argument:
+ return sys.maxsize
+
+ return len(self._arguments)
+
+ @property
+ def required_argument_count(self) -> int:
+ return self._required_count
+
+ @property
+ def argument_defaults(self) -> dict[str, Any]:
+ values = {}
+
+ for argument in self._arguments.values():
+ values[argument.name] = argument.default
+
+ return values
+
+ @property
+ def options(self) -> list[Option]:
+ return list(self._options.values())
+
+ @property
+ def option_defaults(self) -> dict[str, Any]:
+ values = {}
+ for option in self._options.values():
+ values[option.name] = option.default
+
+ return values
+
+ def set_definition(self, definition: Sequence[Argument | Option]) -> None:
+ arguments = []
+ options = []
+
+ for item in definition:
+ if isinstance(item, Option):
+ options.append(item)
+ else:
+ arguments.append(item)
+
+ self.set_arguments(arguments)
+ self.set_options(options)
+
+ def set_arguments(self, arguments: list[Argument]) -> None:
+ self._arguments = {}
+ self._required_count = 0
+ self._has_a_list_argument = False
+ self._has_optional = False
+ self.add_arguments(arguments)
+
+ def add_arguments(self, arguments: list[Argument]) -> None:
+ for argument in arguments:
+ self.add_argument(argument)
+
+ def add_argument(self, argument: Argument) -> None:
+ if argument.name in self._arguments:
+ raise CleoLogicError(
+ f'An argument with name "{argument.name}" already exists'
+ )
+
+ if self._has_a_list_argument:
+ raise CleoLogicError("Cannot add an argument after a list argument")
+
+ if argument.is_required() and self._has_optional:
+ raise CleoLogicError("Cannot add a required argument after an optional one")
+
+ if argument.is_list():
+ self._has_a_list_argument = True
+
+ if argument.is_required():
+ self._required_count += 1
+ else:
+ self._has_optional = True
+
+ self._arguments[argument.name] = argument
+
+ def argument(self, name: str | int) -> Argument:
+ if not self.has_argument(name):
+ raise ValueError(f'The "{name}" argument does not exist')
+
+ if isinstance(name, int):
+ arguments = list(self._arguments.values())
+ return arguments[name]
+
+ return self._arguments[name]
+
+ def has_argument(self, name: str | int) -> bool:
+ arguments: dict[str, Argument] | list[Argument]
+ if isinstance(name, int):
+ arguments = list(self._arguments.values())
+ else:
+ arguments = self._arguments
+
+ try:
+ arguments[name] # type: ignore[index]
+ except (KeyError, IndexError):
+ return False
+
+ return True
+
+ def set_options(self, options: list[Option]) -> None:
+ self._options = {}
+ self._shortcuts = {}
+ self.add_options(options)
+
+ def add_options(self, options: list[Option]) -> None:
+ for option in options:
+ self.add_option(option)
+
+ def add_option(self, option: Option) -> None:
+ if option.name in self._options and option != self._options[option.name]:
+ raise CleoLogicError(f'An option named "{option.name}" already exists')
+
+ if option.shortcut:
+ for shortcut in option.shortcut.split("|"):
+ if (
+ shortcut in self._shortcuts
+ and option.name != self._shortcuts[shortcut]
+ ):
+ raise CleoLogicError(
+ f'An option with shortcut "{shortcut}" already exists'
+ )
+
+ self._options[option.name] = option
+
+ if option.shortcut:
+ for shortcut in option.shortcut.split("|"):
+ self._shortcuts[shortcut] = option.name
+
+ def option(self, name: str) -> Option:
+ if not self.has_option(name):
+ raise ValueError(f'The option "--{name}" option does not exist')
+
+ return self._options[name]
+
+ def has_option(self, name: str) -> bool:
+ return name in self._options
+
+ def has_shortcut(self, shortcut: str) -> bool:
+ return shortcut in self._shortcuts
+
+ def option_for_shortcut(self, shortcut: str) -> Option:
+ return self._options[self.shortcut_to_name(shortcut)]
+
+ def shortcut_to_name(self, shortcut: str) -> str:
+ if shortcut not in self._shortcuts:
+ raise ValueError(f'The "-{shortcut}" option does not exist')
+
+ return self._shortcuts[shortcut]
+
+ def synopsis(self, short: bool = False) -> str:
+ elements = []
+
+ if short and self._options:
+ elements.append("[options]")
+ elif not short:
+ for option in self._options.values():
+ value = ""
+ if option.accepts_value():
+ formatted = (
+ option.name.upper()
+ if option.requires_value()
+ else f"[{option.name.upper()}]"
+ )
+ value = f" {formatted}"
+
+ shortcut = ""
+ if option.shortcut:
+ shortcut = f"-{option.shortcut}|"
+
+ elements.append(f"[{shortcut}--{option.name}{value}]")
+
+ if elements and self._arguments:
+ elements.append("[--]")
+
+ tail = ""
+ for argument in self._arguments.values():
+ element = f"<{argument.name}>"
+ if argument.is_list():
+ element += "..."
+
+ if not argument.is_required():
+ element = "[" + element
+ tail += "]"
+
+ elements.append(element)
+
+ return " ".join(elements) + tail
diff --git a/src/cleo/io/inputs/input.py b/src/cleo/io/inputs/input.py
new file mode 100644
index 0000000..94e52da
--- /dev/null
+++ b/src/cleo/io/inputs/input.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import re
+
+from typing import Any
+from typing import TextIO
+
+from cleo._compat import shell_quote
+from cleo.exceptions import CleoMissingArgumentsError
+from cleo.exceptions import CleoValueError
+from cleo.io.inputs.definition import Definition
+
+
+class Input:
+ """
+ This class is the base class for concrete Input implementations.
+ """
+
+ def __init__(self, definition: Definition | None = None) -> None:
+ self._definition: Definition
+ self._stream: TextIO = None # type: ignore[assignment]
+ self._options: dict[str, Any] = {}
+ self._arguments: dict[str, Any] = {}
+ self._interactive: bool | None = None
+
+ if definition is None:
+ self._definition = Definition()
+ else:
+ self.bind(definition)
+ self.validate()
+
+ @property
+ def arguments(self) -> dict[str, Any]:
+ return {**self._definition.argument_defaults, **self._arguments}
+
+ @property
+ def options(self) -> dict[str, Any]:
+ return {**self._definition.option_defaults, **self._options}
+
+ @property
+ def stream(self) -> TextIO:
+ return self._stream
+
+ @property
+ def first_argument(self) -> str | None:
+ """
+ Returns the first argument from the raw parameters (not parsed).
+ """
+ raise NotImplementedError()
+
+ @property
+ def script_name(self) -> str | None:
+ raise NotImplementedError()
+
+ def read(self, length: int, default: str = "") -> str:
+ """
+ Reads the given amount of characters from the input stream.
+ """
+ if not self.is_interactive():
+ return default
+
+ return self._stream.read(length)
+
+ def read_line(self, length: int = -1, default: str = "") -> str:
+ """
+ Reads a line from the input stream.
+ """
+ if not self.is_interactive():
+ return default
+
+ return self._stream.readline(length)
+
+ def close(self) -> None:
+ """
+ Closes the input.
+ """
+ self._stream.close()
+
+ def is_closed(self) -> bool:
+ """
+ Returns whether the input is closed.
+ """
+ return self._stream.closed
+
+ def is_interactive(self) -> bool:
+ return True if self._interactive is None else self._interactive
+
+ def interactive(self, interactive: bool = True) -> None:
+ self._interactive = interactive
+
+ def bind(self, definition: Definition) -> None:
+ """
+ Binds the current Input instance with
+ the given definition's arguments and options.
+ """
+ self._arguments = {}
+ self._options = {}
+ self._definition = definition
+
+ self._parse()
+
+ def validate(self) -> None:
+ missing_arguments = []
+
+ for argument in self._definition.arguments:
+ if argument.name not in self._arguments and argument.is_required():
+ missing_arguments.append(argument.name)
+
+ if missing_arguments:
+ raise CleoMissingArgumentsError(
+ f'Not enough arguments (missing: "{", ".join(missing_arguments)}")'
+ )
+
+ def argument(self, name: str) -> Any:
+ if not self._definition.has_argument(name):
+ raise CleoValueError(f'The argument "{name}" does not exist')
+
+ if name in self._arguments:
+ return self._arguments[name]
+
+ return self._definition.argument(name).default
+
+ def set_argument(self, name: str, value: Any) -> None:
+ if not self._definition.has_argument(name):
+ raise CleoValueError(f'The argument "{name}" does not exist')
+
+ self._arguments[name] = value
+
+ def has_argument(self, name: str) -> bool:
+ return self._definition.has_argument(name)
+
+ def option(self, name: str) -> Any:
+ if not self._definition.has_option(name):
+ raise CleoValueError(f'The option "--{name}" does not exist')
+
+ if name in self._options:
+ return self._options[name]
+
+ return self._definition.option(name).default
+
+ def set_option(self, name: str, value: Any) -> None:
+ if not self._definition.has_option(name):
+ raise CleoValueError(f'The option "--{name}" does not exist')
+
+ self._options[name] = value
+
+ def has_option(self, name: str) -> bool:
+ return self._definition.has_option(name)
+
+ def escape_token(self, token: str) -> str:
+ if re.match(r"^[\w-]+$", token):
+ return token
+
+ return shell_quote(token)
+
+ def set_stream(self, stream: TextIO) -> None:
+ self._stream = stream
+
+ def has_parameter_option(
+ self, values: str | list[str], only_params: bool = False
+ ) -> bool:
+ """
+ Returns true if the raw parameters (not parsed) contain a value.
+ """
+ raise NotImplementedError()
+
+ def parameter_option(
+ self,
+ values: str | list[str],
+ default: Any = False,
+ only_params: bool = False,
+ ) -> Any:
+ """
+ Returns the value of a raw option (not parsed).
+ """
+ raise NotImplementedError()
+
+ def _parse(self) -> None:
+ raise NotImplementedError()
diff --git a/src/cleo/io/inputs/option.py b/src/cleo/io/inputs/option.py
new file mode 100644
index 0000000..8fcaeec
--- /dev/null
+++ b/src/cleo/io/inputs/option.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import re
+
+from typing import Any
+
+from cleo.exceptions import CleoLogicError
+from cleo.exceptions import CleoValueError
+
+
+class Option:
+ """
+ A command line option.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ shortcut: str | None = None,
+ flag: bool = True,
+ requires_value: bool = True,
+ is_list: bool = False,
+ description: str | None = None,
+ default: Any | None = None,
+ ) -> None:
+ if name.startswith("--"):
+ name = name[2:]
+
+ if not name:
+ raise CleoValueError("An option name cannot be empty")
+
+ if shortcut is not None:
+ shortcuts = re.split(r"\|-?", shortcut.lstrip("-"))
+ shortcuts = [s for s in shortcuts if s]
+ shortcut = "|".join(shortcuts)
+
+ if not shortcut:
+ raise CleoValueError("An option shortcut cannot be empty")
+
+ self._name = name
+ self._shortcut = shortcut
+ self._flag = flag
+ self._requires_value = requires_value
+ self._is_list = is_list
+ self._description = description or ""
+ self._default = None
+
+ if self._is_list and self._flag:
+ raise CleoLogicError("A flag option cannot be a list as well")
+
+ self.set_default(default)
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def shortcut(self) -> str | None:
+ return self._shortcut
+
+ @property
+ def description(self) -> str:
+ return self._description
+
+ @property
+ def default(self) -> Any | None:
+ return self._default
+
+ def is_flag(self) -> bool:
+ return self._flag
+
+ def accepts_value(self) -> bool:
+ return not self._flag
+
+ def requires_value(self) -> bool:
+ return not self._flag and self._requires_value
+
+ def is_list(self) -> bool:
+ return self._is_list
+
+ def set_default(self, default: Any | None = None) -> None:
+ if self._flag and default is not None:
+ raise CleoLogicError("A flag option cannot have a default value")
+
+ if self._is_list:
+ if default is None:
+ default = []
+ elif not isinstance(default, list):
+ raise CleoLogicError("A default value for a list option must be a list")
+
+ if self._flag:
+ default = False
+
+ self._default = default
+
+ def __repr__(self) -> str:
+ return f"Option({self._name})"
diff --git a/src/cleo/io/inputs/string_input.py b/src/cleo/io/inputs/string_input.py
new file mode 100644
index 0000000..de894b6
--- /dev/null
+++ b/src/cleo/io/inputs/string_input.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from cleo.io.inputs.argv_input import ArgvInput
+from cleo.io.inputs.token_parser import TokenParser
+
+
+class StringInput(ArgvInput):
+ """
+ Represents an input provided as a string
+ """
+
+ def __init__(self, input: str) -> None:
+ super().__init__([])
+
+ self._set_tokens(self._tokenize(input))
+
+ def _tokenize(self, input: str) -> list[str]:
+ return TokenParser().parse(input)
diff --git a/src/cleo/io/inputs/token_parser.py b/src/cleo/io/inputs/token_parser.py
new file mode 100644
index 0000000..334de38
--- /dev/null
+++ b/src/cleo/io/inputs/token_parser.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+
+class TokenParser:
+ """
+ Parses tokens from a string passed to StringArgs.
+ """
+
+ def __init__(self) -> None:
+ self._string: str = ""
+ self._cursor: int = 0
+ self._current: str | None = None
+ self._next_: str | None = None
+
+ def parse(self, string: str) -> list[str]:
+ self._string = string
+ self._cursor = 0
+ self._current = None
+ if len(string) > 0:
+ self._current = string[0]
+
+ self._next_ = None
+ if len(string) > 1:
+ self._next_ = string[1]
+
+ tokens = self._parse()
+
+ return tokens
+
+ def _parse(self) -> list[str]:
+ tokens = []
+
+ while self._current is not None:
+ if self._current.isspace():
+ # Skip spaces
+ self._next()
+
+ continue
+
+ if self._current is not None:
+ tokens.append(self._parse_token())
+
+ return tokens
+
+ def _next(self) -> None:
+ """
+ Advances the cursor to the next position.
+ """
+ if self._current is None:
+ return
+
+ self._cursor += 1
+ self._current = self._next_
+
+ if self._cursor + 1 < len(self._string):
+ self._next_ = self._string[self._cursor + 1]
+ else:
+ self._next_ = None
+
+ def _parse_token(self) -> str:
+ token = ""
+
+ while self._current is not None:
+ if self._current.isspace():
+ self._next()
+
+ break
+
+ if self._current == "\\":
+ token += self._parse_escape_sequence()
+ elif self._current in ["'", '"']:
+ token += self._parse_quoted_string()
+ else:
+ token += self._current
+ self._next()
+
+ return token
+
+ def _parse_quoted_string(self) -> str:
+ string = ""
+ delimiter = self._current
+
+ # Skip first delimiter
+ self._next()
+ while self._current is not None:
+ if self._current == delimiter:
+ # Skip last delimiter
+ self._next()
+
+ break
+
+ if self._current == "\\":
+ string += self._parse_escape_sequence()
+ elif self._current == '"':
+ string += f'"{self._parse_quoted_string()}"'
+ elif self._current == "'":
+ string += f"'{self._parse_quoted_string()}'"
+ else:
+ string += self._current
+ self._next()
+
+ return string
+
+ def _parse_escape_sequence(self) -> str:
+ if self._next_ in ['"', "'"]:
+ sequence = self._next_
+ else:
+ assert self._next_ is not None
+ sequence = "\\" + self._next_
+
+ self._next()
+ self._next()
+
+ return sequence
diff --git a/src/cleo/io/io.py b/src/cleo/io/io.py
new file mode 100644
index 0000000..cf1e099
--- /dev/null
+++ b/src/cleo/io/io.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Iterable
+
+from cleo.io.outputs.output import Type as OutputType
+from cleo.io.outputs.output import Verbosity
+
+
+if TYPE_CHECKING:
+ from cleo.io.inputs.input import Input
+ from cleo.io.outputs.output import Output
+ from cleo.io.outputs.section_output import SectionOutput
+
+
+class IO:
+ def __init__(self, input: Input, output: Output, error_output: Output) -> None:
+ self._input = input
+ self._output = output
+ self._error_output = error_output
+
+ @property
+ def input(self) -> Input:
+ return self._input
+
+ @property
+ def output(self) -> Output:
+ return self._output
+
+ @property
+ def error_output(self) -> Output:
+ return self._error_output
+
+ def read(self, length: int, default: str = "") -> str:
+ """
+ Reads the given amount of characters from the input stream.
+ """
+ return self._input.read(length, default=default)
+
+ def read_line(self, length: int = -1, default: str = "") -> str:
+ """
+ Reads a line from the input stream.
+ """
+ return self._input.read_line(length=length, default=default)
+
+ def write_line(
+ self,
+ messages: str | Iterable[str],
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: OutputType = OutputType.NORMAL,
+ ) -> None:
+ self._output.write_line(messages, verbosity=verbosity, type=type)
+
+ def write(
+ self,
+ messages: str | Iterable[str],
+ new_line: bool = False,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: OutputType = OutputType.NORMAL,
+ ) -> None:
+ self._output.write(messages, new_line=new_line, verbosity=verbosity, type=type)
+
+ def write_error_line(
+ self,
+ messages: str | Iterable[str],
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: OutputType = OutputType.NORMAL,
+ ) -> None:
+ self._error_output.write_line(messages, verbosity=verbosity, type=type)
+
+ def write_error(
+ self,
+ messages: str | Iterable[str],
+ new_line: bool = False,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: OutputType = OutputType.NORMAL,
+ ) -> None:
+ self._error_output.write(
+ messages, new_line=new_line, verbosity=verbosity, type=type
+ )
+
+ def overwrite(self, messages: str | Iterable[str]) -> None:
+ from cleo.cursor import Cursor
+
+ cursor = Cursor(self._output)
+ cursor.move_to_column(1)
+ cursor.clear_line()
+ self.write(messages)
+
+ def overwrite_error(self, messages: str | Iterable[str]) -> None:
+ from cleo.cursor import Cursor
+
+ cursor = Cursor(self._error_output)
+ cursor.move_to_column(1)
+ cursor.clear_line()
+ self.write_error(messages)
+
+ def flush(self) -> None:
+ self._output.flush()
+
+ def is_interactive(self) -> bool:
+ return self._input.is_interactive()
+
+ def interactive(self, interactive: bool = True) -> None:
+ self._input.interactive(interactive)
+
+ def decorated(self, decorated: bool = True) -> None:
+ self._output.decorated(decorated)
+ self._error_output.decorated(decorated)
+
+ def is_decorated(self) -> bool:
+ return self._output.is_decorated()
+
+ def supports_utf8(self) -> bool:
+ return self._output.supports_utf8()
+
+ def set_verbosity(self, verbosity: Verbosity) -> None:
+ self._output.set_verbosity(verbosity)
+ self._error_output.set_verbosity(verbosity)
+
+ def is_verbose(self) -> bool:
+ return self.output.is_verbose()
+
+ def is_very_verbose(self) -> bool:
+ return self.output.is_very_verbose()
+
+ def is_debug(self) -> bool:
+ return self.output.is_debug()
+
+ def set_input(self, input: Input) -> None:
+ self._input = input
+
+ def with_input(self, input: Input) -> IO:
+ return self.__class__(input, self._output, self._error_output)
+
+ def remove_format(self, text: str) -> str:
+ return self._output.remove_format(text)
+
+ def section(self) -> SectionOutput:
+ return self._output.section()
diff --git a/src/cleo/io/null_io.py b/src/cleo/io/null_io.py
new file mode 100644
index 0000000..0a6b9ca
--- /dev/null
+++ b/src/cleo/io/null_io.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from cleo.io.inputs.string_input import StringInput
+from cleo.io.io import IO
+from cleo.io.outputs.null_output import NullOutput
+
+
+if TYPE_CHECKING:
+ from cleo.io.inputs.input import Input
+
+
+class NullIO(IO):
+ def __init__(self, input: Input | None = None) -> None:
+ if input is None:
+ input = StringInput("")
+
+ super().__init__(input, NullOutput(), NullOutput())
diff --git a/src/cleo/io/outputs/__init__.py b/src/cleo/io/outputs/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/io/outputs/__init__.py
diff --git a/src/cleo/io/outputs/buffered_output.py b/src/cleo/io/outputs/buffered_output.py
new file mode 100644
index 0000000..4db86d9
--- /dev/null
+++ b/src/cleo/io/outputs/buffered_output.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from io import StringIO
+from typing import TYPE_CHECKING
+
+from cleo.io.outputs.output import Output
+from cleo.io.outputs.output import Verbosity
+from cleo.io.outputs.section_output import SectionOutput
+
+
+if TYPE_CHECKING:
+ from cleo.formatters.formatter import Formatter
+
+
+class BufferedOutput(Output):
+ def __init__(
+ self,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ decorated: bool = False,
+ formatter: Formatter | None = None,
+ supports_utf8: bool = True,
+ ) -> None:
+ super().__init__(decorated=decorated, verbosity=verbosity, formatter=formatter)
+
+ self._buffer = StringIO()
+ self._supports_utf8 = supports_utf8
+
+ def fetch(self) -> str:
+ """
+ Empties the buffer and returns its content.
+ """
+ content = self._buffer.getvalue()
+ self._buffer = StringIO()
+
+ return content
+
+ def clear(self) -> None:
+ """
+ Empties the buffer.
+ """
+ self._buffer = StringIO()
+
+ def supports_utf8(self) -> bool:
+ return self._supports_utf8
+
+ def set_supports_utf8(self, supports_utf8: bool) -> None:
+ self._supports_utf8 = supports_utf8
+
+ def section(self) -> SectionOutput:
+ return SectionOutput(
+ self._buffer,
+ self._section_outputs,
+ verbosity=self.verbosity,
+ decorated=self.is_decorated(),
+ formatter=self.formatter,
+ )
+
+ def _write(self, message: str, new_line: bool = False) -> None:
+ self._buffer.write(message)
+
+ if new_line:
+ self._buffer.write("\n")
diff --git a/src/cleo/io/outputs/null_output.py b/src/cleo/io/outputs/null_output.py
new file mode 100644
index 0000000..0802670
--- /dev/null
+++ b/src/cleo/io/outputs/null_output.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from typing import Iterable
+
+from cleo.io.outputs.output import Output
+from cleo.io.outputs.output import Type
+from cleo.io.outputs.output import Verbosity
+
+
+class NullOutput(Output):
+ @property
+ def verbosity(self) -> Verbosity:
+ return Verbosity.QUIET
+
+ def is_decorated(self) -> bool:
+ return False
+
+ def decorated(self, decorated: bool = True) -> None:
+ pass
+
+ def supports_utf8(self) -> bool:
+ return True
+
+ def set_verbosity(self, verbosity: Verbosity) -> None:
+ pass
+
+ def is_quiet(self) -> bool:
+ return True
+
+ def is_verbose(self) -> bool:
+ return False
+
+ def is_very_verbose(self) -> bool:
+ return False
+
+ def is_debug(self) -> bool:
+ return False
+
+ def write_line(
+ self,
+ messages: str | Iterable[str],
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: Type = Type.NORMAL,
+ ) -> None:
+ pass
+
+ def write(
+ self,
+ messages: str | Iterable[str],
+ new_line: bool = False,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: Type = Type.NORMAL,
+ ) -> None:
+ pass
+
+ def flush(self) -> None:
+ pass
+
+ def _write(self, message: str, new_line: bool = False) -> None:
+ pass
diff --git a/src/cleo/io/outputs/output.py b/src/cleo/io/outputs/output.py
new file mode 100644
index 0000000..b18b828
--- /dev/null
+++ b/src/cleo/io/outputs/output.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import TYPE_CHECKING
+from typing import Iterable
+
+from cleo._utils import strip_tags
+from cleo.formatters.formatter import Formatter
+
+
+if TYPE_CHECKING:
+ from cleo.io.outputs.section_output import SectionOutput
+
+
+class Verbosity(Enum):
+
+ QUIET: int = 16
+ NORMAL: int = 32
+ VERBOSE: int = 64
+ VERY_VERBOSE: int = 128
+ DEBUG: int = 256
+
+
+class Type(Enum):
+
+ NORMAL: int = 1
+ RAW: int = 2
+ PLAIN: int = 4
+
+
+class Output:
+ def __init__(
+ self,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ decorated: bool = False,
+ formatter: Formatter | None = None,
+ ) -> None:
+ self._verbosity: Verbosity = verbosity
+ self._formatter = formatter or Formatter()
+ self._formatter.decorated(decorated)
+
+ self._section_outputs: list[SectionOutput] = []
+
+ @property
+ def formatter(self) -> Formatter:
+ return self._formatter
+
+ @property
+ def verbosity(self) -> Verbosity:
+ return self._verbosity
+
+ def set_formatter(self, formatter: Formatter) -> None:
+ self._formatter = formatter
+
+ def is_decorated(self) -> bool:
+ return self._formatter.is_decorated()
+
+ def decorated(self, decorated: bool = True) -> None:
+ self._formatter.decorated(decorated)
+
+ def supports_utf8(self) -> bool:
+ """
+ Returns whether the stream supports the UTF-8 encoding.
+ """
+ return True
+
+ def set_verbosity(self, verbosity: Verbosity) -> None:
+ self._verbosity = verbosity
+
+ def is_quiet(self) -> bool:
+ return self._verbosity == Verbosity.QUIET
+
+ def is_verbose(self) -> bool:
+ return self._verbosity.value >= Verbosity.VERBOSE.value
+
+ def is_very_verbose(self) -> bool:
+ return self._verbosity.value >= Verbosity.VERY_VERBOSE.value
+
+ def is_debug(self) -> bool:
+ return self._verbosity == Verbosity.DEBUG
+
+ def write_line(
+ self,
+ messages: str | Iterable[str],
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: Type = Type.NORMAL,
+ ) -> None:
+ self.write(messages, new_line=True, verbosity=verbosity, type=type)
+
+ def write(
+ self,
+ messages: str | Iterable[str],
+ new_line: bool = False,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ type: Type = Type.NORMAL,
+ ) -> None:
+ if isinstance(messages, str):
+ messages = [messages]
+
+ if verbosity.value > self.verbosity.value:
+ return
+
+ for message in messages:
+ if type == Type.NORMAL:
+ message = self._formatter.format(message)
+ elif type == Type.PLAIN:
+ message = strip_tags(self._formatter.format(message))
+
+ self._write(message, new_line=new_line)
+
+ def flush(self) -> None:
+ pass
+
+ def remove_format(self, text: str) -> str:
+ return self.formatter.remove_format(text)
+
+ def section(self) -> SectionOutput:
+ raise NotImplementedError()
+
+ def _write(self, message: str, new_line: bool = False) -> None:
+ raise NotImplementedError()
diff --git a/src/cleo/io/outputs/section_output.py b/src/cleo/io/outputs/section_output.py
new file mode 100644
index 0000000..ad286e7
--- /dev/null
+++ b/src/cleo/io/outputs/section_output.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import math
+import shutil
+
+from typing import TYPE_CHECKING
+from typing import TextIO
+
+from cleo.io.outputs.output import Verbosity
+from cleo.io.outputs.stream_output import StreamOutput
+
+
+if TYPE_CHECKING:
+ from cleo.formatters.formatter import Formatter
+
+
+class SectionOutput(StreamOutput):
+ def __init__(
+ self,
+ stream: TextIO,
+ sections: list[SectionOutput],
+ verbosity: Verbosity = Verbosity.NORMAL,
+ decorated: bool | None = None,
+ formatter: Formatter | None = None,
+ ) -> None:
+ super().__init__(
+ stream, verbosity=verbosity, decorated=decorated, formatter=formatter
+ )
+
+ self._content: list[str] = []
+ self._lines = 0
+ sections.insert(0, self)
+ self._sections = sections
+ self._terminal = shutil.get_terminal_size()
+
+ @property
+ def content(self) -> str:
+ return "".join(self._content)
+
+ @property
+ def lines(self) -> int:
+ return self._lines
+
+ def clear(self, lines: int | None = None) -> None:
+ if not self._content or not self.is_decorated():
+ return
+
+ if lines:
+ # Multiply lines by 2 to cater for each new line added between content
+ del self._content[-(lines * 2) :]
+ else:
+ lines = self._lines
+ self._content = []
+
+ self._lines -= lines
+
+ super()._write(
+ self._pop_stream_content_until_current_section(lines), new_line=False
+ )
+
+ def overwrite(self, message: str) -> None:
+ self.clear()
+ self.write_line(message)
+
+ def add_content(self, content: str) -> None:
+ for line_content in content.split("\n"):
+ self._lines += (
+ math.ceil(
+ len(self.remove_format(line_content).replace("\t", " "))
+ / self._terminal.columns
+ )
+ or 1
+ )
+ self._content.append(line_content)
+ self._content.append("\n")
+
+ def _write(self, message: str, new_line: bool = False) -> None:
+ if not self.is_decorated():
+ return super()._write(message, new_line=new_line)
+
+ erased_content = self._pop_stream_content_until_current_section()
+
+ self.add_content(message)
+
+ super()._write(message, new_line=True)
+ super()._write(erased_content, new_line=False)
+
+ def _pop_stream_content_until_current_section(
+ self, lines_to_clear_count: int = 0
+ ) -> str:
+ erased_content = []
+
+ for section in self._sections:
+ if section is self:
+ break
+
+ lines_to_clear_count += section.lines
+ erased_content.append(section.content)
+
+ if lines_to_clear_count > 0:
+ # Move cursor up n lines
+ super()._write(f"\x1b[{lines_to_clear_count}A", new_line=False)
+ # Erase to end of screen
+ super()._write("\x1b[0J", new_line=False)
+
+ return "".join(reversed(erased_content))
diff --git a/src/cleo/io/outputs/stream_output.py b/src/cleo/io/outputs/stream_output.py
new file mode 100644
index 0000000..a514002
--- /dev/null
+++ b/src/cleo/io/outputs/stream_output.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import codecs
+import io
+import locale
+import os
+import sys
+
+from typing import TYPE_CHECKING
+from typing import TextIO
+
+from cleo.io.outputs.output import Output
+from cleo.io.outputs.output import Verbosity
+
+
+if TYPE_CHECKING:
+ from cleo.formatters.formatter import Formatter
+ from cleo.io.outputs.section_output import SectionOutput
+
+
+class StreamOutput(Output):
+ FILE_TYPE_CHAR = 0x0002
+ FILE_TYPE_REMOTE = 0x8000
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+
+ def __init__(
+ self,
+ stream: TextIO,
+ verbosity: Verbosity = Verbosity.NORMAL,
+ decorated: bool | None = None,
+ formatter: Formatter | None = None,
+ ) -> None:
+ self._stream = stream
+ self._supports_utf8 = self._get_utf8_support_info()
+ super().__init__(
+ verbosity=verbosity,
+ decorated=decorated or self._has_color_support(),
+ formatter=formatter,
+ )
+
+ @property
+ def stream(self) -> TextIO:
+ return self._stream
+
+ def supports_utf8(self) -> bool:
+ return self._supports_utf8
+
+ def _get_utf8_support_info(self) -> bool:
+ """
+ Returns whether the stream supports the UTF-8 encoding.
+ """
+ encoding = self._stream.encoding or locale.getpreferredencoding(False)
+
+ try:
+ encoding = codecs.lookup(encoding).name
+ except Exception:
+ encoding = "utf-8"
+
+ return encoding == "utf-8"
+
+ def flush(self) -> None:
+ self._stream.flush()
+
+ def section(self) -> SectionOutput:
+ from cleo.io.outputs.section_output import SectionOutput
+
+ return SectionOutput(
+ self._stream,
+ self._section_outputs,
+ verbosity=self.verbosity,
+ decorated=self.is_decorated(),
+ formatter=self.formatter,
+ )
+
+ def _write(self, message: str, new_line: bool = False) -> None:
+ if new_line:
+ message += "\n"
+
+ self._stream.write(message)
+ self._stream.flush()
+
+ def _has_color_support(self) -> bool:
+ # Follow https://no-color.org/
+ if "NO_COLOR" in os.environ:
+ return False
+
+ if os.getenv("TERM_PROGRAM") == "Hyper":
+ return True
+
+ if sys.platform == "win32":
+ shell_supported = (
+ os.getenv("ANSICON") is not None
+ or os.getenv("ConEmuANSI") == "ON"
+ or os.getenv("TERM") == "xterm"
+ )
+
+ if shell_supported:
+ return True
+
+ if not hasattr(self._stream, "fileno"):
+ return False
+
+ # Checking for Windows version
+ # If we have a compatible version
+ # activate color support
+ windows_version = sys.getwindowsversion()
+ major, build = windows_version[0], windows_version[2]
+ if (major, build) < (10, 14393):
+ return False
+
+ # Activate colors if possible
+ import ctypes
+ import ctypes.wintypes
+
+ kernel32 = ctypes.windll.kernel32
+
+ fileno = self._stream.fileno()
+
+ if fileno == 1:
+ h = kernel32.GetStdHandle(-11)
+ elif fileno == 2:
+ h = kernel32.GetStdHandle(-12)
+ else:
+ return False
+
+ if h is None or h == ctypes.wintypes.HANDLE(-1):
+ return False
+
+ if (
+ kernel32.GetFileType(h) & ~self.FILE_TYPE_REMOTE
+ ) != self.FILE_TYPE_CHAR:
+ return False
+
+ mode = ctypes.wintypes.DWORD()
+ if not kernel32.GetConsoleMode(h, ctypes.byref(mode)):
+ return False
+
+ if (mode.value & self.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0:
+ kernel32.SetConsoleMode(
+ h, mode.value | self.ENABLE_VIRTUAL_TERMINAL_PROCESSING
+ )
+ return True
+
+ return False
+
+ if not hasattr(self._stream, "fileno"):
+ return False
+
+ try:
+ return os.isatty(self._stream.fileno())
+ except io.UnsupportedOperation:
+ return False
diff --git a/src/cleo/loaders/__init__.py b/src/cleo/loaders/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/loaders/__init__.py
diff --git a/src/cleo/loaders/command_loader.py b/src/cleo/loaders/command_loader.py
new file mode 100644
index 0000000..5e6d81b
--- /dev/null
+++ b/src/cleo/loaders/command_loader.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+
+
+class CommandLoader:
+ @property
+ def names(self) -> list[str]:
+ """
+ All registered command names.
+ """
+ raise NotImplementedError()
+
+ def get(self, name: str) -> Command:
+ """
+ Loads a command.
+ """
+ raise NotImplementedError()
+
+ def has(self, name: str) -> bool:
+ """
+ Checks whether a command exists or not.
+ """
+ raise NotImplementedError()
diff --git a/src/cleo/loaders/factory_command_loader.py b/src/cleo/loaders/factory_command_loader.py
new file mode 100644
index 0000000..4f9dbf0
--- /dev/null
+++ b/src/cleo/loaders/factory_command_loader.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import Callable
+
+from cleo.commands.command import Command
+from cleo.exceptions import CleoCommandNotFoundError
+from cleo.loaders.command_loader import CommandLoader
+
+
+Factory = Callable[[], Command]
+
+
+class FactoryCommandLoader(CommandLoader):
+ """
+ A simple command loader using factories to instantiate commands lazily.
+ """
+
+ def __init__(self, factories: dict[str, Factory]) -> None:
+ self._factories = factories
+
+ @property
+ def names(self) -> list[str]:
+ return list(self._factories.keys())
+
+ def has(self, name: str) -> bool:
+ return name in self._factories
+
+ def get(self, name: str) -> Command:
+ if name not in self._factories:
+ raise CleoCommandNotFoundError(name)
+
+ factory = self._factories[name]
+
+ return factory()
diff --git a/src/cleo/py.typed b/src/cleo/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/py.typed
diff --git a/src/cleo/testers/__init__.py b/src/cleo/testers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/testers/__init__.py
diff --git a/src/cleo/testers/application_tester.py b/src/cleo/testers/application_tester.py
new file mode 100644
index 0000000..e72ceb9
--- /dev/null
+++ b/src/cleo/testers/application_tester.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from io import StringIO
+from typing import TYPE_CHECKING
+
+from cleo.io.buffered_io import BufferedIO
+from cleo.io.inputs.string_input import StringInput
+from cleo.io.outputs.buffered_output import BufferedOutput
+
+
+if TYPE_CHECKING:
+ from cleo.application import Application
+ from cleo.io.outputs.output import Verbosity
+
+
+class ApplicationTester:
+ """
+ Eases the testing of console applications.
+ """
+
+ def __init__(self, application: Application) -> None:
+ self._application = application
+ self._application.auto_exits(False)
+ self._io = BufferedIO()
+ self._status_code = 0
+
+ @property
+ def application(self) -> Application:
+ return self._application
+
+ @property
+ def io(self) -> BufferedIO:
+ return self._io
+
+ @property
+ def status_code(self) -> int:
+ return self._status_code
+
+ def execute(
+ self,
+ args: str = "",
+ inputs: str | None = None,
+ interactive: bool = True,
+ verbosity: Verbosity | None = None,
+ decorated: bool = False,
+ supports_utf8: bool = True,
+ ) -> int:
+ """
+ Executes the command
+ """
+ self._io.clear()
+
+ input = StringInput(args)
+ self._io.set_input(input)
+ self._io.decorated(decorated)
+ assert isinstance(self._io.output, BufferedOutput)
+ assert isinstance(self._io.error_output, BufferedOutput)
+ self._io.output.set_supports_utf8(supports_utf8)
+ self._io.error_output.set_supports_utf8(supports_utf8)
+
+ if inputs is not None:
+ self._io.input.set_stream(StringIO(inputs))
+
+ if interactive is not None:
+ self._io.interactive(interactive)
+
+ if verbosity is not None:
+ self._io.set_verbosity(verbosity)
+
+ self._status_code = self._application.run(
+ self._io.input,
+ self._io.output,
+ self._io.error_output,
+ )
+
+ return self._status_code
diff --git a/src/cleo/testers/command_tester.py b/src/cleo/testers/command_tester.py
new file mode 100644
index 0000000..1bae96a
--- /dev/null
+++ b/src/cleo/testers/command_tester.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from io import StringIO
+from typing import TYPE_CHECKING
+
+from cleo.io.buffered_io import BufferedIO
+from cleo.io.inputs.argv_input import ArgvInput
+from cleo.io.inputs.string_input import StringInput
+from cleo.io.outputs.buffered_output import BufferedOutput
+
+
+if TYPE_CHECKING:
+ from cleo.commands.command import Command
+ from cleo.io.outputs.output import Verbosity
+
+
+class CommandTester:
+ """
+ Eases the testing of console commands.
+ """
+
+ def __init__(self, command: Command) -> None:
+ self._command = command
+ self._io = BufferedIO()
+ self._inputs: list[str] = []
+ self._status_code: int | None = None
+
+ @property
+ def command(self) -> Command:
+ return self._command
+
+ @property
+ def io(self) -> BufferedIO:
+ return self._io
+
+ @property
+ def status_code(self) -> int | None:
+ return self._status_code
+
+ def execute(
+ self,
+ args: str = "",
+ inputs: str | None = None,
+ interactive: bool | None = None,
+ verbosity: Verbosity | None = None,
+ decorated: bool | None = None,
+ supports_utf8: bool = True,
+ ) -> int:
+ """
+ Executes the command
+ """
+ application = self._command.application
+
+ input: StringInput | ArgvInput = StringInput(args)
+ if (
+ application is not None
+ and application.definition.has_argument("command")
+ and self._command.name is not None
+ ):
+ name = self._command.name
+ if " " in name:
+ # If the command is namespaced we rearrange
+ # the input to parse it as a single argument
+ argv = [application.name, self._command.name] + input._tokens
+
+ input = ArgvInput(argv)
+ else:
+ input = StringInput(name + " " + args)
+
+ self._io.set_input(input)
+ assert isinstance(self._io.output, BufferedOutput)
+ assert isinstance(self._io.error_output, BufferedOutput)
+ self._io.output.set_supports_utf8(supports_utf8)
+ self._io.error_output.set_supports_utf8(supports_utf8)
+
+ if inputs is not None:
+ self._io.input.set_stream(StringIO(inputs))
+
+ if interactive is not None:
+ self._io.interactive(interactive)
+
+ if verbosity is not None:
+ self._io.set_verbosity(verbosity)
+
+ if decorated is not None:
+ self._io.decorated(decorated)
+
+ self._status_code = self._command.run(self._io)
+
+ return self._status_code
diff --git a/src/cleo/ui/__init__.py b/src/cleo/ui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/cleo/ui/__init__.py
diff --git a/src/cleo/ui/choice_question.py b/src/cleo/ui/choice_question.py
new file mode 100644
index 0000000..d9920fd
--- /dev/null
+++ b/src/cleo/ui/choice_question.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+import re
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from cleo.exceptions import CleoValueError
+from cleo.ui.question import Question
+
+
+if TYPE_CHECKING:
+ from cleo.io.io import IO
+
+
+class SelectChoiceValidator:
+ def __init__(self, question: ChoiceQuestion) -> None:
+ """
+ Constructor.
+ """
+ self._question = question
+ self._values = question.choices
+
+ def validate(self, selected: str | int) -> str | list[str] | None:
+ """
+ Validate a choice.
+ """
+ # Collapse all spaces.
+ if isinstance(selected, int):
+ selected = str(selected)
+
+ if selected is None:
+ return None
+
+ if self._question.supports_multiple_choices():
+ # Check for a separated comma values
+ _selected = selected.replace(" ", "")
+ if not re.match("^[a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*$", _selected):
+ raise CleoValueError(self._question.error_message.format(selected))
+
+ selected_choices = _selected.split(",")
+ else:
+ selected_choices = [selected]
+
+ multiselect_choices = []
+ for value in selected_choices:
+ results = []
+
+ for key, choice in enumerate(self._values):
+ if choice == value:
+ results.append(key)
+
+ if len(results) > 1:
+ raise CleoValueError(
+ "The provided answer is ambiguous. "
+ f'Value should be one of {" or ".join(str(r) for r in results)}.'
+ )
+
+ if value in self._values:
+ result = value
+ elif value.isdigit() and 0 <= int(value) < len(self._values):
+ result = self._values[int(value)]
+ else:
+ raise CleoValueError(self._question.error_message.format(value))
+
+ multiselect_choices.append(result)
+
+ if self._question.supports_multiple_choices():
+ return multiselect_choices
+
+ return multiselect_choices[0]
+
+
+class ChoiceQuestion(Question):
+ """
+ Multiple choice question.
+ """
+
+ def __init__(
+ self, question: str, choices: list[str], default: Any | None = None
+ ) -> None:
+ super().__init__(question, default)
+
+ self._multi_select = False
+ self._choices = choices
+ self._validator = SelectChoiceValidator(self).validate
+ self._autocomplete_values = choices
+ self._prompt = " > "
+ self._error_message = 'Value "{}" is invalid'
+
+ @property
+ def error_message(self) -> str:
+ return self._error_message
+
+ @property
+ def choices(self) -> list[str]:
+ return self._choices
+
+ def supports_multiple_choices(self) -> bool:
+ return self._multi_select
+
+ def set_multi_select(self, multi_select: bool) -> None:
+ self._multi_select = multi_select
+
+ def set_error_message(self, message: str) -> None:
+ self._error_message = message
+
+ def _write_prompt(self, io: IO) -> None:
+ """
+ Outputs the question prompt.
+ """
+ message = self._question
+ default = self._default
+
+ if default is None:
+ message = f"<question>{message}</question>: "
+ elif self._multi_select:
+ choices = self._choices
+ default = default.split(",")
+
+ for i, value in enumerate(default):
+ default[i] = choices[int(value.strip())]
+
+ message = (
+ f"<question>{message}</question> "
+ f'[<comment>{", ".join(default)}</comment>]:'
+ )
+ else:
+ choices = self._choices
+ message = (
+ f"<question>{message}</question> "
+ f"[<comment>{choices[int(default)]}</comment>]:"
+ )
+
+ if len(self._choices) > 1:
+ width = max(*map(len, [str(k) for k, _ in enumerate(self._choices)]))
+ else:
+ width = 1
+
+ messages = [message]
+ for key, value in enumerate(self._choices):
+ messages.append(f" [<comment>{key: {width}}</>] {value}")
+
+ io.write_error_line("\n".join(messages))
+
+ message = self._prompt
+
+ io.write_error(message)
diff --git a/src/cleo/ui/component.py b/src/cleo/ui/component.py
new file mode 100644
index 0000000..8613fe2
--- /dev/null
+++ b/src/cleo/ui/component.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+
+class Component:
+
+ name: str = "<unnamed_component>"
diff --git a/src/cleo/ui/confirmation_question.py b/src/cleo/ui/confirmation_question.py
new file mode 100644
index 0000000..0460032
--- /dev/null
+++ b/src/cleo/ui/confirmation_question.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import re
+
+from typing import TYPE_CHECKING
+
+from cleo.ui.question import Question
+
+
+if TYPE_CHECKING:
+ from cleo.io.io import IO
+
+
+class ConfirmationQuestion(Question):
+ """
+ Represents a yes/no question.
+ """
+
+ def __init__(
+ self, question: str, default: bool = True, true_answer_regex: str = "(?i)^y"
+ ) -> None:
+ super().__init__(question, default)
+
+ self._true_answer_regex = true_answer_regex
+ self._normalizer = self._default_normalizer
+
+ def _write_prompt(self, io: IO) -> None:
+ message = (
+ f"<question>{self._question} (yes/no)</> "
+ f'[<comment>{"yes" if self._default else "no"}</>] '
+ )
+
+ io.write_error(message)
+
+ def _default_normalizer(self, answer: str) -> bool:
+ """
+ Default answer normalizer.
+ """
+ if isinstance(answer, bool):
+ return answer
+
+ answer_is_true = re.match(self._true_answer_regex, answer) is not None
+ if self.default is False:
+ return (answer and answer_is_true) or False
+
+ return not answer or answer_is_true
diff --git a/src/cleo/ui/exception_trace.py b/src/cleo/ui/exception_trace.py
new file mode 100644
index 0000000..1a5f30c
--- /dev/null
+++ b/src/cleo/ui/exception_trace.py
@@ -0,0 +1,439 @@
+from __future__ import annotations
+
+import ast
+import builtins
+import inspect
+import io
+import keyword
+import os
+import re
+import sys
+import tokenize
+
+from typing import TYPE_CHECKING
+
+from crashtest.frame_collection import FrameCollection
+
+from cleo.formatters.formatter import Formatter
+
+
+if TYPE_CHECKING:
+ from crashtest.frame import Frame
+ from crashtest.solution_providers.solution_provider_repository import (
+ SolutionProviderRepository,
+ )
+
+ from cleo.io.io import IO
+ from cleo.io.outputs.output import Output
+
+
+class Highlighter:
+
+ TOKEN_DEFAULT = "token_default"
+ TOKEN_COMMENT = "token_comment"
+ TOKEN_STRING = "token_string"
+ TOKEN_NUMBER = "token_number"
+ TOKEN_KEYWORD = "token_keyword"
+ TOKEN_BUILTIN = "token_builtin"
+ TOKEN_OP = "token_op"
+ LINE_MARKER = "line_marker"
+ LINE_NUMBER = "line_number"
+
+ DEFAULT_THEME = {
+ TOKEN_STRING: "fg=yellow;options=bold",
+ TOKEN_NUMBER: "fg=blue;options=bold",
+ TOKEN_COMMENT: "fg=default;options=dark,italic",
+ TOKEN_KEYWORD: "fg=magenta;options=bold",
+ TOKEN_BUILTIN: "fg=default;options=bold",
+ TOKEN_DEFAULT: "fg=default",
+ TOKEN_OP: "fg=default;options=dark",
+ LINE_MARKER: "fg=red;options=bold",
+ LINE_NUMBER: "fg=default;options=dark",
+ }
+
+ KEYWORDS = set(keyword.kwlist)
+ BUILTINS = set(dir(builtins))
+
+ UI = {
+ False: {"arrow": ">", "delimiter": "|"},
+ True: {"arrow": "→", "delimiter": "│"},
+ }
+
+ def __init__(self, supports_utf8: bool = True) -> None:
+ self._theme = self.DEFAULT_THEME.copy()
+ self._ui = self.UI[supports_utf8]
+
+ def code_snippet(
+ self, source: str, line: int, lines_before: int = 2, lines_after: int = 2
+ ) -> list[str]:
+ token_lines = self.highlighted_lines(source)
+ token_lines = self.line_numbers(token_lines, line)
+
+ offset = line - lines_before - 1
+ offset = max(offset, 0)
+ length = lines_after + lines_before + 1
+ token_lines = token_lines[offset : offset + length]
+
+ return token_lines
+
+ def highlighted_lines(self, source: str) -> list[str]:
+ source = source.replace("\r\n", "\n").replace("\r", "\n")
+
+ return self.split_to_lines(source)
+
+ def split_to_lines(self, source: str) -> list[str]:
+ lines = []
+ current_line = 1
+ current_col = 0
+ buffer = ""
+ current_type = None
+ source_io = io.BytesIO(source.encode())
+ formatter = Formatter()
+
+ def readline() -> bytes:
+ return formatter.format(
+ formatter.escape(source_io.readline().decode())
+ ).encode()
+
+ tokens = tokenize.tokenize(readline)
+ line = ""
+ for token_info in tokens:
+ token_type, token_string, start, end, _ = token_info
+ lineno = start[0]
+ if lineno == 0:
+ # Encoding line
+ continue
+
+ if token_type == tokenize.ENDMARKER:
+ # End of source
+ if current_type is None:
+ current_type = self.TOKEN_DEFAULT
+
+ line += f"<{self._theme[current_type]}>{buffer}</>"
+ lines.append(line)
+ break
+
+ if lineno > current_line:
+ if current_type is None:
+ current_type = self.TOKEN_DEFAULT
+
+ diff = lineno - current_line
+ if diff > 1:
+ lines += [""] * (diff - 1)
+
+ stripped_buffer = buffer.rstrip("\n")
+ line += f"<{self._theme[current_type]}>{stripped_buffer}</>"
+
+ # New line
+ lines.append(line)
+ line = ""
+ current_line = lineno
+ current_col = 0
+ buffer = ""
+
+ if token_string in self.KEYWORDS:
+ new_type = self.TOKEN_KEYWORD
+ elif token_string in self.BUILTINS or token_string == "self":
+ new_type = self.TOKEN_BUILTIN
+ elif token_type == tokenize.STRING:
+ new_type = self.TOKEN_STRING
+ elif token_type == tokenize.NUMBER:
+ new_type = self.TOKEN_NUMBER
+ elif token_type == tokenize.COMMENT:
+ new_type = self.TOKEN_COMMENT
+ elif token_type == tokenize.OP:
+ new_type = self.TOKEN_OP
+ elif token_type == tokenize.NEWLINE:
+ continue
+ else:
+ new_type = self.TOKEN_DEFAULT
+
+ if current_type is None:
+ current_type = new_type
+
+ if start[1] > current_col:
+ buffer += token_info.line[current_col : start[1]]
+
+ if current_type != new_type:
+ line += f"<{self._theme[current_type]}>{buffer}</>"
+ buffer = ""
+ current_type = new_type
+
+ if lineno < end[0]:
+ # The token spans multiple lines
+ token_lines = token_string.split("\n")
+ line += f"<{self._theme[current_type]}>{token_lines[0]}</>"
+ lines.append(line)
+ for token_line in token_lines[1:-1]:
+ lines.append(f"<{self._theme[current_type]}>{token_line}</>")
+
+ current_line = end[0]
+ buffer = token_lines[-1][: end[1]]
+ line = ""
+ continue
+
+ buffer += token_string
+ current_col = end[1]
+ current_line = lineno
+
+ return lines
+
+ def line_numbers(self, lines: list[str], mark_line: int | None = None) -> list[str]:
+ max_line_length = max(3, len(str(len(lines))))
+
+ snippet_lines = []
+ marker = f'<{self._theme[self.LINE_MARKER]}>{self._ui["arrow"]}</> '
+ no_marker = " "
+ for i, line in enumerate(lines):
+ snippet = ""
+ if mark_line is not None:
+ if mark_line == i + 1:
+ snippet = marker
+ else:
+ snippet = no_marker
+
+ line_number = f"{i + 1:>{max_line_length}}"
+ styling = (
+ "fg=default;options=bold"
+ if mark_line == i + 1
+ else self._theme[self.LINE_NUMBER]
+ )
+ snippet += (
+ f"<{styling}>"
+ f"{line_number}</><{self._theme[self.LINE_NUMBER]}>"
+ f'{self._ui["delimiter"]}</> {line}'
+ )
+ snippet_lines.append(snippet)
+
+ return snippet_lines
+
+
+class ExceptionTrace:
+ """
+ Renders the trace of an exception.
+ """
+
+ THEME = {
+ "comment": "<fg=black;options=bold>",
+ "keyword": "<fg=yellow>",
+ "builtin": "<fg=blue>",
+ "literal": "<fg=magenta>",
+ }
+
+ AST_ELEMENTS = {
+ "builtins": dir(builtins),
+ "keywords": [
+ getattr(ast, cls)
+ for cls in dir(ast)
+ if keyword.iskeyword(cls.lower())
+ and inspect.isclass(getattr(ast, cls))
+ and issubclass(getattr(ast, cls), ast.AST)
+ ],
+ }
+
+ _FRAME_SNIPPET_CACHE: dict[tuple[Frame, int, int], list[str]] = {}
+
+ def __init__(
+ self,
+ exception: Exception,
+ solution_provider_repository: SolutionProviderRepository | None = None,
+ ) -> None:
+ self._exception = exception
+ self._solution_provider_repository = solution_provider_repository
+ self._exc_info = sys.exc_info()
+ self._ignore: str | None = None
+
+ def ignore_files_in(self, ignore: str) -> ExceptionTrace:
+ self._ignore = ignore
+
+ return self
+
+ def render(self, io: IO | Output, simple: bool = False) -> None:
+ # If simple rendering wouldn't show anything useful, abandon it.
+ simple_string = str(self._exception) if simple else ""
+ if simple_string:
+ io.write_line("")
+ io.write_line(f"<error>{simple_string}</error>")
+ else:
+ self._render_exception(io, self._exception)
+
+ self._render_solution(io, self._exception)
+
+ def _render_exception(self, io: IO | Output, exception: BaseException) -> None:
+ from crashtest.inspector import Inspector
+
+ inspector = Inspector(exception)
+ if not inspector.frames:
+ return
+
+ if inspector.has_previous_exception():
+ assert inspector.previous_exception is not None # make mypy happy
+ self._render_exception(io, inspector.previous_exception)
+ io.write_line("")
+ io.write_line(
+ "The following error occurred when trying to handle this error:"
+ )
+ io.write_line("")
+
+ self._render_trace(io, inspector.frames)
+
+ self._render_line(io, f"<error>{inspector.exception_name}</error>", True)
+ io.write_line("")
+ exception_message = (
+ Formatter().format(inspector.exception_message).replace("\n", "\n ")
+ )
+ self._render_line(io, f"<b>{exception_message}</b>")
+
+ current_frame = inspector.frames[-1]
+ self._render_snippet(io, current_frame)
+
+ def _render_snippet(self, io: IO | Output, frame: Frame) -> None:
+ self._render_line(
+ io,
+ f"at <fg=green>{self._get_relative_file_path(frame.filename)}</>"
+ f":<b>{frame.lineno}</b> in <fg=cyan>{frame.function}</>",
+ True,
+ )
+
+ code_lines = Highlighter(supports_utf8=io.supports_utf8()).code_snippet(
+ frame.file_content, frame.lineno, 4, 4
+ )
+
+ for code_line in code_lines:
+ self._render_line(io, code_line, indent=4)
+
+ def _render_solution(self, io: IO | Output, exception: Exception) -> None:
+ if self._solution_provider_repository is None:
+ return
+
+ solutions = self._solution_provider_repository.get_solutions_for_exception(
+ exception
+ )
+ symbol = "•"
+ if not io.supports_utf8():
+ symbol = "*"
+
+ for solution in solutions:
+ title = solution.solution_title
+ description = solution.solution_description
+ links = solution.documentation_links
+
+ description = description.replace("\n", "\n ").strip(" ")
+
+ joined_links = ",".join(f"\n <fg=blue>{link}</>" for link in links)
+ self._render_line(
+ io,
+ f"<fg=blue;options=bold>{symbol} </>"
+ f'<fg=default;options=bold>{title.rstrip(".")}</>:'
+ f" {description}{joined_links}",
+ True,
+ )
+
+ def _render_trace(self, io: IO | Output, frames: FrameCollection) -> None:
+ stack_frames = FrameCollection()
+ for frame in frames:
+ if (
+ self._ignore
+ and re.match(self._ignore, frame.filename)
+ and not io.is_debug()
+ ):
+ continue
+
+ stack_frames.append(frame)
+
+ remaining_frames_length = len(stack_frames) - 1
+ if io.is_very_verbose() and remaining_frames_length:
+ self._render_line(io, "<fg=yellow>Stack trace</>:", True)
+ max_frame_length = len(str(remaining_frames_length))
+ frame_collections = stack_frames.compact()
+ i = remaining_frames_length
+ for collection in frame_collections:
+ if collection.is_repeated():
+ if len(collection) > 1:
+ frames_message = f"<fg=yellow>{len(collection)}</> frames"
+ else:
+ frames_message = "frame"
+
+ self._render_line(
+ io,
+ f'<fg=blue>{"...":>{max_frame_length}}</> '
+ f"Previous {frames_message} repeated "
+ f"<fg=blue>{collection.repetitions + 1}</> times",
+ True,
+ )
+
+ i -= len(collection) * (collection.repetitions + 1)
+
+ for frame in collection:
+ relative_file_path = self._get_relative_file_path(frame.filename)
+ relative_file_path_parts = relative_file_path.split(os.path.sep)
+ relative_file_path = (
+ f"<fg=default;options=dark>{Formatter.escape(os.sep)}</>".join(
+ relative_file_path_parts[:-1]
+ + [
+ "<fg=default;options=bold>"
+ f"{relative_file_path_parts[-1]}</>"
+ ]
+ )
+ )
+ self._render_line(
+ io,
+ f"<fg=yellow>{i:>{max_frame_length}}</> "
+ f"{relative_file_path}<fg=default;options=dark>:</>"
+ f"<b>{frame.lineno}</b> in <fg=cyan>{frame.function}</>",
+ True,
+ )
+
+ if io.is_debug():
+ if (frame, 2, 2) not in self._FRAME_SNIPPET_CACHE:
+ code_lines = Highlighter(
+ supports_utf8=io.supports_utf8()
+ ).code_snippet(
+ frame.file_content,
+ frame.lineno,
+ )
+
+ self._FRAME_SNIPPET_CACHE[(frame, 2, 2)] = code_lines
+
+ code_lines = self._FRAME_SNIPPET_CACHE[(frame, 2, 2)]
+
+ for code_line in code_lines:
+ self._render_line(
+ io,
+ f'{" ":>{max_frame_length}}{code_line}',
+ indent=3,
+ )
+ else:
+ highlighter = Highlighter(supports_utf8=io.supports_utf8())
+ try:
+ code_line = highlighter.highlighted_lines(
+ frame.line.strip()
+ )[0]
+ except tokenize.TokenError:
+ code_line = frame.line.strip()
+
+ self._render_line(
+ io, f'{" ":>{max_frame_length}} {code_line}'
+ )
+
+ i -= 1
+
+ def _render_line(
+ self, io: IO | Output, line: str, new_line: bool = False, indent: int = 2
+ ) -> None:
+ if new_line:
+ io.write_line("")
+
+ io.write_line(f'{indent * " "}{line}')
+
+ def _get_relative_file_path(self, filepath: str) -> str:
+ cwd = os.getcwd()
+
+ if cwd:
+ filepath = filepath.replace(cwd + os.path.sep, "")
+
+ home = os.path.expanduser("~")
+ if home:
+ filepath = filepath.replace(home + os.path.sep, "~" + os.path.sep)
+
+ return filepath
diff --git a/src/cleo/ui/progress_bar.py b/src/cleo/ui/progress_bar.py
new file mode 100644
index 0000000..76e396c
--- /dev/null
+++ b/src/cleo/ui/progress_bar.py
@@ -0,0 +1,453 @@
+from __future__ import annotations
+
+import math
+import re
+import shutil
+import time
+
+from typing import TYPE_CHECKING
+from typing import Match
+
+from cleo._utils import format_time
+from cleo.cursor import Cursor
+from cleo.io.io import IO
+from cleo.io.outputs.section_output import SectionOutput
+from cleo.ui.component import Component
+
+
+if TYPE_CHECKING:
+ from cleo.io.outputs.output import Output
+
+
+class ProgressBar(Component):
+ """
+ The ProgressBar provides helpers to display progress output.
+ """
+
+ name = "progress_bar"
+
+ # Options
+ bar_width = 28
+ bar_char = None
+ empty_bar_char = "-"
+ progress_char = ">"
+ redraw_freq: int | None = 1
+
+ formats = {
+ "normal": " %current%/%max% [%bar%] %percent:3s%%",
+ "normal_nomax": " %current% [%bar%]",
+ "verbose": " %current%/%max% [%bar%] %percent:3s%% %elapsed:-6s%",
+ "verbose_nomax": " %current% [%bar%] %elapsed:6s%",
+ "very_verbose": (
+ " %current%/%max% [%bar%] %percent:3s%%" " %elapsed:6s%/%estimated:-6s%"
+ ),
+ "very_verbose_nomax": " %current% [%bar%] %elapsed:6s%",
+ "debug": " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%",
+ "debug_nomax": " %current% [%bar%] %elapsed:6s%",
+ }
+
+ def __init__(
+ self,
+ io: IO | Output,
+ max: int = 0,
+ min_seconds_between_redraws: float = 0.1,
+ ) -> None:
+ # If we have an IO, ensure we write to the error output
+ if isinstance(io, IO):
+ io = io.error_output
+
+ self._io = io
+ self._terminal = shutil.get_terminal_size()
+ self._max = 0
+ self._step_width: int = 1
+ self._set_max_steps(max)
+ self._step = 0
+ self._percent = 0.0
+ self._format: str | None = None
+ self._internal_format: str | None = None
+ self._format_line_count = 0
+ self._previous_message: str | None = None
+ self._should_overwrite = True
+ self._min_seconds_between_redraws = 0.0
+ self._max_seconds_between_redraws = 1.0
+ self._write_count = 0
+
+ if min_seconds_between_redraws > 0:
+ self.redraw_freq = None
+ self._min_seconds_between_redraws = min_seconds_between_redraws
+
+ if not self._io.formatter.is_decorated():
+ # Disable overwrite when output does not support ANSI codes.
+ self._should_overwrite = False
+
+ # Set a reasonable redraw frequency so output isn't flooded
+ self.redraw_freq = None
+
+ self._messages: dict[str, str] = {}
+
+ self._start_time = time.time()
+ self._last_write_time = 0.0
+ self._cursor = Cursor(self._io)
+
+ def set_message(self, message: str, name: str = "message") -> None:
+ self._messages[name] = message
+
+ def get_message(self, name: str = "message") -> str:
+ return self._messages[name]
+
+ def get_start_time(self) -> float:
+ return self._start_time
+
+ def get_max_steps(self) -> int:
+ return self._max
+
+ def get_progress(self) -> int:
+ return self._step
+
+ def get_progress_percent(self) -> float:
+ return self._percent
+
+ def set_bar_character(self, character: str) -> ProgressBar:
+ self.bar_char = character
+
+ return self
+
+ def get_bar_character(self) -> str:
+ if self.bar_char is None:
+ if self._max:
+ return "="
+
+ return self.empty_bar_char
+
+ return self.bar_char
+
+ def set_bar_width(self, width: int) -> ProgressBar:
+ self.bar_width = width
+
+ return self
+
+ def get_empty_bar_character(self) -> str:
+ return self.empty_bar_char
+
+ def set_empty_bar_character(self, character: str) -> ProgressBar:
+ self.empty_bar_char = character
+
+ return self
+
+ def get_progress_character(self) -> str:
+ return self.progress_char
+
+ def set_progress_character(self, character: str) -> ProgressBar:
+ self.progress_char = character
+
+ return self
+
+ def set_format(self, fmt: str) -> None:
+ self._format = None
+ self._internal_format = fmt
+
+ def set_redraw_frequency(self, freq: int) -> None:
+ if self.redraw_freq is not None:
+ self.redraw_freq = max(freq, 1)
+
+ def min_seconds_between_redraws(self, freq: float) -> None:
+ if freq > 0:
+ self.redraw_freq = None
+ self._min_seconds_between_redraws = freq
+
+ def max_seconds_between_redraws(self, freq: float) -> None:
+ self._max_seconds_between_redraws = freq
+
+ def start(self, max: int | None = None) -> None:
+ """
+ Start the progress output.
+ """
+ self._start_time = time.time()
+ self._step = 0
+ self._percent = 0.0
+
+ if max is not None:
+ self._set_max_steps(max)
+
+ self.display()
+
+ def advance(self, step: int = 1) -> None:
+ """
+ Advances the progress output X steps.
+ """
+ self.set_progress(self._step + step)
+
+ def set_progress(self, step: int) -> None:
+ """
+ Sets the current progress.
+ """
+ if self._max and step > self._max:
+ self._max = step
+ elif step < 0:
+ step = 0
+
+ redraw_freq = (
+ (self._max or 10) / 10 if self.redraw_freq is None else self.redraw_freq
+ )
+ prev_period = int(self._step / redraw_freq)
+ curr_period = int(step / redraw_freq)
+
+ self._step = step
+
+ if self._max:
+ self._percent = self._step / self._max
+ else:
+ self._percent = 0.0
+
+ time_interval = time.time() - self._last_write_time
+
+ # Draw regardless of other limits
+ if step == self._max:
+ self.display()
+
+ return
+
+ # Throttling
+ if time_interval < self._min_seconds_between_redraws:
+ return
+
+ # Draw each step period, but not too late
+ if (
+ prev_period != curr_period
+ or time_interval >= self._max_seconds_between_redraws
+ ):
+ self.display()
+
+ def finish(self) -> None:
+ """
+ Finish the progress output.
+ """
+ if not self._max:
+ self._max = self._step
+
+ if self._step == self._max and not self._should_overwrite:
+ return
+
+ self.set_progress(self._max)
+
+ def display(self) -> None:
+ """
+ Output the current progress string.
+ """
+ if self._io.is_quiet():
+ return
+
+ if self._format is None:
+ self._set_real_format(
+ self._internal_format or self._determine_best_format()
+ )
+
+ self._overwrite(self._build_line())
+
+ def _overwrite_callback(self, matches: Match[str]) -> str:
+ if hasattr(self, f"_formatter_{matches.group(1)}"):
+ text = str(getattr(self, f"_formatter_{matches.group(1)}")())
+ elif matches.group(1) in self._messages:
+ text = self._messages[matches.group(1)]
+ else:
+ return matches.group(0)
+
+ if matches.group(2):
+ if matches.group(2).startswith("-"):
+ text = text.ljust(int(matches.group(2).lstrip("-").rstrip("s")))
+ else:
+ text = text.rjust(int(matches.group(2).rstrip("s")))
+
+ return text
+
+ def clear(self) -> None:
+ """
+ Removes the progress bar from the current line.
+
+ This is useful if you wish to write some output
+ while a progress bar is running.
+ Call display() to show the progress bar again.
+ """
+ if not self._should_overwrite:
+ return
+
+ if self._format is None:
+ self._set_real_format(
+ self._internal_format or self._determine_best_format()
+ )
+
+ self._overwrite("\n" * self._format_line_count)
+
+ def _set_real_format(self, fmt: str) -> None:
+ """
+ Sets the progress bar format.
+ """
+ # try to use the _nomax variant if available
+ if not self._max and fmt + "_nomax" in self.formats:
+ self._format = self.formats[fmt + "_nomax"]
+ else:
+ self._format = self.formats.get(fmt, fmt)
+ assert self._format is not None
+ self._format_line_count = self._format.count("\n")
+
+ def _set_max_steps(self, mx: int) -> None:
+ """
+ Sets the progress bar maximal steps.
+ """
+ self._max = max(0, mx)
+
+ if self._max:
+ self._step_width = len(str(self._max))
+ else:
+ self._step_width = 4
+
+ def _overwrite(self, message: str) -> None:
+ """
+ Overwrites a previous message to the output.
+ """
+ if self._previous_message == message:
+ return
+
+ original_message = message
+
+ if self._should_overwrite:
+ if self._previous_message is not None:
+ if isinstance(self._io, SectionOutput):
+ lines_to_clear = (
+ int(
+ math.floor(
+ len(self._io.remove_format(message))
+ / self._terminal.columns
+ )
+ )
+ + self._format_line_count
+ + 1
+ )
+ self._io.clear(lines_to_clear)
+ else:
+ if self._format_line_count:
+ self._cursor.move_up(self._format_line_count)
+
+ self._cursor.move_to_column(1)
+ self._cursor.clear_line()
+ elif self._step > 0:
+ message = "\n" + message
+
+ self._previous_message = original_message
+ self._last_write_time = time.time()
+
+ self._io.write(message)
+ self._write_count += 1
+
+ def _determine_best_format(self) -> str:
+ if self._io.is_debug():
+ if self._max:
+ return "debug"
+
+ return "debug_nomax"
+ elif self._io.is_very_verbose():
+ if self._max:
+ return "very_verbose"
+
+ return "very_verbose_nomax"
+ elif self._io.is_verbose():
+ if self._max:
+ return "verbose"
+
+ return "verbose_nomax"
+
+ if self._max:
+ return "normal"
+
+ return "normal_nomax"
+
+ @property
+ def bar_offset(self) -> int:
+ if self._max:
+ return math.floor(self._percent * self.bar_width)
+ else:
+ if self.redraw_freq is None:
+ return math.floor(
+ (min(5, self.bar_width // 15) * self._write_count) % self.bar_width
+ )
+
+ return math.floor(self._step % self.bar_width)
+
+ def _formatter_bar(self) -> str:
+ complete_bars = self.bar_offset
+
+ display = self.get_bar_character() * int(complete_bars)
+
+ if complete_bars < self.bar_width:
+ empty_bars = (
+ self.bar_width
+ - complete_bars
+ - len(self._io.remove_format(self.progress_char))
+ )
+ display += self.progress_char + self.empty_bar_char * int(empty_bars)
+
+ return display
+
+ def _formatter_elapsed(self) -> str:
+ return format_time(time.time() - self._start_time)
+
+ def _formatter_remaining(self) -> str:
+ if not self._max:
+ raise RuntimeError(
+ "Unable to display the remaining time "
+ "if the maximum number of steps is not set."
+ )
+
+ if not self._step:
+ remaining = 0
+ else:
+ remaining = round(
+ (time.time() - self._start_time) / self._step * (self._max - self._max)
+ )
+
+ return format_time(remaining)
+
+ def _formatter_estimated(self) -> int:
+ if not self._max:
+ raise RuntimeError(
+ "Unable to display the estimated time "
+ "if the maximum number of steps is not set."
+ )
+
+ if not self._step:
+ estimated = 0
+ else:
+ estimated = round((time.time() - self._start_time) / self._step * self._max)
+
+ return estimated
+
+ def _formatter_current(self) -> str:
+ return str(self._step).rjust(self._step_width, " ")
+
+ def _formatter_max(self) -> int:
+ return self._max
+
+ def _formatter_percent(self) -> int:
+ return int(math.floor(self._percent * 100))
+
+ def _build_line(self) -> str:
+ regex = re.compile(r"(?i)%([a-z\-_]+)(?::([^%]+))?%")
+ assert self._format is not None
+ line = regex.sub(self._overwrite_callback, self._format)
+
+ # gets string length for each sub line with multiline format
+ lines_length = [
+ len(self._io.remove_format(sub_line.rstrip("\r")))
+ for sub_line in line.split("\n")
+ ]
+
+ lines_width = max(lines_length)
+
+ terminal_width = self._terminal.columns
+
+ if lines_width <= terminal_width:
+ return line
+
+ self.set_bar_width(self.bar_width - lines_width + terminal_width)
+
+ return regex.sub(self._overwrite_callback, self._format)
diff --git a/src/cleo/ui/progress_indicator.py b/src/cleo/ui/progress_indicator.py
new file mode 100644
index 0000000..ddf3c28
--- /dev/null
+++ b/src/cleo/ui/progress_indicator.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import re
+import threading
+import time
+
+from contextlib import contextmanager
+from typing import TYPE_CHECKING
+
+from cleo._utils import format_time
+from cleo.io.io import IO
+
+
+if TYPE_CHECKING:
+ from typing import Iterator
+ from typing import Match
+
+ from cleo.io.outputs.output import Output
+
+
+class ProgressIndicator:
+ """
+ A process indicator.
+ """
+
+ NORMAL = " {indicator} {message}"
+ NORMAL_NO_ANSI = " {message}"
+ VERBOSE = " {indicator} {message} ({elapsed:6s})"
+ VERBOSE_NO_ANSI = " {message} ({elapsed:6s})"
+ VERY_VERBOSE = " {indicator} {message} ({elapsed:6s})"
+ VERY_VERBOSE_NO_ANSI = " {message} ({elapsed:6s})"
+
+ def __init__(
+ self,
+ io: IO | Output,
+ fmt: str | None = None,
+ interval: int = 100,
+ values: list[str] | None = None,
+ ) -> None:
+ if isinstance(io, IO):
+ io = io.error_output
+
+ self._io = io
+
+ if fmt is None:
+ fmt = self._determine_best_format()
+
+ self._fmt = fmt
+
+ if values is None:
+ values = ["-", "\\", "|", "/"]
+
+ if len(values) < 2:
+ raise ValueError(
+ "The progress indicator must have at "
+ "least 2 indicator value characters."
+ )
+
+ self._interval = interval
+ self._values = values
+
+ self._message: str | None = None
+ self._update_time: int | None = None
+ self._started = False
+ self._current = 0
+
+ self._auto_running: threading.Event | None = None
+ self._auto_thread: threading.Thread | None = None
+
+ self._start_time: float | None = None
+ self._last_message_length = 0
+
+ @property
+ def message(self) -> str | None:
+ return self._message
+
+ def set_message(self, message: str | None) -> None:
+ self._message = message
+
+ self._display()
+
+ @property
+ def current_value(self) -> str:
+ return self._values[self._current % len(self._values)]
+
+ def start(self, message: str) -> None:
+ if self._started:
+ raise RuntimeError("Progress indicator already started.")
+
+ self._message = message
+ self._started = True
+ self._start_time = time.time()
+ self._update_time = self._get_current_time_in_milliseconds() + self._interval
+ self._current = 0
+
+ self._display()
+
+ def advance(self) -> None:
+ if not self._started:
+ raise RuntimeError("Progress indicator has not yet been started.")
+
+ if not self._io.is_decorated():
+ return None
+
+ current_time = self._get_current_time_in_milliseconds()
+ if self._update_time is not None and current_time < self._update_time:
+ return None
+
+ self._update_time = current_time + self._interval
+ self._current += 1
+
+ self._display()
+
+ def finish(self, message: str, reset_indicator: bool = False) -> None:
+ if not self._started:
+ raise RuntimeError("Progress indicator has not yet been started.")
+
+ if self._auto_thread is not None and self._auto_running is not None:
+ self._auto_running.set()
+ self._auto_thread.join()
+
+ self._message = message
+
+ if reset_indicator:
+ self._current = 0
+
+ self._display()
+ self._io.write_line("")
+ self._started = False
+
+ @contextmanager
+ def auto(self, start_message: str, end_message: str) -> Iterator[ProgressIndicator]:
+ """
+ Auto progress.
+ """
+ self._auto_running = threading.Event()
+ self._auto_thread = threading.Thread(target=self._spin)
+
+ self.start(start_message)
+ self._auto_thread.start()
+
+ try:
+ yield self
+ except (Exception, KeyboardInterrupt):
+ self._io.write_line("")
+
+ self._auto_running.set()
+ self._auto_thread.join()
+
+ raise
+
+ self.finish(end_message, reset_indicator=True)
+
+ def _spin(self) -> None:
+ while self._auto_running is not None and not self._auto_running.is_set():
+ self.advance()
+
+ time.sleep(0.1)
+
+ def _display(self) -> None:
+ if self._io.is_quiet():
+ return
+
+ self._overwrite(
+ re.sub(
+ r"(?i){([a-z\-_]+)(?::([^}]+))?}", self._overwrite_callback, self._fmt
+ )
+ )
+
+ def _overwrite_callback(self, matches: Match[str]) -> str:
+ if hasattr(self, f"_formatter_{matches.group(1)}"):
+ text = str(getattr(self, f"_formatter_{matches.group(1)}")())
+ else:
+ text = matches.group(0)
+
+ return text
+
+ def _overwrite(self, message: str) -> None:
+ """
+ Overwrites a previous message to the output.
+ """
+ if self._io.is_decorated():
+ self._io.write("\x0D\x1B[2K")
+ self._io.write(message)
+ else:
+ self._io.write_line(message)
+
+ def _determine_best_format(self) -> str:
+ decorated = self._io.is_decorated()
+
+ if self._io.is_very_verbose():
+ if decorated:
+ return self.VERY_VERBOSE
+
+ return self.VERY_VERBOSE_NO_ANSI
+ elif self._io.is_verbose():
+ if decorated:
+ return self.VERY_VERBOSE
+
+ return self.VERBOSE_NO_ANSI
+
+ if decorated:
+ return self.NORMAL
+
+ return self.NORMAL_NO_ANSI
+
+ def _get_current_time_in_milliseconds(self) -> int:
+ return round(time.time() * 1000)
+
+ def _formatter_indicator(self) -> str:
+ return self.current_value
+
+ def _formatter_message(self) -> str | None:
+ return self.message
+
+ def _formatter_elapsed(self) -> str:
+ assert self._start_time is not None
+ return format_time(time.time() - self._start_time)
diff --git a/src/cleo/ui/question.py b/src/cleo/ui/question.py
new file mode 100644
index 0000000..1a121a7
--- /dev/null
+++ b/src/cleo/ui/question.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+import getpass
+import os
+import subprocess
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Callable
+
+from cleo.formatters.style import Style
+from cleo.io.outputs.stream_output import StreamOutput
+
+
+if TYPE_CHECKING:
+ from cleo.io.io import IO
+
+Validator = Callable[[str], Any]
+Normalizer = Callable[[str], Any]
+
+
+class Question:
+ """
+ A question that will be asked in a Console.
+ """
+
+ def __init__(self, question: str, default: Any = None) -> None:
+ self._question = question
+ self._default = default
+
+ self._attempts: int | None = None
+ self._hidden = False
+ self._hidden_fallback = True
+ self._autocomplete_values: list[str] = []
+ self._validator: Validator = lambda s: s
+ self._normalizer: Normalizer = lambda s: s
+ self._error_message = 'Value "{}" is invalid'
+
+ @property
+ def question(self) -> str:
+ return self._question
+
+ @property
+ def default(self) -> Any:
+ return self._default
+
+ @property
+ def autocomplete_values(self) -> list[str]:
+ return self._autocomplete_values
+
+ @property
+ def max_attempts(self) -> int | None:
+ return self._attempts
+
+ def is_hidden(self) -> bool:
+ return self._hidden
+
+ def hide(self, hidden: bool = True) -> None:
+ if hidden is True and self._autocomplete_values:
+ raise RuntimeError("A hidden question cannot use the autocompleter.")
+
+ self._hidden = hidden
+
+ def set_autocomplete_values(self, autocomplete_values: list[str]) -> None:
+ if self.is_hidden():
+ raise RuntimeError("A hidden question cannot use the autocompleter.")
+
+ self._autocomplete_values = autocomplete_values
+
+ def set_max_attempts(self, attempts: int | None) -> None:
+ self._attempts = attempts
+
+ def set_validator(self, validator: Validator) -> None:
+ self._validator = validator
+
+ def ask(self, io: IO) -> Any:
+ """
+ Asks the question to the user.
+ """
+ if not io.is_interactive():
+ return self.default
+ return self._validate_attempts(lambda: self._do_ask(io), io)
+
+ def _do_ask(self, io: IO) -> Any:
+ """
+ Asks the question to the user.
+ """
+ self._write_prompt(io)
+
+ if not self._autocomplete_values or not self._has_stty_available():
+ ret: str | None = None
+
+ if self.is_hidden():
+ try:
+ ret = self._get_hidden_response(io)
+ except RuntimeError:
+ if not self._hidden_fallback:
+ raise
+
+ if not ret:
+ ret = self._read_from_input(io)
+ else:
+ ret = self._autocomplete(io)
+
+ if len(ret) <= 0:
+ ret = self._default
+
+ return self._normalizer(ret) # type: ignore[arg-type]
+
+ def _write_prompt(self, io: IO) -> None:
+ """
+ Outputs the question prompt.
+ """
+ io.write_error(f"<question>{self._question}</question> ")
+
+ def _write_error(self, io: IO, error: Exception) -> None:
+ """
+ Outputs an error message.
+ """
+ io.write_error_line(f"<error>{str(error)}</error>")
+
+ def _autocomplete(self, io: IO) -> str:
+ """
+ Autocomplete a question.
+ """
+ autocomplete = self._autocomplete_values
+
+ ret = ""
+
+ i = 0
+ ofs = -1
+ matches = list(autocomplete)
+ num_matches = len(matches)
+
+ # Add highlighted text style
+ style = Style(options=["reverse"])
+ io.error_output.formatter.set_style("hl", style)
+
+ stty_mode = subprocess.check_output(["stty", "-g"]).decode().rstrip("\n")
+
+ # Disable icanon (so we can read each keypress) and
+ # echo (we'll do echoing here instead)
+ subprocess.check_output(["stty", "-icanon", "-echo"])
+ try:
+ # Read a keypress
+ while True:
+ c = io.read(1)
+
+ # Backspace character
+ if c == "\177":
+ if num_matches == 0 and i != 0:
+ i -= 1
+ # Move cursor backwards
+ io.write_error("\033[1D")
+
+ if i == 0:
+ ofs = -1
+ matches = list(autocomplete)
+ num_matches = len(matches)
+ else:
+ num_matches = 0
+
+ # Pop the last character off the end of our string
+ ret = ret[:i]
+ # Did we read an escape sequence
+ elif c == "\033":
+ c += io.read(2)
+
+ # A = Up Arrow. B = Down Arrow
+ if c[2] == "A" or c[2] == "B":
+ if c[2] == "A" and ofs == -1:
+ ofs = 0
+
+ if num_matches == 0:
+ continue
+
+ ofs += -1 if c[2] == "A" else 1
+ ofs = (num_matches + ofs) % num_matches
+ elif ord(c) < 32:
+ if c in ["\t", "\n"]:
+ if num_matches > 0 and ofs != -1:
+ ret = matches[ofs]
+ # Echo out remaining chars for current match
+ io.write_error(ret[i:])
+ i = len(ret)
+
+ if c == "\n":
+ io.write_error(c)
+ break
+
+ num_matches = 0
+
+ continue
+ else:
+ io.write_error(c)
+ ret += c
+ i += 1
+
+ num_matches = 0
+ ofs = 0
+
+ for value in autocomplete:
+ # If typed characters match the beginning
+ # chunk of value (e.g. [AcmeDe]moBundle)
+ if value.startswith(ret) and i != len(value):
+ num_matches += 1
+ matches[num_matches - 1] = value
+
+ # Erase characters from cursor to end of line
+ io.write_error("\033[K")
+
+ if num_matches > 0 and ofs != -1:
+ # Save cursor position
+ io.write_error("\0337")
+ # Write highlighted text
+ io.write_error("<hl>" + matches[ofs][i:] + "</hl>")
+ # Restore cursor position
+ io.write_error("\0338")
+ finally:
+ subprocess.call(["stty", f"{stty_mode}"])
+
+ return ret
+
+ def _get_hidden_response(self, io: IO) -> str:
+ """
+ Gets a hidden response from user.
+ """
+ stream = None
+ if isinstance(io.error_output, StreamOutput):
+ stream = io.error_output.stream
+ return getpass.getpass("", stream=stream)
+
+ def _validate_attempts(self, interviewer: Callable[[], Any], io: IO) -> Any:
+ """
+ Validates an attempt.
+ """
+ error = None
+ attempts = self._attempts
+
+ while attempts is None or attempts:
+ if error is not None:
+ self._write_error(io, error)
+
+ try:
+ return self._validator(interviewer())
+ except Exception as e:
+ error = e
+
+ if attempts is not None:
+ attempts -= 1
+
+ assert error
+ raise error
+
+ def _read_from_input(self, io: IO) -> str:
+ """
+ Read user input.
+ """
+ ret = io.read_line(4096)
+
+ if not ret:
+ raise RuntimeError("Aborted")
+
+ return ret.strip()
+
+ def _has_stty_available(self) -> bool:
+ with open(os.devnull, "w") as devnull:
+ try:
+ exit_code = subprocess.call(["stty"], stdout=devnull, stderr=devnull)
+ except Exception:
+ exit_code = 2
+
+ return exit_code == 0
diff --git a/src/cleo/ui/table.py b/src/cleo/ui/table.py
new file mode 100644
index 0000000..accaf54
--- /dev/null
+++ b/src/cleo/ui/table.py
@@ -0,0 +1,738 @@
+from __future__ import annotations
+
+import math
+import re
+
+from copy import deepcopy
+from typing import TYPE_CHECKING
+from typing import Iterator
+from typing import List
+from typing import Union
+from typing import cast
+
+from cleo.formatters.formatter import Formatter
+from cleo.io.outputs.output import Output
+from cleo.ui.table_cell import TableCell
+from cleo.ui.table_cell_style import TableCellStyle
+from cleo.ui.table_separator import TableSeparator
+from cleo.ui.table_style import TableStyle
+
+
+if TYPE_CHECKING:
+ from cleo.io.io import IO
+
+Row = List[Union[str, TableCell]]
+Rows = List[Union[Row, TableSeparator]]
+Header = Row
+
+
+class Table:
+
+ SEPARATOR_TOP: int = 0
+ SEPARATOR_TOP_BOTTOM: int = 1
+ SEPARATOR_MID: int = 2
+ SEPARATOR_BOTTOM: int = 3
+
+ BORDER_OUTSIDE: int = 0
+ BORDER_INSIDE: int = 1
+
+ _styles: dict[str, TableStyle] | None = None
+
+ def __init__(self, io: IO | Output, style: str | None = None) -> None:
+ self._io = io
+
+ if style is None:
+ style = "default"
+
+ self._header_title: str | None = None
+ self._footer_title: str | None = None
+
+ self._headers: list[Header] = []
+
+ self._rows: Rows = []
+ self._horizontal = False
+
+ self._effective_column_widths: dict[int, int] = {}
+
+ self._number_of_columns: int | None = None
+
+ self._column_styles: dict[int, TableStyle] = {}
+ self._column_widths: dict[int, int] = {}
+ self._column_max_widths: dict[int, int] = {}
+
+ self._rendered = False
+
+ self._style: TableStyle | None = None
+ self._init_styles()
+ self.set_style(style)
+
+ @property
+ def style(self) -> TableStyle:
+ assert self._style is not None
+ return self._style
+
+ def set_style(self, name: str) -> Table:
+ self._init_styles()
+
+ self._style = self._resolve_style(name)
+
+ return self
+
+ def column_style(self, column_index: int) -> TableStyle:
+ if column_index in self._column_styles:
+ return self._column_styles[column_index]
+
+ return self.style
+
+ def set_column_style(self, column_index: int, style: str | TableStyle) -> Table:
+ self._column_styles[column_index] = self._resolve_style(style)
+
+ return self
+
+ def set_column_width(self, column_index: int, width: int) -> Table:
+ self._column_widths[column_index] = width
+
+ return self
+
+ def set_column_widths(self, widths: list[int]) -> Table:
+ self._column_widths = {}
+
+ for i, width in enumerate(widths):
+ self._column_widths[i] = width
+
+ return self
+
+ def set_column_max_width(self, column_index: int, width: int) -> Table:
+ self._column_widths[column_index] = width
+
+ return self
+
+ def set_headers(self, headers: Header | list[Header]) -> Table:
+ if headers and not isinstance(headers[0], list):
+ headers = cast("Header", headers)
+ headers = [headers]
+
+ headers = cast("List[Header]", headers)
+
+ self._headers = headers
+
+ return self
+
+ def set_rows(self, rows: Rows) -> Table:
+ self._rows = []
+
+ return self.add_rows(rows)
+
+ def add_rows(self, rows: Rows) -> Table:
+ for row in rows:
+ self.add_row(row)
+
+ return self
+
+ def add_row(self, row: Row | TableSeparator) -> Table:
+ if isinstance(row, TableSeparator):
+ self._rows.append(row)
+
+ return self
+
+ self._rows.append(row)
+
+ return self
+
+ def set_header_title(self, header_title: str) -> Table:
+ self._header_title = header_title
+
+ return self
+
+ def set_footer_title(self, footer_title: str) -> Table:
+ self._footer_title = footer_title
+
+ return self
+
+ def horizontal(self, horizontal: bool = True) -> Table:
+ self._horizontal = horizontal
+
+ return self
+
+ def render(self) -> None:
+ divider = TableSeparator()
+
+ if self._horizontal:
+ rows: Rows = []
+ headers = self._headers[0] if self._headers else []
+ for i, header in enumerate(headers):
+ rows.append([header])
+ for row in self._rows:
+ if isinstance(row, TableSeparator):
+ continue
+
+ rows_i = rows[i]
+ assert not isinstance(rows_i, TableSeparator)
+
+ if len(row) > i:
+ rows_i.append(row[i])
+ elif isinstance(rows_i[0], TableCell) and rows_i[0].colspan >= 2:
+ # There is a title
+ pass
+ else:
+ rows_i.append("")
+ else:
+ rows = cast("Rows", self._headers) + [divider] + self._rows
+
+ self._calculate_number_of_columns(rows)
+ rows = list(self._build_table_rows(rows))
+ self._calculate_column_widths(rows)
+
+ is_header = not self._horizontal
+ is_first_row = self._horizontal
+
+ for row in rows:
+ if row is divider:
+ is_header = False
+ is_first_row = True
+
+ continue
+
+ if isinstance(row, TableSeparator):
+ self._render_row_separator()
+
+ continue
+
+ if not row:
+ continue
+
+ if is_header or is_first_row:
+ if is_first_row:
+ self._render_row_separator(self.SEPARATOR_TOP_BOTTOM)
+ is_first_row = False
+ else:
+ self._render_row_separator(
+ self.SEPARATOR_TOP,
+ self._header_title,
+ self.style.header_title_format,
+ )
+
+ if self._horizontal:
+ self._render_row(
+ row, self.style.cell_row_format, self.style.cell_header_format
+ )
+ else:
+ self._render_row(
+ row,
+ self.style.cell_header_format
+ if is_header
+ else self.style.cell_row_format,
+ )
+
+ self._render_row_separator(
+ self.SEPARATOR_BOTTOM,
+ self._footer_title,
+ self.style.footer_title_format,
+ )
+
+ self._cleanup()
+ self._rendered = True
+
+ def _render_row_separator(
+ self,
+ type: int = SEPARATOR_MID,
+ title: str | None = None,
+ title_format: str | None = None,
+ ) -> None:
+ """
+ Renders horizontal header separator.
+
+ Example:
+
+ +-----+-----------+-------+
+ """
+ count = self._number_of_columns
+ if not count:
+ return
+
+ borders = self.style.border_chars
+ if not borders[0] and not borders[2] and not self.style.crossing_char:
+ return
+
+ crossings = self.style.crossing_chars
+ if type == self.SEPARATOR_MID:
+ horizontal, left_char, mid_char, right_char = (
+ borders[2],
+ crossings[8],
+ crossings[0],
+ crossings[4],
+ )
+ elif type == self.SEPARATOR_TOP:
+ horizontal, left_char, mid_char, right_char = (
+ borders[0],
+ crossings[1],
+ crossings[2],
+ crossings[3],
+ )
+ elif type == self.SEPARATOR_TOP_BOTTOM:
+ horizontal, left_char, mid_char, right_char = (
+ borders[0],
+ crossings[9],
+ crossings[10],
+ crossings[11],
+ )
+ else:
+ horizontal, left_char, mid_char, right_char = (
+ borders[0],
+ crossings[7],
+ crossings[6],
+ crossings[5],
+ )
+
+ markup = left_char
+ for column in range(count):
+ markup += horizontal * self._effective_column_widths[column]
+ markup += right_char if column == count - 1 else mid_char
+
+ if title is not None:
+ assert title_format is not None
+ formatted_title = title_format.format(title)
+ title_length = len(self._io.remove_format(formatted_title))
+ markup_length = len(markup)
+ limit = markup_length - 4
+
+ if title_length > limit:
+ title_length = limit
+ format_length = len(self._io.remove_format(title_format.format("")))
+ formatted_title = title_format.format(
+ title[: limit - format_length - 3] + "..."
+ )
+
+ title_start = (markup_length - title_length) // 2
+ markup = (
+ markup[:title_start]
+ + formatted_title
+ + markup[title_start + title_length :]
+ )
+
+ self._io.write_line(self.style.border_format.format(markup))
+
+ def _render_column_separator(self, type: int = BORDER_OUTSIDE) -> str:
+ """
+ Renders vertical column separator.
+ """
+ borders = self.style.border_chars
+
+ return self.style.border_format.format(
+ borders[1] if type == self.BORDER_OUTSIDE else borders[3]
+ )
+
+ def _render_row(
+ self, row: list[str], cell_format: str, first_cell_format: str | None = None
+ ) -> None:
+ """
+ Renders table row.
+
+ Example:
+
+ | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens |
+ """
+ row_content = self._render_column_separator(self.BORDER_OUTSIDE)
+ columns = self._get_row_columns(row)
+ last = len(columns) - 1
+ for i, column in enumerate(columns):
+ if first_cell_format and i == 0:
+ row_content += self._render_cell(row, column, first_cell_format)
+ else:
+ row_content += self._render_cell(row, column, cell_format)
+
+ row_content += self._render_column_separator(
+ self.BORDER_OUTSIDE if i == last else self.BORDER_INSIDE
+ )
+
+ self._io.write_line(row_content)
+
+ def _render_cell(self, row: Row, column: int, cell_format: str) -> str:
+ """
+ Renders a table cell with padding.
+ """
+ try:
+ cell = row[column]
+ except IndexError:
+ cell = ""
+
+ width = self._effective_column_widths[column]
+ if isinstance(cell, TableCell) and cell.colspan > 1:
+ # add the width of the following columns(numbers of colspan).
+ for next_column in range(column + 1, column + cell.colspan):
+ width += (
+ self._get_column_separator_width()
+ + self._effective_column_widths[next_column]
+ )
+
+ style = self.column_style(column)
+
+ if isinstance(cell, TableSeparator):
+ return style.border_format.format(style.border_chars[2] * width)
+
+ width += len(cell) - len(self._io.remove_format(cell))
+ content = style.cell_row_content_format.format(cell)
+
+ pad = style.pad
+ if isinstance(cell, TableCell) and isinstance(cell.style, TableCellStyle):
+ is_not_styled_by_tag = not re.match(
+ (
+ r"^<(\w+|((?:fg|bg|options)=[\w,]+;?)+)>"
+ r".+<\/(\w+|((?:fg|bg|options)=[\w,]+;?)+)?>$"
+ ),
+ str(cell),
+ )
+ if is_not_styled_by_tag:
+ cell_format = (
+ cell.style.cell_format
+ if cell.style.cell_format is not None
+ else f"<{cell.style.tag}>{{}}</>"
+ )
+
+ if "</>" in content:
+ content = content.replace("</>", "")
+ width -= 3
+
+ if "<fg=default;bg=default>" in content:
+ content = content.replace("<fg=default;bg=default>", "")
+ width -= len("<fg=default;bg=default>")
+
+ pad = cell.style.pad
+
+ return cell_format.format(pad(content, width, style.padding_char))
+
+ def _calculate_number_of_columns(self, rows: Rows) -> None:
+ columns = [0]
+ for row in rows:
+ if isinstance(row, TableSeparator):
+ continue
+
+ columns.append(self._get_number_of_columns(row))
+
+ self._number_of_columns = max(columns)
+
+ def _build_table_rows(self, rows: Rows) -> Iterator[Row | TableSeparator]:
+ unmerged_rows: dict[int, dict[int, Row]] = {}
+ row_key = 0
+ while row_key < len(rows):
+ rows = self._fill_next_rows(rows, row_key)
+
+ # Remove any new line breaks and replace it with a new line
+ for column, cell in enumerate(rows[row_key]):
+ colspan = cell.colspan if isinstance(cell, TableCell) else 1
+
+ if column in self._column_max_widths and self._column_max_widths[
+ column
+ ] < len(self._io.remove_format(cell)):
+ assert isinstance(self._io, Output)
+ cell = self._io.formatter.format_and_wrap(
+ cell, self._column_max_widths[column] * colspan
+ )
+
+ if "\n" not in cell:
+ continue
+
+ escaped = "\n".join(
+ Formatter.escape_trailing_backslash(c) for c in cell.split("\n")
+ )
+ cell = (
+ TableCell(escaped, colspan=cell.colspan)
+ if isinstance(cell, TableCell)
+ else escaped
+ )
+ lines = cell.replace("\n", "<fg=default;bg=default>\n</>").split("\n")
+
+ for line_key, line in enumerate(lines):
+ if colspan > 1:
+ line = TableCell(line, colspan=colspan)
+
+ if line_key == 0:
+ row = rows[row_key]
+ assert not isinstance(row, TableSeparator)
+ row[column] = line
+ else:
+ if row_key not in unmerged_rows:
+ unmerged_rows[row_key] = {}
+
+ if line_key not in unmerged_rows[row_key]:
+ unmerged_rows[row_key][line_key] = self._copy_row(
+ rows, row_key
+ )
+
+ unmerged_rows[row_key][line_key][column] = line
+
+ row_key += 1
+
+ for row_key, row in enumerate(rows):
+ yield self._fill_cells(row)
+
+ if row_key in unmerged_rows:
+ for unmerged_row in unmerged_rows[row_key].values():
+ yield self._fill_cells(unmerged_row)
+
+ def _calculate_row_count(self) -> int:
+ number_of_rows = len(
+ list(
+ self._build_table_rows(
+ cast("Rows", self._headers) + [TableSeparator()] + self._rows
+ )
+ )
+ )
+
+ if self._headers:
+ number_of_rows += 1
+
+ if len(self._rows) > 0:
+ number_of_rows += 1
+
+ return number_of_rows
+
+ def _fill_next_rows(self, rows: Rows, line: int) -> Rows:
+ """
+ Fill rows that contains rowspan > 1.
+ """
+ unmerged_rows: dict[int, dict[int, str | TableCell]] = {}
+
+ for column, cell in enumerate(rows[line]):
+ if isinstance(cell, TableCell) and cell.rowspan > 1:
+ nb_lines = cell.rowspan - 1
+ lines: Row = [cell]
+ if "\n" in cell:
+ lines = cell.replace("\n", "<fg=default;bg=default>\n</>").split(
+ "\n"
+ )
+ if len(lines) > nb_lines:
+ nb_lines = cell.count("\n")
+
+ row = rows[line]
+ assert not isinstance(row, TableSeparator)
+
+ row[column] = TableCell(
+ lines[0], colspan=cell.colspan, style=cell.style
+ )
+
+ # Create a two dimensional dict (rowspan x colspan)
+ placeholder: dict[int, dict[int, str | TableCell]] = {
+ k: {} for k in range(line + 1, line + 1 + nb_lines)
+ }
+ for k, v in unmerged_rows.items():
+ if k in placeholder:
+ for l, m in unmerged_rows[k].items(): # noqa: E741
+ placeholder[k][l] = m
+ else:
+ placeholder[k] = v
+
+ unmerged_rows = placeholder
+
+ for unmerged_row_key, _ in unmerged_rows.items():
+ value = ""
+ if unmerged_row_key - line < len(lines):
+ value = lines[unmerged_row_key - line]
+
+ unmerged_rows[unmerged_row_key][column] = TableCell(
+ value, colspan=cell.colspan, style=cell.style
+ )
+ if nb_lines == unmerged_row_key - line:
+ break
+
+ for unmerged_row_key, unmerged_row in unmerged_rows.items():
+ # we need to know if unmerged_row will be merged or inserted into rows
+ assert self._number_of_columns is not None
+ this_row = None if unmerged_row_key >= len(rows) else rows[unmerged_row_key]
+ if (
+ this_row is not None
+ and not isinstance(this_row, TableSeparator)
+ and (
+ (
+ self._get_number_of_columns(this_row)
+ + self._get_number_of_columns(
+ list(unmerged_rows[unmerged_row_key].values())
+ )
+ )
+ <= self._number_of_columns
+ )
+ ):
+ # insert cell into row at cell_key position
+ for cell_key, cell in unmerged_row.items():
+ this_row.insert(cell_key, cell)
+ else:
+ row = self._copy_row(rows, unmerged_row_key - 1)
+ for column, cell in unmerged_row.items():
+ if len(cell):
+ row[column] = unmerged_row[column]
+
+ rows.insert(unmerged_row_key, row)
+
+ return rows
+
+ def _fill_cells(self, row: Row | TableSeparator) -> Row | TableSeparator:
+ """
+ Fills cells for a row that contains colspan > 1.
+ """
+ new_row = []
+
+ for column, cell in enumerate(row):
+ new_row.append(cell)
+
+ if isinstance(cell, TableCell) and cell.colspan > 1:
+ for _ in range(column + 1, column + cell.colspan):
+ # insert empty value at column position
+ new_row.append("")
+
+ if new_row:
+ return new_row
+
+ return row
+
+ def _copy_row(self, rows: Rows, line: int) -> Row:
+ """
+ Copies a row.
+ """
+ row = list(rows[line])
+
+ for cell_key, cell_value in enumerate(row):
+ row[cell_key] = ""
+ if isinstance(cell_value, TableCell):
+ row[cell_key] = TableCell("", colspan=cell_value.colspan)
+
+ return row
+
+ def _get_number_of_columns(self, row: Row) -> int:
+ """
+ Gets number of columns by row.
+ """
+ columns = len(row)
+ for column in row:
+ if isinstance(column, TableCell):
+ columns += column.colspan - 1
+
+ return columns
+
+ def _get_row_columns(self, row: Row) -> list[int]:
+ """
+ Gets list of columns for the given row.
+ """
+ assert self._number_of_columns is not None
+ columns = list(range(0, self._number_of_columns))
+
+ for cell_key, cell in enumerate(row):
+ if isinstance(cell, TableCell) and cell.colspan > 1:
+ # exclude grouped columns.
+ columns = [
+ x
+ for x in columns
+ if x not in list(range(cell_key + 1, cell_key + cell.colspan))
+ ]
+
+ return columns
+
+ def _calculate_column_widths(self, rows: Rows) -> None:
+ """
+ Calculates column widths.
+ """
+ assert self._number_of_columns is not None
+ for column in range(0, self._number_of_columns):
+ lengths = [0]
+ for row in rows:
+ if isinstance(row, TableSeparator):
+ continue
+
+ row_ = row.copy()
+ for i, cell in enumerate(row_):
+ if isinstance(cell, TableCell):
+ text_content = self._io.remove_format(cell)
+ text_length = len(text_content)
+ if text_length:
+ length = math.ceil(text_length / cell.colspan)
+ content_columns = [
+ text_content[i : i + length]
+ for i in range(0, text_length, length)
+ ]
+
+ for position, content in enumerate(content_columns):
+ try:
+ row_[i + position] = content
+ except IndexError:
+ row_.append(content)
+
+ lengths.append(self._get_cell_width(row_, column))
+
+ self._effective_column_widths[column] = (
+ max(lengths) + len(self.style.cell_row_content_format) - 2
+ )
+
+ def _get_column_separator_width(self) -> int:
+ return len(self.style.border_format.format(self.style.border_chars[3]))
+
+ def _get_cell_width(self, row: Row, column: int) -> int:
+ """
+ Gets cell width.
+ """
+ cell_width = 0
+
+ try:
+ cell = row[column]
+ cell_width = len(self._io.remove_format(cell))
+ except IndexError:
+ pass
+
+ column_width = (
+ self._column_widths[column] if column in self._column_widths else 0
+ )
+ cell_width = max(cell_width, column_width)
+
+ if column in self._column_max_widths:
+ return min(self._column_max_widths[column], cell_width)
+
+ return cell_width
+
+ def _cleanup(self) -> None:
+ self._column_widths = {}
+ self._number_of_columns = None
+
+ @classmethod
+ def _init_styles(cls) -> None:
+ if cls._styles is not None:
+ return
+
+ borderless = TableStyle()
+ borderless.set_horizontal_border_chars("=")
+ borderless.set_vertical_border_chars(" ")
+ borderless.set_default_crossing_char(" ")
+
+ compact = TableStyle()
+ compact.set_horizontal_border_chars("")
+ compact.set_vertical_border_chars(" ")
+ compact.set_default_crossing_char("")
+ compact.set_cell_row_content_format("{}")
+
+ box = TableStyle()
+ box.set_horizontal_border_chars("─")
+ box.set_vertical_border_chars("│")
+ box.set_crossing_chars("┼", "┌", "┬", "┐", "┤", "┘", "┴", "└", "├")
+
+ box_double = TableStyle()
+ box_double.set_horizontal_border_chars("═", "─")
+ box_double.set_vertical_border_chars("║", "│")
+ box_double.set_crossing_chars(
+ "┼", "╔", "╤", "╗", "╢", "╝", "╧", "╚", "╟", "╠", "╪", "╣"
+ )
+
+ cls._styles = {
+ "default": TableStyle(),
+ "borderless": borderless,
+ "compact": compact,
+ "box": box,
+ "box-double": box_double,
+ }
+
+ @classmethod
+ def _resolve_style(cls, name: str | TableStyle) -> TableStyle:
+ if isinstance(name, TableStyle):
+ return name
+
+ assert cls._styles is not None
+ if name in cls._styles:
+ return deepcopy(cls._styles[name])
+
+ raise ValueError(f'Table style "{name}" is not defined.')
diff --git a/src/cleo/ui/table_cell.py b/src/cleo/ui/table_cell.py
new file mode 100644
index 0000000..c066260
--- /dev/null
+++ b/src/cleo/ui/table_cell.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+
+if TYPE_CHECKING:
+ from cleo.ui.table_cell_style import TableCellStyle
+
+
+class TableCell(str):
+ def __new__(
+ cls,
+ value: str = "",
+ rowspan: int = 1,
+ colspan: int = 1,
+ style: TableCellStyle | None = None,
+ ) -> TableCell:
+ return super().__new__(cls, value)
+
+ def __init__(
+ self,
+ value: str = "",
+ rowspan: int = 1,
+ colspan: int = 1,
+ style: TableCellStyle | None = None,
+ ) -> None:
+ self._rowspan = rowspan
+ self._colspan = colspan
+ self._style = style
+
+ @property
+ def rowspan(self) -> int:
+ return self._rowspan
+
+ @property
+ def colspan(self) -> int:
+ return self._colspan
+
+ @property
+ def style(self) -> TableCellStyle | None:
+ return self._style
diff --git a/src/cleo/ui/table_cell_style.py b/src/cleo/ui/table_cell_style.py
new file mode 100644
index 0000000..78c90a3
--- /dev/null
+++ b/src/cleo/ui/table_cell_style.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Union
+
+
+if TYPE_CHECKING:
+ import sys
+
+ if sys.version_info >= (3, 8):
+ from typing import Literal
+ else:
+ from typing_extensions import Literal
+
+ _Align = Union[Literal["left"], Literal["right"]]
+
+
+class TableCellStyle:
+ def __init__(
+ self,
+ fg: str = "default",
+ bg: str = "default",
+ options: list[str] | None = None,
+ align: _Align = "left",
+ cell_format: str | None = None,
+ ) -> None:
+ self._fg = fg
+ self._bg = bg
+ self._options = options
+ self._align = "left"
+ self._cell_format = cell_format
+
+ @property
+ def cell_format(self) -> str | None:
+ return self._cell_format
+
+ @property
+ def tag(self) -> str:
+ tag = "<fg={};bg={}"
+
+ if self._options:
+ tag += f';options={",".join(self._options)}'
+
+ tag += ">"
+
+ return tag
+
+ def pad(self, string: str, length: int, char: str = " ") -> str:
+ if self._align == "left":
+ return string.rjust(length, char)
+
+ if self._align == "right":
+ return string.ljust(length, char)
+
+ return string.center(length, char)
diff --git a/src/cleo/ui/table_separator.py b/src/cleo/ui/table_separator.py
new file mode 100644
index 0000000..4feb09d
--- /dev/null
+++ b/src/cleo/ui/table_separator.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+from cleo.ui.table_cell import TableCell
+
+
+class TableSeparator(TableCell):
+ def __init__(self) -> None:
+ super().__init__("")
diff --git a/src/cleo/ui/table_style.py b/src/cleo/ui/table_style.py
new file mode 100644
index 0000000..82fe187
--- /dev/null
+++ b/src/cleo/ui/table_style.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+
+
+class TableStyle:
+ """
+ Defines styles for Table instances.
+ """
+
+ def __init__(self) -> None:
+ self._padding_char = " "
+ self._horizontal_outside_border_char = "-"
+ self._horizontal_inside_border_char = "-"
+ self._vertical_outside_border_char = "|"
+ self._vertical_inside_border_char = "|"
+ self._crossing_char = "+"
+ self._crossing_top_right_char = "+"
+ self._crossing_top_mid_char = "+"
+ self._crossing_top_left_char = "+"
+ self._crossing_mid_right_char = "+"
+ self._crossing_bottom_right_char = "+"
+ self._crossing_bottom_mid_char = "+"
+ self._crossing_bottom_left_char = "+"
+ self._crossing_mid_left_char = "+"
+ self._crossing_top_left_bottom_char = "+"
+ self._crossing_top_mid_bottom_char = "+"
+ self._crossing_top_right_bottom_char = "+"
+ self._header_title_format = "<b> {} </b>"
+ self._footer_title_format = "<b> {} </b>"
+ self._cell_header_format = "<c1>{}</c1>"
+ self._cell_row_format = "{}"
+ self._cell_row_content_format = " {} "
+ self._border_format = "{}"
+ self._pad_type = "right"
+
+ @property
+ def padding_char(self) -> str:
+ return self._padding_char
+
+ @property
+ def border_chars(self) -> list[str]:
+ return [
+ self._horizontal_outside_border_char,
+ self._vertical_outside_border_char,
+ self._horizontal_inside_border_char,
+ self._vertical_inside_border_char,
+ ]
+
+ @property
+ def crossing_char(self) -> str:
+ return self._crossing_char
+
+ @property
+ def crossing_chars(self) -> list[str]:
+ return [
+ self._crossing_char,
+ self._crossing_top_left_char,
+ self._crossing_top_mid_char,
+ self._crossing_top_right_char,
+ self._crossing_mid_right_char,
+ self._crossing_bottom_right_char,
+ self._crossing_bottom_mid_char,
+ self._crossing_bottom_left_char,
+ self._crossing_mid_left_char,
+ self._crossing_top_left_bottom_char,
+ self._crossing_top_mid_bottom_char,
+ self._crossing_top_right_bottom_char,
+ ]
+
+ @property
+ def cell_header_format(self) -> str:
+ return self._cell_header_format
+
+ @property
+ def cell_row_format(self) -> str:
+ return self._cell_row_format
+
+ @property
+ def cell_row_content_format(self) -> str:
+ return self._cell_row_content_format
+
+ @property
+ def border_format(self) -> str:
+ return self._border_format
+
+ @property
+ def header_title_format(self) -> str:
+ return self._header_title_format
+
+ @property
+ def footer_title_format(self) -> str:
+ return self._footer_title_format
+
+ @property
+ def pad_type(self) -> str:
+ return self._pad_type
+
+ def set_padding_char(self, padding_char: str) -> TableStyle:
+ """
+ Sets padding character, used for cell padding.
+ """
+ if not padding_char:
+ raise ValueError("The padding char must not be empty.")
+
+ self._padding_char = padding_char
+
+ return self
+
+ def set_horizontal_border_chars(
+ self, outside: str, inside: str | None = None
+ ) -> TableStyle:
+ """
+ Sets horizontal border characters.
+
+ ╔═══════════════╤══════════════════════════╤══════════════════╗
+ 1 ISBN 2 Title │ Author ║
+ ╠═══════════════╪══════════════════════════╪══════════════════╣
+ ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║
+ ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║
+ ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║
+ ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║
+ ╚═══════════════╧══════════════════════════╧══════════════════╝
+ """
+ self._horizontal_outside_border_char = outside
+ self._horizontal_inside_border_char = inside if inside is not None else outside
+
+ return self
+
+ def set_vertical_border_chars(
+ self, outside: str, inside: str | None = None
+ ) -> TableStyle:
+ """
+ Sets vertical border characters.
+
+ ╔═══════════════╤══════════════════════════╤══════════════════╗
+ ║ ISBN │ Title │ Author ║
+ ╠═══════1═══════╪══════════════════════════╪══════════════════╣
+ ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║
+ ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║
+ ╟───────2───────┼──────────────────────────┼──────────────────╢
+ ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║
+ ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║
+ ╚═══════════════╧══════════════════════════╧══════════════════╝
+ """
+ self._vertical_outside_border_char = outside
+ self._vertical_inside_border_char = inside if inside is not None else outside
+
+ return self
+
+ def set_crossing_chars(
+ self,
+ cross: str,
+ top_left: str,
+ top_mid: str,
+ top_right: str,
+ mid_right: str,
+ bottom_right: str,
+ bottom_mid: str,
+ bottom_left: str,
+ mid_left: str,
+ top_left_bottom: str | None = None,
+ top_mid_bottom: str | None = None,
+ top_right_bottom: str | None = None,
+ ) -> TableStyle:
+ """
+ Sets crossing characters.
+
+ Example:
+
+ 1═══════════════2══════════════════════════2══════════════════3
+ ║ ISBN │ Title │ Author ║
+ 8'══════════════0'═════════════════════════0'═════════════════4'
+ ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║
+ ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║
+ 8───────────────0──────────────────────────0──────────────────4
+ ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║
+ ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║
+ 7═══════════════6══════════════════════════6══════════════════5
+ """
+ self._crossing_char = cross
+ self._crossing_top_left_char = top_left
+ self._crossing_top_mid_char = top_mid
+ self._crossing_top_right_char = top_right
+ self._crossing_mid_right_char = mid_right
+ self._crossing_bottom_right_char = bottom_right
+ self._crossing_bottom_mid_char = bottom_mid
+ self._crossing_bottom_left_char = bottom_left
+ self._crossing_mid_left_char = mid_left
+ self._crossing_top_left_bottom_char = (
+ top_left_bottom if top_left_bottom is not None else mid_left
+ )
+ self._crossing_top_mid_bottom_char = (
+ top_mid_bottom if top_mid_bottom is not None else cross
+ )
+ self._crossing_top_right_bottom_char = (
+ top_right_bottom if top_right_bottom is not None else mid_right
+ )
+
+ return self
+
+ def set_default_crossing_char(self, char: str) -> TableStyle:
+ """
+ Sets default crossing character used for each cross.
+ """
+ return self.set_crossing_chars(
+ char, char, char, char, char, char, char, char, char
+ )
+
+ def set_cell_header_format(self, cell_header_format: str) -> TableStyle:
+ """
+ Sets the header cell format.
+ """
+ self._cell_header_format = cell_header_format
+
+ return self
+
+ def set_cell_row_format(self, cell_row_format: str) -> TableStyle:
+ """
+ Sets the row cell format.
+ """
+ self._cell_row_format = cell_row_format
+
+ return self
+
+ def set_cell_row_content_format(self, cell_row_content_format: str) -> TableStyle:
+ """
+ Sets the row cell content format.
+ """
+ self._cell_row_content_format = cell_row_content_format
+
+ return self
+
+ def set_border_format(self, border_format: str) -> TableStyle:
+ """
+ Sets the border format.
+ """
+ self._border_format = border_format
+
+ return self
+
+ def set_header_title_format(self, header_title_format: str) -> TableStyle:
+ """
+ Sets the header title format.
+ """
+ self._header_title_format = header_title_format
+
+ return self
+
+ def set_footer_title_format(self, footer_title_format: str) -> TableStyle:
+ """
+ Sets the footer title format.
+ """
+ self._footer_title_format = footer_title_format
+
+ return self
+
+ def set_pad_type(self, pad_type: str) -> TableStyle:
+ """
+ Sets the padding type.
+ """
+ if pad_type not in {"left", "right", "center"}:
+ raise ValueError(
+ 'Invalid padding type. Expected one of "left", "right", "center").'
+ )
+
+ self._pad_type = pad_type
+
+ return self
+
+ def pad(self, string: str, length: int, char: str = " ") -> str:
+ if self._pad_type == "left":
+ return string.rjust(length, char)
+
+ if self._pad_type == "right":
+ return string.ljust(length, char)
+
+ return string.center(length, char)
diff --git a/src/cleo/ui/ui.py b/src/cleo/ui/ui.py
new file mode 100644
index 0000000..e9a78a3
--- /dev/null
+++ b/src/cleo/ui/ui.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from cleo.exceptions import CleoValueError
+from cleo.ui.component import Component
+
+
+class UI:
+ def __init__(self, components: list[Component] | None = None) -> None:
+ self._components: dict[str, Component] = {}
+
+ if components is None:
+ components = []
+
+ for component in components:
+ self.register(component)
+
+ def register(self, component: Component) -> None:
+ if not isinstance(component, Component):
+ raise CleoValueError(
+ "A UI component must inherit from the Component class."
+ )
+
+ if not component.name:
+ raise CleoValueError("A UI component cannot be anonymous.")
+
+ self._components[component.name] = component
+
+ def component(self, name: str) -> Component:
+ if name not in self._components:
+ raise CleoValueError(f'UI component "{name}" does not exist.')
+
+ return self._components[name]