cf-tool-cli 1.0.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.
cf_tool/ui.py ADDED
@@ -0,0 +1,455 @@
1
+ """Console output and interactive prompts.
2
+
3
+ Messages are colored lines; richer views (tables, panels) live in views.py.
4
+ Every helper here prints text literally, never as Rich markup, because it
5
+ carries foreign text (sample I/O, file names, messages from Codeforces)
6
+ where "[a]" or "[/]" must not be eaten or crash.
7
+
8
+ Prompts use questionary for arrow-key selection / fuzzy filtering / inline
9
+ validation when attached to a terminal, and fall back to plain typed input
10
+ otherwise (pipes, CI, the test harness) so nothing hangs waiting on a TTY.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import os
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from typing import Callable, Iterator, Sequence, TypeVar
20
+
21
+ from rich.cells import cell_len
22
+ from rich.console import Console
23
+ from rich.errors import LiveError
24
+ from rich.text import Text
25
+
26
+ WIDE = 200 # columns when no terminal tells the width (a log, a pipe, an AI agent)
27
+
28
+
29
+ def _width() -> int | None:
30
+ """The console's width: the terminal's (None lets Rich ask it), COLUMNS's,
31
+ or WIDE without either, so tables and messages aren't cut at 80."""
32
+ streams = (sys.stdin, sys.stdout, sys.stderr)
33
+ columns = os.environ.get("COLUMNS", "")
34
+ if columns.isdigit() and int(columns) > 0: # NOTE: zsh can export COLUMNS=0
35
+ return None
36
+ return None if any(s is not None and s.isatty() for s in streams) else WIDE
37
+
38
+
39
+ # NOTE: on Windows, output to a pipe (CP Editor) or a file is in the locale's code
40
+ # \ page, which has no ✓ or →: UTF-8 instead of a crash
41
+ for _stream in (sys.stdout, sys.stderr):
42
+ if (getattr(_stream, "encoding", None) or "utf-8").lower().replace("-", "") != "utf8":
43
+ with contextlib.suppress(AttributeError, ValueError):
44
+ _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
45
+
46
+ console = Console(highlight=False, emoji=False, width=_width()) # NOTE: ":x:" in a sample is text
47
+
48
+ # The palette: soft colors that read well on dark and light terminals. Plain
49
+ # color names in styles ("green", "bold cyan") are drawn in these, so every
50
+ # message, table and menu shares them.
51
+ ACCENT = "#7aa2f7" # blue: what to look at, names, the menu pointer
52
+ SUCCESS = "#9ece6a"
53
+ WARNING = "#e0af68"
54
+ ERROR = "#f7768e"
55
+ MUTED = "#8a8fa8" # secondary text: values, times, hints
56
+ INFO = "#7dcfff" # a lighter blue, for the odd verdict that is neither
57
+ _COLORS = {
58
+ "cyan": ACCENT,
59
+ "green": SUCCESS,
60
+ "yellow": WARNING,
61
+ "red": ERROR,
62
+ "blue": INFO,
63
+ "dim": MUTED,
64
+ "white": "default",
65
+ }
66
+
67
+ T = TypeVar("T")
68
+
69
+ # (label, value) pair for select(); value is returned, label is shown.
70
+ Choice = tuple[str, T]
71
+ # A validator returns True when ok, or an error message string to show.
72
+ Validator = Callable[[str], "bool | str"]
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Heading:
77
+ """A line between groups of select() choices; it cannot be selected."""
78
+
79
+ text: str
80
+
81
+
82
+ class Canceled(Exception):
83
+ """The user canceled a prompt (Esc, Ctrl-C, or no input at all)."""
84
+
85
+
86
+ def styled(style: str) -> str:
87
+ """A style with the palette's colors for the plain color names in it."""
88
+ return " ".join(_COLORS.get(word, word) for word in style.split())
89
+
90
+
91
+ def line(*parts: tuple[str, str]) -> None:
92
+ """One line of differently styled (text, style) parts."""
93
+ console.print(Text.assemble(*((text, styled(style)) for text, style in parts)))
94
+
95
+
96
+ def info(message: str) -> None:
97
+ line(("› ", "cyan bold"), (message, ""))
98
+
99
+
100
+ def success(message: str) -> None:
101
+ line(("✓ ", "green bold"), (message, ""))
102
+
103
+
104
+ def warn(message: str) -> None:
105
+ line(("! ", "yellow bold"), (message, ""))
106
+
107
+
108
+ def error(message: str) -> None:
109
+ line(("✗ ", "red bold"), (message, ""))
110
+
111
+
112
+ def plain(message: str) -> None:
113
+ console.print(message, markup=False)
114
+
115
+
116
+ def dim(message: str) -> None:
117
+ console.print(message, style=MUTED, markup=False)
118
+
119
+
120
+ # the names messages had before the palette
121
+ cyan, green, yellow, red = info, success, warn, error
122
+
123
+
124
+ def row(label: str, value: str = "", note: str = "") -> str:
125
+ """A menu item that shows a setting: its label, its current value (in a
126
+ quieter color, lined up with the others) and a note ("recommended")."""
127
+ return f"{label}{_FIELD}{value}{_FIELD}{note}"
128
+
129
+
130
+ _FIELD = "\x1f" # NOTE: between a row's parts; select() lays them out
131
+
132
+
133
+ @contextlib.contextmanager
134
+ def status(message: str) -> Iterator[Callable[[str], None]]:
135
+ """A spinner while something slow runs; yields a function that changes
136
+ its message. A no-op off a terminal, or on an older Rich that cannot nest
137
+ it inside another live display."""
138
+ if not console.is_terminal:
139
+ yield lambda text: None
140
+ return
141
+ spinner = console.status(Text(message, style=ACCENT), spinner_style=ACCENT)
142
+ try:
143
+ spinner.start()
144
+ except LiveError:
145
+ yield lambda text: None
146
+ return
147
+ try:
148
+ yield lambda text: spinner.update(Text(text, style=ACCENT))
149
+ finally:
150
+ spinner.stop()
151
+
152
+
153
+ def scanline() -> str:
154
+ try:
155
+ return input()
156
+ except (EOFError, KeyboardInterrupt):
157
+ raise Canceled() from None
158
+
159
+
160
+ # --------------------------------------------------------------- interactive
161
+
162
+ _QMARK = "◆"
163
+ _POINTER = "❯"
164
+
165
+
166
+ def interactive() -> bool:
167
+ """True when both ends are a real terminal, so questionary can run."""
168
+ return sys.stdin.isatty() and sys.stdout.isatty()
169
+
170
+
171
+ def _style():
172
+ import questionary
173
+
174
+ return questionary.Style(
175
+ [
176
+ ("qmark", f"fg:{ACCENT} bold"),
177
+ ("question", "bold"),
178
+ ("answer", f"fg:{SUCCESS} bold"),
179
+ ("pointer", f"fg:{ACCENT} bold"),
180
+ ("highlighted", f"fg:{ACCENT} bold"),
181
+ ("selected", f"fg:{SUCCESS}"),
182
+ ("separator", f"fg:{MUTED}"),
183
+ ("instruction", f"fg:{MUTED}"),
184
+ ("value", f"fg:{MUTED}"),
185
+ ("value-on", f"fg:{ACCENT}"),
186
+ ("note", f"fg:{SUCCESS}"),
187
+ ("text", ""),
188
+ ]
189
+ )
190
+
191
+
192
+ # questionary's shortcut keys: 1-9, 0, a-z
193
+ MAX_SHORTCUTS = 36
194
+
195
+
196
+ def _ask(question):
197
+ """The answer to a questionary question; None when the user cancels
198
+ (Ctrl-C, or Esc). Unlike Question.ask(), prints nothing then."""
199
+ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
200
+ from prompt_toolkit.keys import Keys
201
+
202
+ escape = KeyBindings()
203
+
204
+ # NOTE: Esc cancels, like Ctrl-C. Left to prompt_toolkit, Esc then Enter
205
+ # \ (Meta-Enter) submits the prompt's input line as it is
206
+ @escape.add(Keys.Escape, eager=True)
207
+ def _(event) -> None:
208
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
209
+
210
+ app = question.application
211
+ app.key_bindings = merge_key_bindings([app.key_bindings, escape])
212
+ try:
213
+ return question.unsafe_ask()
214
+ except KeyboardInterrupt:
215
+ return None
216
+
217
+
218
+ def _canceled(answer) -> None:
219
+ """Raise Canceled when the user canceled the prompt (answer None)."""
220
+ if answer is None:
221
+ raise Canceled()
222
+
223
+
224
+ def choose_index(count: int, default: int = 0) -> int:
225
+ """Ask until the user types a number from 1 to count (Enter: the
226
+ default); returns the index of the choice."""
227
+ cyan(f"Your choice (1-{count}, Enter for {default + 1}): ")
228
+ while True:
229
+ text_in = scanline().strip()
230
+ if not text_in:
231
+ return default
232
+ if text_in.isdigit() and 1 <= int(text_in) <= count:
233
+ return int(text_in) - 1
234
+ red(f"A number from 1 to {count}, please: ")
235
+
236
+
237
+ def _laid_out(labels: Sequence[str]) -> list[str]:
238
+ """Menu labels with their row() parts lined up: each label padded to the
239
+ longest, then its value, then its note."""
240
+ width = max(
241
+ (cell_len(label.split(_FIELD)[0]) for label in labels if _FIELD in label), default=0
242
+ )
243
+ laid = []
244
+ for label in labels:
245
+ if _FIELD in label:
246
+ name, value, note = label.split(_FIELD)
247
+ label = name + " " * (width - cell_len(name)) + _FIELD + value + _FIELD + note
248
+ laid.append(label)
249
+ return laid
250
+
251
+
252
+ def _plain_row(label: str) -> str:
253
+ """A laid-out row as plain text (off a terminal)."""
254
+ if _FIELD not in label:
255
+ return label
256
+ name, value, note = label.split(_FIELD)
257
+ return f"{name} {value}" + (f" ({note})" if note else "")
258
+
259
+
260
+ def _styled_rows(control) -> None:
261
+ """Draw row() parts in their colors: the value quiet (bright under the
262
+ pointer), the note green."""
263
+ original = control.text # NOTE: the bound _get_choice_tokens, taken at construction
264
+
265
+ def tokens():
266
+ drawn = []
267
+ for style, text in original():
268
+ if _FIELD not in text:
269
+ drawn.append((style, text))
270
+ continue
271
+ name, value, note = text.split(_FIELD)
272
+ on = "highlighted" in style
273
+ drawn.append((style, name + " "))
274
+ drawn.append(("class:value-on" if on else "class:value", value))
275
+ if note:
276
+ drawn.append(("class:note", f" {note}"))
277
+ return drawn
278
+
279
+ control.text = tokens
280
+
281
+
282
+ def _enter_picks(control):
283
+ """Enter takes the item under the pointer, as in questionary, but the
284
+ answer line then shows a row() by its label alone."""
285
+ from prompt_toolkit.key_binding import KeyBindings
286
+ from prompt_toolkit.keys import Keys
287
+
288
+ bindings = KeyBindings()
289
+
290
+ @bindings.add(Keys.ControlM, eager=True)
291
+ def _(event) -> None:
292
+ choice = control.get_pointed_at()
293
+ if isinstance(choice.title, str) and _FIELD in choice.title:
294
+ choice.title = choice.title.split(_FIELD)[0].rstrip()
295
+ control.is_answered = True
296
+ event.app.exit(result=choice.value)
297
+
298
+ return bindings
299
+
300
+
301
+ def select(
302
+ message: str,
303
+ choices: Sequence[Choice | Heading],
304
+ *,
305
+ default: T | None = None,
306
+ search: bool = False,
307
+ ) -> T:
308
+ """Pick one value from (label, value) choices, which Headings may split
309
+ into groups; default is pre-selected. Raises Canceled on Esc or Ctrl-C.
310
+
311
+ Interactive: arrow keys, plus a number/letter key per item (lists of up
312
+ to MAX_SHORTCUTS) or type-to-filter (search=True); Enter confirms.
313
+ Fallback: numbered list + index typing (keeps choose_index patchable).
314
+ """
315
+ options = [c for c in choices if not isinstance(c, Heading)]
316
+ labels = iter(_laid_out([c[0] for c in options]))
317
+ laid = [c if isinstance(c, Heading) else (next(labels), c[1]) for c in choices]
318
+ options = [c for c in laid if not isinstance(c, Heading)]
319
+ if not interactive():
320
+ info(message)
321
+ number = 0
322
+ for c in laid:
323
+ if isinstance(c, Heading):
324
+ plain(f" {c.text}") # NOTE: in line with the labels
325
+ else:
326
+ number += 1
327
+ plain(f"{number:3}) {_plain_row(c[0])}")
328
+ values = [value for _, value in options]
329
+ first = values.index(default) if default in values else 0
330
+ return options[choose_index(len(options), first)][1]
331
+
332
+ import questionary
333
+ from prompt_toolkit.key_binding import merge_key_bindings
334
+ from questionary.prompts.common import InquirerControl
335
+
336
+ shortcuts = not search and len(options) <= MAX_SHORTCUTS
337
+ q_choices = [
338
+ (
339
+ questionary.Separator(c.text)
340
+ if isinstance(c, Heading)
341
+ else questionary.Choice(title=c[0], value=c[1])
342
+ )
343
+ # a filtered list has no groups left to head
344
+ for c in (options if search else laid)
345
+ ]
346
+ question = questionary.select(
347
+ message,
348
+ choices=q_choices,
349
+ default=default,
350
+ style=_style(),
351
+ qmark=_QMARK,
352
+ pointer=_POINTER,
353
+ use_search_filter=search,
354
+ use_shortcuts=shortcuts,
355
+ # j/k move the pointer, unless typing filters or they are shortcuts
356
+ # themselves (items 20 and 21 get "j" and "k")
357
+ use_jk_keys=not search and not (shortcuts and len(options) > 19),
358
+ show_selected=search, # long lists: which item the pointer is on
359
+ )
360
+ # questionary also paints the default as "selected", in reverse video: a
361
+ # solid bar that stays put when the pointer moves. The pointer alone shows
362
+ # where the answer is (Enter takes the item under it).
363
+ app = question.application
364
+ for control in app.layout.find_all_controls():
365
+ if isinstance(control, InquirerControl):
366
+ control.selected_options.clear()
367
+ _styled_rows(control)
368
+ app.key_bindings = merge_key_bindings([app.key_bindings, _enter_picks(control)])
369
+ answer = _ask(question)
370
+ if answer is not None and answer not in [value for _, value in options]:
371
+ answer = None # some other key ended the prompt without an item: canceled
372
+ _canceled(answer)
373
+ return answer
374
+
375
+
376
+ def text(
377
+ message: str,
378
+ *,
379
+ default: str = "",
380
+ validate: Validator | None = None,
381
+ allow_empty: bool = True,
382
+ ) -> str:
383
+ """Free-text input with optional inline validation."""
384
+ if not interactive():
385
+ while True:
386
+ cyan(message)
387
+ value = scanline().strip()
388
+ if not value and default:
389
+ return default
390
+ ok = True if validate is None else validate(value)
391
+ if ok is True and (value or allow_empty):
392
+ return value
393
+ red(ok if isinstance(ok, str) else "Invalid input. Please input again: ")
394
+
395
+ import questionary
396
+
397
+ def _validate(raw: str):
398
+ raw = raw.strip()
399
+ if not raw and not allow_empty and not default:
400
+ return "This field can not be empty"
401
+ if validate is None or not raw:
402
+ return True
403
+ return validate(raw)
404
+
405
+ answer = _ask(
406
+ questionary.text(message, default=default, validate=_validate, style=_style(), qmark=_QMARK)
407
+ )
408
+ _canceled(answer)
409
+ return answer.strip()
410
+
411
+
412
+ def path(message: str, *, validate: Validator | None = None) -> str:
413
+ """Filesystem path input with tab/auto completion."""
414
+ if not interactive():
415
+ while True:
416
+ cyan(message)
417
+ value = scanline().strip()
418
+ ok = True if validate is None else validate(value)
419
+ if ok is True:
420
+ return value
421
+ red(ok if isinstance(ok, str) else "Invalid path. Please input again: ")
422
+
423
+ import questionary
424
+
425
+ answer = _ask(questionary.path(message, validate=validate, style=_style(), qmark=_QMARK))
426
+ _canceled(answer)
427
+ return answer.strip()
428
+
429
+
430
+ def confirm(message: str, *, default: bool = False) -> bool:
431
+ """Yes/no prompt; an empty answer, or no input at all, takes the default."""
432
+ if not interactive():
433
+ cyan(message + (" (Y/n)? " if default else " (y/N)? "))
434
+ while True:
435
+ try:
436
+ text_in = input().strip().lower()
437
+ except EOFError:
438
+ return default # NOTE: nobody to ask (input from a pipe, or none)
439
+ except KeyboardInterrupt:
440
+ raise Canceled() from None
441
+ if not text_in:
442
+ return default
443
+ if text_in in ("y", "n"):
444
+ return text_in == "y"
445
+ red("Invalid input. Please input again: ")
446
+
447
+ import questionary
448
+
449
+ answer = _ask(
450
+ questionary.confirm(
451
+ message, default=default, style=_style(), qmark=_QMARK, auto_enter=False
452
+ )
453
+ )
454
+ _canceled(answer)
455
+ return answer