clitint 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.
clitint-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christos Synodinos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
clitint-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: clitint
3
+ Version: 0.1.0
4
+ Summary: Coloured terminal text with no dependencies, plain wherever it isn't read on a terminal.
5
+ License-Expression: MIT
6
+ Keywords: terminal,colour,color,ansi,cli
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Terminals
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: mypy; extra == "dev"
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: twine; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # clitint
27
+
28
+ Coloured terminal text for Python 3.13+, with no dependencies. Text is coloured only when someone is reading it
29
+ on a terminal: redirect the output to a file, a pipe or a CI log and it comes out plain.
30
+
31
+ ```python
32
+ from clitint import formatter
33
+
34
+ print(formatter("error:", "bold_red"), "file not found")
35
+ print(formatter("warning", "underline_black_highlight_yellow"))
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install clitint
42
+ ```
43
+
44
+ ## Specs
45
+
46
+ A spec is a set of styles joined by `_`, in any order: `"red"`, `"bold_red"`, `"red_bold"`, `"italic_white_highlight_blue"`.
47
+
48
+ | Kind | Names |
49
+ |---|---|
50
+ | Attributes | `bold`, `dim`, `italic`, `underline` |
51
+ | Colours | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `grey`, `orange` |
52
+ | Backgrounds | `highlight_` followed by a colour: `highlight_blue` |
53
+
54
+
55
+ You can also combine: bold_highlight_blue, italic_red, underline_green, etc.
56
+
57
+ An unknown name, two colours or two backgrounds raise a `ValueError` that names the problem. `formatter` takes any
58
+ object and writes its `str`. Each spec is worked out once and remembered.
59
+
60
+ ## When text is coloured
61
+
62
+ `formatter` decides at every call, in this order:
63
+
64
+ 1. what the program chose: `turn_colours_off()` or `turn_colours_on()` (`let_the_environment_decide()` undoes it);
65
+ 2. `CLITINT_COLOURS_OFF=1`, `NO_COLOR` or `ANSI_COLORS_DISABLED`: plain;
66
+ 3. `FORCE_COLOR`: coloured;
67
+ 4. `TERM=dumb`: plain;
68
+ 5. otherwise, coloured only when **both** stdout and stderr are terminals.
69
+
70
+ The last rule is there because a string doesn't know where it is going. An error message might be printed,
71
+ logged or raised. `2> errors.log` with stdout on a terminal still gives a plain message, so no escape codes end up
72
+ in the log.
73
+
74
+ `colours_on()` tells you the answer now.
75
+
76
+ ## Other tools
77
+
78
+ ```python
79
+ from clitint import strip_colours, escapes, one_decision, coloured
80
+
81
+ strip_colours(formatter("x", "red")) # 'x': every ANSI style sequence removed (for tests and logs)
82
+
83
+ with one_decision(): # one message: decide once, not at every formatter call
84
+ line = formatter("a", "red") + " " + formatter("b", "blue")
85
+
86
+ red, reset = escapes("red") # the raw sequences, for a hot f-string
87
+ text = f"{red}x{reset}" if coloured() else "x"
88
+ ```
89
+
90
+ ## Licence
91
+
92
+ MIT
@@ -0,0 +1,67 @@
1
+ # clitint
2
+
3
+ Coloured terminal text for Python 3.13+, with no dependencies. Text is coloured only when someone is reading it
4
+ on a terminal: redirect the output to a file, a pipe or a CI log and it comes out plain.
5
+
6
+ ```python
7
+ from clitint import formatter
8
+
9
+ print(formatter("error:", "bold_red"), "file not found")
10
+ print(formatter("warning", "underline_black_highlight_yellow"))
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install clitint
17
+ ```
18
+
19
+ ## Specs
20
+
21
+ A spec is a set of styles joined by `_`, in any order: `"red"`, `"bold_red"`, `"red_bold"`, `"italic_white_highlight_blue"`.
22
+
23
+ | Kind | Names |
24
+ |---|---|
25
+ | Attributes | `bold`, `dim`, `italic`, `underline` |
26
+ | Colours | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `grey`, `orange` |
27
+ | Backgrounds | `highlight_` followed by a colour: `highlight_blue` |
28
+
29
+
30
+ You can also combine: bold_highlight_blue, italic_red, underline_green, etc.
31
+
32
+ An unknown name, two colours or two backgrounds raise a `ValueError` that names the problem. `formatter` takes any
33
+ object and writes its `str`. Each spec is worked out once and remembered.
34
+
35
+ ## When text is coloured
36
+
37
+ `formatter` decides at every call, in this order:
38
+
39
+ 1. what the program chose: `turn_colours_off()` or `turn_colours_on()` (`let_the_environment_decide()` undoes it);
40
+ 2. `CLITINT_COLOURS_OFF=1`, `NO_COLOR` or `ANSI_COLORS_DISABLED`: plain;
41
+ 3. `FORCE_COLOR`: coloured;
42
+ 4. `TERM=dumb`: plain;
43
+ 5. otherwise, coloured only when **both** stdout and stderr are terminals.
44
+
45
+ The last rule is there because a string doesn't know where it is going. An error message might be printed,
46
+ logged or raised. `2> errors.log` with stdout on a terminal still gives a plain message, so no escape codes end up
47
+ in the log.
48
+
49
+ `colours_on()` tells you the answer now.
50
+
51
+ ## Other tools
52
+
53
+ ```python
54
+ from clitint import strip_colours, escapes, one_decision, coloured
55
+
56
+ strip_colours(formatter("x", "red")) # 'x': every ANSI style sequence removed (for tests and logs)
57
+
58
+ with one_decision(): # one message: decide once, not at every formatter call
59
+ line = formatter("a", "red") + " " + formatter("b", "blue")
60
+
61
+ red, reset = escapes("red") # the raw sequences, for a hot f-string
62
+ text = f"{red}x{reset}" if coloured() else "x"
63
+ ```
64
+
65
+ ## Licence
66
+
67
+ MIT
@@ -0,0 +1,42 @@
1
+ """clitint: coloured terminal text with no dependencies, plain wherever it isn't read on a terminal."""
2
+ from ._tint import (
3
+ formatter,
4
+ strip_colours,
5
+ colours_on,
6
+ turn_colours_off,
7
+ turn_colours_on,
8
+ let_the_environment_decide,
9
+ coloured,
10
+ decide,
11
+ decision,
12
+ release,
13
+ add_off_switch,
14
+ escapes,
15
+ one_decision,
16
+ ATTRIBUTES,
17
+ COLOURS,
18
+ BACKGROUNDS,
19
+ RESET,
20
+ )
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "formatter",
26
+ "strip_colours",
27
+ "colours_on",
28
+ "turn_colours_off",
29
+ "turn_colours_on",
30
+ "let_the_environment_decide",
31
+ "one_decision",
32
+ "coloured",
33
+ "decide",
34
+ "decision",
35
+ "release",
36
+ "add_off_switch",
37
+ "escapes",
38
+ "ATTRIBUTES",
39
+ "COLOURS",
40
+ "BACKGROUNDS",
41
+ "RESET",
42
+ ]
@@ -0,0 +1,291 @@
1
+ from typing import Any
2
+ import re
3
+ import os
4
+ import sys
5
+
6
+
7
+ #* The variables that decide whether text is coloured (see ``colours_on``), in the order they are asked.
8
+ COLOURS_OFF = "CLITINT_COLOURS_OFF"
9
+ NO_COLOR = "NO_COLOR"
10
+ ANSI_COLORS_DISABLED = "ANSI_COLORS_DISABLED"
11
+ FORCE_COLOR = "FORCE_COLOR"
12
+ TERM = "TERM"
13
+
14
+ #* The attributes a spec may name, and the sequence that switches each on. Written in front of a colour.
15
+ ATTRIBUTES: dict[str, str] = {
16
+ "bold": "\x1b[1m", "dim": "\x1b[2m", "italic": "\x1b[3m", "underline": "\x1b[4m",
17
+ }
18
+ #* The colours, as the text's colour. ``highlight_<colour>`` is the same colour as its background.
19
+ #* The sequences are the ones termcolor writes (``orange`` is its (255, 165, 0)), so text made
20
+ #* with termcolor and with clitint is the same, byte for byte.
21
+ COLOURS: dict[str, str] = {
22
+ "black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m", "yellow": "\x1b[33m", "blue": "\x1b[34m",
23
+ "magenta": "\x1b[35m", "cyan": "\x1b[36m", "white": "\x1b[37m", "grey": "\x1b[90m",
24
+ "orange": "\x1b[38;2;255;165;0m",
25
+ }
26
+ BACKGROUNDS: dict[str, str] = {
27
+ "black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m", "yellow": "\x1b[43m", "blue": "\x1b[44m",
28
+ "magenta": "\x1b[45m", "cyan": "\x1b[46m", "white": "\x1b[47m", "grey": "\x1b[100m",
29
+ "orange": "\x1b[48;2;255;165;0m",
30
+ }
31
+ RESET = "\x1b[0m"
32
+
33
+
34
+ #* What ``turn_colours_off`` or ``turn_colours_on`` chose, or None: the environment and the streams decide.
35
+ _CHOSEN: bool | None = None
36
+
37
+
38
+ def turn_colours_off() -> None:
39
+ """Plain text from now on, whatever the environment and the streams say (as ``CLITINT_COLOURS_OFF=1``)."""
40
+ global _CHOSEN
41
+ _CHOSEN = False
42
+
43
+
44
+ def turn_colours_on() -> None:
45
+ """Coloured text from now on, also where it goes to no terminal (as ``FORCE_COLOR``)."""
46
+ global _CHOSEN
47
+ _CHOSEN = True
48
+
49
+
50
+ def let_the_environment_decide() -> None:
51
+ """Undo ``turn_colours_off`` / ``turn_colours_on``: the environment and the streams decide again."""
52
+ global _CHOSEN
53
+ _CHOSEN = None
54
+
55
+
56
+ def add_off_switch(name: str) -> None:
57
+ """
58
+ Make ``name=1`` switch colours off too, as ``CLITINT_COLOURS_OFF=1`` does: for a library
59
+ that offers its users a variable of its own (``add_off_switch("MYLIB_COLOURS_OFF")``). Only
60
+ the value ``"1"`` counts. Asked with the other off switches, before ``FORCE_COLOR``.
61
+ """
62
+ global _OFF, _K_OFFS
63
+ if not isinstance(name, str) or not name:
64
+ raise TypeError(f"an off switch is the name of an environment variable, not {name!r}")
65
+ if name in _OFF:
66
+ return
67
+ _OFF = (*_OFF, name)
68
+ if _FAST:
69
+ _K_OFFS = (*_K_OFFS, _encode_key(name))
70
+
71
+
72
+ def colours_on() -> bool:
73
+ """
74
+ Whether text is coloured now.
75
+
76
+ A string doesn't know where it will end up: an uncaught error goes to stderr, ``print`` to
77
+ stdout, a logger to a file. So colours are used only when both standard streams are
78
+ terminals. Redirect either one (``2> errors.log``, ``| less``, a CI job) and text is plain:
79
+ no escape sequence lands in a file.
80
+
81
+ ``turn_colours_off`` and ``turn_colours_on`` decide before anything else. Then the
82
+ environment, in this order: ``CLITINT_COLOURS_OFF=1`` (and each ``add_off_switch``), ``NO_COLOR`` and
83
+ ``ANSI_COLORS_DISABLED`` switch colours off, ``FORCE_COLOR`` switches them on whatever the
84
+ streams are, and ``TERM=dumb`` switches them off. Read at every call, so a change takes
85
+ effect at once.
86
+ """
87
+ if _CHOSEN is not None: # turn_colours_off / turn_colours_on: the program has said
88
+ return _CHOSEN
89
+ env = os.environ
90
+ #* Read from ``os.environ`` as it is now: a program may have put another mapping in its place.
91
+ data = getattr(env, "_data", None) if _FAST else None
92
+ if type(data) is not dict:
93
+ if NO_COLOR in env or ANSI_COLORS_DISABLED in env:
94
+ return False
95
+ for name in _OFF:
96
+ if env.get(name) == "1":
97
+ return False
98
+ if FORCE_COLOR in env:
99
+ return True
100
+ if env.get(TERM) == "dumb":
101
+ return False
102
+ return _both_terminals()
103
+ if _K_NO_COLOR in data or _K_ANSI in data:
104
+ return False
105
+ for key in _K_OFFS:
106
+ if data.get(key) == _V_ONE:
107
+ return False
108
+ if _K_FORCE in data:
109
+ return True
110
+ if data.get(_K_TERM) == _V_DUMB:
111
+ return False
112
+ return _both_terminals()
113
+
114
+
115
+ #* ``os.environ`` encodes the name at every lookup and decodes the value: five of them took 1 µs.
116
+ #* Below it is the dictionary it keeps, which is asked directly, with the names and the two
117
+ #* values encoded once, the way ``os.environ`` does it on this platform. It is the same data:
118
+ #* what ``os.environ`` is given lands there. Where a Python has no such dictionary (it is a
119
+ #* CPython detail), ``os.environ`` is asked as usual.
120
+ #* The variables that switch colours off with the value "1": clitint's own, then each ``add_off_switch``.
121
+ _OFF: tuple[str, ...] = (COLOURS_OFF,)
122
+ _K_OFFS: tuple[Any, ...] = ()
123
+ _FAST = False
124
+ try:
125
+ _encode_key, _encode_value = os.environ.encodekey, os.environ.encodevalue
126
+ _K_OFFS = (_encode_key(COLOURS_OFF),)
127
+ _K_NO_COLOR, _K_ANSI, _K_FORCE, _K_TERM = (
128
+ _encode_key(name) for name in (NO_COLOR, ANSI_COLORS_DISABLED, FORCE_COLOR, TERM)
129
+ )
130
+ _V_ONE, _V_DUMB = _encode_value("1"), _encode_value("dumb")
131
+ _FAST = type(os.environ._data) is dict # type: ignore[attr-defined]
132
+ except Exception:
133
+ _FAST = False
134
+
135
+
136
+ #* (sys.stdout, sys.stderr, whether both are terminals): asked again only when a stream was replaced.
137
+ _streams: tuple[Any, Any, bool] = (None, None, False)
138
+
139
+
140
+ def _both_terminals() -> bool:
141
+ global _streams
142
+ out, err = sys.stdout, sys.stderr
143
+ if _streams[0] is out and _streams[1] is err:
144
+ return _streams[2]
145
+ answer = _is_terminal(out) and _is_terminal(err)
146
+ _streams = (out, err, answer)
147
+ return answer
148
+
149
+
150
+ def _is_terminal(stream: Any) -> bool:
151
+ try:
152
+ return bool(stream.isatty())
153
+ except Exception: # no stream at all (pythonw), a closed one, an object without isatty
154
+ return False
155
+
156
+
157
+ #* While one message is built: the answer of ``colours_on`` for all of it (None: ask each time).
158
+ _decided: bool | None = None
159
+
160
+
161
+ def decide() -> bool:
162
+ """
163
+ Ask ``colours_on`` once and keep the answer: every ``formatter`` and ``coloured`` call gives
164
+ it until ``release()``. For a message built of many coloured words (the environment is slow
165
+ to read). The functions are the fast form of ``one_decision``; mind to call ``release``.
166
+ """
167
+ global _decided
168
+ _decided = answer = colours_on()
169
+ return answer
170
+
171
+
172
+ def decision() -> bool | None:
173
+ """The answer ``decide`` kept, or None where nothing has been decided."""
174
+ return _decided
175
+
176
+
177
+ def release() -> None:
178
+ """Forget the answer ``decide`` kept: each call asks ``colours_on`` again."""
179
+ global _decided
180
+ _decided = None
181
+
182
+
183
+ class one_decision:
184
+ """
185
+ ``with one_decision():`` around the building of one message asks once whether it is
186
+ coloured, where every ``formatter`` call in it would ask again. Inside another one it keeps
187
+ the outer answer, and leaves it in place. Not for threads that build messages at once: the
188
+ decision is the module's.
189
+ """
190
+ __slots__ = ("_outer",)
191
+
192
+ def __enter__(self) -> bool:
193
+ global _decided
194
+ self._outer = _decided
195
+ if _decided is None:
196
+ _decided = colours_on()
197
+ return _decided
198
+
199
+ def __exit__(self, *_: Any) -> None:
200
+ global _decided
201
+ _decided = self._outer
202
+ return
203
+
204
+
205
+ def coloured() -> bool:
206
+ """Whether the message being built is coloured: the one decision made for it, or ``colours_on()``."""
207
+ return colours_on() if _decided is None else _decided
208
+
209
+
210
+ def _escapes(spec: str) -> tuple[str, str]:
211
+ """
212
+ What is written in front of and behind a text for ``spec``: the attributes' sequences, then
213
+ the colour's, then the background's, and the sequence that switches everything off.
214
+ """
215
+ attributes: list[str] = []
216
+ colour = background = ""
217
+ names = spec.split("_")
218
+ i = 0
219
+ while i < len(names):
220
+ name = names[i]
221
+ if name == "highlight" and i + 1 < len(names) and names[i + 1] in BACKGROUNDS:
222
+ if background:
223
+ raise ValueError(f"two backgrounds in {spec!r}")
224
+ background = BACKGROUNDS[names[i + 1]]
225
+ i += 2
226
+ continue
227
+ if name in ATTRIBUTES:
228
+ if ATTRIBUTES[name] not in attributes:
229
+ attributes.append(ATTRIBUTES[name])
230
+ elif name in COLOURS:
231
+ if colour and colour != COLOURS[name]:
232
+ raise ValueError(f"two colours in {spec!r}: name one, and 'highlight_<colour>' for a background")
233
+ colour = COLOURS[name]
234
+ else:
235
+ raise ValueError(
236
+ f"unknown style {name!r} in {spec!r}: a spec is made of {', '.join(ATTRIBUTES)}, "
237
+ f"{', '.join(COLOURS)} and highlight_<colour>, joined by '_'"
238
+ )
239
+ i += 1
240
+ order = list(ATTRIBUTES.values())
241
+ attributes.sort(key = order.index)
242
+ return "".join(attributes) + colour + background, RESET
243
+
244
+
245
+ #* spec -> what ``_escapes`` answered.
246
+ _ESCAPES: dict[str, tuple[str, str]] = {}
247
+
248
+
249
+ def formatter(text: Any, spec: str) -> str:
250
+ """
251
+ ``text`` in the styles ``spec`` names, or as it is when colours are off (see ``colours_on``).
252
+
253
+ Args:
254
+ text (Any): The text, or any object: its ``str`` is what is written.
255
+ spec (str): Styles joined by ``_``, in any order: ``"bold_red"``, ``"cyan"``,
256
+ ``"underline_white_highlight_blue"``. Attributes: ``bold``, ``dim``, ``italic``,
257
+ ``underline``. Colours: ``black``, ``red``, ``green``, ``yellow``, ``blue``,
258
+ ``magenta``, ``cyan``, ``white``, ``grey``, ``orange``; ``highlight_<colour>`` is a
259
+ background. Anything else is a ``ValueError``.
260
+
261
+ Returns:
262
+ str: The text between the escape sequences of its styles, or the plain text.
263
+
264
+ Example:
265
+ >>> formatter("hello", "bold_red")
266
+ '\\x1b[1m\\x1b[31mhello\\x1b[0m'
267
+ """
268
+ if not (colours_on() if _decided is None else _decided):
269
+ return str(text)
270
+ escapes = _ESCAPES.get(spec)
271
+ if escapes is None:
272
+ escapes = _ESCAPES[spec] = _escapes(spec)
273
+ return escapes[0] + str(text) + escapes[1]
274
+
275
+
276
+ def escapes(*specs: str) -> tuple[str, ...]:
277
+ """
278
+ What is written in front of and behind a text for each of ``specs``, in one flat tuple:
279
+ ``before, after, before, after ...``. For a message put together in one f-string, with the
280
+ sequences as constants: cheaper than a ``formatter`` call per coloured word. Always the
281
+ sequences, whatever ``colours_on`` says: the caller decides (``coloured()``).
282
+ """
283
+ return tuple([part for spec in specs for part in _escapes(spec)])
284
+
285
+
286
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
287
+
288
+
289
+ def strip_colours(text: str) -> str:
290
+ """``text`` without its colours: every ANSI style sequence taken out, the words left as they are."""
291
+ return _ANSI.sub("", text)
File without changes
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: clitint
3
+ Version: 0.1.0
4
+ Summary: Coloured terminal text with no dependencies, plain wherever it isn't read on a terminal.
5
+ License-Expression: MIT
6
+ Keywords: terminal,colour,color,ansi,cli
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Terminals
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: mypy; extra == "dev"
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: twine; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # clitint
27
+
28
+ Coloured terminal text for Python 3.13+, with no dependencies. Text is coloured only when someone is reading it
29
+ on a terminal: redirect the output to a file, a pipe or a CI log and it comes out plain.
30
+
31
+ ```python
32
+ from clitint import formatter
33
+
34
+ print(formatter("error:", "bold_red"), "file not found")
35
+ print(formatter("warning", "underline_black_highlight_yellow"))
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install clitint
42
+ ```
43
+
44
+ ## Specs
45
+
46
+ A spec is a set of styles joined by `_`, in any order: `"red"`, `"bold_red"`, `"red_bold"`, `"italic_white_highlight_blue"`.
47
+
48
+ | Kind | Names |
49
+ |---|---|
50
+ | Attributes | `bold`, `dim`, `italic`, `underline` |
51
+ | Colours | `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `grey`, `orange` |
52
+ | Backgrounds | `highlight_` followed by a colour: `highlight_blue` |
53
+
54
+
55
+ You can also combine: bold_highlight_blue, italic_red, underline_green, etc.
56
+
57
+ An unknown name, two colours or two backgrounds raise a `ValueError` that names the problem. `formatter` takes any
58
+ object and writes its `str`. Each spec is worked out once and remembered.
59
+
60
+ ## When text is coloured
61
+
62
+ `formatter` decides at every call, in this order:
63
+
64
+ 1. what the program chose: `turn_colours_off()` or `turn_colours_on()` (`let_the_environment_decide()` undoes it);
65
+ 2. `CLITINT_COLOURS_OFF=1`, `NO_COLOR` or `ANSI_COLORS_DISABLED`: plain;
66
+ 3. `FORCE_COLOR`: coloured;
67
+ 4. `TERM=dumb`: plain;
68
+ 5. otherwise, coloured only when **both** stdout and stderr are terminals.
69
+
70
+ The last rule is there because a string doesn't know where it is going. An error message might be printed,
71
+ logged or raised. `2> errors.log` with stdout on a terminal still gives a plain message, so no escape codes end up
72
+ in the log.
73
+
74
+ `colours_on()` tells you the answer now.
75
+
76
+ ## Other tools
77
+
78
+ ```python
79
+ from clitint import strip_colours, escapes, one_decision, coloured
80
+
81
+ strip_colours(formatter("x", "red")) # 'x': every ANSI style sequence removed (for tests and logs)
82
+
83
+ with one_decision(): # one message: decide once, not at every formatter call
84
+ line = formatter("a", "red") + " " + formatter("b", "blue")
85
+
86
+ red, reset = escapes("red") # the raw sequences, for a hot f-string
87
+ text = f"{red}x{reset}" if coloured() else "x"
88
+ ```
89
+
90
+ ## Licence
91
+
92
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ clitint/__init__.py
5
+ clitint/_tint.py
6
+ clitint/py.typed
7
+ clitint.egg-info/PKG-INFO
8
+ clitint.egg-info/SOURCES.txt
9
+ clitint.egg-info/dependency_links.txt
10
+ clitint.egg-info/requires.txt
11
+ clitint.egg-info/top_level.txt
12
+ tests/test_tint.py
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest
4
+ mypy
5
+ build
6
+ twine
@@ -0,0 +1 @@
1
+ clitint
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clitint"
7
+ dynamic = ["version"]
8
+ description = "Coloured terminal text with no dependencies, plain wherever it isn't read on a terminal."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+ keywords = ["terminal", "colour", "color", "ansi", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Terminals",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest", "mypy", "build", "twine"]
29
+
30
+ [tool.setuptools.dynamic]
31
+ version = { attr = "clitint.__version__" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["clitint*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ clitint = ["py.typed"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,318 @@
1
+ from clitint import formatter, strip_colours, escapes, one_decision, coloured
2
+ from typing import Any
3
+ import clitint
4
+ import clitint._tint as tint
5
+ import subprocess
6
+ import sys
7
+ import os
8
+ import pytest
9
+
10
+
11
+ @pytest.fixture(autouse = True)
12
+ def colours_wanted(monkeypatch):
13
+ """
14
+ Most tests are about what a spec is turned into, so they need colours on: whatever the shell
15
+ exports, and although pytest's captured output is no terminal.
16
+ """
17
+ for name in ("CLITINT_COLOURS_OFF", "NO_COLOR", "ANSI_COLORS_DISABLED"):
18
+ monkeypatch.delenv(name, raising = False)
19
+ monkeypatch.setenv("FORCE_COLOR", "1")
20
+ monkeypatch.setattr(tint, "_CHOSEN", None)
21
+ monkeypatch.setattr(tint, "_decided", None)
22
+
23
+
24
+ #* The escape sequences, written out here a second time: what a terminal is to be sent.
25
+ CODES = {"bold": "\x1b[1m", "dim": "\x1b[2m", "italic": "\x1b[3m", "underline": "\x1b[4m",
26
+ "black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m", "yellow": "\x1b[33m", "blue": "\x1b[34m",
27
+ "magenta": "\x1b[35m", "cyan": "\x1b[36m", "white": "\x1b[37m", "grey": "\x1b[90m",
28
+ "orange": "\x1b[38;2;255;165;0m"}
29
+ BACK = {"black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m", "yellow": "\x1b[43m", "blue": "\x1b[44m",
30
+ "magenta": "\x1b[45m", "cyan": "\x1b[46m", "white": "\x1b[47m", "grey": "\x1b[100m",
31
+ "orange": "\x1b[48;2;255;165;0m"}
32
+ COLOURS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "grey", "orange"]
33
+ ATTRS = ["bold", "dim", "italic", "underline"]
34
+
35
+
36
+ def styled(text: str, color: Any = None, attrs: Any = (), on: Any = None) -> str:
37
+ """``text`` as ``formatter`` has to return it: attributes, colour, background, and a reset behind."""
38
+ return ("".join(CODES[attr] for attr in attrs) + (CODES[color] if color else "") + (BACK[on] if on else "")
39
+ + text + "\x1b[0m")
40
+
41
+
42
+ # ═════════════════════════════════════════════════════════════════════════════
43
+ # WHAT A SPEC IS TURNED INTO
44
+ # ═════════════════════════════════════════════════════════════════════════════
45
+
46
+ class TestFormatter:
47
+
48
+ def test_returns_the_text_between_the_sequences(self):
49
+ assert formatter("hello", "red") == "\x1b[31mhello\x1b[0m"
50
+ assert formatter("hello", "bold_red") == "\x1b[1m\x1b[31mhello\x1b[0m"
51
+ assert formatter("hello", "bold") == "\x1b[1mhello\x1b[0m"
52
+
53
+ def test_the_tables_are_the_sequences_written_here(self):
54
+ assert clitint.ATTRIBUTES | clitint.COLOURS == CODES
55
+ assert clitint.BACKGROUNDS == BACK
56
+
57
+ @pytest.mark.parametrize("color", COLOURS)
58
+ def test_each_colour_alone_with_each_attribute_and_as_a_background(self, color):
59
+ assert formatter("text", color) == styled("text", color)
60
+ for attr in ATTRS:
61
+ assert formatter("text", f"{attr}_{color}") == styled("text", color, [attr])
62
+ assert formatter("text", f"highlight_{color}") == styled("text", on = color)
63
+ assert formatter("text", f"bold_white_highlight_{color}") == styled("text", "white", ["bold"], color)
64
+
65
+ @pytest.mark.parametrize("color", COLOURS)
66
+ def test_the_order_in_a_spec_does_not_matter(self, color):
67
+ expected = styled("t", color, ["bold", "underline"], "blue")
68
+ for spec in (f"bold_underline_{color}_highlight_blue", f"highlight_blue_{color}_underline_bold", f"underline_highlight_blue_bold_{color}"):
69
+ assert formatter("t", spec) == expected
70
+
71
+ def test_a_style_named_twice_is_written_once(self):
72
+ assert formatter("t", "bold_red_bold_red") == styled("t", "red", ["bold"])
73
+
74
+ # --- text variants ---
75
+
76
+ def test_empty_text(self):
77
+ assert formatter("", "red") == styled("", "red")
78
+
79
+ def test_text_with_special_characters(self):
80
+ assert formatter("hi\nthere", "red") == styled("hi\nthere", "red")
81
+
82
+ @pytest.mark.parametrize("text", [5, 2.5, None, ("a", 1), ["x"], b"raw", ValueError("no")], ids = repr)
83
+ def test_text_that_is_no_string(self, text):
84
+ assert formatter(text, "bold_red") == styled(str(text), "red", ["bold"])
85
+
86
+ @pytest.mark.parametrize("text", ["\x1b[31mred\x1b[0m", "%d %s", "{0}", "\x00x\x00", "a\x00b"])
87
+ def test_text_is_never_read_as_anything_else(self, text):
88
+ assert formatter(text, "bold_green") == styled(text, "green", ["bold"])
89
+
90
+ # --- what there is no style for ---
91
+
92
+ @pytest.mark.parametrize("spec", ["purple", "", "bold_purple", "highlight", "highlight_bold", "light_red", "teal", "red_highlight", "on_blue"])
93
+ def test_an_unknown_style_is_refused_by_name(self, spec):
94
+ for _ in range(2): # the refusal is not remembered as an answer
95
+ with pytest.raises(ValueError, match = "unknown style"):
96
+ formatter("text", spec)
97
+
98
+ @pytest.mark.parametrize("spec, why", [("red_blue", "two colours"), ("highlight_red_highlight_blue", "two backgrounds")])
99
+ def test_two_colours_or_two_backgrounds_are_refused(self, spec, why):
100
+ with pytest.raises(ValueError, match = why):
101
+ formatter("text", spec)
102
+
103
+ def test_with_colours_off_the_spec_is_not_read(self, monkeypatch):
104
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
105
+ assert formatter("text", "bold_red") == "text" and formatter(5, "purple") == "5"
106
+
107
+
108
+ def test_the_sequences_are_the_ones_termcolor_writes():
109
+ #* Where termcolor happens to be installed: text is, to the byte, what it makes.
110
+ termcolor = pytest.importorskip("termcolor")
111
+ for color in COLOURS:
112
+ name: Any = (255, 165, 0) if color == "orange" else ("light_grey" if color == "white" else color)
113
+ if color in ("grey", "white"):
114
+ continue # termcolor's names for 90 / 37 changed across versions
115
+ assert formatter("x", color) == termcolor.colored("x", name, force_color = True)
116
+ assert formatter("x", f"bold_{color}") == termcolor.colored("x", name, attrs = ["bold"], force_color = True)
117
+ assert formatter("x", "bold") == termcolor.colored("x", attrs = ["bold"], force_color = True)
118
+
119
+
120
+ def test_the_package_imports_nothing_but_the_standard_library():
121
+ code = ("import sys; before = set(sys.modules); import clitint\n"
122
+ "new = {name.split('.')[0] for name in set(sys.modules) - before}\n"
123
+ "print(sorted(new - set(sys.stdlib_module_names) - {'clitint'}))")
124
+ done = subprocess.run([sys.executable, "-c", code], capture_output = True, text = True)
125
+ assert done.stdout.strip() == "[]", done.stdout + done.stderr
126
+
127
+
128
+ def test_a_spec_is_worked_out_once_not_once_per_text(monkeypatch):
129
+ asked = []
130
+ real = tint._escapes
131
+ monkeypatch.setattr(tint, "_escapes", lambda spec: asked.append(spec) or real(spec))
132
+ monkeypatch.setattr(tint, "_ESCAPES", {})
133
+ texts = [formatter(word, "bold_yellow") for word in ("int", "str", "float")] + [formatter("x", "bold_red")]
134
+ assert texts == [styled(w, "yellow", ["bold"]) for w in ("int", "str", "float")] + [styled("x", "red", ["bold"])]
135
+ assert asked == ["bold_yellow", "bold_red"]
136
+
137
+
138
+ # ═════════════════════════════════════════════════════════════════════════════
139
+ # STRIPPING, ESCAPES, ONE DECISION
140
+ # ═════════════════════════════════════════════════════════════════════════════
141
+
142
+ @pytest.mark.parametrize("spec", ["red", "bold_orange", "underline_white_highlight_grey", "highlight_orange"])
143
+ def test_strip_colours_gives_the_text_back(spec):
144
+ assert strip_colours(formatter("a b\nc", spec)) == "a b\nc"
145
+ assert strip_colours("plain") == "plain"
146
+ assert strip_colours(formatter("x", "red") + " and " + formatter("y", "bold")) == "x and y"
147
+
148
+
149
+ def test_escapes_are_the_formatter_s_sequences_whatever_colours_say(monkeypatch):
150
+ before, after, bold_before, bold_after = escapes("red", "bold")
151
+ assert f"{before}x{after}" == formatter("x", "red") and f"{bold_before}x{bold_after}" == formatter("x", "bold")
152
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
153
+ assert escapes("red") == ("\x1b[31m", "\x1b[0m")
154
+ with pytest.raises(ValueError):
155
+ escapes("purple")
156
+
157
+
158
+ def test_one_decision_asks_once_and_leaves_nothing_behind(monkeypatch):
159
+ asked = []
160
+ real = tint.colours_on
161
+ monkeypatch.setattr(tint, "colours_on", lambda: asked.append(1) or real())
162
+ with one_decision():
163
+ words = [formatter(w, "bold") for w in "abcde"]
164
+ assert coloured()
165
+ assert len(asked) == 1 and all("\x1b[" in w for w in words)
166
+ assert tint._decided is None
167
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
168
+ with one_decision():
169
+ assert formatter("x", "bold") == "x" and not coloured()
170
+
171
+
172
+ # ═════════════════════════════════════════════════════════════════════════════
173
+ # WHEN TEXT IS COLOURED
174
+ # ═════════════════════════════════════════════════════════════════════════════
175
+
176
+ class _Stream:
177
+ def __init__(self, terminal: bool) -> None:
178
+ self.terminal = terminal
179
+
180
+ def isatty(self) -> bool:
181
+ return self.terminal
182
+
183
+ def write(self, text: str) -> int:
184
+ return len(text)
185
+
186
+ def flush(self) -> None:
187
+ pass
188
+
189
+
190
+ def _streams(monkeypatch, out: bool, err: bool) -> None:
191
+ monkeypatch.delenv("FORCE_COLOR", raising = False)
192
+ monkeypatch.delenv("TERM", raising = False)
193
+ monkeypatch.setattr(sys, "stdout", _Stream(out))
194
+ monkeypatch.setattr(sys, "stderr", _Stream(err))
195
+
196
+
197
+ @pytest.mark.parametrize("out, err, expected", [
198
+ (True, True, True), # a terminal: someone is reading
199
+ (True, False, False), # 2> errors.log
200
+ (False, True, False), # | less, > out.txt
201
+ (False, False, False), # a CI job, a service
202
+ ])
203
+ def test_colours_need_both_streams_to_be_terminals(monkeypatch, out, err, expected):
204
+ _streams(monkeypatch, out, err)
205
+ assert clitint.colours_on() is expected
206
+ assert (formatter("x", "bold") != "x") is expected
207
+
208
+
209
+ def test_the_environment_decides_before_the_streams(monkeypatch):
210
+ _streams(monkeypatch, True, True)
211
+ for name, value in (("CLITINT_COLOURS_OFF", "1"), ("NO_COLOR", "1"), ("ANSI_COLORS_DISABLED", "1"), ("TERM", "dumb")):
212
+ monkeypatch.setenv(name, value)
213
+ assert clitint.colours_on() is False, name
214
+ monkeypatch.delenv(name)
215
+ _streams(monkeypatch, False, False)
216
+ monkeypatch.setenv("FORCE_COLOR", "1")
217
+ assert clitint.colours_on() is True
218
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1") # off wins over forced
219
+ assert clitint.colours_on() is False
220
+
221
+
222
+ def test_the_program_decides_before_the_environment(monkeypatch):
223
+ _streams(monkeypatch, False, False)
224
+ clitint.turn_colours_on()
225
+ assert clitint.colours_on() and formatter("x", "red") == styled("x", "red")
226
+ monkeypatch.setenv("FORCE_COLOR", "1")
227
+ clitint.turn_colours_off()
228
+ assert not clitint.colours_on() and formatter("x", "red") == "x"
229
+ clitint.let_the_environment_decide()
230
+ assert clitint.colours_on() # FORCE_COLOR again
231
+
232
+
233
+ def test_a_stream_without_isatty_is_no_terminal(monkeypatch):
234
+ monkeypatch.delenv("FORCE_COLOR", raising = False)
235
+ monkeypatch.setattr(sys, "stdout", None) # pythonw, a detached process
236
+ monkeypatch.setattr(sys, "stderr", object())
237
+ assert clitint.colours_on() is False and formatter("x", "red") == "x"
238
+
239
+
240
+ def test_the_environment_is_read_as_it_is_now(monkeypatch):
241
+ #* colours_on reads the dictionary below os.environ. That is the same data, whichever way it is
242
+ #* changed, and another mapping put in the place of os.environ is read as well.
243
+ for name in ("CLITINT_COLOURS_OFF", "NO_COLOR", "ANSI_COLORS_DISABLED", "FORCE_COLOR", "TERM"):
244
+ monkeypatch.delenv(name, raising = False)
245
+ monkeypatch.setattr(tint, "_both_terminals", lambda: True)
246
+ assert tint._FAST and tint.colours_on()
247
+ steps = [("TERM", "dumb", False), ("FORCE_COLOR", "1", True), ("ANSI_COLORS_DISABLED", "", False)]
248
+ for name, value, expected in steps:
249
+ os.environ[name] = value
250
+ assert tint.colours_on() is expected
251
+ for name, _, _ in steps:
252
+ del os.environ[name]
253
+ assert tint.colours_on()
254
+ os.environ.update({"CLITINT_COLOURS_OFF": "1"})
255
+ assert not tint.colours_on()
256
+ os.environ.pop("CLITINT_COLOURS_OFF")
257
+ monkeypatch.setattr(os, "environ", {"NO_COLOR": "1"}) # a plain dictionary in its place
258
+ assert not tint.colours_on()
259
+ monkeypatch.setattr(os, "environ", {"TERM": "xterm"})
260
+ assert tint.colours_on()
261
+ monkeypatch.setattr(tint, "_FAST", False) # a Python without that dictionary: the same answers
262
+ assert tint.colours_on()
263
+ monkeypatch.setattr(os, "environ", {"TERM": "dumb"})
264
+ assert not tint.colours_on()
265
+
266
+
267
+ def test_a_child_process_is_plain_when_piped_and_coloured_when_forced():
268
+ code = "from clitint import formatter; print(repr(formatter('x', 'red')))"
269
+ env = {k: v for k, v in os.environ.items() if k not in ("FORCE_COLOR", "NO_COLOR", "CLITINT_COLOURS_OFF")}
270
+ piped = subprocess.run([sys.executable, "-c", code], capture_output = True, text = True, env = env)
271
+ forced = subprocess.run([sys.executable, "-c", code], capture_output = True, text = True, env = env | {"FORCE_COLOR": "1"})
272
+ assert piped.stdout.strip() == "'x'"
273
+ assert forced.stdout.strip() == repr(styled("x", "red"))
274
+
275
+
276
+ # ═════════════════════════════════════════════════════════════════════════════
277
+ # WHAT A LIBRARY BUILT ON CLITINT USES
278
+ # ═════════════════════════════════════════════════════════════════════════════
279
+
280
+ @pytest.mark.parametrize("fast", [True, False])
281
+ def test_an_off_switch_of_a_library_s_own(monkeypatch, fast):
282
+ monkeypatch.setattr(tint, "_OFF", tint._OFF)
283
+ monkeypatch.setattr(tint, "_K_OFFS", tint._K_OFFS)
284
+ monkeypatch.setattr(tint, "_FAST", fast and tint._FAST)
285
+ clitint.add_off_switch("MYLIB_COLOURS_OFF")
286
+ clitint.add_off_switch("MYLIB_COLOURS_OFF") # twice is once
287
+ assert tint._OFF == ("CLITINT_COLOURS_OFF", "MYLIB_COLOURS_OFF")
288
+ assert clitint.colours_on() # FORCE_COLOR, from the fixture
289
+ for value, on in (("0", True), ("", True), ("yes", True), ("1", False)):
290
+ monkeypatch.setenv("MYLIB_COLOURS_OFF", value)
291
+ assert clitint.colours_on() is on, value # "1" and nothing else
292
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
293
+ monkeypatch.delenv("MYLIB_COLOURS_OFF")
294
+ assert not clitint.colours_on() # clitint's own still counts
295
+
296
+
297
+ @pytest.mark.parametrize("name", ["", None, 5])
298
+ def test_an_off_switch_is_a_name(name):
299
+ with pytest.raises(TypeError):
300
+ clitint.add_off_switch(name)
301
+
302
+
303
+ def test_decide_keeps_the_answer_until_release(monkeypatch):
304
+ assert clitint.decision() is None
305
+ assert clitint.decide() is True
306
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
307
+ assert clitint.decision() is True and coloured() and formatter("x", "red") == styled("x", "red")
308
+ clitint.release()
309
+ assert clitint.decision() is None and formatter("x", "red") == "x"
310
+
311
+
312
+ def test_one_decision_inside_another_keeps_the_outer_answer(monkeypatch):
313
+ with one_decision() as outer:
314
+ monkeypatch.setenv("CLITINT_COLOURS_OFF", "1")
315
+ with one_decision() as inner:
316
+ assert outer is inner is True
317
+ assert clitint.decision() is True # the inner one left it in place
318
+ assert clitint.decision() is None