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
sparcli/input/fuzzy.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.fuzzy
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines :class:`FuzzySelect`, an inline fzf-style filtering picker.
|
|
6
|
+
|
|
7
|
+
The user types a query, the option list is filtered and ranked against it, and
|
|
8
|
+
one (or several, in multi mode) entries can be selected. Filtering is backed by
|
|
9
|
+
:func:`score_match`, a small dependency-free subsequence scorer that rewards
|
|
10
|
+
contiguous runs and start-of-word matches. The prompt reuses
|
|
11
|
+
:class:`~sparcli.input.line_edit.LineEditor` for the query and follows the
|
|
12
|
+
shared prompt pattern of a kwargs constructor, fluent builders, a guarded
|
|
13
|
+
:meth:`FuzzySelect.run` and a headless :meth:`FuzzySelect.run_with`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from sparcli.core.render import Rendered
|
|
21
|
+
from sparcli.core.style import Style
|
|
22
|
+
from sparcli.core.terminal import is_input_tty
|
|
23
|
+
from sparcli.core.text import Line, Span
|
|
24
|
+
from sparcli.core.theme import Theme, theme
|
|
25
|
+
from sparcli.errors import NoTerminalError
|
|
26
|
+
from sparcli.input.event import (
|
|
27
|
+
EventKind,
|
|
28
|
+
EventSource,
|
|
29
|
+
InputEvent,
|
|
30
|
+
KeyCode,
|
|
31
|
+
KeyKind,
|
|
32
|
+
KeyPress,
|
|
33
|
+
TerminalSource,
|
|
34
|
+
)
|
|
35
|
+
from sparcli.input.guard import TerminalGuard
|
|
36
|
+
from sparcli.input.line_edit import LineEditor
|
|
37
|
+
from sparcli.input.outcome import Outcome
|
|
38
|
+
from sparcli.input.prompt import Flow, run_prompt
|
|
39
|
+
from sparcli.input.shortcut import Shortcut, find, hint_line
|
|
40
|
+
|
|
41
|
+
# Default number of visible result rows.
|
|
42
|
+
DEFAULT_VISIBLE = 10
|
|
43
|
+
|
|
44
|
+
# Scoring weights for the subsequence matcher (larger is a better match).
|
|
45
|
+
_MATCH_SCORE = 16 # base reward for each matched character
|
|
46
|
+
_CONTIGUOUS_BONUS = 8 # extra reward when a match follows the previous one
|
|
47
|
+
_WORD_START_BONUS = 8 # extra reward when a match begins a word
|
|
48
|
+
|
|
49
|
+
# Space key handled specially in multi mode (else typed into the query).
|
|
50
|
+
_SPACE = KeyCode.char(" ")
|
|
51
|
+
|
|
52
|
+
# The prompt collects indices, so every flow it produces carries this type.
|
|
53
|
+
_Flow = Flow[list[int]]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def score_match(query: str, option: str) -> int | None:
|
|
57
|
+
"""
|
|
58
|
+
Scores ``option`` against a lower-cased ``query`` (order-preserving).
|
|
59
|
+
|
|
60
|
+
Matching is case-insensitive and greedy: each query character is matched
|
|
61
|
+
against the next occurrence in ``option``. Contiguous matches and matches
|
|
62
|
+
at the start of a word score higher, so tighter and word-aligned hits rank
|
|
63
|
+
above scattered ones.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
query : str
|
|
68
|
+
The already lower-cased search query; must be non-empty.
|
|
69
|
+
option : str
|
|
70
|
+
The candidate option text.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
int | None
|
|
75
|
+
The match score, or ``None`` when ``option`` does not contain every
|
|
76
|
+
query character in order.
|
|
77
|
+
"""
|
|
78
|
+
haystack = option.lower()
|
|
79
|
+
score = 0
|
|
80
|
+
query_index = 0
|
|
81
|
+
previous_match = -1
|
|
82
|
+
for position, character in enumerate(haystack):
|
|
83
|
+
if query_index >= len(query):
|
|
84
|
+
break
|
|
85
|
+
if character != query[query_index]:
|
|
86
|
+
continue
|
|
87
|
+
score += _character_score(haystack, position, previous_match)
|
|
88
|
+
previous_match = position
|
|
89
|
+
query_index += 1
|
|
90
|
+
if query_index < len(query):
|
|
91
|
+
return None
|
|
92
|
+
return score
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _character_score(haystack: str, position: int, previous_match: int) -> int:
|
|
96
|
+
"""Scores a single matched character with its contiguity/word bonuses."""
|
|
97
|
+
score = _MATCH_SCORE
|
|
98
|
+
if previous_match >= 0 and position == previous_match + 1:
|
|
99
|
+
score += _CONTIGUOUS_BONUS
|
|
100
|
+
if _is_word_start(haystack, position):
|
|
101
|
+
score += _WORD_START_BONUS
|
|
102
|
+
return score
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_word_start(haystack: str, position: int) -> bool:
|
|
106
|
+
"""Returns whether ``position`` begins a word in ``haystack``."""
|
|
107
|
+
if position == 0:
|
|
108
|
+
return True
|
|
109
|
+
return not haystack[position - 1].isalnum()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _State:
|
|
113
|
+
"""The mutable state of a running fuzzy prompt."""
|
|
114
|
+
|
|
115
|
+
__slots__ = ("query", "filtered", "cursor", "offset", "checked")
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
query: LineEditor,
|
|
120
|
+
filtered: list[int],
|
|
121
|
+
checked: list[bool],
|
|
122
|
+
) -> None:
|
|
123
|
+
self.query = query
|
|
124
|
+
self.filtered = filtered
|
|
125
|
+
self.cursor = 0
|
|
126
|
+
self.offset = 0
|
|
127
|
+
self.checked = checked
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FuzzySelect:
|
|
131
|
+
"""
|
|
132
|
+
An inline fuzzy-select prompt with a live query filter.
|
|
133
|
+
|
|
134
|
+
Attributes
|
|
135
|
+
----------
|
|
136
|
+
prompt : str
|
|
137
|
+
The label shown before the query field.
|
|
138
|
+
options : Iterable[str]
|
|
139
|
+
The candidate rows to filter.
|
|
140
|
+
max_visible : int
|
|
141
|
+
The maximum number of result rows shown at once.
|
|
142
|
+
multi : bool
|
|
143
|
+
Whether checkboxes and multi-selection are enabled.
|
|
144
|
+
shortcuts : Iterable[Shortcut]
|
|
145
|
+
Custom shortcuts shown in the footer hint line.
|
|
146
|
+
initial_query : str
|
|
147
|
+
The query the prompt starts with.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
__slots__ = (
|
|
151
|
+
"_prompt",
|
|
152
|
+
"_options",
|
|
153
|
+
"_max_visible",
|
|
154
|
+
"_multi",
|
|
155
|
+
"_shortcuts",
|
|
156
|
+
"_initial_query",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
prompt: str,
|
|
162
|
+
*,
|
|
163
|
+
options: Iterable[str] = (),
|
|
164
|
+
max_visible: int = DEFAULT_VISIBLE,
|
|
165
|
+
multi: bool = False,
|
|
166
|
+
shortcuts: Iterable[Shortcut] = (),
|
|
167
|
+
initial_query: str = "",
|
|
168
|
+
) -> None:
|
|
169
|
+
self._prompt = prompt
|
|
170
|
+
self._options = list(options)
|
|
171
|
+
self._max_visible = max(max_visible, 1)
|
|
172
|
+
self._multi = multi
|
|
173
|
+
self._shortcuts = list(shortcuts)
|
|
174
|
+
self._initial_query = initial_query
|
|
175
|
+
|
|
176
|
+
def options(self, options: Iterable[str]) -> FuzzySelect:
|
|
177
|
+
"""Sets the candidate rows and returns ``self``."""
|
|
178
|
+
self._options = list(options)
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def multi(self) -> FuzzySelect:
|
|
182
|
+
"""Enables multi-selection with checkboxes and returns ``self``."""
|
|
183
|
+
self._multi = True
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def max_visible(self, rows: int) -> FuzzySelect:
|
|
187
|
+
"""Sets the maximum number of result rows and returns ``self``."""
|
|
188
|
+
self._max_visible = max(rows, 1)
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def query(self, query: str) -> FuzzySelect:
|
|
192
|
+
"""Pre-fills the search query and returns ``self``."""
|
|
193
|
+
self._initial_query = query
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def shortcuts(self, shortcuts: Iterable[Shortcut]) -> FuzzySelect:
|
|
197
|
+
"""Registers custom shortcuts and returns ``self``."""
|
|
198
|
+
self._shortcuts = list(shortcuts)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def run(self) -> Outcome[int]:
|
|
202
|
+
"""
|
|
203
|
+
Runs a single-select fuzzy prompt and returns the chosen index.
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
Outcome[int]
|
|
208
|
+
The submitted index, a cancellation, or a fired shortcut.
|
|
209
|
+
|
|
210
|
+
Raises
|
|
211
|
+
------
|
|
212
|
+
NoTerminalError
|
|
213
|
+
If standard input or output is not an interactive terminal.
|
|
214
|
+
"""
|
|
215
|
+
return _first_index(self._run_collect())
|
|
216
|
+
|
|
217
|
+
def run_multi(self) -> Outcome[list[int]]:
|
|
218
|
+
"""
|
|
219
|
+
Runs a multi-select fuzzy prompt and returns all checked indices.
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
Outcome[list[int]]
|
|
224
|
+
The submitted indices, a cancellation, or a fired shortcut.
|
|
225
|
+
|
|
226
|
+
Raises
|
|
227
|
+
------
|
|
228
|
+
NoTerminalError
|
|
229
|
+
If standard input or output is not an interactive terminal.
|
|
230
|
+
"""
|
|
231
|
+
return self._run_collect()
|
|
232
|
+
|
|
233
|
+
def run_with(self, source: EventSource) -> Outcome[list[int]]:
|
|
234
|
+
"""
|
|
235
|
+
Runs the prompt against ``source`` and returns the checked indices.
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
source : EventSource
|
|
240
|
+
The event source driving the prompt (a fake in tests).
|
|
241
|
+
|
|
242
|
+
Returns
|
|
243
|
+
-------
|
|
244
|
+
Outcome[list[int]]
|
|
245
|
+
The submitted indices, a cancellation, or a fired shortcut.
|
|
246
|
+
"""
|
|
247
|
+
state = self._initial_state()
|
|
248
|
+
return run_prompt(source, state, self._render, self._handle)
|
|
249
|
+
|
|
250
|
+
def frame(self) -> Rendered:
|
|
251
|
+
"""Renders the opening frame without running the prompt."""
|
|
252
|
+
return self._render(self._initial_state(), False)
|
|
253
|
+
|
|
254
|
+
def _run_collect(self) -> Outcome[list[int]]:
|
|
255
|
+
"""Sets up the terminal and runs the prompt loop."""
|
|
256
|
+
if not is_input_tty():
|
|
257
|
+
raise NoTerminalError
|
|
258
|
+
with TerminalGuard():
|
|
259
|
+
return self.run_with(TerminalSource())
|
|
260
|
+
|
|
261
|
+
def _initial_state(self) -> _State:
|
|
262
|
+
"""Builds the starting state, applying the initial query."""
|
|
263
|
+
return _State(
|
|
264
|
+
query=LineEditor(self._initial_query),
|
|
265
|
+
filtered=self._filter(self._initial_query),
|
|
266
|
+
checked=[False] * len(self._options),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _render(self, state: _State, final_frame: bool) -> Rendered:
|
|
270
|
+
"""Builds the frame: the query field plus the filtered results."""
|
|
271
|
+
active = theme()
|
|
272
|
+
lines = [_query_line(self._prompt, state, active, final_frame)]
|
|
273
|
+
end = min(state.offset + self._max_visible, len(state.filtered))
|
|
274
|
+
for row in range(state.offset, end):
|
|
275
|
+
lines.append(self._result_line(state, row, active))
|
|
276
|
+
if not final_frame and self._shortcuts:
|
|
277
|
+
lines.append(hint_line(self._shortcuts))
|
|
278
|
+
return Rendered(lines)
|
|
279
|
+
|
|
280
|
+
def _result_line(self, state: _State, row: int, active: Theme) -> Line:
|
|
281
|
+
"""Renders one result row at filtered position ``row``."""
|
|
282
|
+
option_index = state.filtered[row]
|
|
283
|
+
is_cursor = row == state.cursor
|
|
284
|
+
marker = active.cursor_marker() if is_cursor else active.marker()
|
|
285
|
+
spans = [Span.styled(marker, active.selection)]
|
|
286
|
+
if self._multi:
|
|
287
|
+
checkbox = (
|
|
288
|
+
active.checkbox_on()
|
|
289
|
+
if state.checked[option_index]
|
|
290
|
+
else active.checkbox_off()
|
|
291
|
+
)
|
|
292
|
+
spans.append(Span.raw(checkbox))
|
|
293
|
+
style = active.selection if is_cursor else Style.new()
|
|
294
|
+
spans.append(Span.styled(self._options[option_index], style))
|
|
295
|
+
return Line(spans)
|
|
296
|
+
|
|
297
|
+
def _handle(self, state: _State, event: InputEvent) -> _Flow:
|
|
298
|
+
"""Handles one input event."""
|
|
299
|
+
if event.kind is EventKind.PASTE and event.text is not None:
|
|
300
|
+
state.query.insert_str(event.text)
|
|
301
|
+
self._refilter(state)
|
|
302
|
+
return _Flow.cont()
|
|
303
|
+
if event.kind is EventKind.KEY and event.key is not None:
|
|
304
|
+
return self._handle_key(state, event.key)
|
|
305
|
+
return _Flow.cont()
|
|
306
|
+
|
|
307
|
+
def _handle_key(self, state: _State, key: KeyPress) -> _Flow:
|
|
308
|
+
"""Handles a single key press."""
|
|
309
|
+
shortcut_id = find(key, self._shortcuts)
|
|
310
|
+
if shortcut_id is not None:
|
|
311
|
+
return _Flow.shortcut(shortcut_id)
|
|
312
|
+
if key.code == KeyCode.ESC:
|
|
313
|
+
return _Flow.cancel()
|
|
314
|
+
if key.code == KeyCode.ENTER:
|
|
315
|
+
return self._submit(state)
|
|
316
|
+
self._edit_or_move(state, key.code)
|
|
317
|
+
return _Flow.cont()
|
|
318
|
+
|
|
319
|
+
def _edit_or_move(self, state: _State, code: KeyCode) -> None:
|
|
320
|
+
"""Applies a navigation or query-editing key to the state."""
|
|
321
|
+
if code == KeyCode.UP:
|
|
322
|
+
self._move_cursor(state, -1)
|
|
323
|
+
elif code == KeyCode.DOWN:
|
|
324
|
+
self._move_cursor(state, 1)
|
|
325
|
+
elif code == _SPACE and self._multi:
|
|
326
|
+
self._toggle(state)
|
|
327
|
+
elif code == KeyCode.BACKSPACE:
|
|
328
|
+
state.query.backspace()
|
|
329
|
+
self._refilter(state)
|
|
330
|
+
elif code.kind is KeyKind.CHAR and code.ch is not None:
|
|
331
|
+
state.query.insert_char(code.ch)
|
|
332
|
+
self._refilter(state)
|
|
333
|
+
|
|
334
|
+
def _submit(self, state: _State) -> _Flow:
|
|
335
|
+
"""Submits the current selection when possible."""
|
|
336
|
+
if self._multi:
|
|
337
|
+
indices = [
|
|
338
|
+
index
|
|
339
|
+
for index in range(len(self._options))
|
|
340
|
+
if state.checked[index]
|
|
341
|
+
]
|
|
342
|
+
return _Flow.submit(indices)
|
|
343
|
+
if 0 <= state.cursor < len(state.filtered):
|
|
344
|
+
return _Flow.submit([state.filtered[state.cursor]])
|
|
345
|
+
return _Flow.cont()
|
|
346
|
+
|
|
347
|
+
def _toggle(self, state: _State) -> None:
|
|
348
|
+
"""Toggles the checkbox of the row under the cursor."""
|
|
349
|
+
if 0 <= state.cursor < len(state.filtered):
|
|
350
|
+
option_index = state.filtered[state.cursor]
|
|
351
|
+
state.checked[option_index] = not state.checked[option_index]
|
|
352
|
+
|
|
353
|
+
def _refilter(self, state: _State) -> None:
|
|
354
|
+
"""Recomputes the filtered list and resets the cursor to the top."""
|
|
355
|
+
state.filtered = self._filter(state.query.value())
|
|
356
|
+
state.cursor = 0
|
|
357
|
+
state.offset = 0
|
|
358
|
+
|
|
359
|
+
def _move_cursor(self, state: _State, delta: int) -> None:
|
|
360
|
+
"""Moves the cursor within the results, keeping it visible."""
|
|
361
|
+
length = len(state.filtered)
|
|
362
|
+
if length == 0:
|
|
363
|
+
return
|
|
364
|
+
state.cursor = (state.cursor + delta) % length
|
|
365
|
+
if state.cursor < state.offset:
|
|
366
|
+
state.offset = state.cursor
|
|
367
|
+
elif state.cursor >= state.offset + self._max_visible:
|
|
368
|
+
state.offset = state.cursor + 1 - self._max_visible
|
|
369
|
+
|
|
370
|
+
def _filter(self, query: str) -> list[int]:
|
|
371
|
+
"""Filters and ranks options for ``query`` (original order if empty)."""
|
|
372
|
+
if not query:
|
|
373
|
+
return list(range(len(self._options)))
|
|
374
|
+
query_lower = query.lower()
|
|
375
|
+
scored: list[tuple[int, int]] = []
|
|
376
|
+
for index, option in enumerate(self._options):
|
|
377
|
+
score = score_match(query_lower, option)
|
|
378
|
+
if score is not None:
|
|
379
|
+
scored.append((index, score))
|
|
380
|
+
scored.sort(key=lambda item: (-item[1], item[0]))
|
|
381
|
+
return [index for index, _ in scored]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _query_line(
|
|
385
|
+
prompt: str, state: _State, active: Theme, final_frame: bool
|
|
386
|
+
) -> Line:
|
|
387
|
+
"""Builds the query input line (the cursor is hidden on the final frame)."""
|
|
388
|
+
spans = [Span.styled(f"{prompt} ", active.title)]
|
|
389
|
+
spans.append(Span.raw(state.query.value()))
|
|
390
|
+
if not final_frame:
|
|
391
|
+
spans.append(Span.styled(" ", active.cursor))
|
|
392
|
+
return Line(spans)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _first_index(outcome: Outcome[list[int]]) -> Outcome[int]:
|
|
396
|
+
"""Reduces a collected outcome to its first index (single-select)."""
|
|
397
|
+
if outcome.is_shortcut:
|
|
398
|
+
return Outcome.shortcut(outcome.shortcut_id or 0)
|
|
399
|
+
if outcome.is_submitted and outcome.value:
|
|
400
|
+
return Outcome.submitted(outcome.value[0])
|
|
401
|
+
return Outcome.cancelled()
|
sparcli/input/guard.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.guard
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines :class:`TerminalGuard`, an RAII context manager for raw mode.
|
|
6
|
+
|
|
7
|
+
Entering the guard puts the terminal into raw mode and (best effort) enables
|
|
8
|
+
bracketed paste; leaving it - even on an exception - restores cooked mode and
|
|
9
|
+
disables bracketed paste. Restore failures are logged, never raised, so a
|
|
10
|
+
cancelled or crashing prompt cannot leave the terminal wedged. Off a TTY or on
|
|
11
|
+
platforms without ``termios`` the guard degrades to a harmless no-op.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
|
|
20
|
+
from sparcli.core import cursor
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_ENABLE_PASTE = "\x1b[?2004h"
|
|
25
|
+
_DISABLE_PASTE = "\x1b[?2004l"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TerminalGuard:
|
|
29
|
+
"""Enables raw mode for its lifetime and restores the terminal on exit."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_fd", "_saved", "_paste")
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._fd: int = -1
|
|
35
|
+
self._saved: list[int | list[bytes | int]] | None = None
|
|
36
|
+
self._paste: bool = False
|
|
37
|
+
|
|
38
|
+
def __enter__(self) -> TerminalGuard:
|
|
39
|
+
"""Enables raw mode and bracketed paste, best effort."""
|
|
40
|
+
self._enable()
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(
|
|
44
|
+
self,
|
|
45
|
+
exc_type: type[BaseException] | None,
|
|
46
|
+
exc: BaseException | None,
|
|
47
|
+
traceback: TracebackType | None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Restores cooked mode and disables bracketed paste."""
|
|
50
|
+
self._restore()
|
|
51
|
+
|
|
52
|
+
def _enable(self) -> None:
|
|
53
|
+
"""Puts the terminal into raw mode and hides the cursor."""
|
|
54
|
+
if sys.platform != "win32":
|
|
55
|
+
self._enable_raw()
|
|
56
|
+
self._enable_paste()
|
|
57
|
+
cursor.hide()
|
|
58
|
+
|
|
59
|
+
def _enable_raw(self) -> None:
|
|
60
|
+
"""Enables POSIX raw mode, saving the previous terminal settings."""
|
|
61
|
+
import termios # available on every non-win32 platform
|
|
62
|
+
import tty
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self._fd = sys.stdin.fileno()
|
|
66
|
+
self._saved = termios.tcgetattr(self._fd)
|
|
67
|
+
tty.setraw(self._fd)
|
|
68
|
+
except (OSError, ValueError, termios.error) as error:
|
|
69
|
+
self._saved = None
|
|
70
|
+
logger.warning("could not enable raw mode: %s", error)
|
|
71
|
+
|
|
72
|
+
def _enable_paste(self) -> None:
|
|
73
|
+
"""Requests bracketed paste from the terminal, best effort."""
|
|
74
|
+
try:
|
|
75
|
+
sys.stdout.write(_ENABLE_PASTE)
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
self._paste = True
|
|
78
|
+
except (OSError, ValueError) as error:
|
|
79
|
+
logger.warning("could not enable bracketed paste: %s", error)
|
|
80
|
+
|
|
81
|
+
def _restore(self) -> None:
|
|
82
|
+
"""Restores cooked mode, disables bracketed paste, shows the cursor."""
|
|
83
|
+
cursor.show()
|
|
84
|
+
if self._paste:
|
|
85
|
+
self._disable_paste()
|
|
86
|
+
if self._saved is None:
|
|
87
|
+
return
|
|
88
|
+
import termios # available on every non-win32 platform
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved)
|
|
92
|
+
except (OSError, ValueError, termios.error) as error:
|
|
93
|
+
logger.warning(
|
|
94
|
+
"could not restore terminal from raw mode: %s", error
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _disable_paste(self) -> None:
|
|
98
|
+
"""Disables bracketed paste, best effort."""
|
|
99
|
+
try:
|
|
100
|
+
sys.stdout.write(_DISABLE_PASTE)
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
except (OSError, ValueError) as error:
|
|
103
|
+
logger.warning("could not disable bracketed paste: %s", error)
|
sparcli/input/history.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.input.history
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Defines :class:`History`, a bounded list of past input lines.
|
|
6
|
+
|
|
7
|
+
A history collapses blanks and consecutive duplicates, caps its length, and can
|
|
8
|
+
persist to a file under the platform state directory (``XDG_STATE_HOME``,
|
|
9
|
+
``~/.local/state`` or ``%LOCALAPPDATA%``). Persistence is best effort: I/O
|
|
10
|
+
errors on load or save are logged and swallowed rather than raised, so a prompt
|
|
11
|
+
never crashes because its history file is unreadable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Default maximum number of retained entries.
|
|
24
|
+
DEFAULT_MAX = 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class History:
|
|
28
|
+
"""A bounded list of past input lines, optionally backed by a file."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_entries", "_max", "_path", "_keep_duplicates")
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._entries: list[str] = []
|
|
34
|
+
self._max: int = DEFAULT_MAX
|
|
35
|
+
self._path: Path | None = None
|
|
36
|
+
self._keep_duplicates: bool = False
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def for_app(cls, app: str) -> History:
|
|
40
|
+
"""
|
|
41
|
+
Returns a history persisted under the app's state directory.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
app : str
|
|
46
|
+
The application name, used as the state subdirectory.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
History
|
|
51
|
+
A history whose backing file is ``<state>/<app>/history``.
|
|
52
|
+
"""
|
|
53
|
+
history = cls()
|
|
54
|
+
directory = _state_dir()
|
|
55
|
+
if directory is not None:
|
|
56
|
+
history._path = directory / app / "history"
|
|
57
|
+
return history
|
|
58
|
+
|
|
59
|
+
def max_entries(self, maximum: int) -> History:
|
|
60
|
+
"""Sets the maximum number of entries and returns ``self``."""
|
|
61
|
+
self._max = max(maximum, 1)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def keep_duplicates(self) -> History:
|
|
65
|
+
"""Keeps consecutive duplicate entries instead of collapsing them."""
|
|
66
|
+
self._keep_duplicates = True
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def entries(self) -> list[str]:
|
|
70
|
+
"""Returns the entries, oldest first."""
|
|
71
|
+
return list(self._entries)
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
"""Returns the number of entries."""
|
|
75
|
+
return len(self._entries)
|
|
76
|
+
|
|
77
|
+
def add(self, line: str) -> None:
|
|
78
|
+
"""Adds a line, skipping blanks and (by default) consecutive dups."""
|
|
79
|
+
if not line.strip():
|
|
80
|
+
return
|
|
81
|
+
if (
|
|
82
|
+
not self._keep_duplicates
|
|
83
|
+
and self._entries
|
|
84
|
+
and self._entries[-1] == line
|
|
85
|
+
):
|
|
86
|
+
return
|
|
87
|
+
self._entries.append(line)
|
|
88
|
+
overflow = len(self._entries) - self._max
|
|
89
|
+
if overflow > 0:
|
|
90
|
+
del self._entries[:overflow]
|
|
91
|
+
|
|
92
|
+
def load(self) -> None:
|
|
93
|
+
"""Loads entries from the backing file, if configured."""
|
|
94
|
+
if self._path is None or not self._path.exists():
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
contents = self._path.read_text(encoding="utf-8")
|
|
98
|
+
except OSError as error:
|
|
99
|
+
logger.warning("could not read history file: %s", error)
|
|
100
|
+
return
|
|
101
|
+
self._entries = _split_lines(contents)
|
|
102
|
+
|
|
103
|
+
def save(self) -> None:
|
|
104
|
+
"""Saves entries to the backing file, creating directories as needed."""
|
|
105
|
+
if self._path is None:
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
_atomic_write(self._path, "\n".join(self._entries))
|
|
110
|
+
except OSError as error:
|
|
111
|
+
logger.warning("could not write history file: %s", error)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
115
|
+
"""Writes ``content`` to ``path`` atomically via a temp file and rename.
|
|
116
|
+
|
|
117
|
+
A crash or a concurrent writer can never leave a half-written history
|
|
118
|
+
file: the content is written to a sibling temp file and then renamed over
|
|
119
|
+
the target in a single step.
|
|
120
|
+
"""
|
|
121
|
+
handle, temp = tempfile.mkstemp(
|
|
122
|
+
dir=path.parent, prefix=f"{path.name}.", suffix=".tmp"
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
126
|
+
stream.write(content)
|
|
127
|
+
os.replace(temp, path)
|
|
128
|
+
except OSError:
|
|
129
|
+
_remove_quietly(temp)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _remove_quietly(path: str) -> None:
|
|
134
|
+
"""Removes ``path``, ignoring a missing file."""
|
|
135
|
+
try:
|
|
136
|
+
os.unlink(path)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _split_lines(text: str) -> list[str]:
|
|
142
|
+
"""Splits ``text`` into lines the way Rust's ``str::lines`` does."""
|
|
143
|
+
if not text:
|
|
144
|
+
return []
|
|
145
|
+
parts = text.split("\n")
|
|
146
|
+
if parts and parts[-1] == "":
|
|
147
|
+
parts.pop()
|
|
148
|
+
return [part.removesuffix("\r") for part in parts]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _state_dir() -> Path | None:
|
|
152
|
+
"""Resolves the platform state directory for persisted history."""
|
|
153
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
154
|
+
if xdg:
|
|
155
|
+
return Path(xdg)
|
|
156
|
+
if os.name == "nt":
|
|
157
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
158
|
+
return Path(local) if local else None
|
|
159
|
+
home = os.environ.get("HOME")
|
|
160
|
+
return Path(home) / ".local" / "state" if home else None
|