Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 106 additions & 10 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ classifiers = [
python = "^3.9.1"
textual = ">=2.1.2"
click = ">=8.0.0"
textual-autocomplete = ">=4.0.0"
typer = {version = ">=0.9.0", optional = true}

[tool.poetry.extras]
Expand All @@ -39,8 +40,12 @@ typer = ["typer"]
mypy = "^1.2.0"
black = "^24.3.0"
pytest = ">=8.0.0"
pytest-asyncio = ">=0.23.0"
textual-dev = ">=1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
asyncio_mode = "auto"
39 changes: 39 additions & 0 deletions tests/test_introspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import click

from trogon.introspect import MultiValueParamData, introspect_click_app


def test_option_with_no_default_does_not_leak_click_sentinel():
@click.command()
@click.option("--path", type=click.Path())
def cli(path):
pass

data = introspect_click_app(cli)
option = data["cli"].options[0]

assert option.default == MultiValueParamData([])


def test_argument_with_no_default_does_not_leak_click_sentinel():
@click.command()
@click.argument("path", type=click.Path(), required=False)
def cli(path):
pass

data = introspect_click_app(cli)
argument = data["cli"].arguments[0]

assert argument.default == MultiValueParamData([])


def test_option_with_explicit_default_is_preserved():
@click.command()
@click.option("--name", type=click.STRING, default="trogon")
def cli(name):
pass

data = introspect_click_app(cli)
option = data["cli"].options[0]

assert option.default == MultiValueParamData([("trogon",)])
59 changes: 59 additions & 0 deletions tests/test_parameter_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import click
from textual.widgets import Input
from textual_autocomplete import PathAutoComplete

from trogon.trogon import Trogon
from trogon.widgets.parameter_controls import ControlGroup, ParameterControls


@click.group()
@click.option("--in-path", type=click.Path(), help="An input path")
@click.option("--name", type=str, help="A plain string")
def path_cli(in_path, name):
pass


@path_cli.command()
def sub():
pass


async def test_path_option_gets_autocomplete_targeting_its_input():
"""A click.Path option's Input is targeted by exactly one PathAutoComplete."""
app = Trogon(path_cli, app_name="path_cli", command_name="tui")
async with app.run_test() as pilot:
app.query_one("CommandTree").focus()
await pilot.pause()

autocompletes = list(app.query(PathAutoComplete))
assert len(autocompletes) == 1

path_controls = next(
pc
for pc in app.query(ParameterControls)
if pc.schema.name == ["--in-path"]
)
path_input = path_controls.query_one(f".{path_controls.schema.key}", Input)
assert autocompletes[0].target is path_input


async def test_non_path_option_has_no_autocomplete():
"""A plain string option gets no PathAutoComplete, and single-item styling
still counts only the real control widgets."""
app = Trogon(path_cli, app_name="path_cli", command_name="tui")
async with app.run_test() as pilot:
app.query_one("CommandTree").focus()
await pilot.pause()

name_controls = next(
pc for pc in app.query(ParameterControls) if pc.schema.name == ["--name"]
)
assert not list(name_controls.query(PathAutoComplete))

for control_group in app.query(ControlGroup):
children = list(control_group.children)
real_controls = [
c for c in children if not isinstance(c, PathAutoComplete)
]
if "single-item" in control_group.classes:
assert len(real_controls) == 1
10 changes: 9 additions & 1 deletion trogon/introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
import click
from click import BaseCommand, ParamType

try:
# Click 8.3+ uses a dedicated sentinel to distinguish "no default was
# supplied" from a default value of `None`. Older versions of Click
# don't have this sentinel, and just use `None` for both cases.
from click.core import UNSET as CLICK_UNSET
except ImportError:
CLICK_UNSET = None


def generate_unique_id():
return f"id_{str(uuid.uuid4())[:8]}"
Expand All @@ -18,7 +26,7 @@ class MultiValueParamData:

@staticmethod
def process_cli_option(value) -> "MultiValueParamData":
if value is None:
if value is None or value is CLICK_UNSET:
value = MultiValueParamData([])
elif isinstance(value, tuple):
value = MultiValueParamData([value])
Expand Down
23 changes: 23 additions & 0 deletions trogon/widgets/parameter_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Static,
Button,
)
from textual_autocomplete import PathAutoComplete

from trogon.introspect import ArgumentSchema, OptionSchema, MultiValueParamData
from trogon.widgets.multiple_choice import MultipleChoice
Expand Down Expand Up @@ -163,6 +164,10 @@ def compose(self) -> ComposeResult:
if first_focus_control is None:
first_focus_control = control_widget

yield from self._make_path_autocompletes(
widget_group, argument_type
)

# We always need to display the original group of controls,
# regardless of whether there are defaults
if multiple or not default.values:
Expand All @@ -177,6 +182,10 @@ def compose(self) -> ComposeResult:
if first_focus_control is None:
first_focus_control = control_widget

yield from self._make_path_autocompletes(
widget_group, argument_type
)

# Take note of the first form control, so we can easily focus it
if self.first_control is None:
self.first_control = first_focus_control
Expand Down Expand Up @@ -221,6 +230,20 @@ def make_widget_group(self) -> Iterable[ControlWidgetType]:
)
yield from control_widgets

@staticmethod
def _make_path_autocompletes(
widget_group: list[ControlWidgetType], parameter_type: click.ParamType
) -> Iterable[PathAutoComplete]:
"""Pair each Input backed by a click.Path type with a PathAutoComplete."""
parameter_types = (
parameter_type.types
if isinstance(parameter_type, click.Tuple)
else [parameter_type]
)
for control_widget, _type in zip(widget_group, parameter_types):
if isinstance(_type, click.Path) and isinstance(control_widget, Input):
yield PathAutoComplete(target=control_widget)

@on(Button.Pressed, ".add-another-button")
def add_another_widget_group(self, event: Button.Pressed) -> None:
widget_group = list(self.make_widget_group())
Expand Down