py-sparcli 0.4.0__tar.gz → 0.5.0__tar.gz

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 (117) hide show
  1. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CHANGELOG.md +14 -1
  2. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CLAUDE.md +5 -5
  3. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/PKG-INFO +4 -2
  4. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/README.md +2 -0
  5. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/docs/DEVELOPMENT.md +1 -1
  6. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/pyproject.toml +8 -8
  7. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/color.py +42 -2
  8. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/markup.py +3 -2
  9. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/theme.py +18 -0
  10. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/confirm.py +2 -3
  11. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/diff.py +6 -6
  12. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/progress.py +5 -2
  13. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/table.py +2 -2
  14. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_streams.py +1 -1
  15. py_sparcli-0.5.0/tests/test_theme_only_colours.py +58 -0
  16. py_sparcli-0.5.0/tests/test_theme_styles.py +89 -0
  17. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/uv.lock +1 -1
  18. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.editorconfig +0 -0
  19. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.gitattributes +0 -0
  20. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.gitignore +0 -0
  21. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CONTRIBUTING.md +0 -0
  22. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/LICENSE +0 -0
  23. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/TODO.md +0 -0
  24. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_dynamic.py +0 -0
  25. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_gallery.py +0 -0
  26. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_readme.py +0 -0
  27. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/prompt_readme.py +0 -0
  28. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/prompts.py +0 -0
  29. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/images/screenshot-1.png +0 -0
  30. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/images/screenshot-2.png +0 -0
  31. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/__init__.py +0 -0
  32. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/__init__.py +0 -0
  33. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/border.py +0 -0
  34. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/command.py +0 -0
  35. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/cursor.py +0 -0
  36. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/geometry.py +0 -0
  37. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/hsl.py +0 -0
  38. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/inplace.py +0 -0
  39. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/render.py +0 -0
  40. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/style.py +0 -0
  41. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/terminal.py +0 -0
  42. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/text.py +0 -0
  43. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/__init__.py +0 -0
  44. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/line.py +0 -0
  45. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/measure.py +0 -0
  46. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/errors.py +0 -0
  47. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/__init__.py +0 -0
  48. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/calc.py +0 -0
  49. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/completion.py +0 -0
  50. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/datepicker.py +0 -0
  51. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/editor.py +0 -0
  52. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/event.py +0 -0
  53. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/field.py +0 -0
  54. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/fuzzy.py +0 -0
  55. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/guard.py +0 -0
  56. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/history.py +0 -0
  57. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/keydecode.py +0 -0
  58. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/line_edit.py +0 -0
  59. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/number.py +0 -0
  60. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/outcome.py +0 -0
  61. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/password.py +0 -0
  62. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/prompt.py +0 -0
  63. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/recall.py +0 -0
  64. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/select.py +0 -0
  65. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/selection.py +0 -0
  66. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/shortcut.py +0 -0
  67. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/text.py +0 -0
  68. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/textarea.py +0 -0
  69. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/validate.py +0 -0
  70. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/__init__.py +0 -0
  71. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/alert.py +0 -0
  72. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/badge.py +0 -0
  73. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/box.py +0 -0
  74. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/__init__.py +0 -0
  75. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/card.py +0 -0
  76. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/palette.py +0 -0
  77. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/render.py +0 -0
  78. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/columns.py +0 -0
  79. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/compose.py +0 -0
  80. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/kv.py +0 -0
  81. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/layout.py +0 -0
  82. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/list.py +0 -0
  83. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/live.py +0 -0
  84. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/multiprogress.py +0 -0
  85. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/pager.py +0 -0
  86. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/panel.py +0 -0
  87. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/rule.py +0 -0
  88. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/spinner.py +0 -0
  89. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/__init__.py +0 -0
  90. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/plan.py +0 -0
  91. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/render.py +0 -0
  92. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/tree.py +0 -0
  93. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/py.typed +0 -0
  94. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/conftest.py +0 -0
  95. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_box.py +0 -0
  96. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_card.py +0 -0
  97. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_confirm.py +0 -0
  98. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_core.py +0 -0
  99. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_cursor.py +0 -0
  100. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_datepicker.py +0 -0
  101. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_external_commands.py +0 -0
  102. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_fuzzy.py +0 -0
  103. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_history.py +0 -0
  104. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_input_collaborators.py +0 -0
  105. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_input_infra.py +0 -0
  106. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_keydecode.py +0 -0
  107. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_number.py +0 -0
  108. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_basic.py +0 -0
  109. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_dynamic.py +0 -0
  110. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_structured.py +0 -0
  111. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_password.py +0 -0
  112. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_public_api.py +0 -0
  113. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_select.py +0 -0
  114. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_table.py +0 -0
  115. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_table_plan.py +0 -0
  116. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_text_input.py +0 -0
  117. {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_textarea.py +0 -0
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] – 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - `Theme` has five new fields for styles that were fixed in code: `diff_add` and `diff_delete` (the diff's added and removed lines, default green and red), `progress_empty` (the empty part of a progress bar, default dark gray), `code` (backtick code spans in markup, default cyan) and `stripe` (the shaded rows of a striped table, default dim). `Diff`, `ProgressOpts`, the markup parser and `Table` read them, so an app can theme them; the defaults are the previous look. The fields have defaults, so existing `Theme(...)` calls keep working. Mirrors the Rust release 0.5.0.
14
+ - `Color.from_name` accepts the ratada `ansi:<name>` tokens (`ansi:dark_gray`, `ansi:light_red`, ...; `ansi:white` is the bright white), so one token names the same color in the Python and the Rust toolkits.
15
+ - `tests/test_theme_only_colours.py` fails on a color literal in the package outside the color type and the built-in theme.
16
+
17
+ ### Changed
18
+
19
+ - The unselected buttons of `Confirm` use `theme.hint` instead of a fixed dim style; the default `hint` is that same dim style.
20
+
9
21
  ## [0.4.0] – 2026-07-19
10
22
 
11
23
  ### Added
@@ -88,7 +100,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
100
  - Unified `Theme`, inline `markup`, width utilities, terminal capability detection, validators, shortcuts and command history.
89
101
  - Dependency-free (standard library only).
90
102
 
91
- [Unreleased]: https://github.com/cgroening/py-sparcli/compare/v0.4.0...HEAD
103
+ [Unreleased]: https://github.com/cgroening/py-sparcli/compare/v0.5.0...HEAD
104
+ [0.5.0]: https://github.com/cgroening/py-sparcli/compare/v0.4.0...v0.5.0
92
105
  [0.4.0]: https://github.com/cgroening/py-sparcli/compare/v0.3.0...v0.4.0
93
106
  [0.3.0]: https://github.com/cgroening/py-sparcli/compare/v0.1.0...v0.3.0
94
107
  [0.1.0]: https://github.com/cgroening/py-sparcli/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  # CLAUDE.md – sparcli (Python)
2
2
 
3
- Requirements for all future sessions in this project. On conflict, this file takes precedence over general conventions, but not over explicit user instructions. The global style guide (`~/.claude/CLAUDE.md` → §12 Python) applies additionally; this file refines and overrides it wherever it is more specific.
3
+ Requirements for all future sessions in this project. On conflict, this file takes precedence over general conventions, but not over explicit user instructions.
4
4
 
5
5
  ## What is this?
6
6
 
@@ -37,13 +37,13 @@ Requirements for all future sessions in this project. On conflict, this file tak
37
37
 
38
38
  Where a rule applies in more than one widget it lives in exactly one place. Do not reimplement these per call site: `core.width.truncate`/`truncate_line` for `…` clipping, `input.selection.SelectionCursor` for cursor and scroll window, `input.prompt.run_on_terminal` for the terminal handover, `core.command.split_command`/`resolve_from_env` for external commands.
39
39
 
40
- ## Output streams (§1.6)
40
+ ## Output streams
41
41
 
42
- sparcli is a library, not a CLI program, so §1.6 binds it only through what it prints:
42
+ sparcli is a library, not a CLI program, so the CLI output rules bind it only through what it prints:
43
43
 
44
44
  - Payload goes to **stdout**, progress indicators to **stderr**, and progress draws only when stderr is a terminal. `InPlace.progress()` is the seam.
45
45
  - `print`/`print_to` do **not** truncate when stdout is not a terminal: they lay out at `UNCONSTRAINED_WIDTH`, because clipping to an invented width loses piped data silently. An explicit `render(max_width)` still truncates – that width is the caller's decision.
46
- - §1.7 (TUI) rules – scrollbar on overflow, a `?` overlay in every prompt, `Ctrl+Q` – are deliberately **not** implemented. They govern terminal UIs, not a CLI output library.
46
+ - TUI rules – scrollbar on overflow, a `?` overlay in every prompt, `Ctrl+Q` – are deliberately **not** implemented. They govern terminal UIs, not a CLI output library.
47
47
  - The public API is re-exported flat (`from sparcli import ...`, 81 symbols) plus the namespaces `sparcli.width/terminal/markup/event/validate/shortcut`. Every `__init__.py` carries an explicit `__all__` list.
48
48
 
49
49
  ## Dependencies
@@ -60,7 +60,7 @@ sparcli is a library, not a CLI program, so §1.6 binds it only through what it
60
60
 
61
61
  - Target Python: **3.12+** (`requires-python = ">=3.12"`).
62
62
  - **Strings: double quotes `"..."`** everywhere, including nested inside f-strings (PEP 701). Enforced via the `Q` lint rules with `inline-quotes = "double"` and `avoid-escape = false`; the formatter uses `quote-style = "preserve"` so it cannot rewrite the nested case back to single quotes. `repr` outputs in doctests keep their single quotes, because that is what Python prints.
63
- - **Formatting/linting:** `ruff` (format + lint), running the full rule set from the style guide. 80-character lines, enforced (`E501` is on). Imports isort-sorted (stdlib → third-party → local). `from __future__ import annotations` as the first line of every module. Every entry in the `ignore` list carries a comment saying why.
63
+ - **Formatting/linting:** `ruff` (format + lint) with the rule set in `pyproject.toml`. 80-character lines, enforced (`E501` is on). Imports isort-sorted (stdlib → third-party → local). `from __future__ import annotations` as the first line of every module. Every entry in the `ignore` list carries a comment saying why.
64
64
  - **Docstrings:** NumPy convention. The fluent builder setters keep a single-line docstring instead of full `Parameters`/`Returns` blocks – they take one argument and return `self`, so the blocks would add length without information. Full blocks are required everywhere else.
65
65
  - **Type checking:** `basedpyright` in **strict** mode must report 0 errors. Complete, modern type hints (`X | None`, builtin generics, PEP 695).
66
66
  - No em dash; straight quotes/apostrophes; named constants instead of magic numbers/strings.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: py-sparcli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Styled CLI output and interactive input widgets, dependency-free
5
5
  Project-URL: Homepage, https://github.com/cgroening/py-sparcli
6
6
  Project-URL: Repository, https://github.com/cgroening/py-sparcli
@@ -190,6 +190,8 @@ set_theme(
190
190
  active = theme()
191
191
  ```
192
192
 
193
+ Every color a widget draws comes from the theme: the accent, the message roles (`success`, `error`, `warning`, `info`, `debug`), `secondary`/`hint`, `selection`/`cursor`, and the widget styles `diff_add`, `diff_delete`, `progress_empty`, `code` and `stripe`. `Color.from_name` also accepts the `ansi:<name>` tokens of the Rust toolkit family (`ansi:dark_gray`, `ansi:light_red`, ...), so one token means the same color everywhere.
194
+
193
195
  ## Output components
194
196
 
195
197
  The `output_readme.py` example composes a hero panel, a three-column dashboard and a progress bar:
@@ -164,6 +164,8 @@ set_theme(
164
164
  active = theme()
165
165
  ```
166
166
 
167
+ Every color a widget draws comes from the theme: the accent, the message roles (`success`, `error`, `warning`, `info`, `debug`), `secondary`/`hint`, `selection`/`cursor`, and the widget styles `diff_add`, `diff_delete`, `progress_empty`, `code` and `stripe`. `Color.from_name` also accepts the `ansi:<name>` tokens of the Rust toolkit family (`ansi:dark_gray`, `ansi:light_red`, ...), so one token means the same color everywhere.
168
+
167
169
  ## Output components
168
170
 
169
171
  The `output_readme.py` example composes a hero panel, a three-column dashboard and a progress bar:
@@ -54,7 +54,7 @@ basedpyright sparcli examples # strict type checking
54
54
 
55
55
  Both `ruff check` and `basedpyright` must be clean. The project targets strict typing: every public item is annotated, and `ruff` runs the full documented rule set, including docstring style (NumPy convention), annotations, complexity, security (`flake8-bandit`), `pathlib` usage and import ordering. Line length is enforced at 80 columns.
56
56
 
57
- Every entry in the `ignore` list in `pyproject.toml` carries a comment saying why it is there. The two that most often surprise newcomers: `D401` is off because the style guide mandates third-person docstring summaries ("Returns ...") where that rule wants the imperative, and `FBT001`/`FBT002` are off because the fluent builder API takes booleans by design (`Table.striped(True)`) to mirror the Rust port.
57
+ Every entry in the `ignore` list in `pyproject.toml` carries a comment saying why it is there. The two that most often surprise newcomers: `D401` is off because docstring summaries are written in the third person ("Returns ...") where that rule wants the imperative, and `FBT001`/`FBT002` are off because the fluent builder API takes booleans by design (`Table.striped(True)`) to mirror the Rust port.
58
58
 
59
59
  Dependencies are audited separately:
60
60
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "py-sparcli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Styled CLI output and interactive input widgets, dependency-free"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -82,8 +82,8 @@ select = [
82
82
  "TID", "PL", "RUF",
83
83
  ]
84
84
  # The numpy convention disables D212 and D213 alike, which silently permits
85
- # both layouts. D213 has to be re-enabled explicitly to enforce the one the
86
- # style guide mandates: summary on the line after the opening quotes.
85
+ # both layouts. D213 has to be re-enabled explicitly to enforce the one used
86
+ # here: summary on the line after the opening quotes.
87
87
  extend-select = ["D213"]
88
88
 
89
89
  ignore = [
@@ -91,8 +91,8 @@ ignore = [
91
91
  "TRY003",
92
92
  # Forbids deferred imports, which break the layering here.
93
93
  "PLC0415",
94
- # Demands imperative docstrings; the style guide mandates third
95
- # person singular ("Returns ..."), so this rule is inverted for us.
94
+ # Demands imperative docstrings; summaries here use the third person
95
+ # singular ("Returns ..."), so this rule is inverted for us.
96
96
  "D401",
97
97
  # Widget constructors are keyword-only by design and mirror the Rust
98
98
  # port's builder API, so their argument count is intentional.
@@ -112,15 +112,15 @@ ignore = [
112
112
  # callbacks are fixed-signature contracts; an implementation that does not
113
113
  # need a parameter still has to accept it under that exact name.
114
114
  "ARG002",
115
- # The style guide mandates the en dash in prose, which this rule flags as
116
- # an ambiguous character.
115
+ # Prose uses the en dash, which this rule flags as an ambiguous
116
+ # character.
117
117
  "RUF002",
118
118
  # Conflicts with D213 (summary on the line after the opening quotes).
119
119
  "D212",
120
120
  ]
121
121
 
122
122
  [tool.ruff.lint.pylint]
123
- # Guard-clause style (style guide 1.2.5) produces many early returns by
123
+ # Guard-clause style produces many early returns by
124
124
  # design; the dispatch functions here top out at eight.
125
125
  max-returns = 8
126
126
 
@@ -103,6 +103,28 @@ _NAME_ALIASES: dict[str, ColorName] = {
103
103
  "brightwhite": ColorName.WHITE,
104
104
  }
105
105
 
106
+ # The ratada `ansi:<name>` tokens, so one token names the same color in every
107
+ # crate and package of the family; `ansi:white` is the bright white.
108
+ _ANSI_PREFIX = "ansi:"
109
+ _ANSI_TOKENS: dict[str, ColorName] = {
110
+ "black": ColorName.BLACK,
111
+ "red": ColorName.RED,
112
+ "green": ColorName.GREEN,
113
+ "yellow": ColorName.YELLOW,
114
+ "blue": ColorName.BLUE,
115
+ "magenta": ColorName.MAGENTA,
116
+ "cyan": ColorName.CYAN,
117
+ "gray": ColorName.GRAY,
118
+ "dark_gray": ColorName.DARK_GRAY,
119
+ "light_red": ColorName.LIGHT_RED,
120
+ "light_green": ColorName.LIGHT_GREEN,
121
+ "light_yellow": ColorName.LIGHT_YELLOW,
122
+ "light_blue": ColorName.LIGHT_BLUE,
123
+ "light_magenta": ColorName.LIGHT_MAGENTA,
124
+ "light_cyan": ColorName.LIGHT_CYAN,
125
+ "white": ColorName.WHITE,
126
+ }
127
+
106
128
  _HEX_DIGITS = 6
107
129
 
108
130
  # Standard xterm RGB values of the sixteen named colors, by palette index.
@@ -210,17 +232,35 @@ class Color:
210
232
  """
211
233
  Parses a color name (case-insensitive), returning ``None`` if unknown.
212
234
 
235
+ The ``ansi:<name>`` tokens of ratada are accepted too
236
+ (``ansi:dark_gray``, ``ansi:light_red``, ..., where ``ansi:white`` is
237
+ the bright white), so one token names the same color across the
238
+ toolkit family.
239
+
213
240
  Parameters
214
241
  ----------
215
242
  text : str
216
- A color name such as ``"red"`` or an alias such as ``"purple"``.
243
+ A color name such as ``"red"``, an alias such as ``"purple"`` or a
244
+ token such as ``"ansi:dark_gray"``.
217
245
 
218
246
  Returns
219
247
  -------
220
248
  Color | None
221
249
  The matching color, or ``None`` when the name is not recognized.
250
+
251
+ Examples
252
+ --------
253
+ >>> Color.from_name("ansi:dark_gray") == Color.DARK_GRAY
254
+ True
255
+ >>> Color.from_name("ansi:white") == Color.WHITE
256
+ True
222
257
  """
223
- name = _NAME_ALIASES.get(text.strip().lower())
258
+ lowered = text.strip().lower()
259
+ if lowered.startswith(_ANSI_PREFIX):
260
+ token = lowered.removeprefix(_ANSI_PREFIX).replace("grey", "gray")
261
+ name = _ANSI_TOKENS.get(token)
262
+ else:
263
+ name = _NAME_ALIASES.get(lowered)
224
264
  return cls(name=name) if name is not None else None
225
265
 
226
266
  @classmethod
@@ -18,6 +18,7 @@ from sparcli.core.color import Color
18
18
  from sparcli.core.render import Renderable, Rendered
19
19
  from sparcli.core.style import Attribute, Style
20
20
  from sparcli.core.text import Line, Span, Text
21
+ from sparcli.core.theme import theme
21
22
 
22
23
  _BACKGROUND_KEYWORD = "on"
23
24
 
@@ -163,13 +164,13 @@ class _Parser:
163
164
  return end + 1
164
165
 
165
166
  def _toggle_code(self) -> None:
166
- """Toggles a cyan code style on or off at the current nesting."""
167
+ """Toggles the theme's code style on or off at the current nesting."""
167
168
  self._flush_buffer()
168
169
  if self._in_code:
169
170
  if len(self._stack) > 1:
170
171
  self._stack.pop()
171
172
  else:
172
- code = self._current().patch(Style.new().with_fg(Color.CYAN))
173
+ code = self._current().patch(theme().code)
173
174
  self._stack.append(code)
174
175
  self._in_code = not self._in_code
175
176
 
@@ -30,6 +30,11 @@ _DEBUG = Style.from_color(Color.MAGENTA)
30
30
  _SECONDARY = Style.new().dim()
31
31
  _SELECTION = Style.from_color(DEFAULT_ACCENT).bold()
32
32
  _CURSOR = Style.new().with_fg(Color.BLACK).with_bg(DEFAULT_ACCENT)
33
+ _DIFF_ADD = Style.from_color(Color.GREEN)
34
+ _DIFF_DELETE = Style.from_color(Color.RED)
35
+ _PROGRESS_EMPTY = Color.DARK_GRAY
36
+ _CODE = Style.from_color(Color.CYAN)
37
+ _STRIPE = Style.new().dim()
33
38
 
34
39
  # Two-step glyphs: (unicode, ascii). ASCII is the fallback for plain terminals.
35
40
  _BULLET = ("•", "*")
@@ -53,6 +58,14 @@ class Theme:
53
58
  Semantic styles applied throughout the library.
54
59
  selection, cursor : Style
55
60
  Styles for the highlighted item and the text cursor.
61
+ diff_add, diff_delete : Style
62
+ Styles for added and removed lines in a diff.
63
+ progress_empty : Color
64
+ Color of the empty part of a progress bar.
65
+ code : Style
66
+ Style for backtick code spans in markup.
67
+ stripe : Style
68
+ Style for the shaded rows of a striped table.
56
69
  border : BorderType
57
70
  The default border look.
58
71
  unicode : bool
@@ -71,6 +84,11 @@ class Theme:
71
84
  hint: Style = _SECONDARY
72
85
  selection: Style = _SELECTION
73
86
  cursor: Style = _CURSOR
87
+ diff_add: Style = _DIFF_ADD
88
+ diff_delete: Style = _DIFF_DELETE
89
+ progress_empty: Color = _PROGRESS_EMPTY
90
+ code: Style = _CODE
91
+ stripe: Style = _STRIPE
74
92
  border: BorderType = BorderType.ROUNDED
75
93
  unicode: bool = True
76
94
 
@@ -16,7 +16,6 @@ from __future__ import annotations
16
16
  from typing import TYPE_CHECKING
17
17
 
18
18
  from sparcli.core.render import Rendered
19
- from sparcli.core.style import Style
20
19
  from sparcli.core.text import Line, Span
21
20
  from sparcli.core.theme import Theme, theme
22
21
  from sparcli.input.event import (
@@ -197,7 +196,7 @@ class Confirm:
197
196
 
198
197
 
199
198
  def _option_span(label: str, selected: bool, active: Theme) -> Span:
200
- """Renders one option, highlighted when selected and dimmed otherwise."""
199
+ """Renders one option, highlighted when selected and as a hint otherwise."""
201
200
  if selected:
202
201
  return Span.styled(f"[{label}]", active.selection)
203
- return Span.styled(f" {label} ", Style.new().dim())
202
+ return Span.styled(f" {label} ", active.hint)
@@ -14,13 +14,15 @@ fall back to a plain delete-then-insert listing.
14
14
  from __future__ import annotations
15
15
 
16
16
  import enum
17
+ from typing import TYPE_CHECKING
17
18
 
18
- from sparcli.core.color import Color
19
19
  from sparcli.core.render import Renderable, Rendered
20
- from sparcli.core.style import Style
21
20
  from sparcli.core.text import Line
22
21
  from sparcli.core.theme import theme
23
22
 
23
+ if TYPE_CHECKING:
24
+ from sparcli.core.style import Style
25
+
24
26
  # Default number of unchanged context lines kept around each change.
25
27
  DEFAULT_CONTEXT = 3
26
28
  # Maximum lines per side before falling back to a full listing.
@@ -88,12 +90,10 @@ class Diff(Renderable):
88
90
  self._old_label = old_label
89
91
  self._new_label = new_label
90
92
  self._add_style = (
91
- add_style
92
- if add_style is not None
93
- else Style.from_color(Color.GREEN)
93
+ add_style if add_style is not None else theme().diff_add
94
94
  )
95
95
  self._del_style = (
96
- del_style if del_style is not None else Style.from_color(Color.RED)
96
+ del_style if del_style is not None else theme().diff_delete
97
97
  )
98
98
  self._hunk_style = (
99
99
  hunk_style if hunk_style is not None else theme().secondary
@@ -17,14 +17,17 @@ from __future__ import annotations
17
17
  import enum
18
18
  import math
19
19
  from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
20
21
 
21
- from sparcli.core.color import Color
22
22
  from sparcli.core.inplace import InPlace
23
23
  from sparcli.core.render import Rendered
24
24
  from sparcli.core.style import Style
25
25
  from sparcli.core.text import Line, Span
26
26
  from sparcli.core.theme import theme
27
27
 
28
+ if TYPE_CHECKING:
29
+ from sparcli.core.color import Color
30
+
28
31
  # Default bar width in columns.
29
32
  DEFAULT_WIDTH = 30
30
33
 
@@ -127,7 +130,7 @@ class ProgressOpts:
127
130
  left_cap="",
128
131
  right_cap="",
129
132
  fill_color=active.accent,
130
- empty_color=Color.DARK_GRAY,
133
+ empty_color=active.progress_empty,
131
134
  thresholds=None,
132
135
  show_percent=True,
133
136
  show_value=False,
@@ -194,7 +194,7 @@ class TableOpts:
194
194
  header: bool = True
195
195
  header_style: Style = field(default_factory=Style)
196
196
  striped: bool = False
197
- stripe_style: Style = field(default_factory=lambda: Style().dim())
197
+ stripe_style: Style = field(default_factory=lambda: theme().stripe)
198
198
  title: Text | None = None
199
199
  title_style: Style = field(default_factory=Style)
200
200
  pad: int = 1
@@ -261,7 +261,7 @@ class Table(Renderable):
261
261
  ),
262
262
  striped=striped,
263
263
  stripe_style=(
264
- stripe_style if stripe_style is not None else Style().dim()
264
+ stripe_style if stripe_style is not None else active.stripe
265
265
  ),
266
266
  title=into_text(title) if title is not None else None,
267
267
  title_style=(
@@ -1,5 +1,5 @@
1
1
  """
2
- Tests for the CLI output rules of section 1.6 of the style guide.
2
+ Tests for the CLI output rules.
3
3
 
4
4
  Two rules are load-bearing for a program that pipes its output onward: payload
5
5
  goes to stdout while progress goes to stderr, and nothing is truncated when
@@ -0,0 +1,58 @@
1
+ """
2
+ Every color comes from the theme.
3
+
4
+ No color literal may appear in the package outside the files in ``ALLOWED``,
5
+ each listed with its reason. Comments and docstrings are not scanned.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ PACKAGE = Path(__file__).parent.parent / "sparcli"
14
+
15
+ # Files that may hold color values, and why.
16
+ ALLOWED: dict[str, str] = {
17
+ "core/color.py": "the color type, its names and the xterm palette",
18
+ "core/theme.py": "the built-in theme",
19
+ }
20
+
21
+ # A named color constant, an RGB literal or a quoted `#rgb`/`#rrggbb` string.
22
+ LITERAL = re.compile(
23
+ r"Color\.(BLACK|RED|GREEN|YELLOW|BLUE|MAGENTA|CYAN|GRAY|DARK_GRAY|WHITE"
24
+ r"|LIGHT_[A-Z]+)\b"
25
+ r"|Color\.rgb\(\s*\d"
26
+ r"|[\"']#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})[\"']"
27
+ )
28
+
29
+ TRIPLE_QUOTES = ('"""', "'''")
30
+
31
+
32
+ def test_colors_come_only_from_the_theme() -> None:
33
+ found = [
34
+ f"{path.relative_to(PACKAGE)}:{number}: {line.strip()}"
35
+ for path in sorted(PACKAGE.rglob("*.py"))
36
+ if path.relative_to(PACKAGE).as_posix() not in ALLOWED
37
+ for number, line in code_lines(path)
38
+ if LITERAL.search(line)
39
+ ]
40
+ assert not found, "color literals outside the theme:\n" + "\n".join(found)
41
+
42
+
43
+ def code_lines(path: Path) -> list[tuple[int, str]]:
44
+ """Returns the numbered lines of `path` outside comments and docstrings."""
45
+ lines: list[tuple[int, str]] = []
46
+ in_docstring = False
47
+ for number, line in enumerate(path.read_text().splitlines(), start=1):
48
+ stripped = line.strip()
49
+ quotes = sum(stripped.count(mark) for mark in TRIPLE_QUOTES)
50
+ if in_docstring or stripped.startswith(TRIPLE_QUOTES):
51
+ # An odd count opens or closes a docstring spanning lines.
52
+ if quotes % 2 == 1:
53
+ in_docstring = not in_docstring
54
+ continue
55
+ if stripped.startswith("#"):
56
+ continue
57
+ lines.append((number, line))
58
+ return lines
@@ -0,0 +1,89 @@
1
+ """The widget styles that come from the theme, and the ``ansi:`` tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sparcli.core.border import BorderType
6
+ from sparcli.core.color import Color
7
+ from sparcli.core.markup import parse
8
+ from sparcli.core.style import Style
9
+ from sparcli.core.theme import Theme, set_theme
10
+ from sparcli.input.confirm import Confirm
11
+ from sparcli.output.diff import Diff
12
+ from sparcli.output.progress import ProgressOpts
13
+ from sparcli.output.table.table import Table
14
+
15
+
16
+ class TestThemeDefaults:
17
+ def test_the_new_fields_keep_the_previous_look(self) -> None:
18
+ defaults = Theme()
19
+ assert defaults.diff_add == Style.from_color(Color.GREEN)
20
+ assert defaults.diff_delete == Style.from_color(Color.RED)
21
+ assert defaults.progress_empty == Color.DARK_GRAY
22
+ assert defaults.code == Style.from_color(Color.CYAN)
23
+ assert defaults.stripe == Style.new().dim()
24
+
25
+
26
+ class TestWidgetsReadTheTheme:
27
+ def test_diff_lines_take_the_theme_styles(self) -> None:
28
+ set_theme(
29
+ Theme(
30
+ diff_add=Style.from_color(Color.BLUE),
31
+ diff_delete=Style.from_color(Color.MAGENTA),
32
+ )
33
+ )
34
+ rendered = Diff("a", "b").no_header().render(80)
35
+ colors = {
36
+ span.style.fg for line in rendered.lines for span in line.spans
37
+ }
38
+ assert Color.BLUE in colors
39
+ assert Color.MAGENTA in colors
40
+
41
+ def test_the_empty_progress_bar_takes_the_theme_color(self) -> None:
42
+ set_theme(Theme(progress_empty=Color.BLUE))
43
+ assert ProgressOpts.defaults().empty_color == Color.BLUE
44
+
45
+ def test_code_spans_take_the_theme_style(self) -> None:
46
+ set_theme(Theme(code=Style.from_color(Color.MAGENTA)))
47
+ spans = parse("`x`").lines[0].spans
48
+ assert any(span.style.fg == Color.MAGENTA for span in spans)
49
+
50
+ def test_table_stripes_take_the_theme_style(self) -> None:
51
+ set_theme(Theme(stripe=Style().with_bg(Color.BLUE)))
52
+ rendered = (
53
+ Table()
54
+ .striped(True)
55
+ .columns(["A"])
56
+ .row(["1"])
57
+ .row(["2"])
58
+ .border(BorderType.SINGLE)
59
+ .render(80)
60
+ )
61
+ second_body = rendered.lines[4]
62
+ assert any(span.style.bg == Color.BLUE for span in second_body.spans)
63
+
64
+ def test_the_unselected_confirm_button_takes_the_hint_style(self) -> None:
65
+ set_theme(Theme(hint=Style.from_color(Color.BLUE)))
66
+ lines = Confirm("Go?").frame().lines
67
+ spans = [span for line in lines for span in line.spans]
68
+ assert any(
69
+ span.content.strip() == "Yes" and span.style.fg == Color.BLUE
70
+ for span in spans
71
+ )
72
+
73
+
74
+ class TestAnsiTokens:
75
+ def test_every_terminal_color_has_a_token(self) -> None:
76
+ names = [
77
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan",
78
+ "gray", "dark_gray", "light_red", "light_green", "light_yellow",
79
+ "light_blue", "light_magenta", "light_cyan", "white",
80
+ ]
81
+ for name in names:
82
+ assert Color.from_name(f"ansi:{name}") is not None, name
83
+
84
+ def test_white_is_the_bright_white_and_grey_is_accepted(self) -> None:
85
+ assert Color.from_name("ansi:white") == Color.WHITE
86
+ assert Color.from_name("ANSI:Dark_Grey") == Color.DARK_GRAY
87
+
88
+ def test_an_unknown_token_is_none(self) -> None:
89
+ assert Color.from_name("ansi:orange") is None
@@ -513,7 +513,7 @@ wheels = [
513
513
 
514
514
  [[package]]
515
515
  name = "py-sparcli"
516
- version = "0.4.0"
516
+ version = "0.5.0"
517
517
  source = { editable = "." }
518
518
 
519
519
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes