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 +21 -0
- clitint-0.1.0/PKG-INFO +92 -0
- clitint-0.1.0/README.md +67 -0
- clitint-0.1.0/clitint/__init__.py +42 -0
- clitint-0.1.0/clitint/_tint.py +291 -0
- clitint-0.1.0/clitint/py.typed +0 -0
- clitint-0.1.0/clitint.egg-info/PKG-INFO +92 -0
- clitint-0.1.0/clitint.egg-info/SOURCES.txt +12 -0
- clitint-0.1.0/clitint.egg-info/dependency_links.txt +1 -0
- clitint-0.1.0/clitint.egg-info/requires.txt +6 -0
- clitint-0.1.0/clitint.egg-info/top_level.txt +1 -0
- clitint-0.1.0/pyproject.toml +40 -0
- clitint-0.1.0/setup.cfg +4 -0
- clitint-0.1.0/tests/test_tint.py +318 -0
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
|
clitint-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
clitint-0.1.0/setup.cfg
ADDED
|
@@ -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
|