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

Lazy command provider #3756

Merged
merged 4 commits into from
Nov 27, 2023
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed

- Optimized startup time https://github.com/Textualize/textual/pull/3753

- App.COMMANDS or Screen.COMMANDS can now accept a callable which returns a command palette provider https://github.com/Textualize/textual/pull/3756

## [0.42.0] - 2023-11-22

Expand Down
17 changes: 15 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
from ._context import message_hook as message_hook_context_var
from ._event_broker import NoHandler, extract_handler_actions
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._system_commands import SystemCommands
from ._wait import wait_for_idle
from ._worker_manager import WorkerManager
from .actions import ActionParseResult, SkipAction
Expand Down Expand Up @@ -107,6 +106,7 @@
from textual_dev.client import DevtoolsClient
from typing_extensions import Coroutine, Literal, TypeAlias

from ._system_commands import SystemCommands
from ._types import MessageTarget

# Unused & ignored imports are needed for the docs to link to these objects:
Expand Down Expand Up @@ -158,6 +158,17 @@
"""Signature for valid callbacks that can be used to control apps."""


def get_system_commands() -> type[SystemCommands]:
"""Callable to lazy load the system commands.

Returns:
System commands class.
"""
from ._system_commands import SystemCommands

return SystemCommands


class AppError(Exception):
"""Base class for general App related exceptions."""

Expand Down Expand Up @@ -330,7 +341,9 @@ class MyApp(App[None]):
ENABLE_COMMAND_PALETTE: ClassVar[bool] = True
"""Should the [command palette][textual.command.CommandPalette] be enabled for the application?"""

COMMANDS: ClassVar[set[type[Provider]]] = {SystemCommands}
COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = {
get_system_commands
}
"""Command providers used by the [command palette](/guide/command_palette).

Should be a set of [command.Provider][textual.command.Provider] classes.
Expand Down
22 changes: 20 additions & 2 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
from asyncio import CancelledError, Queue, Task, TimeoutError, wait, wait_for
from dataclasses import dataclass
from functools import total_ordering
from inspect import isclass
from time import monotonic
from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, ClassVar
from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, ClassVar, Iterable

import rich.repr
from rich.align import Align
Expand Down Expand Up @@ -486,10 +487,27 @@ def _provider_classes(self) -> set[type[Provider]]:
application][textual.app.App.COMMANDS] and those [defined in
the current screen][textual.screen.Screen.COMMANDS].
"""

def get_providers(root: App | Screen) -> Iterable[type[Provider]]:
"""Get providers from app or screen.

Args:
root: The app or screen.

Returns:
An iterable of providers.
"""
for provider in root.COMMANDS:
if isclass(provider) and issubclass(provider, Provider):
yield provider
else:
# Lazy loaded providers
yield provider() # type: ignore

return (
set()
if self._calling_screen is None
else self.app.COMMANDS | self._calling_screen.COMMANDS
else {*get_providers(self.app), *get_providers(self._calling_screen)}
)

def compose(self) -> ComposeResult:
Expand Down
2 changes: 1 addition & 1 deletion src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class Screen(Generic[ScreenResultType], Widget):
title: Reactive[str | None] = Reactive(None, compute=False)
"""Screen title to override [the app title][textual.app.App.title]."""

COMMANDS: ClassVar[set[type[Provider]]] = set()
COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = set()
"""Command providers used by the [command palette](/guide/command_palette), associated with the screen.

Should be a set of [`command.Provider`][textual.command.Provider] classes.
Expand Down
15 changes: 8 additions & 7 deletions tests/command_palette/test_declare_sources.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from textual._system_commands import SystemCommands
from textual.app import App
from textual.command import CommandPalette, Hit, Hits, Provider
from textual.screen import Screen
Expand Down Expand Up @@ -29,7 +30,7 @@ async def test_no_app_command_sources() -> None:
"""An app with no sources declared should work fine."""
async with AppWithNoSources().run_test() as pilot:
assert isinstance(pilot.app.screen, CommandPalette)
assert pilot.app.screen._provider_classes == App.COMMANDS
assert pilot.app.screen._provider_classes == {SystemCommands}


class AppWithSources(AppWithActiveCommandPalette):
Expand All @@ -40,7 +41,7 @@ async def test_app_command_sources() -> None:
"""Command sources declared on an app should be in the command palette."""
async with AppWithSources().run_test() as pilot:
assert isinstance(pilot.app.screen, CommandPalette)
assert pilot.app.screen._provider_classes == AppWithSources.COMMANDS
assert pilot.app.screen._provider_classes == {ExampleCommandSource}


class AppWithInitialScreen(App[None]):
Expand All @@ -61,7 +62,7 @@ async def test_no_screen_command_sources() -> None:
"""An app with a screen with no sources declared should work fine."""
async with AppWithInitialScreen(ScreenWithNoSources()).run_test() as pilot:
assert isinstance(pilot.app.screen, CommandPalette)
assert pilot.app.screen._provider_classes == App.COMMANDS
assert pilot.app.screen._provider_classes == {SystemCommands}


class ScreenWithSources(ScreenWithNoSources):
Expand All @@ -72,10 +73,10 @@ async def test_screen_command_sources() -> None:
"""Command sources declared on a screen should be in the command palette."""
async with AppWithInitialScreen(ScreenWithSources()).run_test() as pilot:
assert isinstance(pilot.app.screen, CommandPalette)
assert (
pilot.app.screen._provider_classes
== App.COMMANDS | ScreenWithSources.COMMANDS
)
assert pilot.app.screen._provider_classes == {
SystemCommands,
ExampleCommandSource,
}


class AnotherCommandSource(ExampleCommandSource):
Expand Down
Loading