Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes and tidying #90

Merged
merged 13 commits into from
Oct 2, 2024
2 changes: 1 addition & 1 deletion examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,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, tags, extra, category, labels):
"""Add a new task to the to-do list.
Note:
Control the output of this using the verbosity option.
Expand Down
724 changes: 706 additions & 18 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ classifiers = [
]

[tool.poetry.dependencies]
python = "^3.8"
python = "^3.8.1"
textual = ">=0.61.0"
click = ">=8.0.0"

Expand All @@ -40,8 +40,8 @@ typer = ["typer"]
[tool.poetry.group.dev.dependencies]
mypy = "^1.2.0"
black = "^23.3.0"
pytest = "^7.3.1"
textual = ">=0.61.0"
pytest = ">=8.0.0"
textual-dev = ">=1.0"

[build-system]
requires = ["poetry-core"]
Expand Down
6 changes: 3 additions & 3 deletions trogon/detect_run_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import shlex
import sys
from types import ModuleType


def get_orig_argv() -> list[str]:
Expand All @@ -20,10 +21,9 @@ def get_orig_argv() -> list[str]:
return argv


def detect_run_string(path=None, _main=sys.modules["__main__"]) -> str:
def detect_run_string(_main: ModuleType = sys.modules["__main__"]) -> str:
"""This is a slightly modified version of a function from Click."""
if not path:
path = sys.argv[0]
path = sys.argv[0]

# The value of __package__ indicates how Python was called. It may
# not exist if a setuptools script is installed as an egg. It may be
Expand Down
6 changes: 3 additions & 3 deletions trogon/introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ class OptionSchema:
is_flag: bool = False
is_boolean_flag: bool = False
flag_value: Any = ""
opts: list = field(default_factory=list)
opts: list[str] = field(default_factory=list)
counting: bool = False
secondary_opts: list = field(default_factory=list)
secondary_opts: list[str] = field(default_factory=list)
key: str | tuple[str] = field(default_factory=generate_unique_id)
help: str | None = None
choices: Sequence[str] | None = None
Expand Down Expand Up @@ -83,7 +83,7 @@ class CommandSchema:
@property
def path_from_root(self) -> list["CommandSchema"]:
node = self
path = [self]
path: list[CommandSchema] = [self]
while True:
node = node.parent
if node is None:
Expand Down
24 changes: 12 additions & 12 deletions trogon/run_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import itertools
import shlex
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, List, Optional

from rich.text import Text
Expand Down Expand Up @@ -71,13 +71,13 @@ class UserCommandData:
"""

name: CommandName
options: List[UserOptionData]
arguments: List[UserArgumentData]
subcommand: Optional["UserCommandData"] = None
parent: Optional["UserCommandData"] = None
command_schema: Optional["CommandSchema"] = None
options: list[UserOptionData] = field(default_factory=list)
arguments: list[UserArgumentData] = field(default_factory=list)
subcommand: UserCommandData | None = None
parent: UserCommandData | None = None
command_schema: CommandSchema | None = None

def to_cli_args(self, include_root_command: bool = False) -> List[str]:
def to_cli_args(self, include_root_command: bool = False) -> list[str]:
"""
Generates a list of strings representing the CLI invocation based on the user input data.

Expand All @@ -90,11 +90,11 @@ def to_cli_args(self, include_root_command: bool = False) -> List[str]:

return cli_args

def _to_cli_args(self):
args = [self.name]
def _to_cli_args(self) -> list[str]:
args: list[str] = [self.name]

multiples = defaultdict(list)
multiples_schemas = {}
multiples: dict[str, list[tuple[str]]] = defaultdict(list)
multiples_schemas: dict[str, OptionSchema] = {}

for option in self.options:
if option.option_schema.multiple:
Expand Down Expand Up @@ -228,7 +228,7 @@ def to_cli_string(self, include_root_command: bool = False) -> Text:
"""
args = self.to_cli_args(include_root_command=include_root_command)

text_renderables = []
text_renderables: list[Text] = []
for arg in args:
text_renderables.append(
Text(shlex.quote(str(arg)))
Expand Down
82 changes: 38 additions & 44 deletions trogon/trogon.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import shlex
from pathlib import Path
from typing import Any
from webbrowser import open as open_url

import click
Expand All @@ -28,6 +29,7 @@
from trogon.introspect import (
introspect_click_app,
CommandSchema,
CommandName,
)
from trogon.run_command import UserCommandData
from trogon.widgets.command_info import CommandInfo
Expand All @@ -42,7 +44,7 @@
import importlib_metadata as metadata # type: ignore


class CommandBuilder(Screen):
class CommandBuilder(Screen[None]):
COMPONENT_CLASSES = {"version-string", "prompt", "command-name-syntax"}

BINDINGS = [
Expand All @@ -69,7 +71,7 @@ def __init__(
classes: str | None = None,
):
super().__init__(name, id, classes)
self.command_data = None
self.command_data: UserCommandData = UserCommandData(CommandName("_default"))
self.cli = cli
self.is_grouped_cli = isinstance(cli, click.Group)
self.command_schemas = introspect_click_app(cli)
Expand Down Expand Up @@ -143,22 +145,14 @@ def action_about(self) -> None:

self.app.push_screen(AboutDialog())

async def on_mount(self, event: events.Mount) -> None:
await self._refresh_command_form()

async def _refresh_command_form(self, node: TreeNode[CommandSchema] | None = None):
if node is None:
try:
command_tree = self.query_one(CommandTree)
node = command_tree.cursor_node
except NoMatches:
return

self.selected_command_schema = node.data
self._update_command_description(node)
self._update_execution_string_preview(
self.selected_command_schema, self.command_data
)
async def _refresh_command_form(self, node: TreeNode[CommandSchema]) -> None:
selected_command = node.data
if selected_command is None:
return

self.selected_command_schema = selected_command
self._update_command_description(selected_command)
self._update_execution_string_preview()
await self._update_form_body(node)

@on(Tree.NodeHighlighted)
Expand All @@ -172,33 +166,26 @@ async def selected_command_changed(
@on(CommandForm.Changed)
def update_command_data(self, event: CommandForm.Changed) -> None:
self.command_data = event.command_data
self._update_execution_string_preview(
self.selected_command_schema, self.command_data
)
self._update_execution_string_preview()

def _update_command_description(self, node: TreeNode[CommandSchema]) -> None:
def _update_command_description(self, command: CommandSchema) -> None:
"""Update the description of the command at the bottom of the sidebar
based on the currently selected node in the command tree."""
description_box = self.query_one("#home-command-description", Static)
description_text = node.data.docstring or ""
description_text = command.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]{command.name}[/]\n{description_text}"
description_box.update(description_text)

def _update_execution_string_preview(
self, command_schema: CommandSchema, command_data: UserCommandData
) -> None:
def _update_execution_string_preview(self) -> None:
"""Update the preview box showing the command string to be executed"""
if self.command_data is not None:
command_name_syntax_style = self.get_component_rich_style(
"command-name-syntax"
)
prefix = Text(f"{self.click_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")
preview_string = Text.assemble(("$ ", prompt_style), highlighted_new_value)
self.query_one("#home-exec-preview-static", Static).update(preview_string)
command_name_syntax_style = self.get_component_rich_style("command-name-syntax")
prefix = Text(f"{self.click_app_name} ", command_name_syntax_style)
new_value = self.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")
preview_string = Text.assemble(("$ ", prompt_style), highlighted_new_value)
self.query_one("#home-exec-preview-static", Static).update(preview_string)

async def _update_form_body(self, node: TreeNode[CommandSchema]) -> None:
# self.query_one(Pretty).update(node.data)
Expand All @@ -216,12 +203,12 @@ async def _update_form_body(self, node: TreeNode[CommandSchema]) -> None:
command_form.focus()


class Trogon(App):
class Trogon(App[None]):
CSS_PATH = Path(__file__).parent / "trogon.scss"

def __init__(
self,
cli: click.Group,
cli: click.Group | click.Command,
app_name: str | None = None,
command_name: str = "tui",
click_context: click.Context | None = None,
Expand All @@ -234,11 +221,11 @@ def __init__(
if app_name is None and click_context is not None:
self.app_name = detect_run_string()
else:
self.app_name = app_name
self.app_name = app_name or "cli"
self.command_name = command_name

def on_mount(self):
self.push_screen(CommandBuilder(self.cli, self.app_name, self.command_name))
def get_default_screen(self) -> CommandBuilder:
return CommandBuilder(self.cli, self.app_name, self.command_name)

@on(Button.Pressed, "#home-exec-button")
def on_button_pressed(self):
Expand All @@ -247,13 +234,20 @@ def on_button_pressed(self):

def run(
self,
*,
*args: Any,
headless: bool = False,
size: tuple[int, int] | None = None,
auto_pilot: AutopilotCallbackType | None = None,
**kwargs: Any,
) -> None:
try:
super().run(headless=headless, size=size, auto_pilot=auto_pilot)
super().run(
*args,
headless=headless,
size=size,
auto_pilot=auto_pilot,
**kwargs,
)
finally:
if self.post_run_command:
console = Console()
Expand Down
15 changes: 10 additions & 5 deletions trogon/widgets/command_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
from rich.style import Style
from rich.text import TextType, Text
from textual.widgets import Tree
from textual.widgets._tree import TreeNode, TreeDataType
from textual.widgets._tree import TreeNode

from trogon.introspect import CommandSchema, CommandName


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],
command_name: str,
):
super().__init__(label)
self.show_root = False
self.guide_depth = 2
Expand All @@ -20,16 +25,16 @@ def __init__(self, label: TextType, cli_metadata: dict[CommandName, CommandSchem
self.command_name = command_name

def render_label(
self, node: TreeNode[TreeDataType], base_style: Style, style: Style
self, node: TreeNode[CommandSchema], base_style: Style, style: Style
) -> Text:
label = node._label.copy()
label.stylize(style)
return label

def on_mount(self):
def build_tree(
data: dict[CommandName, CommandSchema], node: TreeNode
) -> TreeNode:
data: dict[CommandName, CommandSchema], node: TreeNode[CommandSchema]
) -> TreeNode[CommandSchema]:
data = {key: data[key] for key in sorted(data)}
for cmd_name, cmd_data in data.items():
if cmd_name == self.command_name:
Expand Down
Loading