0
# Testing and Development
1
2
Programmatic testing framework with the Pilot class for automated UI testing, command palette system for built-in development tools, logging utilities for debugging applications, and background task management.
3
4
## Capabilities
5
6
### Programmatic Testing with Pilot
7
8
The Pilot class enables automated testing of Textual applications by simulating user interactions and verifying application state.
9
10
```python { .api }
11
class Pilot:
12
"""Programmatic controller for testing Textual applications."""
13
14
def __init__(self, app: App):
15
"""
16
Initialize pilot for an application.
17
18
Parameters:
19
- app: Textual application to control
20
"""
21
22
async def press(self, *keys: str) -> None:
23
"""
24
Simulate key presses.
25
26
Parameters:
27
- *keys: Key names to press (e.g., "enter", "ctrl+c", "a")
28
"""
29
30
async def click(
31
self,
32
selector: str | None = None,
33
*,
34
offset: tuple[int, int] = (0, 0)
35
) -> None:
36
"""
37
Simulate mouse click.
38
39
Parameters:
40
- selector: CSS selector for target widget (None for current focus)
41
- offset: X,Y offset from widget origin
42
"""
43
44
async def hover(
45
self,
46
selector: str | None = None,
47
*,
48
offset: tuple[int, int] = (0, 0)
49
) -> None:
50
"""
51
Simulate mouse hover.
52
53
Parameters:
54
- selector: CSS selector for target widget
55
- offset: X,Y offset from widget origin
56
"""
57
58
async def scroll_down(self, selector: str | None = None) -> None:
59
"""
60
Simulate scroll down.
61
62
Parameters:
63
- selector: CSS selector for target widget
64
"""
65
66
async def scroll_up(self, selector: str | None = None) -> None:
67
"""
68
Simulate scroll up.
69
70
Parameters:
71
- selector: CSS selector for target widget
72
"""
73
74
async def wait_for_screen(self, screen: type[Screen] | str | None = None) -> None:
75
"""
76
Wait for a specific screen to be active.
77
78
Parameters:
79
- screen: Screen class, name, or None for any screen change
80
"""
81
82
async def wait_for_animation(self) -> None:
83
"""Wait for all animations to complete."""
84
85
async def pause(self, delay: float = 0.1) -> None:
86
"""
87
Pause execution.
88
89
Parameters:
90
- delay: Pause duration in seconds
91
"""
92
93
async def exit(self, result: Any = None) -> None:
94
"""
95
Exit the application.
96
97
Parameters:
98
- result: Exit result value
99
"""
100
101
# Properties
102
app: App # The controlled application
103
104
class OutOfBounds(Exception):
105
"""Raised when pilot operation is out of bounds."""
106
107
class WaitForScreenTimeout(Exception):
108
"""Raised when waiting for screen times out."""
109
```
110
111
### Command Palette System
112
113
Built-in command palette for development tools and application commands.
114
115
```python { .api }
116
class CommandPalette(Widget):
117
"""Built-in command palette widget."""
118
119
class Opened(Message):
120
"""Sent when command palette opens."""
121
122
class Closed(Message):
123
"""Sent when command palette closes."""
124
125
class OptionSelected(Message):
126
"""Sent when command is selected."""
127
def __init__(self, option: Hit): ...
128
129
def __init__(self, **kwargs):
130
"""Initialize command palette."""
131
132
def search(self, query: str) -> None:
133
"""
134
Search for commands.
135
136
Parameters:
137
- query: Search query string
138
"""
139
140
class Provider:
141
"""Base class for command providers."""
142
143
async def search(self, query: str) -> Iterable[Hit]:
144
"""
145
Search for commands matching query.
146
147
Parameters:
148
- query: Search query string
149
150
Returns:
151
Iterable of matching command hits
152
"""
153
154
async def discover(self) -> Iterable[Hit]:
155
"""
156
Discover all available commands.
157
158
Returns:
159
Iterable of all command hits
160
"""
161
162
class Hit:
163
"""Command search result."""
164
165
def __init__(
166
self,
167
match_score: int,
168
renderable: RenderableType,
169
*,
170
command: Callable | None = None,
171
help_text: str | None = None,
172
id: str | None = None
173
):
174
"""
175
Initialize command hit.
176
177
Parameters:
178
- match_score: Relevance score for search
179
- renderable: Display representation
180
- command: Callable to execute
181
- help_text: Help description
182
- id: Unique identifier
183
"""
184
185
# Properties
186
match_score: int
187
renderable: RenderableType
188
command: Callable | None
189
help_text: str | None
190
id: str | None
191
192
CommandList = list[Hit]
193
"""Type alias for list of command hits."""
194
```
195
196
### Logging System
197
198
Comprehensive logging utilities for debugging and development.
199
200
```python { .api }
201
def log(*args, **kwargs) -> None:
202
"""
203
Global logging function.
204
205
Parameters:
206
- *args: Values to log
207
- **kwargs: Keyword arguments to log
208
209
Note:
210
Logs to the currently active app or debug output if no app.
211
"""
212
213
class Logger:
214
"""Logger class that logs to the Textual console."""
215
216
def __init__(
217
self,
218
log_callable: LogCallable | None,
219
group: LogGroup = LogGroup.INFO,
220
verbosity: LogVerbosity = LogVerbosity.NORMAL,
221
app: App | None = None,
222
):
223
"""
224
Initialize logger.
225
226
Parameters:
227
- log_callable: Function to handle log calls
228
- group: Log group classification
229
- verbosity: Logging verbosity level
230
- app: Associated application
231
"""
232
233
def __call__(self, *args: object, **kwargs) -> None:
234
"""Log values and keyword arguments."""
235
236
def verbosity(self, verbose: bool) -> Logger:
237
"""
238
Get logger with different verbosity.
239
240
Parameters:
241
- verbose: True for high verbosity, False for normal
242
243
Returns:
244
New logger instance
245
"""
246
247
# Property loggers for different categories
248
@property
249
def verbose(self) -> Logger:
250
"""High verbosity logger."""
251
252
@property
253
def event(self) -> Logger:
254
"""Event logging."""
255
256
@property
257
def debug(self) -> Logger:
258
"""Debug message logging."""
259
260
@property
261
def info(self) -> Logger:
262
"""Information logging."""
263
264
@property
265
def warning(self) -> Logger:
266
"""Warning message logging."""
267
268
@property
269
def error(self) -> Logger:
270
"""Error message logging."""
271
272
@property
273
def system(self) -> Logger:
274
"""System information logging."""
275
276
@property
277
def logging(self) -> Logger:
278
"""Standard library logging integration."""
279
280
@property
281
def worker(self) -> Logger:
282
"""Worker/background task logging."""
283
284
class LogGroup(Enum):
285
"""Log message categories."""
286
INFO = "INFO"
287
DEBUG = "DEBUG"
288
WARNING = "WARNING"
289
ERROR = "ERROR"
290
EVENT = "EVENT"
291
SYSTEM = "SYSTEM"
292
LOGGING = "LOGGING"
293
WORKER = "WORKER"
294
295
class LogVerbosity(Enum):
296
"""Log verbosity levels."""
297
NORMAL = "NORMAL"
298
HIGH = "HIGH"
299
300
class LoggerError(Exception):
301
"""Raised when logger operation fails."""
302
```
303
304
### Background Task Management
305
306
System for managing background tasks and workers in Textual applications.
307
308
```python { .api }
309
class Worker:
310
"""Background worker for running tasks."""
311
312
def __init__(
313
self,
314
target: Callable,
315
*,
316
name: str | None = None,
317
group: str = "default",
318
description: str = "",
319
exit_on_error: bool = True,
320
start: bool = True
321
):
322
"""
323
Initialize worker.
324
325
Parameters:
326
- target: Function/coroutine to run
327
- name: Worker name
328
- group: Worker group name
329
- description: Worker description
330
- exit_on_error: Whether to exit app on worker error
331
- start: Whether to start immediately
332
"""
333
334
def start(self) -> None:
335
"""Start the worker."""
336
337
def cancel(self) -> None:
338
"""Cancel the worker."""
339
340
async def wait(self) -> Any:
341
"""Wait for worker completion and return result."""
342
343
# Properties
344
is_cancelled: bool # Whether worker is cancelled
345
is_finished: bool # Whether worker completed
346
state: WorkerState # Current worker state
347
result: Any # Worker result (if completed)
348
error: Exception | None # Worker error (if failed)
349
350
class WorkerState(Enum):
351
"""Worker execution states."""
352
PENDING = "PENDING"
353
RUNNING = "RUNNING"
354
CANCELLED = "CANCELLED"
355
ERROR = "ERROR"
356
SUCCESS = "SUCCESS"
357
358
class WorkerError(Exception):
359
"""Base worker error."""
360
361
class WorkerFailed(WorkerError):
362
"""Raised when worker fails."""
363
364
def work(exclusive: bool = False, thread: bool = False):
365
"""
366
Decorator for creating background workers.
367
368
Parameters:
369
- exclusive: Cancel other workers in same group when starting
370
- thread: Run in separate thread instead of async task
371
372
Usage:
373
@work
374
async def background_task(self):
375
# Background work here
376
pass
377
"""
378
```
379
380
### Timer System
381
382
Scheduling and timing utilities for delayed execution and periodic tasks.
383
384
```python { .api }
385
class Timer:
386
"""Scheduled callback timer."""
387
388
def __init__(
389
self,
390
name: str,
391
interval: float,
392
callback: TimerCallback,
393
*,
394
repeat: int | None = None,
395
pause: bool = False
396
):
397
"""
398
Initialize timer.
399
400
Parameters:
401
- name: Timer identifier
402
- interval: Callback interval in seconds
403
- callback: Function to call
404
- repeat: Number of times to repeat (None for infinite)
405
- pause: Start timer paused
406
"""
407
408
def start(self) -> None:
409
"""Start the timer."""
410
411
def stop(self) -> None:
412
"""Stop the timer."""
413
414
def pause(self) -> None:
415
"""Pause the timer."""
416
417
def resume(self) -> None:
418
"""Resume paused timer."""
419
420
def reset(self) -> None:
421
"""Reset timer to initial state."""
422
423
# Properties
424
time: float # Current time
425
next_fire: float # Time of next callback
426
is_active: bool # Whether timer is running
427
428
TimerCallback = Callable[[Timer], None]
429
"""Type alias for timer callback functions."""
430
```
431
432
### Development Utilities
433
434
```python { .api }
435
def get_app() -> App:
436
"""
437
Get the currently active Textual application.
438
439
Returns:
440
Active App instance
441
442
Raises:
443
RuntimeError: If no app is active
444
"""
445
446
def set_title(title: str) -> None:
447
"""
448
Set terminal window title.
449
450
Parameters:
451
- title: Window title text
452
"""
453
454
class DevConsole:
455
"""Development console for runtime debugging."""
456
457
def __init__(self, app: App):
458
"""
459
Initialize dev console.
460
461
Parameters:
462
- app: Application to debug
463
"""
464
465
def run_code(self, code: str) -> Any:
466
"""
467
Execute Python code in app context.
468
469
Parameters:
470
- code: Python code to execute
471
472
Returns:
473
Execution result
474
"""
475
476
def inspect_widget(self, selector: str) -> dict:
477
"""
478
Inspect widget properties.
479
480
Parameters:
481
- selector: CSS selector for widget
482
483
Returns:
484
Widget property dictionary
485
"""
486
```
487
488
## Usage Examples
489
490
### Basic Application Testing
491
492
```python
493
import pytest
494
from textual.app import App
495
from textual.widgets import Button, Input
496
497
class TestApp(App):
498
def compose(self):
499
yield Input(placeholder="Enter text", id="input")
500
yield Button("Submit", id="submit")
501
yield Button("Clear", id="clear")
502
503
def on_button_pressed(self, event: Button.Pressed):
504
if event.button.id == "submit":
505
input_widget = self.query_one("#input", Input)
506
self.log(f"Submitted: {input_widget.value}")
507
elif event.button.id == "clear":
508
input_widget = self.query_one("#input", Input)
509
input_widget.value = ""
510
511
@pytest.mark.asyncio
512
async def test_app_interaction():
513
"""Test basic app interaction with Pilot."""
514
app = TestApp()
515
516
async with app.run_test() as pilot:
517
# Type in input field
518
await pilot.click("#input")
519
await pilot.press("h", "e", "l", "l", "o")
520
521
# Verify input value
522
input_widget = app.query_one("#input", Input)
523
assert input_widget.value == "hello"
524
525
# Click submit button
526
await pilot.click("#submit")
527
528
# Click clear button and verify input is cleared
529
await pilot.click("#clear")
530
assert input_widget.value == ""
531
532
@pytest.mark.asyncio
533
async def test_keyboard_shortcuts():
534
"""Test keyboard shortcuts."""
535
app = TestApp()
536
537
async with app.run_test() as pilot:
538
# Enter text and use keyboard shortcut
539
await pilot.press("tab") # Focus input
540
await pilot.press("w", "o", "r", "l", "d")
541
await pilot.press("enter") # Should trigger submit
542
543
input_widget = app.query_one("#input", Input)
544
assert input_widget.value == "world"
545
```
546
547
### Screen Navigation Testing
548
549
```python
550
from textual.app import App
551
from textual.screen import Screen
552
from textual.widgets import Button, Static
553
554
class SecondScreen(Screen):
555
def compose(self):
556
yield Static("Second Screen")
557
yield Button("Go Back", id="back")
558
559
def on_button_pressed(self, event: Button.Pressed):
560
if event.button.id == "back":
561
self.dismiss("result from second screen")
562
563
class NavigationApp(App):
564
def compose(self):
565
yield Static("Main Screen")
566
yield Button("Go to Second", id="goto")
567
568
def on_button_pressed(self, event: Button.Pressed):
569
if event.button.id == "goto":
570
self.push_screen(SecondScreen())
571
572
@pytest.mark.asyncio
573
async def test_screen_navigation():
574
"""Test screen stack navigation."""
575
app = NavigationApp()
576
577
async with app.run_test() as pilot:
578
# Start on main screen
579
assert len(app.screen_stack) == 1
580
581
# Navigate to second screen
582
await pilot.click("#goto")
583
await pilot.wait_for_screen(SecondScreen)
584
585
assert len(app.screen_stack) == 2
586
assert isinstance(app.screen, SecondScreen)
587
588
# Go back to main screen
589
await pilot.click("#back")
590
591
# Should be back on main screen
592
assert len(app.screen_stack) == 1
593
```
594
595
### Custom Command Provider
596
597
```python
598
from textual.app import App
599
from textual.command import Provider, Hit
600
from textual.widgets import Static
601
602
class CustomCommands(Provider):
603
"""Custom command provider."""
604
605
async def search(self, query: str) -> list[Hit]:
606
"""Search for custom commands."""
607
commands = [
608
("hello", "Say hello", self.say_hello),
609
("goodbye", "Say goodbye", self.say_goodbye),
610
("time", "Show current time", self.show_time),
611
]
612
613
# Filter commands by query
614
matches = []
615
for name, description, callback in commands:
616
if query.lower() in name.lower():
617
score = 100 - len(name) # Shorter names score higher
618
hit = Hit(
619
score,
620
f"[bold]{name}[/bold] - {description}",
621
command=callback,
622
help_text=description
623
)
624
matches.append(hit)
625
626
return matches
627
628
async def say_hello(self) -> None:
629
"""Say hello command."""
630
app = self.app
631
status = app.query_one("#status", Static)
632
status.update("Hello from command palette!")
633
634
async def say_goodbye(self) -> None:
635
"""Say goodbye command."""
636
app = self.app
637
status = app.query_one("#status", Static)
638
status.update("Goodbye from command palette!")
639
640
async def show_time(self) -> None:
641
"""Show time command."""
642
import datetime
643
app = self.app
644
status = app.query_one("#status", Static)
645
current_time = datetime.datetime.now().strftime("%H:%M:%S")
646
status.update(f"Current time: {current_time}")
647
648
class CommandApp(App):
649
COMMANDS = {CustomCommands} # Register command provider
650
651
def compose(self):
652
yield Static("Press Ctrl+P to open command palette", id="status")
653
654
def on_mount(self):
655
# Command palette is automatically available with Ctrl+P
656
pass
657
```
658
659
### Background Worker Usage
660
661
```python
662
from textual.app import App
663
from textual.widgets import Button, ProgressBar, Static
664
from textual import work
665
import asyncio
666
667
class WorkerApp(App):
668
def compose(self):
669
yield Static("Click button to start background task")
670
yield Button("Start Task", id="start")
671
yield Button("Cancel Task", id="cancel")
672
yield ProgressBar(id="progress")
673
674
@work(exclusive=True) # Cancel previous workers
675
async def long_running_task(self):
676
"""Background task that updates progress."""
677
progress_bar = self.query_one("#progress", ProgressBar)
678
679
for i in range(100):
680
# Check if cancelled
681
if self.workers.get("long_running_task").is_cancelled:
682
break
683
684
# Update progress
685
progress_bar.progress = i + 1
686
687
# Simulate work
688
await asyncio.sleep(0.1)
689
690
# Task completed
691
self.log("Background task completed!")
692
693
def on_button_pressed(self, event: Button.Pressed):
694
if event.button.id == "start":
695
# Start background worker
696
self.long_running_task()
697
698
elif event.button.id == "cancel":
699
# Cancel running workers
700
worker = self.workers.get("long_running_task")
701
if worker and not worker.is_finished:
702
worker.cancel()
703
self.log("Task cancelled")
704
```
705
706
### Advanced Logging
707
708
```python
709
from textual.app import App
710
from textual.widgets import Button
711
from textual import log
712
713
class LoggingApp(App):
714
def compose(self):
715
yield Button("Info", id="info")
716
yield Button("Warning", id="warning")
717
yield Button("Error", id="error")
718
yield Button("Debug", id="debug")
719
720
def on_button_pressed(self, event: Button.Pressed):
721
button_id = event.button.id
722
723
if button_id == "info":
724
log.info("This is an info message")
725
726
elif button_id == "warning":
727
log.warning("This is a warning message")
728
729
elif button_id == "error":
730
log.error("This is an error message")
731
732
elif button_id == "debug":
733
log.debug("This is a debug message")
734
735
# Log with structured data
736
log("Button pressed", button=button_id, timestamp=time.time())
737
738
# Verbose logging
739
log.verbose("Detailed information about button press",
740
widget=event.button,
741
coordinates=(event.button.region.x, event.button.region.y))
742
```
743
744
### Timer-based Updates
745
746
```python
747
from textual.app import App
748
from textual.widgets import Static
749
from textual.timer import Timer
750
import datetime
751
752
class ClockApp(App):
753
def compose(self):
754
yield Static("", id="clock")
755
756
def on_mount(self):
757
# Update clock every second
758
self.set_interval(1.0, self.update_clock)
759
760
# Initial update
761
self.update_clock()
762
763
def update_clock(self):
764
"""Update the clock display."""
765
current_time = datetime.datetime.now().strftime("%H:%M:%S")
766
clock = self.query_one("#clock", Static)
767
clock.update(f"Current time: {current_time}")
768
```