From 91db7dd0973a7e171a812be8431ba454ca5bd108 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 8 Jun 2023 00:00:00 -0500 Subject: [PATCH 01/13] feat!: refactor to make click optional --- README.md | 4 +- examples/README.md | 24 +++ examples/demo_argv.py | 58 ++++++ examples/{demo.py => demo_click.py} | 10 +- ...{nogroup_demo.py => demo_click_nogroup.py} | 6 +- examples/requirements.txt | 1 + pyproject.toml | 4 +- tests/test_help.py | 2 +- tests/test_run_command.py | 70 +++++-- trogon/__init__.py | 4 +- trogon/click.py | 166 +++++++++++++++ trogon/constants.py | 1 + trogon/introspect.py | 191 ------------------ trogon/run_command.py | 31 +-- trogon/schemas.py | 90 +++++++++ trogon/trogon.py | 125 ++++++------ trogon/widgets/command_info.py | 4 +- trogon/widgets/command_tree.py | 16 +- trogon/widgets/form.py | 2 +- trogon/widgets/parameter_controls.py | 75 ++----- 20 files changed, 518 insertions(+), 366 deletions(-) create mode 100644 examples/README.md create mode 100755 examples/demo_argv.py rename examples/{demo.py => demo_click.py} (93%) mode change 100644 => 100755 rename examples/{nogroup_demo.py => demo_click_nogroup.py} (94%) mode change 100644 => 100755 create mode 100644 examples/requirements.txt create mode 100644 trogon/click.py delete mode 100644 trogon/introspect.py create mode 100644 trogon/schemas.py diff --git a/README.md b/README.md index e05768d..3148e96 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ pip install trogon ## Quickstart -1. Import `from trogon import tui` +1. Import `from trogon.click import tui` 2. Add the `@tui` decorator above your click app. e.g. ```python @tui() @@ -100,7 +100,7 @@ pip install trogon ``` 3. Your click app will have a new `tui` command available. -See also the `examples` folder for two example apps. +See also the `examples` folder for example apps. ## Custom command name and custom help diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..dd44df6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,24 @@ +# Trogon Example TUI Apps + +To run the example TUI apps, first clone this repo and install the requirements: + +```sh +git clone https://github.com/Textualize/trogon.git + +cd trogon/examples + +pip install -r requirements.txt +``` + +```sh +./demo_argv.py tui +``` + +```sh +./demo_click.py tui +``` + +```sh +./demo_click_nogroup.py tui +``` + diff --git a/examples/demo_argv.py b/examples/demo_argv.py new file mode 100755 index 0000000..cce1c4d --- /dev/null +++ b/examples/demo_argv.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import sys + +from trogon import Trogon +from trogon.schemas import ArgumentSchema, CommandName, CommandSchema, OptionSchema +from trogon.constants import DEFAULT_COMMAND_NAME + +root_schema: CommandSchema = CommandSchema( + name=CommandName("hello"), + docstring="just sayin'", + options=[ + OptionSchema( + name=["--name"], + default="world", + ), + OptionSchema( + name=["-u", "--to-upper"], + type=bool, + is_flag=True, + ), + OptionSchema(name=["-t", "--test"], type=int, choices=[1, 2, 3]), + OptionSchema( + name=["-s", "--subjects"], type=str, multiple=True, multi_value=True + ), + ], + arguments=[ + ArgumentSchema(name=["subjects"], type=str, multiple=True, multi_value=True), + ], +) + +subcmd_1: CommandSchema = CommandSchema( + name=CommandName("wat"), + arguments=[ + ArgumentSchema(name="anything", help="wat!"), + ], + options=[ + OptionSchema( + name=["--name"], + default="world", + ), + OptionSchema( + name=["--to-upper"], + type=bool, + is_flag=True, + ), + ], +) + +tui: Trogon = Trogon.from_schemas(root_schema, subcmd_1, app_name=None) + +if __name__ == "__main__": + if DEFAULT_COMMAND_NAME in sys.argv: + tui.run() + else: + print(sys.argv) diff --git a/examples/demo.py b/examples/demo_click.py old mode 100644 new mode 100755 similarity index 93% rename from examples/demo.py rename to examples/demo_click.py index 253e1db..f52d4ab --- a/examples/demo.py +++ b/examples/demo_click.py @@ -1,6 +1,7 @@ -import click +#!/usr/bin/env python3 -from trogon import tui +import click +from trogon.click import tui @tui() @@ -45,7 +46,7 @@ def cli(ctx, verbose): help="Add labels to the task (repeatable)", ) @click.pass_context -def add(ctx, task, priority, tags, extra): +def add(ctx, task, priority, category, tags, labels): """Add a new task to the to-do list. Note: Control the output of this using the verbosity option. @@ -54,7 +55,6 @@ def add(ctx, task, priority, tags, extra): click.echo(f"Adding task: {task}") click.echo(f"Priority: {priority}") click.echo(f'Tags: {", ".join(tags)}') - click.echo(f"Extra data: {extra}") elif ctx.obj["verbose"] >= 1: click.echo(f"Adding task: {task}") else: @@ -81,7 +81,7 @@ def remove(ctx, task_id): def list_tasks(ctx, all, completed): """List tasks from the to-do list.""" if ctx.obj["verbose"] >= 1: - click.echo(f"Listing tasks:") + click.echo("Listing tasks:") # Implement the task listing functionality here diff --git a/examples/nogroup_demo.py b/examples/demo_click_nogroup.py old mode 100644 new mode 100755 similarity index 94% rename from examples/nogroup_demo.py rename to examples/demo_click_nogroup.py index 31f4351..6faa89b --- a/examples/nogroup_demo.py +++ b/examples/demo_click_nogroup.py @@ -1,6 +1,7 @@ -import click +#!/usr/bin/env python3 -from trogon import tui +import click +from trogon.click import tui @tui() @@ -15,6 +16,7 @@ nargs=2, type=(str, int), multiple=True, + default=[("one", 1), ("two", 2)], help="Add extra data as key-value pairs (repeatable)", ) @click.option( diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 0000000..12c6166 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1 @@ +..[click] diff --git a/pyproject.toml b/pyproject.toml index a784a50..78cb9a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,10 @@ classifiers = [ python = "^3.7" textual = {version = ">=0.26.0"} #textual = {extras = ["dev"], path = "../textual", develop = true} -click = ">=8.0.0" +click = {version = ">=8.0.0", optional = true} +[tool.poetry.extras] +click = ["click"] [tool.poetry.group.dev.dependencies] mypy = "^1.2.0" diff --git a/tests/test_help.py b/tests/test_help.py index 1d5eab7..8260810 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -1,7 +1,7 @@ import click from click.testing import CliRunner import re -from trogon import tui +from trogon.click import tui @tui() diff --git a/tests/test_run_command.py b/tests/test_run_command.py index 8f6f0f6..3704aaf 100644 --- a/tests/test_run_command.py +++ b/tests/test_run_command.py @@ -1,11 +1,11 @@ -import click import pytest -from trogon.introspect import ( +from trogon.schemas import ( + ArgumentSchema, + CommandName, CommandSchema, + MultiValueParamData, OptionSchema, - ArgumentSchema, - CommandName, MultiValueParamData, ) from trogon.run_command import UserCommandData, UserOptionData, UserArgumentData @@ -15,18 +15,28 @@ def command_schema(): return CommandSchema( name=CommandName("test"), arguments=[ - ArgumentSchema(name="arg1", type=click.INT, required=False, default=MultiValueParamData([(123,)])), + ArgumentSchema( + name="arg1", + type=int, + required=False, + default=MultiValueParamData([(123,)]), + ), ], options=[ OptionSchema( - name=["--option1"], type=click.STRING, required=False, default=MultiValueParamData([("default1",)]) + name=["--option1"], + type=str, + required=False, + default=MultiValueParamData([("default1",)]), ), OptionSchema( - name=["--option2"], type=click.INT, required=False, default=MultiValueParamData([(42,)]) + name=["--option2"], + type=int, + required=False, + default=MultiValueParamData([(42,)]), ), ], subcommands={}, - function=lambda: 1, ) @@ -37,11 +47,13 @@ def command_schema_with_subcommand(command_schema): name=CommandName("sub"), options=[ OptionSchema( - name=["--sub-option"], type=click.BOOL, required=False, default=MultiValueParamData([(False,)]) + name=["--sub-option"], + type=bool, + required=False, + default=MultiValueParamData([(False,)]), ) ], arguments=[], - function=lambda: 2, ) } return command_schema @@ -52,13 +64,23 @@ def user_command_data_no_subcommand(): return UserCommandData( name=CommandName("test"), options=[ - UserOptionData(name="--option1", value=("value1",), - option_schema=OptionSchema(name=["--option1", "-o1"], type=click.STRING)), - UserOptionData(name="--option2", value=("42",), - option_schema=OptionSchema(name=["--option2", "-o2"], type=click.STRING)), + UserOptionData( + name="--option1", + value=("value1",), + option_schema=OptionSchema(name=["--option1", "-o1"], type=str), + ), + UserOptionData( + name="--option2", + value=("42",), + option_schema=OptionSchema(name=["--option2", "-o2"], type=str), + ), ], arguments=[ - UserArgumentData(name="arg1", value=("123",), argument_schema=ArgumentSchema("arg1", click.INT)), + UserArgumentData( + name="arg1", + value=("123",), + argument_schema=ArgumentSchema("arg1", int), + ), ], ) @@ -72,8 +94,11 @@ def user_command_data_with_subcommand(user_command_data_no_subcommand): subcommand=UserCommandData( name=CommandName("sub"), options=[ - UserOptionData(name="--sub-option", value=("True",), - option_schema=OptionSchema(name=["--sub-option"], type=click.BOOL)) + UserOptionData( + name="--sub-option", + value=("True",), + option_schema=OptionSchema(name=["--sub-option"], type=bool), + ) ], arguments=[], ), @@ -82,18 +107,18 @@ def user_command_data_with_subcommand(user_command_data_no_subcommand): def test_to_cli_args_no_subcommand(user_command_data_no_subcommand): cli_args = user_command_data_no_subcommand.to_cli_args(True) - assert cli_args == ["test", "--option1", "value1", "--option2", "42", "123"] + assert cli_args == ["test", "123", "--option1", "value1", "--option2", "42"] def test_to_cli_args_with_subcommand(user_command_data_with_subcommand): cli_args = user_command_data_with_subcommand.to_cli_args(True) assert cli_args == [ "test", + "123", "--option1", "value1", "--option2", "42", - "123", "sub", "--sub-option", "True", @@ -103,10 +128,13 @@ def test_to_cli_args_with_subcommand(user_command_data_with_subcommand): def test_to_cli_string_no_subcommand(user_command_data_no_subcommand): cli_string = user_command_data_no_subcommand.to_cli_string(True) - assert cli_string.plain == "test --option1 value1 --option2 42 123" + assert cli_string.plain == "test 123 --option1 value1 --option2 42" def test_to_cli_string_with_subcommand(user_command_data_with_subcommand): cli_string = user_command_data_with_subcommand.to_cli_string(True) - assert cli_string.plain == "test --option1 value1 --option2 42 123 sub --sub-option True" + assert ( + cli_string.plain + == "test 123 --option1 value1 --option2 42 sub --sub-option True" + ) diff --git a/trogon/__init__.py b/trogon/__init__.py index 665d5e7..c0c633f 100644 --- a/trogon/__init__.py +++ b/trogon/__init__.py @@ -1,3 +1,3 @@ -from trogon.trogon import Trogon, tui +from trogon.trogon import Trogon -__all__ = ["tui", "Trogon"] +__all__ = ["Trogon"] diff --git a/trogon/click.py b/trogon/click.py new file mode 100644 index 0000000..6d613d6 --- /dev/null +++ b/trogon/click.py @@ -0,0 +1,166 @@ +from __future__ import annotations +from typing import Sequence + + +from trogon import Trogon + +try: + import click + from click import BaseCommand +except ImportError as e: + raise ImportError( + "The extra `trogon[click]` is required to enable tui generation from Typer apps." + ) from e + +from trogon.constants import DEFAULT_COMMAND_NAME +from trogon.trogon import Trogon +from trogon.schemas import ( + ArgumentSchema, + CommandName, + CommandSchema, + OptionSchema, +) +from typing import Type, Any +from uuid import UUID +from datetime import datetime +from pathlib import Path + +CLICK_TO_PY_TYPES: dict[click.ParamType, Type[Any]] = { + click.types.StringParamType: str, + click.types.IntParamType: int, + click.types.FloatParamType: float, + click.types.BoolParamType: bool, + click.types.UUIDParameterType: UUID, + click.IntRange: int, + click.FloatRange: float, + click.DateTime: datetime, + click.Path: Path, +} + + +def introspect_click_app( + app: BaseCommand, cmd_ignorelist: list[str] | None = None +) -> dict[CommandName, CommandSchema]: + """ + Introspect a Click application and build a data structure containing + information about all commands, options, arguments, and subcommands, + including the docstrings and command function references. + + This function recursively processes each command and its subcommands + (if any), creating a nested dictionary that includes details about + options, arguments, and subcommands, as well as the docstrings and + command function references. + + Args: + app (click.BaseCommand): The Click application's top-level group or command instance. + + Returns: + Dict[str, CommandData]: A nested dictionary containing the Click application's + structure. The structure is defined by the CommandData TypedDict and its related + TypedDicts (OptionData and ArgumentData). + """ + + def process_command( + cmd_name: CommandName, + cmd_obj: click.Command, + parent=None, + ) -> CommandSchema: + cmd_data = CommandSchema( + name=cmd_name, + docstring=cmd_obj.help, + options=[], + arguments=[], + subcommands={}, + parent=parent, + ) + for param in cmd_obj.params: + param_type: Type[Any] = CLICK_TO_PY_TYPES.get( + param.type, CLICK_TO_PY_TYPES.get(type(param.type), str) + ) + param_choices: Sequence[str] | None = None + if isinstance(param.type, click.Choice): + param_choices = param.type.choices + + if isinstance(param, (click.Option, click.core.Group)): + option_data = OptionSchema( + name=param.opts, + type=param_type, + is_flag=param.is_flag, + counting=param.count, + secondary_opts=param.secondary_opts, + required=param.required, + default=param.default, + help=param.help, + choices=param_choices, + multiple=param.multiple, + nargs=param.nargs, + ) + cmd_data.options.append(option_data) + + elif isinstance(param, click.Argument): + argument_data = ArgumentSchema( + name=param.name, + type=param_type, + required=param.required, + choices=param_choices, + multiple=param.multiple, + default=param.default, + nargs=param.nargs, + ) + cmd_data.arguments.append(argument_data) + + if isinstance(cmd_obj, click.core.Group): + for subcmd_name, subcmd_obj in cmd_obj.commands.items(): + if subcmd_name not in cmd_ignorelist: + cmd_data.subcommands[CommandName(subcmd_name)] = process_command( + CommandName(subcmd_name), + subcmd_obj, + parent=cmd_data, + ) + + return cmd_data + + data: dict[CommandName, CommandSchema] = {} + + # Special case for the root group + if isinstance(app, click.Group): + root_cmd_name = CommandName("root") + data[root_cmd_name] = process_command(root_cmd_name, app) + app = data[root_cmd_name] + + if isinstance(app, click.Group): + for cmd_name, cmd_obj in app.commands.items(): + data[CommandName(cmd_name)] = process_command( + CommandName(cmd_name), + cmd_obj, + ) + elif isinstance(app, click.Command): + cmd_name = CommandName(app.name) + data[cmd_name] = process_command(cmd_name, app) + + return data + + +def tui( + name: str | None = None, + command: str = DEFAULT_COMMAND_NAME, + help: str = "Open Textual TUI.", +): + def decorator(app: click.Group | click.Command): + @click.pass_context + def wrapped_tui(ctx, *args, **kwargs): + Trogon( + introspect_click_app(app, cmd_ignorelist=[command]), app_name=name + ).run() + + if isinstance(app, click.Group): + app.command(name=command, help=help)(wrapped_tui) + else: + new_group = click.Group() + new_group.add_command(app) + new_group.command(name=command, help=help)(wrapped_tui) + return new_group + + return app + + return decorator diff --git a/trogon/constants.py b/trogon/constants.py index 14d2452..47465db 100644 --- a/trogon/constants.py +++ b/trogon/constants.py @@ -2,3 +2,4 @@ PACKAGE_NAME = "trogon" TEXTUAL_URL = "https://github.com/textualize/textual" ORGANIZATION_NAME = "T" +DEFAULT_COMMAND_NAME='tui' \ No newline at end of file diff --git a/trogon/introspect.py b/trogon/introspect.py deleted file mode 100644 index b0855ed..0000000 --- a/trogon/introspect.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import annotations - -import uuid -from dataclasses import dataclass, field -from typing import Any, Callable, Sequence, NewType - -import click -from click import BaseCommand, ParamType - - -def generate_unique_id(): - return f"id_{str(uuid.uuid4())[:8]}" - - -@dataclass -class MultiValueParamData: - values: list[tuple[int | float | str]] - - @staticmethod - def process_cli_option(value) -> "MultiValueParamData": - if value is None: - value = MultiValueParamData([]) - elif isinstance(value, tuple): - value = MultiValueParamData([value]) - elif isinstance(value, list): - processed_list = [ - (item,) if not isinstance(item, tuple) else item for item in value - ] - value = MultiValueParamData(processed_list) - else: - value = MultiValueParamData([(value,)]) - - return value - - -@dataclass -class OptionSchema: - name: list[str] - type: ParamType - default: MultiValueParamData | None = None - required: bool = False - is_flag: bool = False - is_boolean_flag: bool = False - flag_value: Any = "" - opts: list = field(default_factory=list) - counting: bool = False - secondary_opts: list = field(default_factory=list) - key: str | tuple[str] = field(default_factory=generate_unique_id) - help: str | None = None - choices: Sequence[str] | None = None - multiple: bool = False - multi_value: bool = False - nargs: int = 1 - - def __post_init__(self): - self.multi_value = isinstance(self.type, click.Tuple) - - -@dataclass -class ArgumentSchema: - name: str - type: str - required: bool = False - key: str = field(default_factory=generate_unique_id) - default: MultiValueParamData | None = None - choices: Sequence[str] | None = None - multiple: bool = False - nargs: int = 1 - - -@dataclass -class CommandSchema: - name: CommandName - function: Callable[..., Any | None] - key: str = field(default_factory=generate_unique_id) - docstring: str | None = None - options: list[OptionSchema] = field(default_factory=list) - arguments: list[ArgumentSchema] = field(default_factory=list) - subcommands: dict["CommandName", "CommandSchema"] = field(default_factory=dict) - parent: "CommandSchema | None" = None - is_group: bool = False - - @property - def path_from_root(self) -> list["CommandSchema"]: - node = self - path = [self] - while True: - node = node.parent - if node is None: - break - path.append(node) - return list(reversed(path)) - - -def introspect_click_app(app: BaseCommand) -> dict[CommandName, CommandSchema]: - """ - Introspect a Click application and build a data structure containing - information about all commands, options, arguments, and subcommands, - including the docstrings and command function references. - - This function recursively processes each command and its subcommands - (if any), creating a nested dictionary that includes details about - options, arguments, and subcommands, as well as the docstrings and - command function references. - - Args: - app (click.BaseCommand): The Click application's top-level group or command instance. - - Returns: - Dict[str, CommandData]: A nested dictionary containing the Click application's - structure. The structure is defined by the CommandData TypedDict and its related - TypedDicts (OptionData and ArgumentData). - """ - - def process_command( - cmd_name: CommandName, cmd_obj: click.Command, parent=None - ) -> CommandSchema: - cmd_data = CommandSchema( - name=cmd_name, - docstring=cmd_obj.help, - function=cmd_obj.callback, - options=[], - arguments=[], - subcommands={}, - parent=parent, - is_group=isinstance(cmd_obj, click.Group), - ) - - for param in cmd_obj.params: - default = MultiValueParamData.process_cli_option(param.default) - if isinstance(param, (click.Option, click.core.Group)): - option_data = OptionSchema( - name=param.opts, - type=param.type, - is_flag=param.is_flag, - is_boolean_flag=param.is_bool_flag, - flag_value=param.flag_value, - counting=param.count, - opts=param.opts, - secondary_opts=param.secondary_opts, - required=param.required, - default=default, - help=param.help, - multiple=param.multiple, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - option_data.choices = param.type.choices - cmd_data.options.append(option_data) - elif isinstance(param, click.Argument): - argument_data = ArgumentSchema( - name=param.name, - type=param.type, - required=param.required, - multiple=param.multiple, - default=default, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - argument_data.choices = param.type.choices - cmd_data.arguments.append(argument_data) - - if isinstance(cmd_obj, click.core.Group): - for subcmd_name, subcmd_obj in cmd_obj.commands.items(): - cmd_data.subcommands[CommandName(subcmd_name)] = process_command( - CommandName(subcmd_name), subcmd_obj, parent=cmd_data - ) - - return cmd_data - - data: dict[CommandName, CommandSchema] = {} - - # Special case for the root group - if isinstance(app, click.Group): - root_cmd_name = CommandName("root") - data[root_cmd_name] = process_command(root_cmd_name, app) - app = data[root_cmd_name] - - if isinstance(app, click.Group): - for cmd_name, cmd_obj in app.commands.items(): - data[CommandName(cmd_name)] = process_command( - CommandName(cmd_name), cmd_obj - ) - elif isinstance(app, click.Command): - cmd_name = CommandName(app.name) - data[cmd_name] = process_command(cmd_name, app) - - return data - - -CommandName = NewType("CommandName", str) diff --git a/trogon/run_command.py b/trogon/run_command.py index 0cf6cc0..fac9ddb 100644 --- a/trogon/run_command.py +++ b/trogon/run_command.py @@ -8,12 +8,12 @@ from rich.text import Text -from trogon.introspect import ( - CommandSchema, - CommandName, - OptionSchema, +from trogon.schemas import ( ArgumentSchema, + CommandName, + CommandSchema, MultiValueParamData, + OptionSchema, ) from trogon.widgets.parameter_controls import ValueNotSupplied @@ -93,8 +93,14 @@ def to_cli_args(self, include_root_command: bool = False) -> List[str]: def _to_cli_args(self): args = [self.name] + for argument in self.arguments: + this_arg_values = argument.value + for argument_value in this_arg_values: + if argument_value != ValueNotSupplied(): + args.append(argument_value) + multiples = defaultdict(list) - multiples_schemas = {} + multiples_schemas: dict[str, OptionSchema] = {} for option in self.options: if option.option_schema.multiple: @@ -180,7 +186,8 @@ def _to_cli_args(self): for option_name, values in multiples.items(): # Check if the values given for this option differ from the default - defaults = multiples_schemas[option_name].default or [] + schema = multiples_schemas[option_name] + defaults = schema.default or [] default_values = list(itertools.chain.from_iterable(defaults.values)) supplied_defaults = [ value for value in default_values if value != ValueNotSupplied() @@ -202,16 +209,14 @@ def _to_cli_args(self): # If the user has supplied any non-default values, include them... if values_supplied and not values_are_defaults: - for value_data in values: + for i, value_data in enumerate(values): if not all(value == ValueNotSupplied() for value in value_data): - args.append(option_name) + # without multi-value (default): -u x -u y -u z + # with multi-value: -u x y z + if i == 0 or not schema.multi_value: + args.append(option_name) args.extend(v for v in value_data) - for argument in self.arguments: - this_arg_values = argument.value - for argument_value in this_arg_values: - if argument_value != ValueNotSupplied(): - args.append(argument_value) if self.subcommand: args.extend(self.subcommand._to_cli_args()) diff --git a/trogon/schemas.py b/trogon/schemas.py new file mode 100644 index 0000000..78c5089 --- /dev/null +++ b/trogon/schemas.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Sequence, NewType, Type + + + +def generate_unique_id(): + return f"id_{str(uuid.uuid4())[:8]}" + + +@dataclass +class MultiValueParamData: + values: list[tuple[int | float | str]] + + @staticmethod + def process_cli_option(value) -> "MultiValueParamData": + if value is None: + value = MultiValueParamData([]) + elif isinstance(value, tuple): + value = MultiValueParamData([value]) + elif isinstance(value, list): + processed_list = [ + (item,) if not isinstance(item, tuple) else item for item in value + ] + value = MultiValueParamData(processed_list) + else: + value = MultiValueParamData([(value,)]) + + return value + + +@dataclass +class ArgumentSchema: + name: str | list[str] + type: Type[Any] | None = None + required: bool = False + help: str | None = None + key: str | tuple[str] = field(default_factory=generate_unique_id) + default: MultiValueParamData | Any | None = None + choices: Sequence[str] | None = None + multiple: bool = False + multi_value: bool = False + nargs: int = 1 + + def __post_init__(self): + if not isinstance(self.default, MultiValueParamData): + self.default = MultiValueParamData.process_cli_option(self.default) + + if not self.type: + self.type = str + + if self.multi_value: + self.multiple = True + + if self.choices: + self.choices = [str(x) for x in self.choices] + + +@dataclass +class OptionSchema(ArgumentSchema): + is_flag: bool = False + counting: bool = False + secondary_opts: list[str] | None = None + + +@dataclass +class CommandSchema: + name: CommandName + docstring: str | None = None + key: str = field(default_factory=generate_unique_id) + options: list[OptionSchema] = field(default_factory=list) + arguments: list[ArgumentSchema] = field(default_factory=list) + subcommands: dict["CommandName", "CommandSchema"] = field(default_factory=dict) + parent: "CommandSchema | None" = None + + @property + def path_from_root(self) -> list["CommandSchema"]: + node = self + path = [self] + while True: + node = node.parent + if node is None: + break + path.append(node) + return list(reversed(path)) + + +CommandName = NewType("CommandName", str) diff --git a/trogon/trogon.py b/trogon/trogon.py index 1238747..a0f9566 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -2,10 +2,11 @@ import os import shlex +import sys +from contextlib import suppress from pathlib import Path from webbrowser import open as open_url -import click from rich.console import Console from rich.highlighter import ReprHighlighter from rich.text import Text @@ -25,21 +26,17 @@ from textual.widgets.tree import TreeNode from trogon.detect_run_string import detect_run_string -from trogon.introspect import ( - introspect_click_app, - CommandSchema, -) +from trogon.schemas import CommandName, CommandSchema from trogon.run_command import UserCommandData from trogon.widgets.command_info import CommandInfo from trogon.widgets.command_tree import CommandTree from trogon.widgets.form import CommandForm from trogon.widgets.multiple_choice import NonFocusableVerticalScroll -try: - from importlib import metadata # type: ignore -except ImportError: - # Python < 3.8 - import importlib_metadata as metadata # type: ignore +if sys.version_info >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata class CommandBuilder(Screen): @@ -57,32 +54,28 @@ class CommandBuilder(Screen): def __init__( self, - cli: click.BaseCommand, - click_app_name: str, - command_name: str, + command_schemas: dict[CommandName, CommandSchema], + app_name: str, + app_version: str | None, + is_grouped_cli: bool, name: str | None = None, id: str | None = None, classes: str | None = None, ): super().__init__(name, id, classes) self.command_data = None - self.cli = cli - self.is_grouped_cli = isinstance(cli, click.Group) - self.command_schemas = introspect_click_app(cli) - self.click_app_name = click_app_name - self.command_name = command_name + self.command_schemas = command_schemas + self.is_grouped_cli = is_grouped_cli - try: - self.version = metadata.version(self.click_app_name) - except Exception: - self.version = None + self.app_name = app_name + self.version = app_version self.highlighter = ReprHighlighter() def compose(self) -> ComposeResult: - tree = CommandTree("Commands", self.command_schemas, self.command_name) + tree = CommandTree("Commands", self.command_schemas) - title_parts = [Text(self.click_app_name, style="b")] + title_parts = [Text(self.app_name, style="b")] if self.version: version_style = self.get_component_rich_style("version-string") title_parts.extend(["\n", (f"v{self.version}", version_style)]) @@ -108,7 +101,7 @@ def compose(self) -> ComposeResult: with Vertical(id="home-body"): with Horizontal(id="home-command-description-container") as vs: vs.can_focus = False - yield Static(self.click_app_name or "", id="home-command-description") + yield Static(self.app_name or "", id="home-command-description") scrollable_body = VerticalScroll( Static(""), @@ -178,7 +171,7 @@ def _update_command_description(self, node: TreeNode[CommandSchema]) -> None: description_box = self.query_one("#home-command-description", Static) description_text = node.data.docstring or "" description_text = description_text.lstrip() - description_text = f"[b]{node.label if self.is_grouped_cli else self.click_app_name}[/]\n{description_text}" + description_text = f"[b]{node.label if self.is_grouped_cli else self.app_name}[/]\n{description_text}" description_box.update(description_text) def _update_execution_string_preview( @@ -189,7 +182,7 @@ def _update_execution_string_preview( command_name_syntax_style = self.get_component_rich_style( "command-name-syntax" ) - prefix = Text(f"{self.click_app_name} ", command_name_syntax_style) + prefix = Text(f"{self.app_name} ", command_name_syntax_style) new_value = command_data.to_cli_string(include_root_command=False) highlighted_new_value = Text.assemble(prefix, self.highlighter(new_value)) prompt_style = self.get_component_rich_style("prompt") @@ -217,24 +210,47 @@ class Trogon(App): def __init__( self, - cli: click.Group, - app_name: str | None = None, - command_name: str = "tui", - click_context: click.Context | None = None, + command_schemas: dict[CommandName, CommandSchema], + app_name: str | None, + app_version: str | None = None, ) -> None: super().__init__() - self.cli = cli self.post_run_command: list[str] = [] - self.is_grouped_cli = isinstance(cli, click.Group) + self.command_schemas = command_schemas + self.is_grouped_cli = any(v.subcommands for v in command_schemas.values()) self.execute_on_exit = False - if app_name is None and click_context is not None: - self.app_name = detect_run_string() - else: - self.app_name = app_name - self.command_name = command_name + + self.app_name = app_name if app_name else detect_run_string() + self.app_version = app_version + + if not self.app_version: + with suppress(Exception): + self.app_version = metadata.version(self.app_name) + + @classmethod + def from_schemas(cls, *args: CommandSchema, **kwargs) -> "Trogon": + if not args: + raise ValueError("No schemas provided.") + + root_schema = args[0] + + schemas: dict[CommandName, CommandSchema] = {root_schema.name: root_schema} + + for schema in args[1:]: + schema.parent = root_schema + root_schema.subcommands[schema.name] = schema + + return cls(schemas, **kwargs) def on_mount(self): - self.push_screen(CommandBuilder(self.cli, self.app_name, self.command_name)) + self.push_screen( + CommandBuilder( + self.command_schemas, + app_name=self.app_name, + app_version=self.app_version, + is_grouped_cli=self.is_grouped_cli, + ), + ) @on(Button.Pressed, "#home-exec-button") def on_button_pressed(self): @@ -259,14 +275,18 @@ def run( ) split_app_name = shlex.split(self.app_name) - program_name = shlex.split(self.app_name)[0] + program_name = split_app_name[0] arguments = [*split_app_name, *self.post_run_command] - os.execvp(program_name, arguments) + # update PATH to include current working dir. + env: dict[str, str] = os.environ.copy() + env["PATH"] = os.pathsep.join([os.getcwd(), env["PATH"]]) + os.execvpe(program_name, arguments, env) @on(CommandForm.Changed) def update_command_to_run(self, event: CommandForm.Changed): - include_root_command = not self.is_grouped_cli - self.post_run_command = event.command_data.to_cli_args(include_root_command) + self.post_run_command = event.command_data.to_cli_args( + include_root_command=False + ) def action_focus_command_tree(self) -> None: try: @@ -287,22 +307,3 @@ def action_visit(self, url: str) -> None: url: The URL to visit. """ open_url(url) - - -def tui(name: str | None = None, command: str = "tui", help: str = "Open Textual TUI."): - def decorator(app: click.Group | click.Command): - @click.pass_context - def wrapped_tui(ctx, *args, **kwargs): - Trogon(app, app_name=name, command_name=command, click_context=ctx).run() - - if isinstance(app, click.Group): - app.command(name=command, help=help)(wrapped_tui) - else: - new_group = click.Group() - new_group.add_command(app) - new_group.command(name=command, help=help)(wrapped_tui) - return new_group - - return app - - return decorator diff --git a/trogon/widgets/command_info.py b/trogon/widgets/command_info.py index 4a0fb6a..074e7dd 100644 --- a/trogon/widgets/command_info.py +++ b/trogon/widgets/command_info.py @@ -8,7 +8,7 @@ from textual.screen import ModalScreen from textual.widgets import Static, Tabs, Tab, ContentSwitcher, DataTable -from trogon.introspect import CommandSchema +from trogon.schemas import CommandSchema from trogon.widgets.multiple_choice import NonFocusableVerticalScroll @@ -43,7 +43,7 @@ def on_mount(self) -> None: getattr(schema.parent, "name", "No parent"), ), (Text("Subcommands", style="b"), list(schema.subcommands.keys())), - (Text("Group", style="b"), schema.is_group), + (Text("Group", style="b"), bool(schema.subcommands)), (Text("Arguments", style="b"), len(schema.arguments)), (Text("Options", style="b"), len(schema.options)), ] diff --git a/trogon/widgets/command_tree.py b/trogon/widgets/command_tree.py index b489ed0..94d76ad 100644 --- a/trogon/widgets/command_tree.py +++ b/trogon/widgets/command_tree.py @@ -5,19 +5,18 @@ from textual.widgets import Tree from textual.widgets._tree import TreeNode, TreeDataType -from trogon.introspect import CommandSchema, CommandName +from trogon.schemas import CommandName, CommandSchema class CommandTree(Tree[CommandSchema]): COMPONENT_CLASSES = {"group"} - def __init__(self, label: TextType, cli_metadata: dict[CommandName, CommandSchema], command_name: str): + def __init__(self, label: TextType, cli_metadata: dict[CommandName, CommandSchema]): super().__init__(label) self.show_root = False self.guide_depth = 2 self.show_guides = False self.cli_metadata = cli_metadata - self.command_name = command_name def render_label( self, node: TreeNode[TreeDataType], base_style: Style, style: Style @@ -32,15 +31,12 @@ def build_tree( ) -> TreeNode: data = {key: data[key] for key in sorted(data)} for cmd_name, cmd_data in data.items(): - if cmd_name == self.command_name: - continue if cmd_data.subcommands: label = Text(cmd_name) - if cmd_data.is_group: - group_style = self.get_component_rich_style("group") - label.stylize(group_style) - label.append(" ") - label.append("group", "dim i") + group_style = self.get_component_rich_style("group") + label.stylize(group_style) + label.append(" ") + label.append("group", "dim i") child = node.add(label, allow_expand=False, data=cmd_data) build_tree(cmd_data.subcommands, child) else: diff --git a/trogon/widgets/form.py b/trogon/widgets/form.py index b084c3e..d433cb3 100644 --- a/trogon/widgets/form.py +++ b/trogon/widgets/form.py @@ -9,7 +9,7 @@ from textual.widget import Widget from textual.widgets import Label, Input -from trogon.introspect import ( +from trogon.schemas import ( CommandSchema, CommandName, ArgumentSchema, diff --git a/trogon/widgets/parameter_controls.py b/trogon/widgets/parameter_controls.py index 3ea3b13..c4a99f4 100644 --- a/trogon/widgets/parameter_controls.py +++ b/trogon/widgets/parameter_controls.py @@ -2,9 +2,8 @@ import functools from functools import partial -from typing import Any, Callable, Iterable, TypeVar, Union, cast +from typing import Any, Callable, Iterable, Sequence, Type, TypeVar, Union, cast -import click from rich.text import Text from textual import log, on from textual.app import ComposeResult @@ -21,7 +20,8 @@ Button, ) -from trogon.introspect import ArgumentSchema, OptionSchema, MultiValueParamData + +from trogon.schemas import ArgumentSchema, MultiValueParamData, OptionSchema from trogon.widgets.multiple_choice import MultipleChoice ControlWidgetType: TypeVar = Union[Input, Checkbox, MultipleChoice, Select] @@ -127,15 +127,15 @@ def compose(self) -> ComposeResult: # If there are N defaults, we render the "group" N times. # Each group will contain `nargs` widgets. with ControlGroupsContainer(): - if not argument_type == click.BOOL: + if argument_type is not bool: yield Label(label, classes="command-form-label") - if isinstance(argument_type, click.Choice) and multiple: + if schema.choices and multiple: # Display a MultipleChoice widget # There's a special case where we have a Choice with multiple=True, # in this case, we can just render a single MultipleChoice widget # instead of multiple radio-sets. - control_method = self.get_control_method(argument_type) + control_method = self.get_control_method(schema=schema) multiple_choice_widget = control_method( default=default, label=label, @@ -187,7 +187,7 @@ def compose(self) -> ComposeResult: # If it's a multiple, and it's a Choice parameter, then we display # our special case MultiChoice widget, and so there's no need for this # button. - if multiple or nargs == -1 and not isinstance(argument_type, click.Choice): + if multiple or nargs == -1 and not schema.choices: with Horizontal(classes="add-another-button-container"): yield Button("+ value", variant="success", classes="add-another-button") @@ -209,16 +209,12 @@ def make_widget_group(self) -> Iterable[Widget]: ) # Get the types of the parameter. We can map these types on to widgets that will be rendered. - parameter_types = ( - parameter_type.types - if isinstance(parameter_type, click.Tuple) - else [parameter_type] - ) + parameter_types = [parameter_type] * schema.nargs if schema.nargs > 1 else [parameter_type] # For each of the these parameters, render the corresponding widget for it. # At this point we don't care about filling in the default values. for _type in parameter_types: - control_method = self.get_control_method(_type) + control_method = self.get_control_method(schema=schema) control_widgets = control_method( default, label, multiple, schema, schema.key ) @@ -305,33 +301,15 @@ def list_to_tuples( return MultiValueParamData.process_cli_option(collected_values) def get_control_method( - self, argument_type: Any + self, schema: ArgumentSchema ) -> Callable[[Any, Text, bool, OptionSchema | ArgumentSchema, str], Widget]: - text_click_types = { - click.STRING, - click.FLOAT, - click.INT, - click.UUID, - } - text_types = ( - click.Path, - click.File, - click.IntRange, - click.FloatRange, - click.types.FuncParamType, - ) + if schema.choices: + return partial(self.make_choice_control, choices=schema.choices) - is_text_type = argument_type in text_click_types or isinstance( - argument_type, text_types - ) - if is_text_type: - return self.make_text_control - elif argument_type == click.BOOL: + if schema.type is bool: return self.make_checkbox_control - elif isinstance(argument_type, click.types.Choice): - return partial(self.make_choice_control, choices=argument_type.choices) - else: - return self.make_text_control + + return self.make_text_control @staticmethod def make_text_control( @@ -379,7 +357,7 @@ def make_choice_control( choices: list[str], ) -> Widget: # The MultipleChoice widget is only for single-valued parameters. - if isinstance(schema.type, click.Tuple): + if schema.nargs != 1: multiple = False if multiple: @@ -401,26 +379,17 @@ def make_choice_control( @staticmethod def _make_command_form_control_label( name: str | list[str], - type: click.ParamType, + type: Type[Any], is_option: bool, is_required: bool, multiple: bool, ) -> Text: - if isinstance(name, str): - text = Text.from_markup( - f"{name}[dim]{' multiple' if multiple else ''} {type.name}[/] {' [b red]*[/]required' if is_required else ''}" - ) - else: - names = Text(" / ", style="dim").join([Text(n) for n in name]) - text = Text.from_markup( - f"{names}[dim]{' multiple' if multiple else ''} {type.name}[/] {' [b red]*[/]required' if is_required else ''}" - ) + names: list[str] = [name] if isinstance(name, str) else name - if isinstance(type, (click.IntRange, click.FloatRange)): - if type.min is not None: - text = Text.assemble(text, Text(f"min={type.min} ", "dim")) - if type.max is not None: - text = Text.assemble(text, Text(f"max={type.max}", "dim")) + names = Text(" / ", style="dim").join([Text(n) for n in names]) + text = Text.from_markup( + f"{names}[dim]{' multiple' if multiple else ''} <{type.__name__}>[/] {' [b red]*[/]required' if is_required else ''}" + ) return text From 705e432759cf03ca2018ce155473879e5be7509a Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 8 Jun 2023 00:00:00 -0500 Subject: [PATCH 02/13] feat: support typer --- README.md | 2 +- examples/README.md | 4 ++++ examples/demo_typer.py | 23 +++++++++++++++++++++++ examples/requirements.txt | 2 +- pyproject.toml | 2 ++ trogon/typer.py | 29 +++++++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) create mode 100755 examples/demo_typer.py create mode 100644 trogon/typer.py diff --git a/README.md b/README.md index 3148e96..dff943b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ https://github.com/Textualize/trogon/assets/554369/c9e5dabb-5624-45cb-8612-f6ecf -Trogon works with the popular [Click](https://click.palletsprojects.com/) library for Python, but will support other libraries and languages in the future. +Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. ## How it works diff --git a/examples/README.md b/examples/README.md index dd44df6..d554e18 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,3 +22,7 @@ pip install -r requirements.txt ./demo_click_nogroup.py tui ``` +```sh +./demo_typer.py tui +``` + diff --git a/examples/demo_typer.py b/examples/demo_typer.py new file mode 100755 index 0000000..4c9cce0 --- /dev/null +++ b/examples/demo_typer.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +import typer +from trogon.typer import tui + +app = typer.Typer() + + +@app.command() +def hello(name: str): + print(f"Hello {name}") + + +@app.command() +def goodbye(name: str, formal: bool = False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + tui(app)() diff --git a/examples/requirements.txt b/examples/requirements.txt index 12c6166..85c31a7 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1 +1 @@ -..[click] +..[click,typer] diff --git a/pyproject.toml b/pyproject.toml index 78cb9a5..99cfda9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,11 @@ python = "^3.7" textual = {version = ">=0.26.0"} #textual = {extras = ["dev"], path = "../textual", develop = true} click = {version = ">=8.0.0", optional = true} +typer = {version = ">=0.9.0", optional = true} [tool.poetry.extras] click = ["click"] +typer = ["typer"] [tool.poetry.group.dev.dependencies] mypy = "^1.2.0" diff --git a/trogon/typer.py b/trogon/typer.py new file mode 100644 index 0000000..b872fc5 --- /dev/null +++ b/trogon/typer.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from trogon.click import click, introspect_click_app +from trogon.constants import DEFAULT_COMMAND_NAME + +try: + import typer +except ImportError as e: + raise ImportError( + "The extra `trogon[typer]` is required to enable tui generation from Typer apps." + ) from e + +from trogon.trogon import Trogon + + +def tui( + app: typer.Typer, + name: str | None = None, + command: str = DEFAULT_COMMAND_NAME, + help: str = "Open Textual TUI.", +): + def wrapped_tui(): + Trogon( + introspect_click_app(typer.main.get_group(app), cmd_ignorelist=[command]), + app_name=name, + ).run() + + app.command(name=command, help=help)(wrapped_tui) + + return app From dd71d05eea9c5ed61d9d950ee94b9e722d7f8870 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 8 Jun 2023 00:00:00 -0500 Subject: [PATCH 03/13] docs: add yapx examples --- README.md | 2 +- examples/README.md | 4 ++++ examples/demo_yapx.py | 18 ++++++++++++++++++ examples/requirements.txt | 1 + 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100755 examples/demo_yapx.py diff --git a/README.md b/README.md index dff943b..62240c3 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ https://github.com/Textualize/trogon/assets/554369/c9e5dabb-5624-45cb-8612-f6ecf -Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. +Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. Trogon is integrated into the Python library [yapx](https://github.com/fresh2dev/yapx), and can even be used in conjunction with plain ol' `sys.argv`. See the `examples/` directory for examples of each. ## How it works diff --git a/examples/README.md b/examples/README.md index d554e18..ad06b8e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,3 +26,7 @@ pip install -r requirements.txt ./demo_typer.py tui ``` +```sh +./demo_yapx.py --tui +``` + diff --git a/examples/demo_yapx.py b/examples/demo_yapx.py new file mode 100755 index 0000000..5e22e65 --- /dev/null +++ b/examples/demo_yapx.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + +import yapx + + +def hello(name): + print(f"Hello {name}") + + +def goodbye(name, formal=False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + yapx.run_commands([hello, goodbye]) diff --git a/examples/requirements.txt b/examples/requirements.txt index 85c31a7..360be37 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1 +1,2 @@ ..[click,typer] +yapx[tui]==0.1.* From 4842a3ccad63a3e532d85cd117cc46e68c91aac6 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 8 Jun 2023 00:00:00 -0500 Subject: [PATCH 04/13] docs: add myke examples --- README.md | 2 +- examples/README.md | 3 +++ examples/demo_myke.py | 17 +++++++++++++++++ examples/requirements.txt | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100755 examples/demo_myke.py diff --git a/README.md b/README.md index 62240c3..f102890 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ https://github.com/Textualize/trogon/assets/554369/c9e5dabb-5624-45cb-8612-f6ecf -Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. Trogon is integrated into the Python library [yapx](https://github.com/fresh2dev/yapx), and can even be used in conjunction with plain ol' `sys.argv`. See the `examples/` directory for examples of each. +Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. Trogon is integrated into the Python libraries [yapx](https://github.com/fresh2dev/yapx) and [myke](https://github.com/fresh2dev/myke), and can even be used in conjunction with plain ol' `sys.argv`. See the `examples/` directory for examples of each. ## How it works diff --git a/examples/README.md b/examples/README.md index ad06b8e..ac34e58 100644 --- a/examples/README.md +++ b/examples/README.md @@ -30,3 +30,6 @@ pip install -r requirements.txt ./demo_yapx.py --tui ``` +```sh +myke --tui --myke-file ./demo_myke.py +``` diff --git a/examples/demo_myke.py b/examples/demo_myke.py new file mode 100755 index 0000000..2143cf9 --- /dev/null +++ b/examples/demo_myke.py @@ -0,0 +1,17 @@ +from myke import task + +@task(root=True) +def setup(log_level="info"): + print(f"Log level: {log_level}") + +@task +def hello(name): + print(f"Hello {name}") + +@task +def goodbye(name, formal=False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + diff --git a/examples/requirements.txt b/examples/requirements.txt index 360be37..a0edecb 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1,2 +1,3 @@ ..[click,typer] yapx[tui]==0.1.* +myke[tui]==0.1.* From 0a7ed9dc7ee279ee52be2f0612e376143daf39f1 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Fri, 23 Jun 2023 00:00:00 -0500 Subject: [PATCH 05/13] feat: omit hidden parameters and subcommands --- examples/demo_click.py | 10 +++++++++- trogon/click.py | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/demo_click.py b/examples/demo_click.py index f52d4ab..4f4544e 100755 --- a/examples/demo_click.py +++ b/examples/demo_click.py @@ -9,8 +9,11 @@ @click.option( "--verbose", "-v", count=True, default=1, help="Increase verbosity level." ) +@click.option( + "--hidden-arg", type=int, default=1, hidden=True, help="Set task priority (default: 1)" +) @click.pass_context -def cli(ctx, verbose): +def cli(ctx, verbose, hidden_arg): ctx.ensure_object(dict) ctx.obj["verbose"] = verbose @@ -85,5 +88,10 @@ def list_tasks(ctx, all, completed): # Implement the task listing functionality here +@cli.command(hidden=True) +@click.option("--user", help="User Name") +def cant_see_me(): + pass + if __name__ == "__main__": cli(obj={}) diff --git a/trogon/click.py b/trogon/click.py index 6d613d6..349d406 100644 --- a/trogon/click.py +++ b/trogon/click.py @@ -82,6 +82,9 @@ def process_command( param_choices = param.type.choices if isinstance(param, (click.Option, click.core.Group)): + if param.hidden: + continue + option_data = OptionSchema( name=param.opts, type=param_type, @@ -111,7 +114,7 @@ def process_command( if isinstance(cmd_obj, click.core.Group): for subcmd_name, subcmd_obj in cmd_obj.commands.items(): - if subcmd_name not in cmd_ignorelist: + if not subcmd_obj.hidden and subcmd_name not in cmd_ignorelist: cmd_data.subcommands[CommandName(subcmd_name)] = process_command( CommandName(subcmd_name), subcmd_obj, From 48809bfc8fc8e329698e12eaf4cde37695379c7d Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Fri, 23 Jun 2023 00:00:00 -0500 Subject: [PATCH 06/13] feat: support click prompt_required This adds `read_only` and `placeholder` to the ArgumentSchema, which enables other use-cases as well. --- examples/demo_click.py | 12 ++++++++++++ trogon/click.py | 4 ++++ trogon/schemas.py | 2 ++ trogon/widgets/parameter_controls.py | 2 ++ 4 files changed, 20 insertions(+) diff --git a/examples/demo_click.py b/examples/demo_click.py index 4f4544e..b9fbf5f 100755 --- a/examples/demo_click.py +++ b/examples/demo_click.py @@ -87,6 +87,18 @@ def list_tasks(ctx, all, completed): click.echo("Listing tasks:") # Implement the task listing functionality here +@cli.command() +@click.option("--user", help="User Name") +@click.option("--password", prompt=True, prompt_required=True, hide_input=True, help="Required prompt.") +@click.pass_context +def auth( + ctx, + user, + password, +): + print('---') + print('User:', user) + print('Password:', password) @cli.command(hidden=True) @click.option("--user", help="User Name") diff --git a/trogon/click.py b/trogon/click.py index 349d406..5b467ff 100644 --- a/trogon/click.py +++ b/trogon/click.py @@ -85,6 +85,8 @@ def process_command( if param.hidden: continue + prompt_required: bool = param.prompt and param.prompt_required + option_data = OptionSchema( name=param.opts, type=param_type, @@ -97,6 +99,8 @@ def process_command( choices=param_choices, multiple=param.multiple, nargs=param.nargs, + read_only=prompt_required, + placeholder="< You will be prompted. >" if prompt_required else "", ) cmd_data.options.append(option_data) diff --git a/trogon/schemas.py b/trogon/schemas.py index 78c5089..30b8461 100644 --- a/trogon/schemas.py +++ b/trogon/schemas.py @@ -43,6 +43,8 @@ class ArgumentSchema: multiple: bool = False multi_value: bool = False nargs: int = 1 + read_only: bool = False + placeholder: str = "" def __post_init__(self): if not isinstance(self.default, MultiValueParamData): diff --git a/trogon/widgets/parameter_controls.py b/trogon/widgets/parameter_controls.py index c4a99f4..1c885b7 100644 --- a/trogon/widgets/parameter_controls.py +++ b/trogon/widgets/parameter_controls.py @@ -321,6 +321,8 @@ def make_text_control( ) -> Widget: control = Input( classes=f"command-form-input {control_id}", + disabled=schema.read_only, + placeholder=schema.placeholder, ) yield control return control From ca0df1689b503c3a1a0aa39a4d390c8298ccffea Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Fri, 23 Jun 2023 00:00:00 -0500 Subject: [PATCH 07/13] feat: redact sensitive values --- examples/demo_click.py | 3 ++ trogon/click.py | 3 +- trogon/run_command.py | 46 +++++++++++++++++++--------- trogon/schemas.py | 1 + trogon/trogon.py | 8 +++-- trogon/widgets/parameter_controls.py | 1 + 6 files changed, 45 insertions(+), 17 deletions(-) diff --git a/examples/demo_click.py b/examples/demo_click.py index b9fbf5f..904f5d5 100755 --- a/examples/demo_click.py +++ b/examples/demo_click.py @@ -90,15 +90,18 @@ def list_tasks(ctx, all, completed): @cli.command() @click.option("--user", help="User Name") @click.option("--password", prompt=True, prompt_required=True, hide_input=True, help="Required prompt.") +@click.option("--tokens", multiple=True, hide_input=True, help="Sensitive input.") @click.pass_context def auth( ctx, user, password, + tokens, ): print('---') print('User:', user) print('Password:', password) + print('Tokens:', list(tokens)) @cli.command(hidden=True) @click.option("--user", help="User Name") diff --git a/trogon/click.py b/trogon/click.py index 5b467ff..e6e36b2 100644 --- a/trogon/click.py +++ b/trogon/click.py @@ -84,7 +84,7 @@ def process_command( if isinstance(param, (click.Option, click.core.Group)): if param.hidden: continue - + prompt_required: bool = param.prompt and param.prompt_required option_data = OptionSchema( @@ -99,6 +99,7 @@ def process_command( choices=param_choices, multiple=param.multiple, nargs=param.nargs, + sensitive=param.hide_input, read_only=prompt_required, placeholder="< You will be prompted. >" if prompt_required else "", ) diff --git a/trogon/run_command.py b/trogon/run_command.py index fac9ddb..72bc863 100644 --- a/trogon/run_command.py +++ b/trogon/run_command.py @@ -17,6 +17,8 @@ ) from trogon.widgets.parameter_controls import ValueNotSupplied +REDACTED_PLACEHOLDER: str = "" + @dataclass class UserOptionData: @@ -77,27 +79,29 @@ class UserCommandData: parent: Optional["UserCommandData"] = None command_schema: Optional["CommandSchema"] = None - def to_cli_args(self, include_root_command: bool = False) -> List[str]: + def to_cli_args(self, include_root_command: bool = False, redact_sensitive: bool = False) -> List[str]: """ Generates a list of strings representing the CLI invocation based on the user input data. Returns: A list of strings that can be passed to subprocess.run to execute the command. """ - cli_args = self._to_cli_args() + cli_args = self._to_cli_args(redact_sensitive=redact_sensitive) if not include_root_command: cli_args = cli_args[1:] return cli_args - def _to_cli_args(self): + def _to_cli_args(self, redact_sensitive: bool = False): args = [self.name] for argument in self.arguments: - this_arg_values = argument.value - for argument_value in this_arg_values: - if argument_value != ValueNotSupplied(): - args.append(argument_value) + this_arg_values = [value for value in argument.value if value != ValueNotSupplied()] + + if redact_sensitive and argument.argument_schema.sensitive: + args.extend([REDACTED_PLACEHOLDER] * len(this_arg_values)) + else: + args.extend(this_arg_values) multiples = defaultdict(list) multiples_schemas: dict[str, OptionSchema] = {} @@ -167,8 +171,16 @@ def _to_cli_args(self): # actually the nominal case... single value options e.g. # `--foo bar`. args.append(option_name) - for subvalue_tuple in value_data: - args.extend(subvalue_tuple) + if redact_sensitive and option.option_schema.sensitive: + args.extend( + [REDACTED_PLACEHOLDER] * sum(len(subvalue_tuple) for subvalue_tuple in value_data) + ) + else: + args.extend( + subvalue + for subvalue_tuple in value_data + for subvalue in subvalue_tuple + ) else: # Get the value of the counting option count = next(itertools.chain.from_iterable(value_data), 1) @@ -215,23 +227,29 @@ def _to_cli_args(self): # with multi-value: -u x y z if i == 0 or not schema.multi_value: args.append(option_name) - args.extend(v for v in value_data) + if redact_sensitive and schema.sensitive: + args.extend([REDACTED_PLACEHOLDER] * len(value_data)) + else: + args.extend(value_data) if self.subcommand: - args.extend(self.subcommand._to_cli_args()) + args.extend(self.subcommand._to_cli_args(redact_sensitive=redact_sensitive)) return args def to_cli_string(self, include_root_command: bool = False) -> Text: """ - Generates a string representing the CLI invocation as if typed directly into the - command line. + Generates a redacted string representing the CLI invocation as if typed + directly into the command line. Returns: A string representing the command invocation. """ - args = self.to_cli_args(include_root_command=include_root_command) + args = self.to_cli_args( + include_root_command=include_root_command, + redact_sensitive=True, + ) text_renderables = [] for arg in args: diff --git a/trogon/schemas.py b/trogon/schemas.py index 30b8461..bc589aa 100644 --- a/trogon/schemas.py +++ b/trogon/schemas.py @@ -43,6 +43,7 @@ class ArgumentSchema: multiple: bool = False multi_value: bool = False nargs: int = 1 + sensitive: bool = False read_only: bool = False placeholder: str = "" diff --git a/trogon/trogon.py b/trogon/trogon.py index a0f9566..bf0f454 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -124,6 +124,7 @@ def compose(self) -> ComposeResult: yield Footer() def action_close_and_run(self) -> None: + self.app.post_run_command_redacted = self.command_data.to_cli_string() self.app.execute_on_exit = True self.app.exit() @@ -183,7 +184,7 @@ def _update_execution_string_preview( "command-name-syntax" ) prefix = Text(f"{self.app_name} ", command_name_syntax_style) - new_value = command_data.to_cli_string(include_root_command=False) + new_value = command_data.to_cli_string() highlighted_new_value = Text.assemble(prefix, self.highlighter(new_value)) prompt_style = self.get_component_rich_style("prompt") preview_string = Text.assemble(("$ ", prompt_style), highlighted_new_value) @@ -215,7 +216,10 @@ def __init__( app_version: str | None = None, ) -> None: super().__init__() + self.post_run_command: list[str] = [] + self.post_run_command_redacted: str = "" + self.command_schemas = command_schemas self.is_grouped_cli = any(v.subcommands for v in command_schemas.values()) self.execute_on_exit = False @@ -271,7 +275,7 @@ def run( console = Console() if self.post_run_command and self.execute_on_exit: console.print( - f"Running [b cyan]{self.app_name} {' '.join(shlex.quote(s) for s in self.post_run_command)}[/]" + f"Running [b cyan]{self.app_name} {self.post_run_command_redacted}[/]" ) split_app_name = shlex.split(self.app_name) diff --git a/trogon/widgets/parameter_controls.py b/trogon/widgets/parameter_controls.py index 1c885b7..aec8f43 100644 --- a/trogon/widgets/parameter_controls.py +++ b/trogon/widgets/parameter_controls.py @@ -321,6 +321,7 @@ def make_text_control( ) -> Widget: control = Input( classes=f"command-form-input {control_id}", + password=schema.sensitive, disabled=schema.read_only, placeholder=schema.placeholder, ) From afd8883cdc3e1c99027398b370c74de0cdedb2b5 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Fri, 23 Jun 2023 00:00:00 -0500 Subject: [PATCH 08/13] refactor: generate `post_run_command` on-closed, not on-changed Results in far fewer calls to `to_cli_args()` --- trogon/trogon.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/trogon/trogon.py b/trogon/trogon.py index bf0f454..904abaa 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -125,6 +125,7 @@ def compose(self) -> ComposeResult: def action_close_and_run(self) -> None: self.app.post_run_command_redacted = self.command_data.to_cli_string() + self.app.post_run_command = self.command_data.to_cli_args() self.app.execute_on_exit = True self.app.exit() @@ -286,12 +287,6 @@ def run( env["PATH"] = os.pathsep.join([os.getcwd(), env["PATH"]]) os.execvpe(program_name, arguments, env) - @on(CommandForm.Changed) - def update_command_to_run(self, event: CommandForm.Changed): - self.post_run_command = event.command_data.to_cli_args( - include_root_command=False - ) - def action_focus_command_tree(self) -> None: try: command_tree = self.query_one(CommandTree) From ca15753586357414974c295677dfd9a31a38c0cb Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Mon, 26 Jun 2023 00:00:00 -0500 Subject: [PATCH 09/13] fix: regain feature parity with trogon/main Allow `ArgumentSchema` to contain one or more types. Define `ChoiceSchema` to allow choice-types in a sequence of types; used in place of `click.Choice`. --- examples/demo_click.py | 6 +++--- examples/demo_click_nogroup.py | 4 ++-- trogon/click.py | 29 +++++++++++++++---------- trogon/schemas.py | 25 +++++++++++++++++----- trogon/widgets/parameter_controls.py | 32 ++++++++++++++++++---------- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/examples/demo_click.py b/examples/demo_click.py index 904f5d5..cbfb477 100755 --- a/examples/demo_click.py +++ b/examples/demo_click.py @@ -28,9 +28,9 @@ def cli(ctx, verbose, hidden_arg): "--extra", "-e", nargs=2, - type=(str, int), + type=(str, click.Choice(["1", "2", "3"])), multiple=True, - default=[("one", 1), ("two", 2)], + default=[("one", "1"), ("two", "2")], help="Add extra data as key-value pairs (repeatable)", ) @click.option( @@ -49,7 +49,7 @@ def cli(ctx, verbose, hidden_arg): help="Add labels to the task (repeatable)", ) @click.pass_context -def add(ctx, task, priority, category, tags, labels): +def add(ctx, task, extra, priority, category, tags, labels): """Add a new task to the to-do list. Note: Control the output of this using the verbosity option. diff --git a/examples/demo_click_nogroup.py b/examples/demo_click_nogroup.py index 6faa89b..4492aa7 100755 --- a/examples/demo_click_nogroup.py +++ b/examples/demo_click_nogroup.py @@ -14,9 +14,9 @@ "--extra", "-e", nargs=2, - type=(str, int), + type=(str, click.Choice(["1", "2", "3"])), multiple=True, - default=[("one", 1), ("two", 2)], + default=[("one", "1"), ("two", "2")], help="Add extra data as key-value pairs (repeatable)", ) @click.option( diff --git a/trogon/click.py b/trogon/click.py index e6e36b2..82035a6 100644 --- a/trogon/click.py +++ b/trogon/click.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Sequence +from typing import Type from trogon import Trogon @@ -19,6 +19,7 @@ CommandName, CommandSchema, OptionSchema, + ChoiceSchema, ) from typing import Type, Any from uuid import UUID @@ -37,6 +38,13 @@ click.Path: Path, } +def _convert_click_to_py_type(click_type: Type[Any]) -> Type[Any]: + if isinstance(click_type, click.Choice): + return ChoiceSchema(choices=click_type.choices) + + return CLICK_TO_PY_TYPES.get( + click_type, CLICK_TO_PY_TYPES.get(type(click_type), str) + ) def introspect_click_app( app: BaseCommand, cmd_ignorelist: list[str] | None = None @@ -73,13 +81,12 @@ def process_command( subcommands={}, parent=parent, ) + for param in cmd_obj.params: - param_type: Type[Any] = CLICK_TO_PY_TYPES.get( - param.type, CLICK_TO_PY_TYPES.get(type(param.type), str) - ) - param_choices: Sequence[str] | None = None - if isinstance(param.type, click.Choice): - param_choices = param.type.choices + + click_types: list[Type[Any]] = param.type.types if isinstance(param.type, click.Tuple) else [param.type] + + param_types: list[Type[Any]] = [_convert_click_to_py_type(x) for x in click_types] if isinstance(param, (click.Option, click.core.Group)): if param.hidden: @@ -89,14 +96,14 @@ def process_command( option_data = OptionSchema( name=param.opts, - type=param_type, + type=param_types, is_flag=param.is_flag, counting=param.count, secondary_opts=param.secondary_opts, required=param.required, default=param.default, help=param.help, - choices=param_choices, + choices=None, multiple=param.multiple, nargs=param.nargs, sensitive=param.hide_input, @@ -108,9 +115,9 @@ def process_command( elif isinstance(param, click.Argument): argument_data = ArgumentSchema( name=param.name, - type=param_type, + type=param_types, required=param.required, - choices=param_choices, + choices=None, multiple=param.multiple, default=param.default, nargs=param.nargs, diff --git a/trogon/schemas.py b/trogon/schemas.py index bc589aa..610efd6 100644 --- a/trogon/schemas.py +++ b/trogon/schemas.py @@ -31,10 +31,19 @@ def process_cli_option(value) -> "MultiValueParamData": return value +@dataclass +class ChoiceSchema: + # this is used in place of click.Choice + choices: Sequence[str] + + def __post_init__(self): + self.__name__ = 'choice' + + @dataclass class ArgumentSchema: name: str | list[str] - type: Type[Any] | None = None + type: Type[Any] | Sequence[Type[Any]] | None = None required: bool = False help: str | None = None key: str | tuple[str] = field(default_factory=generate_unique_id) @@ -52,14 +61,20 @@ def __post_init__(self): self.default = MultiValueParamData.process_cli_option(self.default) if not self.type: - self.type = str - - if self.multi_value: - self.multiple = True + self.type = [str] + elif isinstance(self.type, Type): + self.type = [self.type] + elif len(self.type) == 1 and isinstance(self.type[0], ChoiceSchema): + # if there is only one type is it is a 'ChoiceSchema': + self.choices = self.type[0].choices + self.type = [str] if self.choices: self.choices = [str(x) for x in self.choices] + if self.multi_value: + self.multiple = True + @dataclass class OptionSchema(ArgumentSchema): diff --git a/trogon/widgets/parameter_controls.py b/trogon/widgets/parameter_controls.py index aec8f43..144510a 100644 --- a/trogon/widgets/parameter_controls.py +++ b/trogon/widgets/parameter_controls.py @@ -21,7 +21,7 @@ ) -from trogon.schemas import ArgumentSchema, MultiValueParamData, OptionSchema +from trogon.schemas import ArgumentSchema, MultiValueParamData, OptionSchema, ChoiceSchema from trogon.widgets.multiple_choice import MultipleChoice ControlWidgetType: TypeVar = Union[Input, Checkbox, MultipleChoice, Select] @@ -135,7 +135,9 @@ def compose(self) -> ComposeResult: # There's a special case where we have a Choice with multiple=True, # in this case, we can just render a single MultipleChoice widget # instead of multiple radio-sets. - control_method = self.get_control_method(schema=schema) + control_method = self.get_control_method( + param_type=ChoiceSchema(choices=schema.choices) + ) multiple_choice_widget = control_method( default=default, label=label, @@ -187,7 +189,7 @@ def compose(self) -> ComposeResult: # If it's a multiple, and it's a Choice parameter, then we display # our special case MultiChoice widget, and so there's no need for this # button. - if multiple or nargs == -1 and not schema.choices: + if (multiple or nargs == -1) and not schema.choices: with Horizontal(classes="add-another-button-container"): yield Button("+ value", variant="success", classes="add-another-button") @@ -209,12 +211,20 @@ def make_widget_group(self) -> Iterable[Widget]: ) # Get the types of the parameter. We can map these types on to widgets that will be rendered. - parameter_types = [parameter_type] * schema.nargs if schema.nargs > 1 else [parameter_type] + parameter_types = [ + parameter_type[i] if i < len(parameter_type) else parameter_type[-1] + for i in range(schema.nargs if schema.nargs > 1 else 1) + ] + # The above ensures that len(parameter_types) == nargs. + # if there are more parameter_types than args, parameter_types is truncated. + # if there are fewer parameter_types than args, the *last* parameter type is repeated as much as necessary. # For each of the these parameters, render the corresponding widget for it. # At this point we don't care about filling in the default values. for _type in parameter_types: - control_method = self.get_control_method(schema=schema) + if schema.choices: + _type = ChoiceSchema(choices=schema.choices) + control_method = self.get_control_method(param_type=_type) control_widgets = control_method( default, label, multiple, schema, schema.key ) @@ -301,12 +311,12 @@ def list_to_tuples( return MultiValueParamData.process_cli_option(collected_values) def get_control_method( - self, schema: ArgumentSchema + self, param_type: Type[Any], ) -> Callable[[Any, Text, bool, OptionSchema | ArgumentSchema, str], Widget]: - if schema.choices: - return partial(self.make_choice_control, choices=schema.choices) + if isinstance(param_type, ChoiceSchema): + return partial(self.make_choice_control, choices=param_type.choices) - if schema.type is bool: + if param_type is bool: return self.make_checkbox_control return self.make_text_control @@ -382,7 +392,7 @@ def make_choice_control( @staticmethod def _make_command_form_control_label( name: str | list[str], - type: Type[Any], + types: list[Type[Any]], is_option: bool, is_required: bool, multiple: bool, @@ -391,7 +401,7 @@ def _make_command_form_control_label( names = Text(" / ", style="dim").join([Text(n) for n in names]) text = Text.from_markup( - f"{names}[dim]{' multiple' if multiple else ''} <{type.__name__}>[/] {' [b red]*[/]required' if is_required else ''}" + f"{names}[dim]{' multiple' if multiple else ''} <{', '.join(x.__name__ for x in types)}>[/] {' [b red]*[/]required' if is_required else ''}" ) return text From 394d4dc3c80c92fc819cdc54bf4591a4d46c6916 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 29 Jun 2023 00:00:00 -0500 Subject: [PATCH 10/13] feat: argparse support --- examples/demo_argparse.py | 123 +++++++++ tests/test_help_argparse.py | 57 +++++ tests/{test_help.py => test_help_click.py} | 0 trogon/argparse.py | 275 +++++++++++++++++++++ trogon/click.py | 2 +- trogon/run_command.py | 16 +- trogon/schemas.py | 24 +- trogon/trogon.py | 16 ++ trogon/widgets/parameter_controls.py | 4 +- 9 files changed, 503 insertions(+), 14 deletions(-) create mode 100755 examples/demo_argparse.py create mode 100644 tests/test_help_argparse.py rename tests/{test_help.py => test_help_click.py} (100%) create mode 100644 trogon/argparse.py diff --git a/examples/demo_argparse.py b/examples/demo_argparse.py new file mode 100755 index 0000000..008f97d --- /dev/null +++ b/examples/demo_argparse.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +import sys +import argparse + +from trogon.argparse import add_tui_argument, add_tui_command + +from getpass import getpass +from pprint import pprint + + +def _print_args(command: str, **kwargs): + print("---") + print("*** Command:", command) + print("*** kwargs:") + pprint(kwargs) + + +def root(**kwargs): + _print_args("root", **kwargs) + + +def add(**kwargs): + _print_args("add", **kwargs) + + +def remove(**kwargs): + _print_args("remove", **kwargs) + + +def auth(password: str, **kwargs): + if not password: + password = getpass("Password: ") + _print_args("auth", password=password, **kwargs) + + +def list_tasks(**kwargs): + _print_args("list_tasks", **kwargs) + + +def cant_see_me(**kwargs): + _print_args("cant_see_me", **kwargs) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + + parser.add_argument( + "--verbose", "-v", action="count", default=0, help="Increase verbosity level." + ) + parser.add_argument("--hidden-arg", help=argparse.SUPPRESS) + parser.set_defaults(_func=root) + + subparsers = parser.add_subparsers() + + sp_add = subparsers.add_parser("add") + sp_add.set_defaults(_func=add) + sp_add.add_argument("task") + sp_add.add_argument( + "--priority", "-p", default=1, help="Set task priority (default: 1)" + ) + sp_add.add_argument( + "--tags", "-t", action="append", help="Add tags to the task (repeatable)" + ) + sp_add.add_argument( + "--extra", + "-e", + nargs=2, + type=str, + default=[("one", "1"), ("two", "2")], + action="append", + ) + sp_add.add_argument( + "--category", "-c", default="home", choices=["work", "home", "leisure"] + ) + sp_add.add_argument( + "--labels", + "-l", + action="append", + choices=["important", "urgent", "later"], + default=["urgent"], + help="Add labels to the task (repeatable)", + ) + + sp_remove = subparsers.add_parser("remove") + sp_remove.set_defaults(_func=remove) + sp_remove.add_argument("task_id", type=int) + + sp_auth = subparsers.add_parser("auth") + sp_auth.set_defaults(_func=auth) + sp_auth.add_argument("--user", help="User Name") + sp_auth.add_argument("--password", help="User Password. ") + sp_auth.add_argument("--tokens", action="append", help="Sensitive input. ") + + sp_list_tasks = subparsers.add_parser("list-tasks") + sp_list_tasks.set_defaults(_func=list_tasks) + if sys.version_info >= (3, 9): + sp_list_tasks.add_argument("--all", default=True, action=argparse.BooleanOptionalAction) + sp_list_tasks.add_argument("--completed", "-c", action="store_true") + + sp_cant_see_me = subparsers.add_parser("cant-see-me", description=argparse.SUPPRESS) + sp_cant_see_me.set_defaults(_func=cant_see_me) + sp_cant_see_me.add_argument("--user") + + # add tui argument (my-cli --tui) + add_tui_argument(parser) + # and/or, add tui command (my-cli tui) + add_tui_command(parser) + + args = sys.argv[1:] + + if not args: + # if no args given, print help and exit. + parser.print_help() + parser.exit() + + # parse args + parsed_args = parser.parse_args(args) + + # call matching function with parsed args + parsed_args._func( + **{k: v for k, v in vars(parsed_args).items() if not k.startswith("_")} + ) diff --git a/tests/test_help_argparse.py b/tests/test_help_argparse.py new file mode 100644 index 0000000..72c689d --- /dev/null +++ b/tests/test_help_argparse.py @@ -0,0 +1,57 @@ +import argparse +from trogon.argparse import add_tui_argument, add_tui_command +import re + + +def test_default_help(capsys): + parser = argparse.ArgumentParser() + add_tui_command(parser) + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"tui\s+Open Textual TUI", result.out) is not None + + +def test_custom_command(capsys): + parser = argparse.ArgumentParser() + add_tui_command(parser, command="custom") + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"custom\s+Open Textual TUI", result.out) is not None + + +def test_custom_help(capsys): + parser = argparse.ArgumentParser() + add_tui_command(parser, help="Custom help") + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"tui\s+Custom help", result.out) is not None + + +def test_default_help_argument(capsys): + parser = argparse.ArgumentParser() + add_tui_argument(parser) + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"--tui\s+\[CMD\]\s+Open Textual TUI", result.out) is not None + + +def test_custom_command_argument(capsys): + parser = argparse.ArgumentParser() + add_tui_argument(parser, option_strings="--custom") + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"--custom\s+\[CMD\]\s+Open Textual TUI", result.out) is not None + + +def test_custom_help_argument(capsys): + parser = argparse.ArgumentParser() + add_tui_argument(parser, help="Custom help") + parser.print_help() + + result = capsys.readouterr() + assert re.search(r"--tui\s+\[CMD\]\s+Custom help", result.out) is not None diff --git a/tests/test_help.py b/tests/test_help_click.py similarity index 100% rename from tests/test_help.py rename to tests/test_help_click.py diff --git a/trogon/argparse.py b/trogon/argparse.py new file mode 100644 index 0000000..2a0c58d --- /dev/null +++ b/trogon/argparse.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import sys +from typing import Type +from collections import defaultdict + + +from trogon import Trogon + +from trogon.constants import DEFAULT_COMMAND_NAME +from trogon.trogon import Trogon +from trogon.schemas import ( + ArgumentSchema, + CommandName, + CommandSchema, + OptionSchema, + ChoiceSchema, +) +from typing import Type, Any +from uuid import UUID +from datetime import datetime +import argparse + + +def introspect_argparse_parser( + parser: argparse.ArgumentParser, cmd_ignorelist: list[str] | None = None +) -> dict[CommandName, CommandSchema]: + """ + Introspect a argparse parser and build a data structure containing + information about all commands, options, arguments, and subcommands, + including the docstrings and command function references. + + This function recursively processes each command and its subcommands + (if any), creating a nested dictionary that includes details about + options, arguments, and subcommands, as well as the docstrings and + command function references. + + Args: + parser (argparse.ArgumentParser): The argparse parser instance. + + Returns: + Dict[str, CommandData]: A nested dictionary containing the Click application's + structure. The structure is defined by the CommandData TypedDict and its related + TypedDicts (OptionData and ArgumentData). + """ + + def process_command( + cmd_name: CommandName, + parser: argparse.ArgumentParser, + parent=None, + ) -> CommandSchema: + cmd_data = CommandSchema( + name=cmd_name, + docstring=parser.description, + options=[], + arguments=[], + subcommands={}, + parent=parent, + ) + + # this is specific to yapx. + param_types: dict[str, Type[Any]] | None = getattr(parser, "_dest_type", None) + + + for param in parser._actions: + if ( + isinstance(param, TuiAction) + or argparse.SUPPRESS in [param.help, param.default] + ): + continue + + if isinstance(param, argparse._SubParsersAction): + for subparser_name, subparser in param.choices.items(): + if subparser.description != argparse.SUPPRESS and ( + not cmd_ignorelist or subparser not in cmd_ignorelist + ): + cmd_data.subcommands[ + CommandName(subparser_name) + ] = process_command( + CommandName(subparser_name), + subparser, + parent=cmd_data, + ) + continue + + param_type: Type[Any] | None = None + if param_types: + param_type = param_types.get(param.dest, param.type) + + if param_type is None and param.default is not None: + param_type = type(param.default) + + is_counting: bool = False + is_multiple: bool = False + is_flag: bool = False + + opts: list[str] = param.option_strings + secondary_opts: list[str] = [] + + if isinstance(param, argparse._CountAction): + is_counting = True + elif isinstance(param, argparse._AppendAction): + is_multiple = True + elif isinstance(param, argparse._StoreConstAction): + is_flag = True + elif (sys.version_info >= (3, 9) and isinstance( + param, argparse.BooleanOptionalAction + )) or type(param).__name__ == 'BooleanOptionalAction': + # check the type by name, because 'BooleanOptionalAction' + # is often manually backported to Python versions < 3.9. + if param_type is None: + param_type = bool + is_flag = True + secondary_prefix: str = "--no-" + opts = [ + x + for x in param.option_strings + if not x.startswith(secondary_prefix) + ] + secondary_opts = [ + x + for x in param.option_strings + if x.startswith(secondary_prefix) + ] + + # look for these "tags" in the help text: "secret", "prompt" + # if present, set variables and remove from the help text. + is_secret: bool = False + is_prompt: bool = False + param_help: str = param.help + if param_help: + param_help = param_help.replace('%(default)s', str(param.default)) + + tag_prefix: str = "<" + tag_suffix: str = ">" + tag_start: int = param_help.find(tag_prefix) + if tag_start >= 0: + tag_end: int = param_help.find(tag_suffix) + if tag_end > tag_start: + tag_txt: str = param_help[tag_start : tag_end + 1] + tags: list[str] = [ + x.strip() for x in tag_txt[1:-1].split(",") + ] + is_secret = "secret" in tags + is_prompt = "prompt" in tags + if any([is_secret, is_prompt]): + param_help = param_help.replace(tag_txt, "") + + nargs: int = ( + 1 + if param.nargs in [None, "?"] + else -1 + if param.nargs in ["+", "*"] + else int(param.nargs) + ) + multi_value: bool = nargs < 0 or nargs > 1 + + if param.option_strings: + option_data = OptionSchema( + name=opts, + type=param_type, + is_flag=is_flag, + counting=is_counting, + secondary_opts=secondary_opts, + required=param.required, + default=param.default, + help=param_help, + choices=param.choices, + multiple=is_multiple, + multi_value=multi_value, + nargs=nargs, + secret=is_secret, + read_only=is_prompt, + placeholder="< You will be prompted. >" + if is_prompt + else "", + ) + cmd_data.options.append(option_data) + + else: + argument_data = ArgumentSchema( + name=param.dest, + type=param_type, + required=param.required, + default=param.default, + help=param_help, + choices=param.choices, + multiple=is_multiple, + multi_value=multi_value, + nargs=nargs, + secret=is_secret, + read_only=is_prompt, + placeholder="< You will be prompted. >" + if is_prompt + else "", + ) + cmd_data.arguments.append(argument_data) + + return cmd_data + + data: dict[CommandName, CommandSchema] = {} + + root_cmd_name = CommandName("root") + data[root_cmd_name] = process_command(root_cmd_name, parser) + + return data + + +class TuiAction(argparse.Action): + def __init__( + self, + option_strings, + dest=argparse.SUPPRESS, + default=argparse.SUPPRESS, + help="Open Textual TUI.", + const: str = None, + metavar: str = None, + nargs: int | str | None = None, + **_kwargs: Any, + ): + super(TuiAction, self).__init__( + option_strings=option_strings, + dest=dest, + default=default, + nargs="?" if nargs is None else nargs, + help=help, + const=const, + metavar=metavar + ) + + def __call__(self, parser, namespace, values, option_string=None): + root_parser: argparse.ArgumentParser = getattr(namespace, '_parent_parser', parser) + + Trogon( + introspect_argparse_parser(root_parser, cmd_ignorelist=[parser]), + app_name=root_parser.prog, + command_filter = values, + ).run() + + parser.exit() + + +def add_tui_argument( + parser: argparse.ArgumentParser, + option_strings: str | list[str] = None, + help: str = "Open Textual TUI.", + default=argparse.SUPPRESS, + **kwargs, +) -> None: + if not option_strings: + option_strings = [f"--{DEFAULT_COMMAND_NAME.replace('_', '-').lstrip('-')}"] + elif isinstance(option_strings, str): + option_strings = [option_strings] + + parser.add_argument(*option_strings, metavar='CMD', action=TuiAction, default=default, help=help, **kwargs) + + +def add_tui_command( + parser: argparse.ArgumentParser, + command: str = DEFAULT_COMMAND_NAME, + help: str = "Open Textual TUI.", +) -> None: + subparsers: argparse._SubParsersAction + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + subparsers = action + break + else: + subparsers = parser.add_subparsers() + + tui_parser = subparsers.add_parser(command, description=argparse.SUPPRESS, help=help) + tui_parser.set_defaults(_parent_parser=parser) + + add_tui_argument(tui_parser, option_strings=['cmd_filter'], default=None, help="Command filter") + diff --git a/trogon/click.py b/trogon/click.py index 82035a6..f7d3b2c 100644 --- a/trogon/click.py +++ b/trogon/click.py @@ -106,7 +106,7 @@ def process_command( choices=None, multiple=param.multiple, nargs=param.nargs, - sensitive=param.hide_input, + secret=param.hide_input, read_only=prompt_required, placeholder="< You will be prompted. >" if prompt_required else "", ) diff --git a/trogon/run_command.py b/trogon/run_command.py index 72bc863..cdb8a7f 100644 --- a/trogon/run_command.py +++ b/trogon/run_command.py @@ -79,26 +79,26 @@ class UserCommandData: parent: Optional["UserCommandData"] = None command_schema: Optional["CommandSchema"] = None - def to_cli_args(self, include_root_command: bool = False, redact_sensitive: bool = False) -> List[str]: + def to_cli_args(self, include_root_command: bool = False, redact_secret: bool = False) -> List[str]: """ Generates a list of strings representing the CLI invocation based on the user input data. Returns: A list of strings that can be passed to subprocess.run to execute the command. """ - cli_args = self._to_cli_args(redact_sensitive=redact_sensitive) + cli_args = self._to_cli_args(redact_secret=redact_secret) if not include_root_command: cli_args = cli_args[1:] return cli_args - def _to_cli_args(self, redact_sensitive: bool = False): + def _to_cli_args(self, redact_secret: bool = False): args = [self.name] for argument in self.arguments: this_arg_values = [value for value in argument.value if value != ValueNotSupplied()] - if redact_sensitive and argument.argument_schema.sensitive: + if redact_secret and argument.argument_schema.secret: args.extend([REDACTED_PLACEHOLDER] * len(this_arg_values)) else: args.extend(this_arg_values) @@ -171,7 +171,7 @@ def _to_cli_args(self, redact_sensitive: bool = False): # actually the nominal case... single value options e.g. # `--foo bar`. args.append(option_name) - if redact_sensitive and option.option_schema.sensitive: + if redact_secret and option.option_schema.secret: args.extend( [REDACTED_PLACEHOLDER] * sum(len(subvalue_tuple) for subvalue_tuple in value_data) ) @@ -228,13 +228,13 @@ def _to_cli_args(self, redact_sensitive: bool = False): if i == 0 or not schema.multi_value: args.append(option_name) - if redact_sensitive and schema.sensitive: + if redact_secret and schema.secret: args.extend([REDACTED_PLACEHOLDER] * len(value_data)) else: args.extend(value_data) if self.subcommand: - args.extend(self.subcommand._to_cli_args(redact_sensitive=redact_sensitive)) + args.extend(self.subcommand._to_cli_args(redact_secret=redact_secret)) return args @@ -248,7 +248,7 @@ def to_cli_string(self, include_root_command: bool = False) -> Text: """ args = self.to_cli_args( include_root_command=include_root_command, - redact_sensitive=True, + redact_secret=True, ) text_renderables = [] diff --git a/trogon/schemas.py b/trogon/schemas.py index 610efd6..4bd122d 100644 --- a/trogon/schemas.py +++ b/trogon/schemas.py @@ -3,6 +3,7 @@ import uuid from dataclasses import dataclass, field from typing import Any, Callable, Sequence, NewType, Type +from functools import partial @@ -52,7 +53,7 @@ class ArgumentSchema: multiple: bool = False multi_value: bool = False nargs: int = 1 - sensitive: bool = False + secret: bool = False read_only: bool = False placeholder: str = "" @@ -60,14 +61,31 @@ def __post_init__(self): if not isinstance(self.default, MultiValueParamData): self.default = MultiValueParamData.process_cli_option(self.default) + default_type: list[Type[Any]] = [str] + if not self.type: - self.type = [str] + self.type = default_type + elif isinstance(self.type, partial): + # if this is an instance of `functools.partial`, + # iterate over the args/kwargs looking for a type. + # if not found, default to `str`. + for x in self.type.args: + if isinstance(x, Type): + self.type = [x] + break + else: + for k, v in self.type.kwargs.items(): + if isinstance(v, Type): + self.type = [v] + break + else: + self.type = default_type elif isinstance(self.type, Type): self.type = [self.type] elif len(self.type) == 1 and isinstance(self.type[0], ChoiceSchema): # if there is only one type is it is a 'ChoiceSchema': self.choices = self.type[0].choices - self.type = [str] + self.type = default_type if self.choices: self.choices = [str(x) for x in self.choices] diff --git a/trogon/trogon.py b/trogon/trogon.py index 904abaa..f06fcea 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -5,6 +5,7 @@ import sys from contextlib import suppress from pathlib import Path +from fnmatch import fnmatch from webbrowser import open as open_url from rich.console import Console @@ -215,12 +216,27 @@ def __init__( command_schemas: dict[CommandName, CommandSchema], app_name: str | None, app_version: str | None = None, + command_filter: str | None = None, ) -> None: super().__init__() self.post_run_command: list[str] = [] self.post_run_command_redacted: str = "" + root_cmd_name: str = list(command_schemas.keys())[0] + if command_filter and command_schemas[root_cmd_name].subcommands: + matching_schemas: dict[CommandName, CommandSchema] = { + k: v + for k, v in command_schemas[root_cmd_name].subcommands.items() + if fnmatch(k, command_filter) + } + if len(matching_schemas) == 1 and not any(x in command_filter for x in ('*', '?')): + command_schemas = matching_schemas + root_cmd_name = list(command_schemas.keys())[0] + # app_name = app_name + " " + root_cmd_name + else: + command_schemas[root_cmd_name].subcommands = matching_schemas + self.command_schemas = command_schemas self.is_grouped_cli = any(v.subcommands for v in command_schemas.values()) self.execute_on_exit = False diff --git a/trogon/widgets/parameter_controls.py b/trogon/widgets/parameter_controls.py index 144510a..5591ce1 100644 --- a/trogon/widgets/parameter_controls.py +++ b/trogon/widgets/parameter_controls.py @@ -307,7 +307,7 @@ def list_to_tuples( # We can safely do this since widgets are added to the DOM in the same order # as the types specified in the click Option `type`. We convert a flat list # of widget values into a list of tuples, each tuple of length nargs. - collected_values = list_to_tuples(collected_values, self.schema.nargs) + collected_values = list_to_tuples(collected_values, max(1, self.schema.nargs)) return MultiValueParamData.process_cli_option(collected_values) def get_control_method( @@ -331,7 +331,7 @@ def make_text_control( ) -> Widget: control = Input( classes=f"command-form-input {control_id}", - password=schema.sensitive, + password=schema.secret, disabled=schema.read_only, placeholder=schema.placeholder, ) From 262f231b3aea7760a5882ac79b4bc0a6ec5e306a Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 2 Jul 2023 00:00:00 -0500 Subject: [PATCH 11/13] feat: add hacker keybindings --- trogon/trogon.py | 10 +++++++--- trogon/widgets/command_tree.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/trogon/trogon.py b/trogon/trogon.py index f06fcea..520dd4e 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -46,11 +46,12 @@ class CommandBuilder(Screen): BINDINGS = [ Binding(key="ctrl+r", action="close_and_run", description="Close & Run"), Binding( - key="ctrl+t", action="focus_command_tree", description="Focus Command Tree" + key="ctrl+t,escape", action="focus_command_tree", description="Focus Command Tree" ), - Binding(key="ctrl+o", action="show_command_info", description="Command Info"), - Binding(key="ctrl+s", action="focus('search')", description="Search"), + Binding(key="ctrl+o,?", action="show_command_info", description="Command Info"), + Binding(key="ctrl+s,i,/", action="focus('search')", description="Search"), Binding(key="f1", action="about", description="About"), + Binding("q", "exit", show=False), ] def __init__( @@ -130,6 +131,9 @@ def action_close_and_run(self) -> None: self.app.execute_on_exit = True self.app.exit() + def action_exit(self) -> None: + self.app.exit() + def action_about(self) -> None: from .widgets.about import AboutDialog diff --git a/trogon/widgets/command_tree.py b/trogon/widgets/command_tree.py index 94d76ad..ba9b545 100644 --- a/trogon/widgets/command_tree.py +++ b/trogon/widgets/command_tree.py @@ -2,6 +2,7 @@ from rich.style import Style from rich.text import TextType, Text +from textual.binding import Binding from textual.widgets import Tree from textual.widgets._tree import TreeNode, TreeDataType @@ -11,6 +12,16 @@ class CommandTree(Tree[CommandSchema]): COMPONENT_CLASSES = {"group"} + BINDINGS = [ + Binding("enter", "focus_next", "", priority=True, show=False), + Binding("j", "cursor_down", "", priority=True, show=False), + Binding("k", "cursor_up", "", priority=True, show=False), + Binding("g", "scroll_home", "", priority=True, show=False), + Binding("G", "scroll_end", "", priority=True, show=False), + Binding("u", "page_up", "", priority=True, show=False), + Binding("d", "page_down", "", priority=True, show=False), + ] + def __init__(self, label: TextType, cli_metadata: dict[CommandName, CommandSchema]): super().__init__(label) self.show_root = False From 99d24f295f9020abcd0d08d17803cd3411496acd Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Sun, 2 Jul 2023 00:00:00 -0500 Subject: [PATCH 12/13] feat: ctrl+y to copy command --- trogon/trogon.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/trogon/trogon.py b/trogon/trogon.py index 520dd4e..46ea32d 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -33,6 +33,7 @@ from trogon.widgets.command_tree import CommandTree from trogon.widgets.form import CommandForm from trogon.widgets.multiple_choice import NonFocusableVerticalScroll +from subprocess import run if sys.version_info >= (3, 8): from importlib import metadata @@ -44,7 +45,8 @@ class CommandBuilder(Screen): COMPONENT_CLASSES = {"version-string", "prompt", "command-name-syntax"} BINDINGS = [ - Binding(key="ctrl+r", action="close_and_run", description="Close & Run"), + Binding(key="ctrl+r", action="close_and_run", description="Run Command"), + Binding(key="ctrl+y", action="copy_command_string", description="Copy Command"), Binding( key="ctrl+t,escape", action="focus_command_tree", description="Focus Command Tree" ), @@ -131,6 +133,26 @@ def action_close_and_run(self) -> None: self.app.execute_on_exit = True self.app.exit() + def action_copy_command_string(self) -> None: + cmd: list[str] = ( + ["copy"] + if sys.platform == 'win32' + else ["pbcopy"] + if sys.platform == 'darwin' + else ["xclip", "-selection", "clipboard"] + # if linux + ) + + run( + cmd, + input=self.app_name + " " + " ".join( + shlex.quote(str(x)) + for x in self.command_data.to_cli_args(redact_secret=True) + ), + text=True, + check=False, + ) + def action_exit(self) -> None: self.app.exit() From e5ebd601b99fd2dffe85b2e7b954c68a07bf95b3 Mon Sep 17 00:00:00 2001 From: Donald Mellenbruch Date: Thu, 13 Jul 2023 00:00:00 -0500 Subject: [PATCH 13/13] doc: update README --- README.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f102890..8e5ee12 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ https://github.com/Textualize/trogon/assets/554369/c9e5dabb-5624-45cb-8612-f6ecf -Trogon works with the popular Python libraries [Click](https://click.palletsprojects.com/) and [Typer](https://github.com/tiangolo/typer), and will support other libraries and languages in the future. Trogon is integrated into the Python libraries [yapx](https://github.com/fresh2dev/yapx) and [myke](https://github.com/fresh2dev/myke), and can even be used in conjunction with plain ol' `sys.argv`. See the `examples/` directory for examples of each. +Trogon works with the popular Python libraries [Argparse](https://docs.python.org/3/library/argparse.html), [Click](https://click.palletsprojects.com/), [Typer](https://github.com/tiangolo/typer), [Yapx](https://www.f2dv.com/code/r/yapx/i/), and [myke](https://www.f2dv.com/code/r/myke/i/), and will support other libraries and languages in the future. You can also manually build your own TUI schema and use it however you like, even in conjunction with `sys.argv`. See the `examples/` directory for examples of each. ## How it works @@ -90,6 +90,8 @@ pip install trogon ## Quickstart +### Click + 1. Import `from trogon.click import tui` 2. Add the `@tui` decorator above your click app. e.g. ```python @@ -100,13 +102,30 @@ pip install trogon ``` 3. Your click app will have a new `tui` command available. +### Argparse + +1. Import `from trogon.argparse import add_tui_argument` + or, `from trogon.argparse import add_tui_command` +2. Add the TUI argument/command to your argparse parser. e.g. + ```python + parser = argparse.ArgumentParser() + + # add tui argument (my-cli --tui) + add_tui_argument(parser) + # and/or, add tui command (my-cli tui) + add_tui_command(parser) + ``` +3. Your argparse parser will have a new parameter `--tui` and/or a new command `tui`. + See also the `examples` folder for example apps. ## Custom command name and custom help By default the command added will be called `tui` and the help text for it will be `Open Textual TUI.` -You can customize one or both of these using the `help=` and `command=` parameters: +You can customize one or both of these using the `help=` and `command=` parameters. + +### Click ```python @tui(command="ui", help="Open terminal UI") @@ -115,6 +134,17 @@ def cli(): ... ``` +### Argparse + +```python +parser = argparse.ArgumentParser() + +# add tui argument (my-cli --tui) +add_tui_argument(parser, option_strings=["--ui"], help="Open terminal UI") +# and/or, add tui command (my-cli tui) +add_tui_command(parser, command="ui", help="Open terminal UI") +``` + ## Follow this project If this app interests you, you may want to join the Textual [Discord server](https://discord.gg/Enf6Z3qhVr) where you can talk to Textual developers / community.