escape-colors 0.1.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.
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.3
2
+ Name: escape-colors
3
+ Version: 0.1.0
4
+ Summary: ANSI colors, nested text styles, and portable terminal printing
5
+ Author: ellainng
6
+ Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
7
+ Requires-Python: >=3.14
8
+ Project-URL: Repository, https://github.com/outside-labs/escape_colors
9
+ Project-URL: Issues, https://github.com/outside-labs/escape_colors/issues
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Escape Colors
13
+
14
+ Readable ANSI colors and nested text styles for Python 3.14+, with no runtime
15
+ dependencies.
16
+
17
+ ```python
18
+ from escape_colors import ansi_print, blue, escape, escape_ansi, init_windows
19
+
20
+ ansi_print("blue(hello world)")
21
+ ansi_print("bold(blue(hello red(world) again))")
22
+
23
+ # For direct print/f-string output on Windows:
24
+ init_windows()
25
+ print(escape_ansi("blue(hello red(world))"))
26
+ print(f"{blue}hello world{escape}")
27
+ ```
28
+
29
+ `escape_ansi` returns a string containing real ANSI SGR codes. `ansi_print` renders
30
+ markup and chooses whether to use color for the destination stream. Redirected
31
+ output is plain by default; use `color=True` to force ANSI sequences.
32
+
33
+ ## Install and develop
34
+
35
+ Install from PyPI with Python 3.14 or later:
36
+
37
+ ```sh
38
+ python -m pip install escape-colors
39
+ ```
40
+
41
+ From this checkout:
42
+
43
+ ```sh
44
+ uv sync --locked
45
+ uv run python examples/basic.py
46
+ python tools/review_source.py
47
+ uv run python -m unittest discover -s tests -v
48
+ uv build
49
+ uv run python tools/verify_distribution.py
50
+ ```
51
+
52
+ Install a built wheel into another Python environment with
53
+ `uv pip install /path/to/escape_colors-0.1.0-py3-none-any.whl`.
54
+
55
+ ## Releases
56
+
57
+ Publish a GitHub release with a tag matching the package version, such as
58
+ `v0.1.0`. `.github/workflows/release.yaml` tests and verifies the distributions
59
+ before publishing to PyPI through the `pypi` environment using Trusted Publishing.
60
+ The workflow can also be run manually against an existing release tag.
61
+
62
+ ## Colors and styles
63
+
64
+ The 16 named foreground colors are `black`, `red`, `green`, `yellow`, `blue`,
65
+ `magenta`, `cyan`, and `white`, plus `bright_black`, `bright_red`, `bright_green`,
66
+ `bright_yellow`, `bright_blue`, `bright_magenta`, `bright_cyan`, and `bright_white`.
67
+ Add `bg_` for a background: `bg_blue`, `bg_bright_white`, and so on. Each name is
68
+ both a markup expression and an importable ANSI string constant.
69
+
70
+ ```python
71
+ from escape_colors import background, color, escape, escape_ansi
72
+
73
+ text = escape_ansi("bright_blue(hello bg_white(world))")
74
+ indexed = escape_ansi("15(white) 196(red) bg_232(dark background)")
75
+ direct = f"{color(196)}red {background(232)}on dark{escape}"
76
+ ```
77
+
78
+ Numeric markup selects a **256-color palette index**, not an SGR parameter:
79
+ `15(text)` means `ESC[38;5;15m`, and `bg_15(text)` means `ESC[48;5;15m`.
80
+ Indices 0–15 address the base palette, 16–231 the color cube, and 232–255 the
81
+ grayscale ramp. The terminal controls the actual palette and appearance.
82
+ `color(index)` and `background(index)` accept integers 0–255; booleans, floats,
83
+ strings, and values outside that range are rejected.
84
+
85
+ | Expression / constant | SGR | Effect |
86
+ | --- | --- | --- |
87
+ | `bold` | 1 | Bold/increased intensity |
88
+ | `faint`, `dim` | 2 | Decreased intensity |
89
+ | `italic`, `italics` | 3 | Italic |
90
+ | `underline` | 4 | Underline |
91
+ | `double_underline` | 21 | Double underline |
92
+ | `blink` | 5 | Slow blink |
93
+ | `reverse` | 7 | Swap foreground/background |
94
+ | `conceal` | 8 | Conceal text |
95
+ | `strike`, `strikethrough` | 9 | Strike through |
96
+ | `overline` | 53 | Overline |
97
+ | `font0` … `font9` | 10 … 19 | Default/alternative terminal font |
98
+ | `escape`, `reset` | 0 | Reset constant for direct output |
99
+
100
+ Use `bold(text)`, `underline(text)`, `italics(text)`, or combine them by nesting.
101
+ When an inner expression closes, its enclosing styles are restored. A top-level
102
+ expression ends with a reset. Font selection, italics, blink, and other attributes
103
+ depend on terminal support. SGR does not select arbitrary installed typefaces or
104
+ font sizes; older terminals may treat SGR 21 as an intensity change.
105
+ `escape` and `reset` are constants, rather than markup expressions.
106
+
107
+ ## Grammar and literal text
108
+
109
+ Markup is case-sensitive. A standalone ASCII name or decimal index immediately
110
+ followed by `(` opens an expression. Whitespace between a name and `(` makes it
111
+ ordinary text. Unknown names and ordinary parentheses remain in the output;
112
+ recognized expressions inside unknown parentheses still work.
113
+
114
+ ```python
115
+ from escape_colors import escape_ansi, strip_ansi
116
+
117
+ assert strip_ansi(escape_ansi("blue(hello (world))")) == "hello (world)"
118
+ assert escape_ansi("unknown(hello)") == "unknown(hello)"
119
+ assert escape_ansi(r"blue\(literal\)") == "blue(literal)"
120
+ assert strip_ansi(escape_ansi(r"blue(a\) b\( c\\d)")) == "a) b( c\\d"
121
+ ```
122
+
123
+ `\(` and `\)` quote parentheses; `\\` quotes a backslash. Other backslashes,
124
+ Unicode characters, line breaks, and tabs are preserved. Recognized unclosed
125
+ expressions and invalid numeric color indices raise `ValueError` with a
126
+ zero-based character position. Unmatched ordinary parentheses remain literal.
127
+ The parser is iterative and does not evaluate Python or depend on its recursion
128
+ limit. Existing raw SGR codes pass through; scope restoration tracks markup
129
+ styles, rather than arbitrary control sequences embedded in the input.
130
+
131
+ ## Unicode, hex, and octal notation
132
+
133
+ These three Python literals hold exactly the same one-character string:
134
+
135
+ ```python
136
+ assert "\u001b" == "\x1b" == "\033" == chr(27)
137
+ ```
138
+
139
+ The spelling is consumed by Python when it creates the string. No format warning
140
+ is emitted, and normal terminal output is identical for all three choices.
141
+ `ESCAPE_COLOR_FORMAT` controls the visible representation produced by
142
+ `literal=True` or `format_ansi`, and defaults to `"unicode"`.
143
+
144
+ ```python
145
+ import escape_colors
146
+
147
+ escape_colors.ESCAPE_COLOR_FORMAT = "octal"
148
+ assert escape_colors.escape_ansi("blue(hello)", literal=True) == r"\033[34mhello\033[0m"
149
+ assert escape_colors.escape_ansi("blue(hello)", literal=True, format="hex") == r"\x1b[34mhello\x1b[0m"
150
+
151
+ # The live runtime string always contains actual ESC characters.
152
+ assert escape_colors.escape_ansi("blue(hello)") == "\u001b[34mhello\u001b[0m"
153
+ escape_colors.ESCAPE_COLOR_FORMAT = "unicode"
154
+ ```
155
+
156
+ Alternatively set the environment variable **before importing**:
157
+
158
+ ```sh
159
+ ESCAPE_COLOR_FORMAT=octal uv run python examples/basic.py
160
+ ```
161
+
162
+ On PowerShell, use `$env:ESCAPE_COLOR_FORMAT = 'octal'` before starting Python.
163
+ Accepted values are exactly `unicode`, `hex`, and `octal`. A per-call `format`
164
+ overrides the package setting. Assign `escape_colors.ESCAPE_COLOR_FORMAT` on the
165
+ module; assigning a separately imported local name does not update the module.
166
+ Invalid settings raise an error when rendering or serializing.
167
+
168
+ ```python
169
+ from escape_colors import blue, escape, escape_ansi, format_ansi, normalize_ansi
170
+
171
+ assert format_ansi(f"{blue}café{escape}", format="unicode") == r"\u001b[34mcafé\u001b[0m"
172
+ assert normalize_ansi(r"\x1b[34mcafé\033[0m") == f"{blue}café{escape}"
173
+ assert escape_ansi(r"\u001b[34mcafé\033[0m", decode=True) == f"{blue}café{escape}"
174
+ ```
175
+
176
+ Textual notation decoding is opt-in. `normalize_ansi` and `decode=True` recognize
177
+ only `\u001b`, `\x1b` (also with uppercase `B`), and `\033` followed by complete
178
+ numeric SGR sequences. They do not decode general Python escapes, change Unicode,
179
+ or decode a backslash-prefixed spelling. Literal output replaces ESC only: it is
180
+ readable escape notation, not a complete quoted/escaped Python string literal.
181
+
182
+ ## Printing and Windows
183
+
184
+ `ansi_print(*objects, sep=" ", end="\n", file=None, flush=False, color=None)`
185
+ renders each object's string representation and supports the usual `print`
186
+ arguments, including `sep=None` and `end=None`. It returns `None`.
187
+
188
+ ```python
189
+ import io
190
+ from escape_colors import ansi_print
191
+
192
+ capture = io.StringIO()
193
+ ansi_print("blue(hello)", "bold(world)", sep=" / ", end="!", file=capture)
194
+ assert capture.getvalue() == "hello / world!"
195
+ ansi_print("196(forced ANSI)", color=True)
196
+ ansi_print("blue(plain text)", color=False)
197
+ ```
198
+
199
+ - `color=None`: use color for a TTY unless `NO_COLOR` is nonempty or `TERM=dumb`.
200
+ Streams without a working `isatty()` get plain text. Windows also needs
201
+ successful VT initialization.
202
+ - `color=True`: attempt Windows initialization and emit ANSI, even on redirection
203
+ or if initialization fails. This overrides environment color preferences.
204
+ - `color=False`: remove rendered SGR codes, including existing SGR in objects,
205
+ separators, and endings.
206
+
207
+ `ansi_print` initializes Windows automatically for the destination stream.
208
+ `init_windows(file=None)` is available for direct `print`/f-string output and can
209
+ also initialize stderr or another console stream. It preserves existing console
210
+ mode bits and enables processed output and virtual-terminal processing. Repeated
211
+ calls are safe. It returns `True` on Windows success and `False` on other systems,
212
+ unsupported consoles, or redirected/invalid handles. Importing the module does
213
+ not change console state. Obsolete Windows consoles without VT support are not
214
+ emulated; automatic printing uses plain text there.
215
+
216
+ `strip_ansi` removes numeric SGR sequences only. These APIs format text and do not
217
+ sanitize arbitrary terminal control commands in untrusted input.
218
+
219
+ ## Verification and references
220
+
221
+ `python tools/review_source.py` performs a static review using only Python's
222
+ standard library. It checks first-party Python files for syntax errors and
223
+ `SyntaxWarning`s, checks runtime imports against the standard library and local
224
+ modules, and reports missing internal modules. It compiles source for validation
225
+ without executing the inspected code. Use `--format json` for structured results
226
+ or `--root /path/to/checkout` for another source tree. Findings exit with status 1;
227
+ invalid invocation exits with status 2. Review covers ordinary import statements,
228
+ including conditional imports; dynamic imports and imported attributes are
229
+ outside these checks. Runtime behavior is covered by the test suite.
230
+
231
+ CI tests Python 3.14 on Linux, macOS, and Windows, builds the wheel and source
232
+ distribution, and runs the suite from freshly installed wheel artifacts outside
233
+ the checkout. The verification script also rebuilds a wheel from the source
234
+ archive. Tests cover nested restoration, every indexed color, notation, Unicode,
235
+ printing, and Windows API behavior. Visual support still depends on the terminal.
236
+
237
+ - [Python string escapes](https://docs.python.org/3.14/reference/lexical_analysis.html#escape-sequences)
238
+ - [ANSI escape code overview](https://en.wikipedia.org/wiki/ANSI_escape_code)
239
+ - [Xterm control sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
240
+ - [Windows VT sequences](https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences)
241
+ - [SetConsoleMode](https://learn.microsoft.com/en-us/windows/console/setconsolemode)
@@ -0,0 +1,230 @@
1
+ # Escape Colors
2
+
3
+ Readable ANSI colors and nested text styles for Python 3.14+, with no runtime
4
+ dependencies.
5
+
6
+ ```python
7
+ from escape_colors import ansi_print, blue, escape, escape_ansi, init_windows
8
+
9
+ ansi_print("blue(hello world)")
10
+ ansi_print("bold(blue(hello red(world) again))")
11
+
12
+ # For direct print/f-string output on Windows:
13
+ init_windows()
14
+ print(escape_ansi("blue(hello red(world))"))
15
+ print(f"{blue}hello world{escape}")
16
+ ```
17
+
18
+ `escape_ansi` returns a string containing real ANSI SGR codes. `ansi_print` renders
19
+ markup and chooses whether to use color for the destination stream. Redirected
20
+ output is plain by default; use `color=True` to force ANSI sequences.
21
+
22
+ ## Install and develop
23
+
24
+ Install from PyPI with Python 3.14 or later:
25
+
26
+ ```sh
27
+ python -m pip install escape-colors
28
+ ```
29
+
30
+ From this checkout:
31
+
32
+ ```sh
33
+ uv sync --locked
34
+ uv run python examples/basic.py
35
+ python tools/review_source.py
36
+ uv run python -m unittest discover -s tests -v
37
+ uv build
38
+ uv run python tools/verify_distribution.py
39
+ ```
40
+
41
+ Install a built wheel into another Python environment with
42
+ `uv pip install /path/to/escape_colors-0.1.0-py3-none-any.whl`.
43
+
44
+ ## Releases
45
+
46
+ Publish a GitHub release with a tag matching the package version, such as
47
+ `v0.1.0`. `.github/workflows/release.yaml` tests and verifies the distributions
48
+ before publishing to PyPI through the `pypi` environment using Trusted Publishing.
49
+ The workflow can also be run manually against an existing release tag.
50
+
51
+ ## Colors and styles
52
+
53
+ The 16 named foreground colors are `black`, `red`, `green`, `yellow`, `blue`,
54
+ `magenta`, `cyan`, and `white`, plus `bright_black`, `bright_red`, `bright_green`,
55
+ `bright_yellow`, `bright_blue`, `bright_magenta`, `bright_cyan`, and `bright_white`.
56
+ Add `bg_` for a background: `bg_blue`, `bg_bright_white`, and so on. Each name is
57
+ both a markup expression and an importable ANSI string constant.
58
+
59
+ ```python
60
+ from escape_colors import background, color, escape, escape_ansi
61
+
62
+ text = escape_ansi("bright_blue(hello bg_white(world))")
63
+ indexed = escape_ansi("15(white) 196(red) bg_232(dark background)")
64
+ direct = f"{color(196)}red {background(232)}on dark{escape}"
65
+ ```
66
+
67
+ Numeric markup selects a **256-color palette index**, not an SGR parameter:
68
+ `15(text)` means `ESC[38;5;15m`, and `bg_15(text)` means `ESC[48;5;15m`.
69
+ Indices 0–15 address the base palette, 16–231 the color cube, and 232–255 the
70
+ grayscale ramp. The terminal controls the actual palette and appearance.
71
+ `color(index)` and `background(index)` accept integers 0–255; booleans, floats,
72
+ strings, and values outside that range are rejected.
73
+
74
+ | Expression / constant | SGR | Effect |
75
+ | --- | --- | --- |
76
+ | `bold` | 1 | Bold/increased intensity |
77
+ | `faint`, `dim` | 2 | Decreased intensity |
78
+ | `italic`, `italics` | 3 | Italic |
79
+ | `underline` | 4 | Underline |
80
+ | `double_underline` | 21 | Double underline |
81
+ | `blink` | 5 | Slow blink |
82
+ | `reverse` | 7 | Swap foreground/background |
83
+ | `conceal` | 8 | Conceal text |
84
+ | `strike`, `strikethrough` | 9 | Strike through |
85
+ | `overline` | 53 | Overline |
86
+ | `font0` … `font9` | 10 … 19 | Default/alternative terminal font |
87
+ | `escape`, `reset` | 0 | Reset constant for direct output |
88
+
89
+ Use `bold(text)`, `underline(text)`, `italics(text)`, or combine them by nesting.
90
+ When an inner expression closes, its enclosing styles are restored. A top-level
91
+ expression ends with a reset. Font selection, italics, blink, and other attributes
92
+ depend on terminal support. SGR does not select arbitrary installed typefaces or
93
+ font sizes; older terminals may treat SGR 21 as an intensity change.
94
+ `escape` and `reset` are constants, rather than markup expressions.
95
+
96
+ ## Grammar and literal text
97
+
98
+ Markup is case-sensitive. A standalone ASCII name or decimal index immediately
99
+ followed by `(` opens an expression. Whitespace between a name and `(` makes it
100
+ ordinary text. Unknown names and ordinary parentheses remain in the output;
101
+ recognized expressions inside unknown parentheses still work.
102
+
103
+ ```python
104
+ from escape_colors import escape_ansi, strip_ansi
105
+
106
+ assert strip_ansi(escape_ansi("blue(hello (world))")) == "hello (world)"
107
+ assert escape_ansi("unknown(hello)") == "unknown(hello)"
108
+ assert escape_ansi(r"blue\(literal\)") == "blue(literal)"
109
+ assert strip_ansi(escape_ansi(r"blue(a\) b\( c\\d)")) == "a) b( c\\d"
110
+ ```
111
+
112
+ `\(` and `\)` quote parentheses; `\\` quotes a backslash. Other backslashes,
113
+ Unicode characters, line breaks, and tabs are preserved. Recognized unclosed
114
+ expressions and invalid numeric color indices raise `ValueError` with a
115
+ zero-based character position. Unmatched ordinary parentheses remain literal.
116
+ The parser is iterative and does not evaluate Python or depend on its recursion
117
+ limit. Existing raw SGR codes pass through; scope restoration tracks markup
118
+ styles, rather than arbitrary control sequences embedded in the input.
119
+
120
+ ## Unicode, hex, and octal notation
121
+
122
+ These three Python literals hold exactly the same one-character string:
123
+
124
+ ```python
125
+ assert "\u001b" == "\x1b" == "\033" == chr(27)
126
+ ```
127
+
128
+ The spelling is consumed by Python when it creates the string. No format warning
129
+ is emitted, and normal terminal output is identical for all three choices.
130
+ `ESCAPE_COLOR_FORMAT` controls the visible representation produced by
131
+ `literal=True` or `format_ansi`, and defaults to `"unicode"`.
132
+
133
+ ```python
134
+ import escape_colors
135
+
136
+ escape_colors.ESCAPE_COLOR_FORMAT = "octal"
137
+ assert escape_colors.escape_ansi("blue(hello)", literal=True) == r"\033[34mhello\033[0m"
138
+ assert escape_colors.escape_ansi("blue(hello)", literal=True, format="hex") == r"\x1b[34mhello\x1b[0m"
139
+
140
+ # The live runtime string always contains actual ESC characters.
141
+ assert escape_colors.escape_ansi("blue(hello)") == "\u001b[34mhello\u001b[0m"
142
+ escape_colors.ESCAPE_COLOR_FORMAT = "unicode"
143
+ ```
144
+
145
+ Alternatively set the environment variable **before importing**:
146
+
147
+ ```sh
148
+ ESCAPE_COLOR_FORMAT=octal uv run python examples/basic.py
149
+ ```
150
+
151
+ On PowerShell, use `$env:ESCAPE_COLOR_FORMAT = 'octal'` before starting Python.
152
+ Accepted values are exactly `unicode`, `hex`, and `octal`. A per-call `format`
153
+ overrides the package setting. Assign `escape_colors.ESCAPE_COLOR_FORMAT` on the
154
+ module; assigning a separately imported local name does not update the module.
155
+ Invalid settings raise an error when rendering or serializing.
156
+
157
+ ```python
158
+ from escape_colors import blue, escape, escape_ansi, format_ansi, normalize_ansi
159
+
160
+ assert format_ansi(f"{blue}café{escape}", format="unicode") == r"\u001b[34mcafé\u001b[0m"
161
+ assert normalize_ansi(r"\x1b[34mcafé\033[0m") == f"{blue}café{escape}"
162
+ assert escape_ansi(r"\u001b[34mcafé\033[0m", decode=True) == f"{blue}café{escape}"
163
+ ```
164
+
165
+ Textual notation decoding is opt-in. `normalize_ansi` and `decode=True` recognize
166
+ only `\u001b`, `\x1b` (also with uppercase `B`), and `\033` followed by complete
167
+ numeric SGR sequences. They do not decode general Python escapes, change Unicode,
168
+ or decode a backslash-prefixed spelling. Literal output replaces ESC only: it is
169
+ readable escape notation, not a complete quoted/escaped Python string literal.
170
+
171
+ ## Printing and Windows
172
+
173
+ `ansi_print(*objects, sep=" ", end="\n", file=None, flush=False, color=None)`
174
+ renders each object's string representation and supports the usual `print`
175
+ arguments, including `sep=None` and `end=None`. It returns `None`.
176
+
177
+ ```python
178
+ import io
179
+ from escape_colors import ansi_print
180
+
181
+ capture = io.StringIO()
182
+ ansi_print("blue(hello)", "bold(world)", sep=" / ", end="!", file=capture)
183
+ assert capture.getvalue() == "hello / world!"
184
+ ansi_print("196(forced ANSI)", color=True)
185
+ ansi_print("blue(plain text)", color=False)
186
+ ```
187
+
188
+ - `color=None`: use color for a TTY unless `NO_COLOR` is nonempty or `TERM=dumb`.
189
+ Streams without a working `isatty()` get plain text. Windows also needs
190
+ successful VT initialization.
191
+ - `color=True`: attempt Windows initialization and emit ANSI, even on redirection
192
+ or if initialization fails. This overrides environment color preferences.
193
+ - `color=False`: remove rendered SGR codes, including existing SGR in objects,
194
+ separators, and endings.
195
+
196
+ `ansi_print` initializes Windows automatically for the destination stream.
197
+ `init_windows(file=None)` is available for direct `print`/f-string output and can
198
+ also initialize stderr or another console stream. It preserves existing console
199
+ mode bits and enables processed output and virtual-terminal processing. Repeated
200
+ calls are safe. It returns `True` on Windows success and `False` on other systems,
201
+ unsupported consoles, or redirected/invalid handles. Importing the module does
202
+ not change console state. Obsolete Windows consoles without VT support are not
203
+ emulated; automatic printing uses plain text there.
204
+
205
+ `strip_ansi` removes numeric SGR sequences only. These APIs format text and do not
206
+ sanitize arbitrary terminal control commands in untrusted input.
207
+
208
+ ## Verification and references
209
+
210
+ `python tools/review_source.py` performs a static review using only Python's
211
+ standard library. It checks first-party Python files for syntax errors and
212
+ `SyntaxWarning`s, checks runtime imports against the standard library and local
213
+ modules, and reports missing internal modules. It compiles source for validation
214
+ without executing the inspected code. Use `--format json` for structured results
215
+ or `--root /path/to/checkout` for another source tree. Findings exit with status 1;
216
+ invalid invocation exits with status 2. Review covers ordinary import statements,
217
+ including conditional imports; dynamic imports and imported attributes are
218
+ outside these checks. Runtime behavior is covered by the test suite.
219
+
220
+ CI tests Python 3.14 on Linux, macOS, and Windows, builds the wheel and source
221
+ distribution, and runs the suite from freshly installed wheel artifacts outside
222
+ the checkout. The verification script also rebuilds a wheel from the source
223
+ archive. Tests cover nested restoration, every indexed color, notation, Unicode,
224
+ printing, and Windows API behavior. Visual support still depends on the terminal.
225
+
226
+ - [Python string escapes](https://docs.python.org/3.14/reference/lexical_analysis.html#escape-sequences)
227
+ - [ANSI escape code overview](https://en.wikipedia.org/wiki/ANSI_escape_code)
228
+ - [Xterm control sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
229
+ - [Windows VT sequences](https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences)
230
+ - [SetConsoleMode](https://learn.microsoft.com/en-us/windows/console/setconsolemode)
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "escape-colors"
3
+ version = "0.1.0"
4
+ description = "ANSI colors, nested text styles, and portable terminal printing"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = []
8
+
9
+ [[project.authors]]
10
+ name = "ellainng"
11
+ email = "299958882+ellainng@users.noreply.github.com"
12
+
13
+ [project.urls]
14
+ Repository = "https://github.com/outside-labs/escape_colors"
15
+ Issues = "https://github.com/outside-labs/escape_colors/issues"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.19,<0.13.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "escape-colors"
3
+ version = "0.1.0"
4
+ description = "ANSI colors, nested text styles, and portable terminal printing"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = []
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/outside-labs/escape_colors"
14
+ Issues = "https://github.com/outside-labs/escape_colors/issues"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.19,<0.13.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,84 @@
1
+ """ANSI colors and formatting without runtime dependencies."""
2
+
3
+ import os as _os
4
+ import sys as _sys
5
+ from typing import TextIO as _TextIO
6
+
7
+ from ._codes import NAMED_CODES as _NAMED_CODES
8
+ from ._codes import RESET as escape
9
+ from ._codes import background, color
10
+ from ._notation import normalize_ansi, strip_ansi
11
+ from ._notation import require_text as _require_text
12
+ from ._notation import serialize as _serialize
13
+ from ._notation import validate_format as _validate_format
14
+ from ._parser import render as _render
15
+ from ._terminal import color_enabled as _color_enabled
16
+ from ._terminal import init_windows
17
+
18
+ ESCAPE_COLOR_FORMAT = _os.environ.get("ESCAPE_COLOR_FORMAT", "unicode")
19
+ reset = escape
20
+
21
+ # Named constants are ordinary strings, suitable for direct f-string use.
22
+ globals().update(_NAMED_CODES)
23
+
24
+
25
+ def format_ansi(text: str, *, format: str | None = None) -> str:
26
+ """Write ESC using unicode, hex, or octal notation, preserving other text."""
27
+ return _serialize(text, ESCAPE_COLOR_FORMAT if format is None else format)
28
+
29
+
30
+ def escape_ansi(
31
+ text: str,
32
+ *,
33
+ format: str | None = None,
34
+ literal: bool = False,
35
+ decode: bool = False,
36
+ ) -> str:
37
+ """Render nested styles; optionally decode or serialize textual ESC notation."""
38
+ _require_text(text)
39
+ selected = _validate_format(ESCAPE_COLOR_FORMAT if format is None else format)
40
+ if not isinstance(literal, bool) or not isinstance(decode, bool):
41
+ raise TypeError("literal and decode must be bools")
42
+ rendered = _render(normalize_ansi(text) if decode else text)
43
+ return _serialize(rendered, selected) if literal else rendered
44
+
45
+
46
+ def ansi_print(
47
+ *objects: object,
48
+ sep: str | None = " ",
49
+ end: str | None = "\n",
50
+ file: _TextIO | None = None,
51
+ flush: bool = False,
52
+ color: bool | None = None,
53
+ ) -> None:
54
+ """Print nested markup, choosing color automatically unless overridden."""
55
+ if sep is not None and not isinstance(sep, str):
56
+ raise TypeError("sep must be None or a string")
57
+ if end is not None and not isinstance(end, str):
58
+ raise TypeError("end must be None or a string")
59
+ stream = _sys.stdout if file is None else file
60
+ enabled = _color_enabled(stream, color)
61
+ if stream is None:
62
+ return
63
+ rendered = [escape_ansi(str(obj)) for obj in objects]
64
+ if not enabled:
65
+ rendered = [strip_ansi(text) for text in rendered]
66
+ sep = strip_ansi(sep) if sep is not None else None
67
+ end = strip_ansi(end) if end is not None else None
68
+ print(*rendered, sep=sep, end=end, file=stream, flush=flush)
69
+
70
+
71
+ __all__ = [
72
+ "ESCAPE_COLOR_FORMAT",
73
+ "ansi_print",
74
+ "background",
75
+ "color",
76
+ "escape",
77
+ "escape_ansi",
78
+ "format_ansi",
79
+ "init_windows",
80
+ "normalize_ansi",
81
+ "reset",
82
+ "strip_ansi",
83
+ *_NAMED_CODES,
84
+ ]
@@ -0,0 +1,55 @@
1
+ """Select Graphic Rendition codes and indexed palette helpers."""
2
+
3
+ ESC = "\u001b"
4
+ RESET = f"{ESC}[0m"
5
+
6
+
7
+ def _sgr(*parameters: int) -> str:
8
+ return f"{ESC}[{';'.join(map(str, parameters))}m"
9
+
10
+
11
+ def _palette_index(index: int) -> int:
12
+ if isinstance(index, bool) or not isinstance(index, int):
13
+ raise TypeError("color index must be an integer")
14
+ if not 0 <= index <= 255:
15
+ raise ValueError("color index must be between 0 and 255")
16
+ return index
17
+
18
+
19
+ def color(index: int) -> str:
20
+ """Return the foreground SGR sequence for palette index 0 through 255."""
21
+ return _sgr(38, 5, _palette_index(index))
22
+
23
+
24
+ def background(index: int) -> str:
25
+ """Return the background SGR sequence for palette index 0 through 255."""
26
+ return _sgr(48, 5, _palette_index(index))
27
+
28
+
29
+ COLOR_NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
30
+ NAMED_CODES = {}
31
+ for _offset, _name in enumerate(COLOR_NAMES):
32
+ NAMED_CODES[_name] = _sgr(30 + _offset)
33
+ NAMED_CODES[f"bright_{_name}"] = _sgr(90 + _offset)
34
+ NAMED_CODES[f"bg_{_name}"] = _sgr(40 + _offset)
35
+ NAMED_CODES[f"bg_bright_{_name}"] = _sgr(100 + _offset)
36
+
37
+ for _name, _parameter in {
38
+ "bold": 1,
39
+ "faint": 2,
40
+ "dim": 2,
41
+ "italic": 3,
42
+ "italics": 3,
43
+ "underline": 4,
44
+ "double_underline": 21,
45
+ "blink": 5,
46
+ "reverse": 7,
47
+ "conceal": 8,
48
+ "strike": 9,
49
+ "strikethrough": 9,
50
+ "overline": 53,
51
+ }.items():
52
+ NAMED_CODES[_name] = _sgr(_parameter)
53
+
54
+ for _font in range(10):
55
+ NAMED_CODES[f"font{_font}"] = _sgr(10 + _font)
@@ -0,0 +1,39 @@
1
+ """Represent ESC without decoding unrelated user text."""
2
+
3
+ import re
4
+
5
+ from ._codes import ESC
6
+
7
+ SPELLINGS = {"unicode": r"\u001b", "hex": r"\x1b", "octal": r"\033"}
8
+ SGR_PATTERN = re.compile(r"\u001b\[[0-9;]*m")
9
+ _LITERAL_ESC = re.compile(r"(?<!\\)(?:\\u001[bB]|\\x1[bB]|\\033)(?=\[[0-9;]*m)")
10
+
11
+
12
+ def validate_format(format: str) -> str:
13
+ if not isinstance(format, str):
14
+ raise TypeError("escape format must be a string")
15
+ if format not in SPELLINGS:
16
+ raise ValueError("escape format must be 'unicode', 'hex', or 'octal'")
17
+ return format
18
+
19
+
20
+ def require_text(text: str) -> None:
21
+ if not isinstance(text, str):
22
+ raise TypeError("text must be a string")
23
+
24
+
25
+ def serialize(text: str, format: str) -> str:
26
+ require_text(text)
27
+ return text.replace(ESC, SPELLINGS[validate_format(format)])
28
+
29
+
30
+ def normalize_ansi(text: str) -> str:
31
+ """Decode textual ESC spellings only before complete numeric SGR codes."""
32
+ require_text(text)
33
+ return _LITERAL_ESC.sub(lambda match: ESC, text)
34
+
35
+
36
+ def strip_ansi(text: str) -> str:
37
+ """Remove numeric SGR codes; leave other control sequences untouched."""
38
+ require_text(text)
39
+ return SGR_PATTERN.sub("", text)
@@ -0,0 +1,73 @@
1
+ """An iterative parser for nested style expressions."""
2
+
3
+ import re
4
+
5
+ from ._codes import NAMED_CODES, RESET, background, color
6
+
7
+ _OPEN = re.compile(r"(bg_-?[0-9]+|[A-Za-z_][A-Za-z_0-9]*|-?[0-9]+)\(")
8
+ _INDEX = re.compile(r"-?[0-9]+\Z")
9
+
10
+
11
+ def _indexed_code(token: str, position: int, *, bg: bool = False) -> str:
12
+ # Avoid Python's large integer string limit and unnecessary conversion work.
13
+ digits = token.lstrip("0") or "0"
14
+ if token.startswith("-") or len(digits) > 3 or int(digits) > 255:
15
+ raise ValueError(f"color index must be between 0 and 255 at position {position}")
16
+ return background(int(digits)) if bg else color(int(digits))
17
+
18
+
19
+ def _style_code(token: str, position: int) -> str | None:
20
+ if token in NAMED_CODES:
21
+ return NAMED_CODES[token]
22
+ if _INDEX.fullmatch(token):
23
+ return _indexed_code(token, position)
24
+ if token.startswith("bg_") and _INDEX.fullmatch(token[3:]):
25
+ return _indexed_code(token[3:], position, bg=True)
26
+ return None
27
+
28
+
29
+ def render(text: str) -> str:
30
+ output = []
31
+ frames: list[tuple[str | None, int]] = []
32
+ active: list[str] = []
33
+ position = 0
34
+
35
+ while position < len(text):
36
+ char = text[position]
37
+ if char == "\\" and position + 1 < len(text) and text[position + 1] in "()\\":
38
+ output.append(text[position + 1])
39
+ position += 2
40
+ continue
41
+
42
+ standalone = position == 0 or not (text[position - 1].isalnum() or text[position - 1] == "_")
43
+ opening = _OPEN.match(text, position) if standalone else None
44
+ if opening:
45
+ code = _style_code(opening[1], position)
46
+ frames.append((code, position))
47
+ if code is None:
48
+ output.append(opening[0])
49
+ else:
50
+ output.append(code)
51
+ active.append(code)
52
+ position = opening.end()
53
+ continue
54
+
55
+ if char == "(":
56
+ frames.append((None, position))
57
+ output.append(char)
58
+ elif char == ")" and frames:
59
+ code, _ = frames.pop()
60
+ if code is None:
61
+ output.append(char)
62
+ else:
63
+ active.pop()
64
+ output.append(RESET)
65
+ output.extend(active)
66
+ else:
67
+ output.append(char)
68
+ position += 1
69
+
70
+ for code, start in frames:
71
+ if code is not None:
72
+ raise ValueError(f"unclosed style expression at position {start}")
73
+ return "".join(output)
@@ -0,0 +1,59 @@
1
+ """Destination-specific terminal detection and Windows VT initialization."""
2
+
3
+ import ctypes
4
+ import os
5
+ import sys
6
+ from typing import TextIO
7
+
8
+ _PROCESSED_OUTPUT = 0x0001
9
+ _VIRTUAL_TERMINAL_PROCESSING = 0x0004
10
+
11
+
12
+ def init_windows(file: TextIO | None = None) -> bool:
13
+ """Enable Windows VT output on a stream; return False when unavailable."""
14
+ if os.name != "nt":
15
+ return False
16
+ stream = sys.stdout if file is None else file
17
+ try:
18
+ import msvcrt
19
+ from ctypes import wintypes
20
+
21
+ handle = msvcrt.get_osfhandle(stream.fileno())
22
+ if handle in (-1, 0):
23
+ return False
24
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
25
+ get_mode = kernel.GetConsoleMode
26
+ get_mode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
27
+ get_mode.restype = wintypes.BOOL
28
+ set_mode = kernel.SetConsoleMode
29
+ set_mode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
30
+ set_mode.restype = wintypes.BOOL
31
+
32
+ mode = wintypes.DWORD()
33
+ if not get_mode(handle, ctypes.byref(mode)):
34
+ return False
35
+ desired = mode.value | _PROCESSED_OUTPUT | _VIRTUAL_TERMINAL_PROCESSING
36
+ if desired == mode.value:
37
+ return True
38
+ return bool(set_mode(handle, desired))
39
+ except (AttributeError, ImportError, OSError, TypeError, ValueError, OverflowError):
40
+ return False
41
+
42
+
43
+ def color_enabled(stream: TextIO, color: bool | None) -> bool:
44
+ if color is not None and not isinstance(color, bool):
45
+ raise TypeError("color must be True, False, or None")
46
+ if color is False:
47
+ return False
48
+ if color is True:
49
+ if os.name == "nt":
50
+ init_windows(stream)
51
+ return True
52
+ if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
53
+ return False
54
+ try:
55
+ if not stream.isatty():
56
+ return False
57
+ except (AttributeError, OSError, ValueError):
58
+ return False
59
+ return init_windows(stream) if os.name == "nt" else True