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/__init__.py +3 -0
- cf_tool/__main__.py +6 -0
- cf_tool/build.py +211 -0
- cf_tool/cli/__init__.py +117 -0
- cf_tool/cli/account.py +32 -0
- cf_tool/cli/common.py +262 -0
- cf_tool/cli/completion.py +226 -0
- cf_tool/cli/configure.py +386 -0
- cf_tool/cli/problems.py +399 -0
- cf_tool/cli/setup.py +195 -0
- cf_tool/cli/submitting.py +309 -0
- cf_tool/cli/testing.py +354 -0
- cf_tool/codeforces/__init__.py +3 -0
- cf_tool/codeforces/browser.py +346 -0
- cf_tool/codeforces/client.py +521 -0
- cf_tool/codeforces/crypto.py +41 -0
- cf_tool/codeforces/gateway.py +288 -0
- cf_tool/codeforces/http.py +213 -0
- cf_tool/codeforces/pages.py +767 -0
- cf_tool/codeforces/session.py +103 -0
- cf_tool/doctor.py +124 -0
- cf_tool/errors.py +37 -0
- cf_tool/judge.py +481 -0
- cf_tool/languages.py +1195 -0
- cf_tool/listen.py +178 -0
- cf_tool/paths.py +57 -0
- cf_tool/process.py +431 -0
- cf_tool/settings.py +386 -0
- cf_tool/stress.py +167 -0
- cf_tool/ui.py +455 -0
- cf_tool/views.py +306 -0
- cf_tool/workspace.py +777 -0
- cf_tool_cli-1.0.0.dist-info/METADATA +106 -0
- cf_tool_cli-1.0.0.dist-info/RECORD +38 -0
- cf_tool_cli-1.0.0.dist-info/WHEEL +5 -0
- cf_tool_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cf_tool_cli-1.0.0.dist-info/licenses/LICENSE +48 -0
- cf_tool_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|