clitint 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clitint/__init__.py ADDED
@@ -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
+ ]
clitint/_tint.py ADDED
@@ -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)
clitint/py.typed ADDED
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,8 @@
1
+ clitint/__init__.py,sha256=-CjTMDWll75iU2mBfRpbRv5SQ8CH2cBr-8K0lxHPYZM,779
2
+ clitint/_tint.py,sha256=hQFQ56BfW8U8zbXXq3nhK8DP8cABmMHsjqSj12RnKWE,11140
3
+ clitint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ clitint-0.1.0.dist-info/licenses/LICENSE,sha256=hFFm9WCRjRtJHi6-i6r1o3VSfTMU5i4iml8q2N19vy4,1075
5
+ clitint-0.1.0.dist-info/METADATA,sha256=OSWjuT-71_JnpecPlOsiLBgOajlOSxMJCGKCo94LgEc,3162
6
+ clitint-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ clitint-0.1.0.dist-info/top_level.txt,sha256=MwsrlcC5Rw6pKcD4N7MsTJHTQ-bhVYkLeVyZZ8YFnHM,8
8
+ clitint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ clitint