py-sparcli 0.3.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.
- py_sparcli-0.3.0.dist-info/METADATA +257 -0
- py_sparcli-0.3.0.dist-info/RECORD +61 -0
- py_sparcli-0.3.0.dist-info/WHEEL +4 -0
- py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- sparcli/__init__.py +184 -0
- sparcli/core/__init__.py +92 -0
- sparcli/core/border.py +160 -0
- sparcli/core/color.py +306 -0
- sparcli/core/cursor.py +59 -0
- sparcli/core/geometry.py +141 -0
- sparcli/core/markup.py +215 -0
- sparcli/core/render.py +245 -0
- sparcli/core/style.py +128 -0
- sparcli/core/terminal.py +127 -0
- sparcli/core/text.py +143 -0
- sparcli/core/theme.py +117 -0
- sparcli/core/width.py +244 -0
- sparcli/errors.py +38 -0
- sparcli/input/__init__.py +42 -0
- sparcli/input/calc.py +214 -0
- sparcli/input/confirm.py +188 -0
- sparcli/input/datepicker.py +298 -0
- sparcli/input/editor.py +189 -0
- sparcli/input/event.py +551 -0
- sparcli/input/field.py +90 -0
- sparcli/input/fuzzy.py +401 -0
- sparcli/input/guard.py +103 -0
- sparcli/input/history.py +160 -0
- sparcli/input/line_edit.py +229 -0
- sparcli/input/number.py +232 -0
- sparcli/input/outcome.py +107 -0
- sparcli/input/password.py +233 -0
- sparcli/input/prompt.py +134 -0
- sparcli/input/select.py +336 -0
- sparcli/input/shortcut.py +123 -0
- sparcli/input/text.py +564 -0
- sparcli/input/textarea.py +242 -0
- sparcli/input/validate.py +67 -0
- sparcli/output/__init__.py +56 -0
- sparcli/output/alert.py +107 -0
- sparcli/output/badge.py +82 -0
- sparcli/output/box.py +196 -0
- sparcli/output/columns.py +157 -0
- sparcli/output/compose.py +111 -0
- sparcli/output/diff.py +229 -0
- sparcli/output/kv.py +175 -0
- sparcli/output/layout.py +77 -0
- sparcli/output/list.py +274 -0
- sparcli/output/live.py +129 -0
- sparcli/output/multiprogress.py +73 -0
- sparcli/output/pager.py +83 -0
- sparcli/output/panel.py +125 -0
- sparcli/output/progress.py +233 -0
- sparcli/output/rule.py +134 -0
- sparcli/output/spinner.py +138 -0
- sparcli/output/table/__init__.py +17 -0
- sparcli/output/table/plan.py +198 -0
- sparcli/output/table/render.py +302 -0
- sparcli/output/table/table.py +351 -0
- sparcli/output/tree.py +157 -0
- sparcli/py.typed +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.datepicker
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
Defines :class:`DatePicker`, a month-grid calendar prompt.
|
|
6
|
+
|
|
7
|
+
The picker navigates a :class:`datetime.date` with the arrow and page keys,
|
|
8
|
+
submits on Enter and cancels on Esc. With :meth:`DatePicker.allow_clear`,
|
|
9
|
+
Delete or Backspace clears the selection to a "no date" state that submits as
|
|
10
|
+
``None``; any navigation key then resumes editing from today. The layout mirrors
|
|
11
|
+
the Rust port: a ``Month Year`` heading, a Monday-first weekday header and a day
|
|
12
|
+
grid with the selected day highlighted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import calendar
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from datetime import MAXYEAR, MINYEAR, date, timedelta
|
|
20
|
+
|
|
21
|
+
from sparcli.core.render import Rendered
|
|
22
|
+
from sparcli.core.style import Style
|
|
23
|
+
from sparcli.core.terminal import is_input_tty
|
|
24
|
+
from sparcli.core.text import Line, Span
|
|
25
|
+
from sparcli.core.theme import Theme, theme
|
|
26
|
+
from sparcli.errors import NoTerminalError
|
|
27
|
+
from sparcli.input.event import (
|
|
28
|
+
EventKind,
|
|
29
|
+
EventSource,
|
|
30
|
+
InputEvent,
|
|
31
|
+
KeyCode,
|
|
32
|
+
KeyPress,
|
|
33
|
+
TerminalSource,
|
|
34
|
+
)
|
|
35
|
+
from sparcli.input.guard import TerminalGuard
|
|
36
|
+
from sparcli.input.outcome import Outcome
|
|
37
|
+
from sparcli.input.prompt import Flow, run_prompt
|
|
38
|
+
from sparcli.input.shortcut import Shortcut, find, help_overlay, hint_line
|
|
39
|
+
|
|
40
|
+
# The picker's result payload: a selected date, or ``None`` when cleared.
|
|
41
|
+
_Result = date | None
|
|
42
|
+
# Flow specialized for this prompt, so control constructors bind their type.
|
|
43
|
+
_DateFlow = Flow[_Result]
|
|
44
|
+
|
|
45
|
+
# Number of days in a calendar week (Monday-first grid columns).
|
|
46
|
+
DAYS_PER_WEEK = 7
|
|
47
|
+
# Number of months in a year (used for month arithmetic and year jumps).
|
|
48
|
+
MONTHS_PER_YEAR = 12
|
|
49
|
+
|
|
50
|
+
# Character that opens the help overlay when shortcuts are registered.
|
|
51
|
+
_HELP_KEY = "?"
|
|
52
|
+
# Monday-first weekday column header.
|
|
53
|
+
_WEEKDAY_HEADER = "Mo Tu We Th Fr Sa Su"
|
|
54
|
+
# Line shown while the selection is cleared.
|
|
55
|
+
_EMPTY_HINT = "(no date) - press an arrow to choose"
|
|
56
|
+
# Width of one day cell in the grid ("nn "), also the empty-lead placeholder.
|
|
57
|
+
_CELL = " "
|
|
58
|
+
# English month names, indexed by ``month - 1``.
|
|
59
|
+
_MONTH_NAMES = (
|
|
60
|
+
"January",
|
|
61
|
+
"February",
|
|
62
|
+
"March",
|
|
63
|
+
"April",
|
|
64
|
+
"May",
|
|
65
|
+
"June",
|
|
66
|
+
"July",
|
|
67
|
+
"August",
|
|
68
|
+
"September",
|
|
69
|
+
"October",
|
|
70
|
+
"November",
|
|
71
|
+
"December",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _State:
|
|
76
|
+
"""Mutable state of a running date picker (selection and help flag)."""
|
|
77
|
+
|
|
78
|
+
__slots__ = ("selected", "help")
|
|
79
|
+
|
|
80
|
+
def __init__(self, selected: date | None) -> None:
|
|
81
|
+
self.selected: date | None = selected
|
|
82
|
+
self.help: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DatePicker:
|
|
86
|
+
"""
|
|
87
|
+
A month-grid date picker prompt.
|
|
88
|
+
|
|
89
|
+
Attributes
|
|
90
|
+
----------
|
|
91
|
+
prompt : str
|
|
92
|
+
The label shown above the calendar.
|
|
93
|
+
|
|
94
|
+
Examples
|
|
95
|
+
--------
|
|
96
|
+
>>> import datetime
|
|
97
|
+
>>> picker = DatePicker("When?").initial(datetime.date(2026, 6, 14))
|
|
98
|
+
>>> picker.frame().height() > 0
|
|
99
|
+
True
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
__slots__ = ("_prompt", "_initial", "_allow_clear", "_shortcuts")
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
prompt: str,
|
|
107
|
+
*,
|
|
108
|
+
initial: date | None = None,
|
|
109
|
+
allow_clear: bool = False,
|
|
110
|
+
shortcuts: Iterable[Shortcut] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._prompt = prompt
|
|
113
|
+
self._initial: date = initial if initial is not None else date.today()
|
|
114
|
+
self._allow_clear = allow_clear
|
|
115
|
+
self._shortcuts: list[Shortcut] = (
|
|
116
|
+
list(shortcuts) if shortcuts is not None else []
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def initial(self, value: date) -> DatePicker:
|
|
120
|
+
"""Sets the initially selected date and returns ``self``."""
|
|
121
|
+
self._initial = value
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def allow_clear(self) -> DatePicker:
|
|
125
|
+
"""Allows Delete/Backspace to clear to a "no date" state."""
|
|
126
|
+
self._allow_clear = True
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def shortcuts(self, shortcuts: Iterable[Shortcut]) -> DatePicker:
|
|
130
|
+
"""Registers footer/help shortcuts and returns ``self``."""
|
|
131
|
+
self._shortcuts = list(shortcuts)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def run(self) -> Outcome[date | None]:
|
|
135
|
+
"""
|
|
136
|
+
Runs the picker on the real terminal.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
Outcome[date | None]
|
|
141
|
+
The submitted date (``None`` when cleared), a cancellation, or a
|
|
142
|
+
fired shortcut.
|
|
143
|
+
|
|
144
|
+
Raises
|
|
145
|
+
------
|
|
146
|
+
NoTerminalError
|
|
147
|
+
If standard input or output is not an interactive terminal.
|
|
148
|
+
"""
|
|
149
|
+
if not is_input_tty():
|
|
150
|
+
raise NoTerminalError()
|
|
151
|
+
with TerminalGuard():
|
|
152
|
+
return self.run_with(TerminalSource())
|
|
153
|
+
|
|
154
|
+
def run_with(self, source: EventSource) -> Outcome[date | None]:
|
|
155
|
+
"""
|
|
156
|
+
Runs the picker against any event source (used for tests).
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
source : EventSource
|
|
161
|
+
The source of input events driving the prompt.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
Outcome[date | None]
|
|
166
|
+
The prompt result.
|
|
167
|
+
"""
|
|
168
|
+
state = _State(self._initial)
|
|
169
|
+
return run_prompt(source, state, self._render, self._handle)
|
|
170
|
+
|
|
171
|
+
def frame(self) -> Rendered:
|
|
172
|
+
"""Renders the picker's static frame without running it."""
|
|
173
|
+
return self._render(_State(self._initial), False)
|
|
174
|
+
|
|
175
|
+
def _render(self, state: _State, final_frame: bool) -> Rendered:
|
|
176
|
+
"""Builds the calendar frame for the current state."""
|
|
177
|
+
active = theme()
|
|
178
|
+
if state.help:
|
|
179
|
+
return Rendered(help_overlay(self._shortcuts))
|
|
180
|
+
lines = [Line.styled(self._prompt, active.title)]
|
|
181
|
+
if state.selected is None:
|
|
182
|
+
lines.append(Line.styled(_EMPTY_HINT, active.secondary))
|
|
183
|
+
else:
|
|
184
|
+
lines.append(_header_line(state.selected, active))
|
|
185
|
+
lines.append(_weekday_header(active))
|
|
186
|
+
lines.extend(_month_grid(state.selected, active))
|
|
187
|
+
if self._shortcuts:
|
|
188
|
+
lines.append(hint_line(self._shortcuts))
|
|
189
|
+
return Rendered(lines)
|
|
190
|
+
|
|
191
|
+
def _handle(self, state: _State, event: InputEvent) -> Flow[date | None]:
|
|
192
|
+
"""Handles one event for the picker."""
|
|
193
|
+
if event.kind is not EventKind.KEY or event.key is None:
|
|
194
|
+
return _DateFlow.cont()
|
|
195
|
+
key = event.key
|
|
196
|
+
if state.help:
|
|
197
|
+
state.help = False
|
|
198
|
+
return _DateFlow.cont()
|
|
199
|
+
if self._shortcuts and key.code == KeyCode.char(_HELP_KEY):
|
|
200
|
+
state.help = True
|
|
201
|
+
return _DateFlow.cont()
|
|
202
|
+
fired = find(key, self._shortcuts)
|
|
203
|
+
if fired is not None:
|
|
204
|
+
return _DateFlow.shortcut(fired)
|
|
205
|
+
return self._navigate(state, key)
|
|
206
|
+
|
|
207
|
+
def _navigate(self, state: _State, key: KeyPress) -> Flow[date | None]:
|
|
208
|
+
"""Applies control and navigation keys to the selection."""
|
|
209
|
+
if key.code == KeyCode.ESC:
|
|
210
|
+
return _DateFlow.cancel()
|
|
211
|
+
if key.code == KeyCode.ENTER:
|
|
212
|
+
return _DateFlow.submit(state.selected)
|
|
213
|
+
if self._allow_clear and key.code in (
|
|
214
|
+
KeyCode.DELETE,
|
|
215
|
+
KeyCode.BACKSPACE,
|
|
216
|
+
):
|
|
217
|
+
state.selected = None
|
|
218
|
+
return _DateFlow.cont()
|
|
219
|
+
if state.selected is None:
|
|
220
|
+
state.selected = date.today()
|
|
221
|
+
return _DateFlow.cont()
|
|
222
|
+
state.selected = _apply_nav(state.selected, key)
|
|
223
|
+
return _DateFlow.cont()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _header_line(value: date, active: Theme) -> Line:
|
|
227
|
+
"""Builds the "Month Year" heading line."""
|
|
228
|
+
label = f"{_MONTH_NAMES[value.month - 1]} {value.year}"
|
|
229
|
+
return Line.styled(label, active.heading)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _weekday_header(active: Theme) -> Line:
|
|
233
|
+
"""Builds the Monday-first weekday column header."""
|
|
234
|
+
return Line.styled(_WEEKDAY_HEADER, active.secondary)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _month_grid(selected: date, active: Theme) -> list[Line]:
|
|
238
|
+
"""Builds the day grid, highlighting the selected day."""
|
|
239
|
+
lead = selected.replace(day=1).weekday()
|
|
240
|
+
_, days = calendar.monthrange(selected.year, selected.month)
|
|
241
|
+
lines: list[Line] = []
|
|
242
|
+
spans: list[Span] = [Span.raw(_CELL * lead)]
|
|
243
|
+
column = lead
|
|
244
|
+
for day in range(1, days + 1):
|
|
245
|
+
spans.append(_day_span(day, day == selected.day, active))
|
|
246
|
+
column += 1
|
|
247
|
+
if column == DAYS_PER_WEEK:
|
|
248
|
+
lines.append(Line(spans))
|
|
249
|
+
spans = []
|
|
250
|
+
column = 0
|
|
251
|
+
if spans:
|
|
252
|
+
lines.append(Line(spans))
|
|
253
|
+
return lines
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _day_span(day: int, selected: bool, active: Theme) -> Span:
|
|
257
|
+
"""Renders one day cell, highlighting the selection."""
|
|
258
|
+
style = active.selection if selected else Style.new()
|
|
259
|
+
return Span.styled(f"{day:>2} ", style)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _apply_nav(current: date, key: KeyPress) -> date:
|
|
263
|
+
"""Returns the selection shifted by a navigation key press."""
|
|
264
|
+
code = key.code
|
|
265
|
+
if code == KeyCode.LEFT:
|
|
266
|
+
return _shift_days(current, -1)
|
|
267
|
+
if code == KeyCode.RIGHT:
|
|
268
|
+
return _shift_days(current, 1)
|
|
269
|
+
if code == KeyCode.UP:
|
|
270
|
+
return _shift_days(current, -DAYS_PER_WEEK)
|
|
271
|
+
if code == KeyCode.DOWN:
|
|
272
|
+
return _shift_days(current, DAYS_PER_WEEK)
|
|
273
|
+
if code == KeyCode.PAGE_UP:
|
|
274
|
+
return _add_months(current, -MONTHS_PER_YEAR if key.shift else -1)
|
|
275
|
+
if code == KeyCode.PAGE_DOWN:
|
|
276
|
+
return _add_months(current, MONTHS_PER_YEAR if key.shift else 1)
|
|
277
|
+
return current
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _shift_days(current: date, delta: int) -> date:
|
|
281
|
+
"""Returns ``current`` shifted by ``delta`` days, clamped to the range."""
|
|
282
|
+
try:
|
|
283
|
+
return current + timedelta(days=delta)
|
|
284
|
+
except OverflowError:
|
|
285
|
+
return date.max if delta > 0 else date.min
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _add_months(value: date, delta: int) -> date:
|
|
289
|
+
"""Returns ``value`` shifted by ``delta`` months, clamping the day."""
|
|
290
|
+
zero_based = value.month - 1 + delta
|
|
291
|
+
year = value.year + zero_based // MONTHS_PER_YEAR
|
|
292
|
+
month = zero_based % MONTHS_PER_YEAR + 1
|
|
293
|
+
if year < MINYEAR:
|
|
294
|
+
return date.min
|
|
295
|
+
if year > MAXYEAR:
|
|
296
|
+
return date.max
|
|
297
|
+
_, last = calendar.monthrange(year, month)
|
|
298
|
+
return date(year, month, min(value.day, last))
|
sparcli/input/editor.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.editor
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines external-editor integration (``$VISUAL`` / ``$EDITOR``).
|
|
6
|
+
|
|
7
|
+
:func:`edit_file` opens an editor on a path; :func:`edit_text` round-trips a
|
|
8
|
+
string through a temp file. The editor command comes from an explicit override,
|
|
9
|
+
then ``$VISUAL``, then ``$EDITOR``, then a platform default (``vi`` on POSIX,
|
|
10
|
+
``notepad`` on Windows). The command is split on whitespace and spawned as an
|
|
11
|
+
argument list - never through a shell - so no input is ever interpreted by one.
|
|
12
|
+
:func:`suspended_raw_mode` cooks the terminal around such a spawn, so the child
|
|
13
|
+
editor sees a canonical terminal rather than the prompt's raw mode.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from collections.abc import Generator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from sparcli.errors import ConfigError, TerminalError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Indices into a termios attribute list ``[iflag, oflag, cflag, lflag, ...]``.
|
|
33
|
+
_IFLAG = 0
|
|
34
|
+
_OFLAG = 1
|
|
35
|
+
_LFLAG = 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def edit_file(command: str | None, path: Path) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Opens an external editor on ``path``, blocking until it exits.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
command : str | None
|
|
45
|
+
An explicit editor command, or ``None`` to resolve from the
|
|
46
|
+
environment. Split on whitespace; the path is appended as a final
|
|
47
|
+
argument.
|
|
48
|
+
path : Path
|
|
49
|
+
The file to edit.
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
ConfigError
|
|
54
|
+
If the resolved editor command is empty.
|
|
55
|
+
TerminalError
|
|
56
|
+
If the editor cannot be spawned.
|
|
57
|
+
"""
|
|
58
|
+
parts = _resolve_command(command).split()
|
|
59
|
+
if not parts:
|
|
60
|
+
raise ConfigError("empty editor command")
|
|
61
|
+
argv = [*parts, str(path)]
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(argv, check=False) # noqa: S603
|
|
64
|
+
except OSError as error:
|
|
65
|
+
raise TerminalError(f"could not launch editor: {error}") from error
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def edit_text(command: str | None, initial: str, suffix: str) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Edits ``initial`` in an external editor via a temp file.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
command : str | None
|
|
75
|
+
An explicit editor command, or ``None`` to resolve from the
|
|
76
|
+
environment.
|
|
77
|
+
initial : str
|
|
78
|
+
The starting contents.
|
|
79
|
+
suffix : str
|
|
80
|
+
The temp-file extension (e.g. ``.md``) for editor syntax detection.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
str
|
|
85
|
+
The edited contents.
|
|
86
|
+
|
|
87
|
+
Raises
|
|
88
|
+
------
|
|
89
|
+
ConfigError
|
|
90
|
+
If the resolved editor command is empty.
|
|
91
|
+
TerminalError
|
|
92
|
+
If the editor cannot be spawned or the temp file cannot be handled.
|
|
93
|
+
"""
|
|
94
|
+
fd, name = tempfile.mkstemp(suffix=suffix, prefix="sparcli-")
|
|
95
|
+
path = Path(name)
|
|
96
|
+
try:
|
|
97
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
98
|
+
handle.write(initial)
|
|
99
|
+
edit_file(command, path)
|
|
100
|
+
return path.read_text(encoding="utf-8")
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise TerminalError(f"could not edit temp file: {error}") from error
|
|
103
|
+
finally:
|
|
104
|
+
_remove_quietly(path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_command(command: str | None) -> str:
|
|
108
|
+
"""Resolves the editor command from the override or environment."""
|
|
109
|
+
if command is not None and command.strip():
|
|
110
|
+
return command
|
|
111
|
+
for key in ("VISUAL", "EDITOR"):
|
|
112
|
+
value = os.environ.get(key)
|
|
113
|
+
if value is not None and value.strip():
|
|
114
|
+
return value
|
|
115
|
+
return _default_editor()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _default_editor() -> str:
|
|
119
|
+
"""Returns the platform's fallback editor."""
|
|
120
|
+
return "notepad" if sys.platform == "win32" else "vi"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _remove_quietly(path: Path) -> None:
|
|
124
|
+
"""Removes a temp file, logging (not raising) on failure."""
|
|
125
|
+
try:
|
|
126
|
+
path.unlink(missing_ok=True)
|
|
127
|
+
except OSError as error:
|
|
128
|
+
logger.debug("could not remove temp file %s: %s", path, error)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@contextlib.contextmanager
|
|
132
|
+
def suspended_raw_mode() -> Generator[None]:
|
|
133
|
+
"""
|
|
134
|
+
Cooks the terminal for the duration of the ``with`` block.
|
|
135
|
+
|
|
136
|
+
Restores canonical input, echo and signal handling so a spawned child
|
|
137
|
+
process (such as ``$EDITOR``) sees a normal terminal, then reinstates the
|
|
138
|
+
prompt's raw mode on exit. A no-op off POSIX or when the terminal state
|
|
139
|
+
cannot be read.
|
|
140
|
+
|
|
141
|
+
Examples
|
|
142
|
+
--------
|
|
143
|
+
>>> with suspended_raw_mode():
|
|
144
|
+
... pass
|
|
145
|
+
"""
|
|
146
|
+
saved = _suspend_raw()
|
|
147
|
+
try:
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
_resume_raw(saved)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _suspend_raw() -> tuple[int, list[Any]] | None:
|
|
154
|
+
"""Switches the terminal to cooked mode, returning the saved state."""
|
|
155
|
+
if sys.platform == "win32":
|
|
156
|
+
return None
|
|
157
|
+
import termios # available on every non-win32 platform
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
fd = sys.stdin.fileno()
|
|
161
|
+
saved = termios.tcgetattr(fd)
|
|
162
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, _cooked_mode(saved))
|
|
163
|
+
except (OSError, ValueError, termios.error):
|
|
164
|
+
return None
|
|
165
|
+
return (fd, saved)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _cooked_mode(mode: list[Any]) -> list[Any]:
|
|
169
|
+
"""Returns a copy of ``mode`` with canonical input and echo enabled."""
|
|
170
|
+
import termios
|
|
171
|
+
|
|
172
|
+
cooked = list(mode)
|
|
173
|
+
cooked[_IFLAG] |= termios.ICRNL
|
|
174
|
+
cooked[_OFLAG] |= termios.OPOST
|
|
175
|
+
cooked[_LFLAG] |= termios.ICANON | termios.ECHO | termios.ISIG
|
|
176
|
+
return cooked
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resume_raw(saved: tuple[int, list[Any]] | None) -> None:
|
|
180
|
+
"""Restores the terminal state captured by :func:`_suspend_raw`."""
|
|
181
|
+
if saved is None:
|
|
182
|
+
return
|
|
183
|
+
fd, mode = saved
|
|
184
|
+
import termios # available on every non-win32 platform
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, mode)
|
|
188
|
+
except (OSError, ValueError, termios.error):
|
|
189
|
+
logger.debug("could not restore raw mode after the editor")
|