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,351 @@
1
+ """
2
+ sparcli.output.table.table
3
+ ===========================
4
+
5
+ Defines the public table classes :class:`Table`, :class:`Column` and
6
+ :class:`Cell`.
7
+
8
+ A :class:`Table` lays out rows of :class:`Cell` values under a set of
9
+ :class:`Column` definitions, honoring per-column alignment, width constraints
10
+ and word wrap, plus zebra striping, a title, footer rows and horizontal or
11
+ vertical cell spanning. Each class offers both a keyword constructor and fluent
12
+ builder methods, so ``Column("Name").align(Align.RIGHT)`` reads like the Rust
13
+ original. The heavy lifting lives in :mod:`sparcli.output.table.plan` (grid and
14
+ width computation) and :mod:`sparcli.output.table.render` (line assembly).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterable
20
+ from dataclasses import dataclass, field
21
+
22
+ from sparcli.core.border import BorderType
23
+ from sparcli.core.geometry import Align
24
+ from sparcli.core.render import Renderable, Rendered
25
+ from sparcli.core.style import Style
26
+ from sparcli.core.text import IntoText, Text, into_text
27
+ from sparcli.core.theme import theme
28
+
29
+
30
+ class Column:
31
+ """
32
+ A single table column definition.
33
+
34
+ Attributes
35
+ ----------
36
+ header : Text
37
+ The column header text.
38
+ alignment : Align
39
+ Horizontal alignment applied to header and body cells.
40
+ width_min : int
41
+ The minimum column width in columns.
42
+ width_max : int | None
43
+ The maximum column width, or ``None`` for unbounded.
44
+ width_fixed : int | None
45
+ A fixed column width that overrides the natural width.
46
+ wrapping : bool
47
+ Whether overflow reflows onto more lines instead of truncating.
48
+ """
49
+
50
+ __slots__ = (
51
+ "header",
52
+ "alignment",
53
+ "width_min",
54
+ "width_max",
55
+ "width_fixed",
56
+ "wrapping",
57
+ )
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ header: IntoText = "",
63
+ alignment: Align = Align.LEFT,
64
+ width_min: int = 0,
65
+ width_max: int | None = None,
66
+ width_fixed: int | None = None,
67
+ wrapping: bool = False,
68
+ ) -> None:
69
+ self.header: Text = into_text(header)
70
+ self.alignment: Align = alignment
71
+ self.width_min: int = width_min
72
+ self.width_max: int | None = width_max
73
+ self.width_fixed: int | None = width_fixed
74
+ self.wrapping: bool = wrapping
75
+
76
+ @classmethod
77
+ def new(cls, header: IntoText) -> Column:
78
+ """Returns a left-aligned column with the given header."""
79
+ return cls(header=header)
80
+
81
+ def align(self, align: Align) -> Column:
82
+ """Sets the column alignment and returns the column."""
83
+ self.alignment = align
84
+ return self
85
+
86
+ def min_width(self, width: int) -> Column:
87
+ """Sets the minimum column width and returns the column."""
88
+ self.width_min = width
89
+ return self
90
+
91
+ def max_width(self, width: int) -> Column:
92
+ """Sets the maximum column width and returns the column."""
93
+ self.width_max = width
94
+ return self
95
+
96
+ def fixed_width(self, width: int) -> Column:
97
+ """Sets a fixed column width and returns the column."""
98
+ self.width_fixed = width
99
+ return self
100
+
101
+ def wrap(self) -> Column:
102
+ """Enables word wrapping instead of truncation and returns self."""
103
+ self.wrapping = True
104
+ return self
105
+
106
+
107
+ class Cell:
108
+ """
109
+ A single table cell.
110
+
111
+ Attributes
112
+ ----------
113
+ content : Text
114
+ The cell content.
115
+ alignment : Align | None
116
+ An override alignment, or ``None`` to inherit the column alignment.
117
+ span_cols : int
118
+ The number of columns the cell spans (at least ``1``).
119
+ span_rows : int
120
+ The number of rows the cell spans (at least ``1``).
121
+ """
122
+
123
+ __slots__ = ("content", "alignment", "span_cols", "span_rows")
124
+
125
+ def __init__(
126
+ self,
127
+ *,
128
+ content: IntoText = "",
129
+ alignment: Align | None = None,
130
+ span_cols: int = 1,
131
+ span_rows: int = 1,
132
+ ) -> None:
133
+ self.content: Text = into_text(content)
134
+ self.alignment: Align | None = alignment
135
+ self.span_cols: int = max(span_cols, 1)
136
+ self.span_rows: int = max(span_rows, 1)
137
+
138
+ @classmethod
139
+ def new(cls, content: IntoText) -> Cell:
140
+ """Returns a cell holding ``content`` with no span."""
141
+ return cls(content=content)
142
+
143
+ def align(self, align: Align) -> Cell:
144
+ """Overrides the cell alignment and returns the cell."""
145
+ self.alignment = align
146
+ return self
147
+
148
+ def colspan(self, columns: int) -> Cell:
149
+ """Spans the cell across ``columns`` columns and returns the cell."""
150
+ self.span_cols = max(columns, 1)
151
+ return self
152
+
153
+ def rowspan(self, rows: int) -> Cell:
154
+ """Spans the cell across ``rows`` rows and returns the cell."""
155
+ self.span_rows = max(rows, 1)
156
+ return self
157
+
158
+
159
+ IntoColumn = Column | IntoText
160
+ IntoCell = Cell | IntoText
161
+
162
+
163
+ def into_column(value: IntoColumn) -> Column:
164
+ """Coerces a string, rich text or column into a :class:`Column`."""
165
+ if isinstance(value, Column):
166
+ return value
167
+ return Column.new(value)
168
+
169
+
170
+ def into_cell(value: IntoCell) -> Cell:
171
+ """Coerces a string, rich text or cell into a :class:`Cell`."""
172
+ if isinstance(value, Cell):
173
+ return value
174
+ return Cell.new(value)
175
+
176
+
177
+ @dataclass(slots=True)
178
+ class Row:
179
+ """A table row of cells, flagged as body or footer."""
180
+
181
+ cells: list[Cell]
182
+ footer: bool
183
+
184
+
185
+ @dataclass(slots=True)
186
+ class TableOpts:
187
+ """The styling and layout options shared with the plan and renderer."""
188
+
189
+ border: BorderType = BorderType.ROUNDED
190
+ border_style: Style = field(default_factory=Style)
191
+ header: bool = True
192
+ header_style: Style = field(default_factory=Style)
193
+ striped: bool = False
194
+ stripe_style: Style = field(default_factory=lambda: Style().dim())
195
+ title: Text | None = None
196
+ title_style: Style = field(default_factory=Style)
197
+ pad: int = 1
198
+ row_separators: bool = False
199
+
200
+
201
+ @dataclass(slots=True)
202
+ class TableLayout:
203
+ """The column definitions and options handed to plan and render."""
204
+
205
+ columns: list[Column]
206
+ opts: TableOpts
207
+
208
+
209
+ class Table(Renderable):
210
+ """
211
+ A data table with headers, footers, borders, alignment and wrapping.
212
+
213
+ A table that fits the render width is left untouched; an overflowing one
214
+ shrinks its flexible columns so its borders stay within the terminal.
215
+ ``wrap`` columns reflow first, then the rest truncate; ``fixed_width``
216
+ columns never shrink and no column falls below its ``min_width``.
217
+
218
+ Examples
219
+ --------
220
+ >>> from sparcli.output.table import Table
221
+ >>> out = (
222
+ ... Table()
223
+ ... .columns(["Name", "Status"])
224
+ ... .row(["web-1", "online"])
225
+ ... .render(40)
226
+ ... )
227
+ >>> "web-1" in out.plain()
228
+ True
229
+ """
230
+
231
+ __slots__ = ("_columns", "_rows", "_opts")
232
+
233
+ def __init__(
234
+ self,
235
+ *,
236
+ border: BorderType | None = None,
237
+ border_style: Style | None = None,
238
+ header: bool = True,
239
+ header_style: Style | None = None,
240
+ striped: bool = False,
241
+ stripe_style: Style | None = None,
242
+ title: IntoText | None = None,
243
+ title_style: Style | None = None,
244
+ pad: int = 1,
245
+ row_separators: bool = False,
246
+ ) -> None:
247
+ active = theme()
248
+ self._columns: list[Column] = []
249
+ self._rows: list[Row] = []
250
+ self._opts = TableOpts(
251
+ border=border if border is not None else active.border,
252
+ border_style=(
253
+ border_style if border_style is not None else active.secondary
254
+ ),
255
+ header=header,
256
+ header_style=(
257
+ header_style if header_style is not None else active.heading
258
+ ),
259
+ striped=striped,
260
+ stripe_style=(
261
+ stripe_style if stripe_style is not None else Style().dim()
262
+ ),
263
+ title=into_text(title) if title is not None else None,
264
+ title_style=(
265
+ title_style if title_style is not None else active.heading
266
+ ),
267
+ pad=pad,
268
+ row_separators=row_separators,
269
+ )
270
+
271
+ def column(self, column: IntoColumn) -> Table:
272
+ """Adds a column and returns the table."""
273
+ self._columns.append(into_column(column))
274
+ return self
275
+
276
+ def columns(self, columns: Iterable[IntoColumn]) -> Table:
277
+ """Adds several columns from an iterable and returns the table."""
278
+ self._columns.extend(into_column(c) for c in columns)
279
+ return self
280
+
281
+ def row(self, cells: Iterable[IntoCell]) -> Table:
282
+ """Adds a data row and returns the table."""
283
+ self._rows.append(Row([into_cell(c) for c in cells], False))
284
+ return self
285
+
286
+ def footer_row(self, cells: Iterable[IntoCell]) -> Table:
287
+ """Adds a footer row (drawn after a separator) and returns the table."""
288
+ self._rows.append(Row([into_cell(c) for c in cells], True))
289
+ return self
290
+
291
+ def border(self, border: BorderType) -> Table:
292
+ """Sets the border type and returns the table."""
293
+ self._opts.border = border
294
+ return self
295
+
296
+ def border_style(self, style: Style) -> Table:
297
+ """Sets the border glyph style and returns the table."""
298
+ self._opts.border_style = style
299
+ return self
300
+
301
+ def header(self, show: bool) -> Table:
302
+ """Enables or disables the header row and returns the table."""
303
+ self._opts.header = show
304
+ return self
305
+
306
+ def header_style(self, style: Style) -> Table:
307
+ """Sets the header row text style and returns the table."""
308
+ self._opts.header_style = style
309
+ return self
310
+
311
+ def striped(self, striped: bool) -> Table:
312
+ """Enables zebra striping of body rows and returns the table."""
313
+ self._opts.striped = striped
314
+ return self
315
+
316
+ def stripe_style(self, style: Style) -> Table:
317
+ """Sets the style used for striped rows and returns the table."""
318
+ self._opts.stripe_style = style
319
+ return self
320
+
321
+ def title_style(self, style: Style) -> Table:
322
+ """Sets the title text style and returns the table."""
323
+ self._opts.title_style = style
324
+ return self
325
+
326
+ def title(self, title: IntoText) -> Table:
327
+ """Sets a table title and returns the table."""
328
+ self._opts.title = into_text(title)
329
+ return self
330
+
331
+ def pad(self, pad: int) -> Table:
332
+ """Sets the horizontal cell padding and returns the table."""
333
+ self._opts.pad = pad
334
+ return self
335
+
336
+ def row_separators(self, on: bool) -> Table:
337
+ """Draws separators between body rows and returns the table."""
338
+ self._opts.row_separators = on
339
+ return self
340
+
341
+ def render(self, max_width: int) -> Rendered:
342
+ """Renders the table into at most ``max_width`` columns."""
343
+ if not self._columns:
344
+ return Rendered.empty()
345
+ from sparcli.output.table.plan import build_plan, column_widths
346
+ from sparcli.output.table.render import Builder
347
+
348
+ layout = TableLayout(self._columns, self._opts)
349
+ grid = build_plan(self._rows, len(self._columns))
350
+ widths = column_widths(layout, grid, max_width)
351
+ return Builder(layout, grid, widths).build()
sparcli/output/tree.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ sparcli.output.tree
3
+ ===================
4
+
5
+ Defines :class:`Tree` and :class:`TreeNode`, hierarchical views drawn with
6
+ box-drawing connectors.
7
+
8
+ A tree renders each node's content on its own row, prefixed by ``├─``/``└─``
9
+ connectors taken from a border glyph set. Vertical ``│`` guides carry the branch
10
+ color down through nested descendants; they can be switched off, and the dash
11
+ count and connector style are configurable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from sparcli.core.border import BorderType
17
+ from sparcli.core.render import Renderable, Rendered
18
+ from sparcli.core.style import Style
19
+ from sparcli.core.text import IntoText, Line, Span, into_text
20
+ from sparcli.core.theme import theme
21
+
22
+ # A connector spans the branch glyph, its dashes and a trailing space.
23
+ _CONNECTOR_FIXED_CELLS = 2
24
+
25
+
26
+ class TreeNode:
27
+ """
28
+ A node in a :class:`Tree`.
29
+
30
+ Attributes
31
+ ----------
32
+ content : Text
33
+ The node's rich-text content.
34
+ children : list[TreeNode]
35
+ The node's direct children, drawn indented beneath it.
36
+ """
37
+
38
+ __slots__ = ("content", "children")
39
+
40
+ def __init__(self, content: IntoText) -> None:
41
+ self.content = into_text(content)
42
+ self.children: list[TreeNode] = []
43
+
44
+ def child(self, node: TreeNode) -> TreeNode:
45
+ """Adds a child node and returns this node for chaining."""
46
+ self.children.append(node)
47
+ return self
48
+
49
+
50
+ class Tree(Renderable):
51
+ """A tree of :class:`TreeNode` objects drawn with connector glyphs."""
52
+
53
+ __slots__ = (
54
+ "_roots",
55
+ "_border",
56
+ "_connector_style",
57
+ "_dashes",
58
+ "_guides",
59
+ )
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ border: BorderType = BorderType.SINGLE,
65
+ connector_style: Style | None = None,
66
+ dashes: int = 1,
67
+ guides: bool = True,
68
+ ) -> None:
69
+ self._roots: list[TreeNode] = []
70
+ self._border = border
71
+ self._connector_style = (
72
+ connector_style
73
+ if connector_style is not None
74
+ else theme().secondary
75
+ )
76
+ self._dashes = dashes
77
+ self._guides = guides
78
+
79
+ def node(self, node: TreeNode) -> Tree:
80
+ """Adds a top-level (root) node and returns the tree."""
81
+ self._roots.append(node)
82
+ return self
83
+
84
+ def border(self, border: BorderType) -> Tree:
85
+ """Sets the connector glyph set and returns the tree."""
86
+ self._border = border
87
+ return self
88
+
89
+ def connector_style(self, style: Style) -> Tree:
90
+ """Sets the connector style and returns the tree."""
91
+ self._connector_style = style
92
+ return self
93
+
94
+ def dashes(self, dashes: int) -> Tree:
95
+ """Sets the number of horizontal dashes per connector."""
96
+ self._dashes = dashes
97
+ return self
98
+
99
+ def no_guides(self) -> Tree:
100
+ """Disables the vertical continuation guides and returns the tree."""
101
+ self._guides = False
102
+ return self
103
+
104
+ def render(self, max_width: int) -> Rendered:
105
+ """Renders the tree; ``max_width`` is accepted but not constraining."""
106
+ del max_width
107
+ lines: list[Line] = []
108
+ for root in self._roots:
109
+ for content_line in root.content.lines:
110
+ lines.append(Line(list(content_line.spans)))
111
+ self._render_children(root.children, "", lines)
112
+ return Rendered(lines)
113
+
114
+ def _connector_width(self) -> int:
115
+ """Returns the column width of a connector."""
116
+ return _CONNECTOR_FIXED_CELLS + self._dashes
117
+
118
+ def _render_children(
119
+ self, children: list[TreeNode], prefix: str, lines: list[Line]
120
+ ) -> None:
121
+ """Renders the children of a node beneath ``prefix``."""
122
+ chars = self._border.chars()
123
+ dash = chars.horizontal * self._dashes
124
+ for index, child in enumerate(children):
125
+ last = index + 1 == len(children)
126
+ branch = chars.bottom_left if last else chars.tee_right
127
+ connector = f"{branch}{dash} "
128
+ continuation = self._continuation(last)
129
+ self._push_node_lines(child, prefix, connector, continuation, lines)
130
+ child_prefix = f"{prefix}{continuation}"
131
+ self._render_children(child.children, child_prefix, lines)
132
+
133
+ def _push_node_lines(
134
+ self,
135
+ node: TreeNode,
136
+ prefix: str,
137
+ connector: str,
138
+ continuation: str,
139
+ lines: list[Line],
140
+ ) -> None:
141
+ """Emits the content lines of a single node."""
142
+ for row, content_line in enumerate(node.content.lines):
143
+ lead = connector if row == 0 else continuation
144
+ spans: list[Span] = [
145
+ Span.styled(prefix, self._connector_style),
146
+ Span.styled(lead, self._connector_style),
147
+ ]
148
+ spans.extend(content_line.spans)
149
+ lines.append(Line(spans))
150
+
151
+ def _continuation(self, last: bool) -> str:
152
+ """Returns the continuation cell shown under a node's connector."""
153
+ width = self._connector_width()
154
+ if last or not self._guides:
155
+ return " " * width
156
+ guide = self._border.chars().vertical
157
+ return f"{guide}{' ' * (width - 1)}"
sparcli/py.typed ADDED
File without changes