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.
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CHANGELOG.md +14 -1
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CLAUDE.md +5 -5
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/PKG-INFO +4 -2
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/README.md +2 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/docs/DEVELOPMENT.md +1 -1
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/pyproject.toml +8 -8
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/color.py +42 -2
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/markup.py +3 -2
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/theme.py +18 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/confirm.py +2 -3
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/diff.py +6 -6
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/progress.py +5 -2
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/table.py +2 -2
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_streams.py +1 -1
- py_sparcli-0.5.0/tests/test_theme_only_colours.py +58 -0
- py_sparcli-0.5.0/tests/test_theme_styles.py +89 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/uv.lock +1 -1
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.editorconfig +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.gitattributes +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/.gitignore +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/CONTRIBUTING.md +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/LICENSE +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/TODO.md +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_dynamic.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_gallery.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/output_readme.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/prompt_readme.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/examples/prompts.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/images/screenshot-1.png +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/images/screenshot-2.png +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/border.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/command.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/cursor.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/geometry.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/hsl.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/inplace.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/render.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/style.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/terminal.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/text.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/line.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/core/width/measure.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/errors.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/calc.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/completion.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/datepicker.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/editor.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/event.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/field.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/fuzzy.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/guard.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/history.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/keydecode.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/line_edit.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/number.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/outcome.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/password.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/prompt.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/recall.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/select.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/selection.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/shortcut.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/text.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/textarea.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/input/validate.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/alert.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/badge.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/box.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/card.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/palette.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/card/render.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/columns.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/compose.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/kv.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/layout.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/list.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/live.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/multiprogress.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/pager.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/panel.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/rule.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/spinner.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/__init__.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/plan.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/table/render.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/output/tree.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/sparcli/py.typed +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/conftest.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_box.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_card.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_confirm.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_core.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_cursor.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_datepicker.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_external_commands.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_fuzzy.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_history.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_input_collaborators.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_input_infra.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_keydecode.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_number.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_basic.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_dynamic.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_output_structured.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_password.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_public_api.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_select.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_table.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_table_plan.py +0 -0
- {py_sparcli-0.4.0 → py_sparcli-0.5.0}/tests/test_text_input.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
40
|
+
## Output streams
|
|
41
41
|
|
|
42
|
-
sparcli is a library, not a CLI program, so
|
|
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
|
-
-
|
|
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)
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: py-sparcli
|
|
3
|
-
Version: 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
|
|
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.
|
|
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
|
|
86
|
-
#
|
|
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;
|
|
95
|
-
#
|
|
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
|
-
#
|
|
116
|
-
#
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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} ",
|
|
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
|
|
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=
|
|
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:
|
|
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
|
|
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=(
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|