textual-console 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- textual_console/__init__.py +26 -0
- textual_console/__main__.py +254 -0
- textual_console/_ansi.py +36 -0
- textual_console/_argparse.py +443 -0
- textual_console/_autocomplete.py +394 -0
- textual_console/_console_input.py +436 -0
- textual_console/_console_log.py +109 -0
- textual_console/_console_output.py +544 -0
- textual_console/_history.py +120 -0
- textual_console/_selection.py +119 -0
- textual_console/_tokens.py +66 -0
- textual_console/py.typed +0 -0
- textual_console-0.1.0.dist-info/METADATA +308 -0
- textual_console-0.1.0.dist-info/RECORD +16 -0
- textual_console-0.1.0.dist-info/WHEEL +4 -0
- textual_console-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Console widgets for Textual."""
|
|
2
|
+
|
|
3
|
+
from textual_console._ansi import strip_ansi
|
|
4
|
+
from textual_console._argparse import ArgparseCompleter, set_completer
|
|
5
|
+
from textual_console._autocomplete import (
|
|
6
|
+
CommandAutoComplete,
|
|
7
|
+
CommandItem,
|
|
8
|
+
CompletionHint,
|
|
9
|
+
)
|
|
10
|
+
from textual_console._console_input import ConsoleInput
|
|
11
|
+
from textual_console._console_log import ConsoleLog
|
|
12
|
+
from textual_console._console_output import ConsoleOutput
|
|
13
|
+
from textual_console._history import CommandHistory
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ArgparseCompleter",
|
|
17
|
+
"CommandAutoComplete",
|
|
18
|
+
"CommandHistory",
|
|
19
|
+
"CommandItem",
|
|
20
|
+
"CompletionHint",
|
|
21
|
+
"ConsoleInput",
|
|
22
|
+
"ConsoleLog",
|
|
23
|
+
"ConsoleOutput",
|
|
24
|
+
"set_completer",
|
|
25
|
+
"strip_ansi",
|
|
26
|
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Demo app. Run it with ``python -m textual_console``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual import on
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.widgets import Footer, Static
|
|
16
|
+
|
|
17
|
+
from textual_console import (
|
|
18
|
+
ArgparseCompleter,
|
|
19
|
+
CommandAutoComplete,
|
|
20
|
+
ConsoleInput,
|
|
21
|
+
ConsoleLog,
|
|
22
|
+
ConsoleOutput,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
COLORS = ("red", "green", "yellow", "blue", "magenta", "cyan")
|
|
26
|
+
"""The colors of the ``colors`` command."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_parsers() -> dict[str, argparse.ArgumentParser]:
|
|
30
|
+
"""Describe the commands of the demo with argparse parsers.
|
|
31
|
+
|
|
32
|
+
The same parsers drive the completion and the parsing of a command line.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def parser(name: str, description: str) -> argparse.ArgumentParser:
|
|
36
|
+
return argparse.ArgumentParser(prog=name, description=description)
|
|
37
|
+
|
|
38
|
+
parsers = {
|
|
39
|
+
"help": parser("help", "Show the commands"),
|
|
40
|
+
"colors": parser("colors", "Write text in some colors"),
|
|
41
|
+
"table": parser("table", "Write a Rich table"),
|
|
42
|
+
"ansi": parser("ansi", "Write text with ANSI escape codes"),
|
|
43
|
+
"flood": parser("flood", "Write many lines to check the scrollback"),
|
|
44
|
+
"clear": parser("clear", "Clear the output"),
|
|
45
|
+
"echo": parser("echo", "Write the text to the output"),
|
|
46
|
+
"quit": parser("quit", "Leave the demo"),
|
|
47
|
+
}
|
|
48
|
+
parsers["colors"].add_argument(
|
|
49
|
+
"--only", nargs="+", choices=COLORS, metavar="COLOR", help="show only these"
|
|
50
|
+
)
|
|
51
|
+
parsers["flood"].add_argument(
|
|
52
|
+
"count",
|
|
53
|
+
nargs="?",
|
|
54
|
+
type=int,
|
|
55
|
+
default=200,
|
|
56
|
+
metavar="COUNT",
|
|
57
|
+
help="how many lines",
|
|
58
|
+
)
|
|
59
|
+
parsers["flood"].add_argument(
|
|
60
|
+
"--style",
|
|
61
|
+
choices=("plain", "red", "green"),
|
|
62
|
+
default="plain",
|
|
63
|
+
help="the style of the lines",
|
|
64
|
+
)
|
|
65
|
+
parsers["echo"].add_argument("text", nargs="*", metavar="TEXT", help="the words")
|
|
66
|
+
return parsers
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
PARSERS = _build_parsers()
|
|
70
|
+
"""The parsers of the commands of the demo, by command name."""
|
|
71
|
+
|
|
72
|
+
COMMANDS = tuple(PARSERS)
|
|
73
|
+
"""The commands of the demo."""
|
|
74
|
+
|
|
75
|
+
HINTS = """\
|
|
76
|
+
Try: drag / double / triple-click to select, ctrl+c copy, middle-click paste,
|
|
77
|
+
Tab complete, shift+enter newline, Esc Esc clear.
|
|
78
|
+
Commands: {commands}; add -h for help."""
|
|
79
|
+
"""The hints that are shown under the output."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConsoleDemo(App[None]):
|
|
83
|
+
"""A small REPL that shows all widgets of the package."""
|
|
84
|
+
|
|
85
|
+
CSS = """
|
|
86
|
+
ConsoleOutput {
|
|
87
|
+
border: round $primary;
|
|
88
|
+
height: 1fr;
|
|
89
|
+
}
|
|
90
|
+
#hints {
|
|
91
|
+
height: auto;
|
|
92
|
+
padding: 0 1;
|
|
93
|
+
color: $text-muted;
|
|
94
|
+
}
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self, log_path: Path | None = None, max_lines: int | None = None
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Create the demo.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
log_path: A file that receives a plain-text transcript, or ``None``.
|
|
104
|
+
max_lines: The scrollback limit of the output, or ``None``.
|
|
105
|
+
"""
|
|
106
|
+
super().__init__()
|
|
107
|
+
self.log_path = log_path
|
|
108
|
+
self.max_lines = max_lines
|
|
109
|
+
self._transcript = ConsoleLog(log_path) if log_path is not None else None
|
|
110
|
+
|
|
111
|
+
def compose(self) -> ComposeResult:
|
|
112
|
+
"""Create the output, the hints, the prompt and the footer."""
|
|
113
|
+
yield ConsoleOutput(
|
|
114
|
+
id="output",
|
|
115
|
+
max_lines=self.max_lines,
|
|
116
|
+
log=self._transcript,
|
|
117
|
+
markup=False,
|
|
118
|
+
)
|
|
119
|
+
yield Static(HINTS.format(commands=", ".join(COMMANDS)), id="hints")
|
|
120
|
+
yield ConsoleInput(
|
|
121
|
+
prompt="> ", placeholder="type a command, then press enter", id="prompt"
|
|
122
|
+
)
|
|
123
|
+
yield CommandAutoComplete("#prompt", ArgparseCompleter(PARSERS))
|
|
124
|
+
yield Footer()
|
|
125
|
+
|
|
126
|
+
def on_mount(self) -> None:
|
|
127
|
+
"""Write the welcome text and focus the prompt."""
|
|
128
|
+
output = self.query_one(ConsoleOutput)
|
|
129
|
+
output.write(Text("textual-console demo", style="bold magenta"))
|
|
130
|
+
output.write("Type 'help' to see the commands.")
|
|
131
|
+
if self.log_path is not None:
|
|
132
|
+
output.write(f"Transcript: {self.log_path}")
|
|
133
|
+
if self.max_lines is not None:
|
|
134
|
+
output.write(f"Scrollback is limited to {self.max_lines} lines.")
|
|
135
|
+
self.query_one(ConsoleInput).focus()
|
|
136
|
+
|
|
137
|
+
def on_unmount(self) -> None:
|
|
138
|
+
"""Close the transcript."""
|
|
139
|
+
if self._transcript is not None:
|
|
140
|
+
self._transcript.close()
|
|
141
|
+
|
|
142
|
+
def copy_to_clipboard(self, text: str) -> None:
|
|
143
|
+
"""Copy the text, and show a notification.
|
|
144
|
+
|
|
145
|
+
``ctrl+c`` copies through the screen, which does not post
|
|
146
|
+
``SelectionCopied``, so the notification is made here.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
text: The text to copy.
|
|
150
|
+
"""
|
|
151
|
+
super().copy_to_clipboard(text)
|
|
152
|
+
self.notify(f"Copied {len(text)} characters")
|
|
153
|
+
|
|
154
|
+
@on(ConsoleOutput.LogFailed)
|
|
155
|
+
def _notify_log_failure(self, event: ConsoleOutput.LogFailed) -> None:
|
|
156
|
+
self.notify(f"Transcript failed: {event.error}", severity="error")
|
|
157
|
+
|
|
158
|
+
@on(ConsoleInput.Submitted)
|
|
159
|
+
def _run_submit(self, event: ConsoleInput.Submitted) -> None:
|
|
160
|
+
# A submit can hold several lines. This app runs each non-blank line as a
|
|
161
|
+
# command: that is the app's choice, ConsoleInput only gives the text.
|
|
162
|
+
for line in event.value.splitlines():
|
|
163
|
+
if line.strip():
|
|
164
|
+
self._run_line(line)
|
|
165
|
+
|
|
166
|
+
def _run_line(self, line: str) -> None:
|
|
167
|
+
output = self.query_one(ConsoleOutput)
|
|
168
|
+
# The command is echoed into the output, so it also reaches the transcript.
|
|
169
|
+
output.write(Text(f"> {line}", style="bold green"))
|
|
170
|
+
try:
|
|
171
|
+
words = shlex.split(line)
|
|
172
|
+
except ValueError as error:
|
|
173
|
+
output.write(Text(f"Error: {error}", style="red"))
|
|
174
|
+
return
|
|
175
|
+
name, args = words[0], words[1:]
|
|
176
|
+
parser = PARSERS.get(name)
|
|
177
|
+
if parser is None:
|
|
178
|
+
output.write(Text(f"Unknown command: {name}", style="red"))
|
|
179
|
+
return
|
|
180
|
+
# parse_args prints usage and calls sys.exit for -h and for errors. Catch
|
|
181
|
+
# that, and show the text in the output.
|
|
182
|
+
buffer = io.StringIO()
|
|
183
|
+
try:
|
|
184
|
+
with (
|
|
185
|
+
contextlib.redirect_stdout(buffer),
|
|
186
|
+
contextlib.redirect_stderr(buffer),
|
|
187
|
+
):
|
|
188
|
+
namespace = parser.parse_args(args)
|
|
189
|
+
except SystemExit as exit_:
|
|
190
|
+
style = "red" if exit_.code else ""
|
|
191
|
+
output.write(Text(buffer.getvalue().rstrip(), style=style))
|
|
192
|
+
return
|
|
193
|
+
self._execute(name, namespace)
|
|
194
|
+
|
|
195
|
+
def _execute(self, name: str, args: argparse.Namespace) -> None:
|
|
196
|
+
output = self.query_one(ConsoleOutput)
|
|
197
|
+
if name == "help":
|
|
198
|
+
output.write(f"Commands: {', '.join(COMMANDS)}")
|
|
199
|
+
elif name == "colors":
|
|
200
|
+
for colour in args.only or COLORS:
|
|
201
|
+
output.write(Text(f"{colour} text", style=colour))
|
|
202
|
+
elif name == "table":
|
|
203
|
+
output.write(self._table())
|
|
204
|
+
elif name == "ansi":
|
|
205
|
+
output.write_ansi(
|
|
206
|
+
"\x1b[1;31mbold red\x1b[0m \x1b[4;32munderlined green\x1b[0m"
|
|
207
|
+
)
|
|
208
|
+
elif name == "flood":
|
|
209
|
+
style = "" if args.style == "plain" else args.style
|
|
210
|
+
for number in range(1, args.count + 1):
|
|
211
|
+
output.write(Text(f"line {number}", style=style))
|
|
212
|
+
elif name == "clear":
|
|
213
|
+
output.clear()
|
|
214
|
+
elif name == "echo":
|
|
215
|
+
output.write(" ".join(args.text))
|
|
216
|
+
elif name == "quit":
|
|
217
|
+
self.exit()
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _table() -> Table:
|
|
221
|
+
table = Table(title="Example", box=box.SIMPLE_HEAVY)
|
|
222
|
+
table.add_column("Name", style="cyan")
|
|
223
|
+
table.add_column("Value", justify="right", style="yellow")
|
|
224
|
+
for name, value in (("alpha", 1), ("beta", 22), ("gamma", 333)):
|
|
225
|
+
table.add_row(name, str(value))
|
|
226
|
+
return table
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
230
|
+
"""Run the demo.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
argv: The command-line arguments. The default is ``sys.argv[1:]``.
|
|
234
|
+
"""
|
|
235
|
+
parser = argparse.ArgumentParser(
|
|
236
|
+
prog="python -m textual_console", description="Demo of textual-console."
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--log", type=Path, metavar="PATH", help="write a plain-text transcript"
|
|
240
|
+
)
|
|
241
|
+
parser.add_argument(
|
|
242
|
+
"--max-lines",
|
|
243
|
+
type=int,
|
|
244
|
+
metavar="N",
|
|
245
|
+
help="limit the scrollback of the output to N lines",
|
|
246
|
+
)
|
|
247
|
+
args = parser.parse_args(argv)
|
|
248
|
+
if args.max_lines is not None and args.max_lines < 1:
|
|
249
|
+
parser.error("--max-lines must be at least 1")
|
|
250
|
+
ConsoleDemo(log_path=args.log, max_lines=args.max_lines).run()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
textual_console/_ansi.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Remove terminal escape sequences from text."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_ESCAPES = re.compile(
|
|
6
|
+
r"""
|
|
7
|
+
\x1b\[[0-?]*[ -/]*[@-~] # CSI: colors, cursor moves
|
|
8
|
+
| \x1b\].*?(?:\x07|\x1b\\|\Z) # OSC: hyperlinks, titles (BEL or ST)
|
|
9
|
+
| \x1b[ -/]*[0-~] # other ESC sequences
|
|
10
|
+
| [\x00-\x08\x0a-\x1f\x7f] # C0 controls and DEL, except tab
|
|
11
|
+
""",
|
|
12
|
+
re.VERBOSE | re.DOTALL,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_ansi(text: str) -> str:
|
|
17
|
+
r"""Remove escape sequences and control characters from text.
|
|
18
|
+
|
|
19
|
+
This removes CSI sequences (which include SGR color codes), OSC sequences
|
|
20
|
+
ended by BEL or ``ESC \``, other ``ESC`` sequences, and the C0 control
|
|
21
|
+
characters except tab. That includes ``\r`` and ``\n``, so pass one line
|
|
22
|
+
at a time.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
text: Text that may hold raw escape codes.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The plain text.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> strip_ansi("\x1b[1;31merror\x1b[0m:\tfailed")
|
|
32
|
+
'error:\tfailed'
|
|
33
|
+
>>> strip_ansi("\x1b]8;;http://x.io\x07link\x1b]8;;\x07")
|
|
34
|
+
'link'
|
|
35
|
+
"""
|
|
36
|
+
return _ESCAPES.sub("", text)
|