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,42 @@
1
+ """
2
+ sparcli.input
3
+ ============
4
+
5
+ Interactive single-input prompts driven by an :class:`EventSource`.
6
+
7
+ Every prompt exposes ``run()`` (real terminal), ``run_with(source)`` (the
8
+ dependency-injection seam used by tests) and ``frame()`` (a static preview).
9
+ Prompts return an :class:`~sparcli.input.outcome.Outcome`. This package
10
+ re-exports the prompt classes and the command :class:`History`; the grouped
11
+ helpers stay reachable as the ``event``, ``validate`` and ``shortcut``
12
+ submodules.
13
+ """
14
+
15
+ from sparcli.input.confirm import Confirm
16
+ from sparcli.input.datepicker import DatePicker
17
+ from sparcli.input.editor import edit_file
18
+ from sparcli.input.fuzzy import FuzzySelect
19
+ from sparcli.input.history import History
20
+ from sparcli.input.number import NumberInput
21
+ from sparcli.input.outcome import Outcome, OutcomeKind
22
+ from sparcli.input.password import PasswordInput
23
+ from sparcli.input.select import Select
24
+ from sparcli.input.shortcut import Shortcut
25
+ from sparcli.input.text import TextInput
26
+ from sparcli.input.textarea import Textarea
27
+
28
+ __all__ = [
29
+ "Confirm",
30
+ "DatePicker",
31
+ "FuzzySelect",
32
+ "History",
33
+ "NumberInput",
34
+ "Outcome",
35
+ "OutcomeKind",
36
+ "PasswordInput",
37
+ "Select",
38
+ "Shortcut",
39
+ "TextInput",
40
+ "Textarea",
41
+ "edit_file",
42
+ ]
sparcli/input/calc.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ sparcli.input.calc
3
+ ==================
4
+
5
+ Defines the calculator-mode expression evaluator behind ``NumberInput``.
6
+
7
+ :func:`evaluate` is a small recursive-descent parser for the four arithmetic
8
+ operators, parentheses and unary minus, with the usual precedence.
9
+ :func:`parse_number` reads a single number for the plain (non-calculator)
10
+ submit path. Both accept ``,`` or ``.`` as the decimal separator and report
11
+ failures through :class:`CalcError`, whose message becomes the prompt's error
12
+ line. This module is an implementation detail, not part of the public API.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+
19
+
20
+ class CalcErrorKind(enum.Enum):
21
+ """The reason a numeric expression could not be evaluated."""
22
+
23
+ NOT_A_NUMBER = "not a number"
24
+ INVALID_NUMBER = "invalid number"
25
+ DIVISION_BY_ZERO = "division by zero"
26
+ MISSING_PARENTHESIS = "missing closing parenthesis"
27
+ TRAILING_INPUT = "unexpected trailing input"
28
+
29
+
30
+ class CalcError(Exception):
31
+ """
32
+ Raised when parsing or evaluating a numeric expression fails.
33
+
34
+ Attributes
35
+ ----------
36
+ kind : CalcErrorKind
37
+ Which failure occurred; its value is the human-readable message.
38
+ """
39
+
40
+ def __init__(self, kind: CalcErrorKind) -> None:
41
+ super().__init__(kind.value)
42
+ self.kind = kind
43
+
44
+
45
+ def parse_number(text: str) -> float:
46
+ """
47
+ Parses a single number, accepting ``,`` or ``.`` as the separator.
48
+
49
+ Parameters
50
+ ----------
51
+ text : str
52
+ The raw field text; surrounding whitespace is ignored.
53
+
54
+ Returns
55
+ -------
56
+ float
57
+ The parsed value.
58
+
59
+ Raises
60
+ ------
61
+ CalcError
62
+ With :attr:`CalcErrorKind.NOT_A_NUMBER` if ``text`` is empty or not a
63
+ valid number.
64
+
65
+ Examples
66
+ --------
67
+ >>> parse_number("3,5")
68
+ 3.5
69
+ """
70
+ normalized = text.strip().replace(",", ".")
71
+ if not normalized:
72
+ raise CalcError(CalcErrorKind.NOT_A_NUMBER)
73
+ try:
74
+ return float(normalized)
75
+ except ValueError:
76
+ raise CalcError(CalcErrorKind.NOT_A_NUMBER) from None
77
+
78
+
79
+ def evaluate(expr: str) -> float:
80
+ """
81
+ Evaluates an arithmetic expression with ``+ - * / ( )``.
82
+
83
+ Accepts ``,`` or ``.`` as the decimal separator and honours the usual
84
+ operator precedence and unary minus.
85
+
86
+ Parameters
87
+ ----------
88
+ expr : str
89
+ The expression to evaluate.
90
+
91
+ Returns
92
+ -------
93
+ float
94
+ The value of the expression.
95
+
96
+ Raises
97
+ ------
98
+ CalcError
99
+ On malformed input or division by zero.
100
+
101
+ Examples
102
+ --------
103
+ >>> evaluate("2 + 3 * 4")
104
+ 14.0
105
+ >>> evaluate("(2 + 3) * 4")
106
+ 20.0
107
+ """
108
+ parser = _Parser(expr.replace(",", "."))
109
+ value = parser.expression()
110
+ parser.skip_spaces()
111
+ if not parser.at_end():
112
+ raise CalcError(CalcErrorKind.TRAILING_INPUT)
113
+ return value
114
+
115
+
116
+ class _Parser:
117
+ """A minimal recursive-descent arithmetic parser over a character list."""
118
+
119
+ __slots__ = ("_chars", "_pos")
120
+
121
+ def __init__(self, text: str) -> None:
122
+ self._chars: list[str] = list(text)
123
+ self._pos: int = 0
124
+
125
+ def expression(self) -> float:
126
+ """Parses ``term (('+'|'-') term)*``."""
127
+ value = self.term()
128
+ while True:
129
+ self.skip_spaces()
130
+ operator = self._peek()
131
+ if operator == "+":
132
+ self._pos += 1
133
+ value += self.term()
134
+ elif operator == "-":
135
+ self._pos += 1
136
+ value -= self.term()
137
+ else:
138
+ return value
139
+
140
+ def term(self) -> float:
141
+ """Parses ``factor (('*'|'/') factor)*``."""
142
+ value = self.factor()
143
+ while True:
144
+ self.skip_spaces()
145
+ operator = self._peek()
146
+ if operator == "*":
147
+ self._pos += 1
148
+ value *= self.factor()
149
+ elif operator == "/":
150
+ self._pos += 1
151
+ value = self._divide(value)
152
+ else:
153
+ return value
154
+
155
+ def factor(self) -> float:
156
+ """Parses a number, a parenthesized expression or a unary minus."""
157
+ self.skip_spaces()
158
+ char = self._peek()
159
+ if char == "(":
160
+ return self._grouped()
161
+ if char == "-":
162
+ self._pos += 1
163
+ return -self.factor()
164
+ return self._number()
165
+
166
+ def skip_spaces(self) -> None:
167
+ """Advances past any run of spaces."""
168
+ while self._peek() == " ":
169
+ self._pos += 1
170
+
171
+ def at_end(self) -> bool:
172
+ """Returns whether the whole input has been consumed."""
173
+ return self._pos >= len(self._chars)
174
+
175
+ def _divide(self, value: float) -> float:
176
+ """Consumes the divisor factor and divides, guarding against zero."""
177
+ divisor = self.factor()
178
+ if divisor == 0.0:
179
+ raise CalcError(CalcErrorKind.DIVISION_BY_ZERO)
180
+ return value / divisor
181
+
182
+ def _grouped(self) -> float:
183
+ """Parses a parenthesized subexpression, requiring a closing ``)``."""
184
+ self._pos += 1
185
+ value = self.expression()
186
+ self.skip_spaces()
187
+ if self._peek() != ")":
188
+ raise CalcError(CalcErrorKind.MISSING_PARENTHESIS)
189
+ self._pos += 1
190
+ return value
191
+
192
+ def _number(self) -> float:
193
+ """Parses a decimal number literal (digits and a decimal point)."""
194
+ start = self._pos
195
+ while _is_number_char(self._peek()):
196
+ self._pos += 1
197
+ text = "".join(self._chars[start : self._pos])
198
+ try:
199
+ return float(text)
200
+ except ValueError:
201
+ raise CalcError(CalcErrorKind.INVALID_NUMBER) from None
202
+
203
+ def _peek(self) -> str | None:
204
+ """Returns the current character without consuming it."""
205
+ if self._pos >= len(self._chars):
206
+ return None
207
+ return self._chars[self._pos]
208
+
209
+
210
+ def _is_number_char(char: str | None) -> bool:
211
+ """Returns whether ``char`` may appear inside a number literal."""
212
+ return char is not None and (
213
+ char.isascii() and char.isdigit() or char == "."
214
+ )
@@ -0,0 +1,188 @@
1
+ """
2
+ sparcli.input.confirm
3
+ =====================
4
+
5
+ Defines :class:`Confirm`, a yes/no confirmation prompt.
6
+
7
+ The prompt shows two options; the selected one is highlighted and the other is
8
+ dimmed. ``y``/``n`` answer directly, Enter accepts the current selection, and
9
+ the arrows, Tab, ``h`` or ``l`` toggle it. Registered
10
+ :class:`~sparcli.input.shortcut.Shortcut` keys end the prompt with a shortcut
11
+ outcome and appear in a footer hint and the ``?`` help overlay.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable
17
+
18
+ from sparcli.core.render import Rendered
19
+ from sparcli.core.style import Style
20
+ from sparcli.core.terminal import is_input_tty
21
+ from sparcli.core.text import Line, Span
22
+ from sparcli.core.theme import Theme, theme
23
+ from sparcli.errors import NoTerminalError
24
+ from sparcli.input.event import (
25
+ EventKind,
26
+ EventSource,
27
+ InputEvent,
28
+ KeyCode,
29
+ KeyPress,
30
+ TerminalSource,
31
+ )
32
+ from sparcli.input.guard import TerminalGuard
33
+ from sparcli.input.outcome import Outcome
34
+ from sparcli.input.prompt import Flow, run_prompt
35
+ from sparcli.input.shortcut import Shortcut, find, help_overlay, hint_line
36
+
37
+ # Keys that toggle the current selection.
38
+ _TOGGLE_CODES = frozenset(
39
+ {
40
+ KeyCode.LEFT,
41
+ KeyCode.RIGHT,
42
+ KeyCode.TAB,
43
+ KeyCode.char("h"),
44
+ KeyCode.char("l"),
45
+ }
46
+ )
47
+
48
+
49
+ class _State:
50
+ """The mutable state of a running confirm prompt."""
51
+
52
+ __slots__ = ("yes", "help")
53
+
54
+ def __init__(self, yes: bool) -> None:
55
+ self.yes = yes
56
+ self.help = False
57
+
58
+
59
+ class Confirm:
60
+ """A yes/no confirmation prompt with custom labels and shortcuts."""
61
+
62
+ __slots__ = (
63
+ "_question",
64
+ "_default_yes",
65
+ "_yes_label",
66
+ "_no_label",
67
+ "_shortcuts",
68
+ )
69
+
70
+ def __init__(
71
+ self,
72
+ question: str = "",
73
+ *,
74
+ default_yes: bool = False,
75
+ yes_label: str = "Yes",
76
+ no_label: str = "No",
77
+ shortcuts: Iterable[Shortcut] | None = None,
78
+ ) -> None:
79
+ self._question = question
80
+ self._default_yes = default_yes
81
+ self._yes_label = yes_label
82
+ self._no_label = no_label
83
+ self._shortcuts: list[Shortcut] = (
84
+ list(shortcuts) if shortcuts is not None else []
85
+ )
86
+
87
+ def shortcuts(self, shortcuts: Iterable[Shortcut]) -> Confirm:
88
+ """Registers footer/overlay shortcuts and returns ``self``."""
89
+ self._shortcuts = list(shortcuts)
90
+ return self
91
+
92
+ def default_yes(self) -> Confirm:
93
+ """Sets the initial selection to "Yes" and returns ``self``."""
94
+ self._default_yes = True
95
+ return self
96
+
97
+ def labels(self, yes: str, no: str) -> Confirm:
98
+ """Sets custom labels for the two options and returns ``self``."""
99
+ self._yes_label = yes
100
+ self._no_label = no
101
+ return self
102
+
103
+ def run(self) -> Outcome[bool]:
104
+ """
105
+ Runs the prompt on the real terminal.
106
+
107
+ Returns
108
+ -------
109
+ Outcome[bool]
110
+ The chosen answer, a cancellation or a fired shortcut.
111
+
112
+ Raises
113
+ ------
114
+ NoTerminalError
115
+ If there is no interactive terminal.
116
+ """
117
+ if not is_input_tty():
118
+ raise NoTerminalError()
119
+ with TerminalGuard():
120
+ return self.run_with(TerminalSource())
121
+
122
+ def run_with(self, source: EventSource) -> Outcome[bool]:
123
+ """Runs the prompt against any event source (used for tests)."""
124
+ state = _State(self._default_yes)
125
+ return run_prompt(source, state, self._render, self._handle)
126
+
127
+ def frame(self) -> Rendered:
128
+ """Renders the prompt's static frame without running it."""
129
+ state = _State(self._default_yes)
130
+ return self._render(state, False)
131
+
132
+ def _render(self, state: _State, final: bool) -> Rendered:
133
+ """Builds the prompt frame for the current selection."""
134
+ active = theme()
135
+ if state.help:
136
+ return Rendered(help_overlay(self._shortcuts))
137
+ spans = [
138
+ Span.raw(f"{self._question} "),
139
+ _option_span(self._yes_label, state.yes, active),
140
+ Span.raw(" "),
141
+ _option_span(self._no_label, not state.yes, active),
142
+ ]
143
+ lines = [Line(spans)]
144
+ if self._shortcuts:
145
+ lines.append(hint_line(self._shortcuts))
146
+ return Rendered(lines)
147
+
148
+ def _handle(self, state: _State, event: InputEvent) -> Flow[bool]:
149
+ """Handles one input event."""
150
+ if event.kind is not EventKind.KEY or event.key is None:
151
+ return Flow[bool].cont()
152
+ return self._handle_key(state, event.key)
153
+
154
+ def _handle_key(self, state: _State, key: KeyPress) -> Flow[bool]:
155
+ """Handles a single key press."""
156
+ if state.help:
157
+ state.help = False
158
+ return Flow[bool].cont()
159
+ if key.is_ctrl("c"):
160
+ return Flow[bool].cancel()
161
+ if key.code == KeyCode.char("?") and self._shortcuts:
162
+ state.help = True
163
+ return Flow[bool].cont()
164
+ fired = find(key, self._shortcuts)
165
+ if fired is not None:
166
+ return Flow[bool].shortcut(fired)
167
+ return self._decide(state, key.code)
168
+
169
+ def _decide(self, state: _State, code: KeyCode) -> Flow[bool]:
170
+ """Applies a selection, submission or cancellation key."""
171
+ if code == KeyCode.ESC:
172
+ return Flow[bool].cancel()
173
+ if code == KeyCode.ENTER:
174
+ return Flow[bool].submit(state.yes)
175
+ if code in (KeyCode.char("y"), KeyCode.char("Y")):
176
+ return Flow[bool].submit(True)
177
+ if code in (KeyCode.char("n"), KeyCode.char("N")):
178
+ return Flow[bool].submit(False)
179
+ if code in _TOGGLE_CODES:
180
+ state.yes = not state.yes
181
+ return Flow[bool].cont()
182
+
183
+
184
+ def _option_span(label: str, selected: bool, active: Theme) -> Span:
185
+ """Renders one option, highlighted when selected and dimmed otherwise."""
186
+ if selected:
187
+ return Span.styled(f"[{label}]", active.selection)
188
+ return Span.styled(f" {label} ", Style.new().dim())