diff options
| author | Emmanuel Arias <eamanu@yaerobi.com> | 2023-01-05 22:48:42 -0300 |
|---|---|---|
| committer | git-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com> | 2023-01-07 10:34:05 +0000 |
| commit | 86a409f1178707fbcb4e098be902fc0e3907c5bb (patch) | |
| tree | 554d6ec6f4b743d24b34c8be6c0149d410b2fbd1 /src | |
| parent | 8a4826ea4b65099cce43cd0c72de43b0e56f1e29 (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')
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] |
