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
@@ -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))
@@ -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")