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.
Files changed (61) hide show
  1. py_sparcli-0.3.0.dist-info/METADATA +257 -0
  2. py_sparcli-0.3.0.dist-info/RECORD +61 -0
  3. py_sparcli-0.3.0.dist-info/WHEEL +4 -0
  4. py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  5. sparcli/__init__.py +184 -0
  6. sparcli/core/__init__.py +92 -0
  7. sparcli/core/border.py +160 -0
  8. sparcli/core/color.py +306 -0
  9. sparcli/core/cursor.py +59 -0
  10. sparcli/core/geometry.py +141 -0
  11. sparcli/core/markup.py +215 -0
  12. sparcli/core/render.py +245 -0
  13. sparcli/core/style.py +128 -0
  14. sparcli/core/terminal.py +127 -0
  15. sparcli/core/text.py +143 -0
  16. sparcli/core/theme.py +117 -0
  17. sparcli/core/width.py +244 -0
  18. sparcli/errors.py +38 -0
  19. sparcli/input/__init__.py +42 -0
  20. sparcli/input/calc.py +214 -0
  21. sparcli/input/confirm.py +188 -0
  22. sparcli/input/datepicker.py +298 -0
  23. sparcli/input/editor.py +189 -0
  24. sparcli/input/event.py +551 -0
  25. sparcli/input/field.py +90 -0
  26. sparcli/input/fuzzy.py +401 -0
  27. sparcli/input/guard.py +103 -0
  28. sparcli/input/history.py +160 -0
  29. sparcli/input/line_edit.py +229 -0
  30. sparcli/input/number.py +232 -0
  31. sparcli/input/outcome.py +107 -0
  32. sparcli/input/password.py +233 -0
  33. sparcli/input/prompt.py +134 -0
  34. sparcli/input/select.py +336 -0
  35. sparcli/input/shortcut.py +123 -0
  36. sparcli/input/text.py +564 -0
  37. sparcli/input/textarea.py +242 -0
  38. sparcli/input/validate.py +67 -0
  39. sparcli/output/__init__.py +56 -0
  40. sparcli/output/alert.py +107 -0
  41. sparcli/output/badge.py +82 -0
  42. sparcli/output/box.py +196 -0
  43. sparcli/output/columns.py +157 -0
  44. sparcli/output/compose.py +111 -0
  45. sparcli/output/diff.py +229 -0
  46. sparcli/output/kv.py +175 -0
  47. sparcli/output/layout.py +77 -0
  48. sparcli/output/list.py +274 -0
  49. sparcli/output/live.py +129 -0
  50. sparcli/output/multiprogress.py +73 -0
  51. sparcli/output/pager.py +83 -0
  52. sparcli/output/panel.py +125 -0
  53. sparcli/output/progress.py +233 -0
  54. sparcli/output/rule.py +134 -0
  55. sparcli/output/spinner.py +138 -0
  56. sparcli/output/table/__init__.py +17 -0
  57. sparcli/output/table/plan.py +198 -0
  58. sparcli/output/table/render.py +302 -0
  59. sparcli/output/table/table.py +351 -0
  60. sparcli/output/tree.py +157 -0
  61. 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)
@@ -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