How to Pretty-Pretty: Style
Let’s see what prettypretty can do for your command line tools. I picked the implementation of a progress bar for several reasons. First, I’ve been itching to write one myself for quite a while now. Second, animated anything is nice and shiny, i.e., makes for a good demo. Third, the script is simple enough to fit into less than 100 lines of Python, yet complex enough to show off most major features. The complete script is part of prettypretty’s distribution.
How to Pretty-Pretty has two parts. This part focuses on prettypretty-specific code in the progress bar script. The other part focuses on prettypretty’s color support. That includes a discussion of how prettypretty adjusts colors to terminal capabilities and plenty of examples for manipulating colors. You probably want to start by working through this part to get a good overview. But if your learning styles favors fundamentals first, then the other part probably is a better start.
Visualizing Progress
In either case, you probably want to get started by running the progress bar script yourself. So please go through the usual incantations for creating a new virtual environment, installing prettypretty into it, and running the progress bar demo:
$ mkdir progress
$ cd progress
$ python -m venv .venv
$ source .venv/bin/activate
$ python -m pip install prettypretty
Downloading prettypretty-0.3.0-py3-none-any.whl (64 kB)
Installing collected packages: prettypretty
Successfully installed prettypretty-0.3.0
$ python -m prettypretty.progress
(The above command line incantations work just as written on Linux and macOS. But you may have to adjust them somewhat, if you use a package manager other than pip or are running Windows. I trust you know what to do differently.)
That last command actually executes the progress bar script. You should see a green bar rapidly go from 0% to 100%. It may end up looking like this:
![A bright green progress bar at 100% against a white background](_images/progress-bar-light.png)
Or, if your terminal’s color theme is a dark theme, it may end up looking more like this:
![A medium green progress bar at 100% against a black background](_images/progress-bar-dark.png)
Of Styles and Terminals
In addition to colors, terminals support a few more attributes for styling text,
including to change its weight or slant, add over-, middle-, and underlines, and
so on. While you can directly write styles to terminal output, the more robust
alternative is to declare all of your application’s styles in one place. That
helps with the design and maintenance of styles. It also makes it easier to
select the right styles for use. The progress bar script uses the rich
builder to declare two styles:
LIGHT_MODE_BAR = rich().fg('p3', 0, 1, 0).style()
DARK_MODE_BAR = rich().fg('rgb256', 3, 151, 49).style()
You could create styles with their constructor but, depending on the number of
attributes you’d like set, that won’t be very readable. The rich
builder is the cleaner option, since it provides a fluent interface for
declaring styles—and also rich text sequences, which increase the repertoire
with hyperlinks and cursor movement. Let’s stick with styles, however, and
see how we might declare a warning style:
WARNING = rich().bold.fg(0).bg(220).style()
Compared to the two styles above, the warning style also sets a text attribute (through a property) and the background color (through a method). The latter color, by the way, is a bright 8-bit orange. When declaring a style, only include attributes that you want set and nothing else. Also, don’t bother with defining styles that undo other styles or incrementally transition from one style to another.
You can easily and automatically compute them with Python’s negation ~
,
subtraction -
, and alternative |
operators. In particular, the style
~style
undoes all attributes of style
, hence restoring the terminal to
its default appearance. The style style2 - style1
incrementally transitions
from style1
to style2
. The style style1 | style2
lets you recover
complete styles from incremental transitions by combining all attributes from
style1
and style2
. Remember that |
does give precedence to the first
operand style1
if both of them impact the same attribute.
For example, the last line
of the format_bar
function in the progress bar script uses negation for its
intended purpose, restoring the default appearance:
return RichText.of(' ┫', style, bar, ~style, '┣', f' {percent:5.1f}%')
RichText
is a sequence of strings, styles, and so on that simplifies
color adjustment during output. You don’t need to use it but it may speed up
output a little bit.
The progress bar script’s main function
illustrates how to go from style declarations to usable styles and how to
display the resulting rich text. It starts out by creating a terminal object,
possibly overwriting its color fidelity, querying the terminal for its current
color scheme, hiding the cursor, and scoping all styles. The with
block
ensures that the cursor reappears and no custom style leaks into your terminal
even if the application raises an exception.
with (
Terminal(fidelity=options.fidelity)
.terminal_theme()
.hidden_cursor()
.scoped_style()
) as term:
I strongly recommend to always scope styles in a with
statement. In all
likelihood, you also want to read the current terminal theme. That’s the
one-line price of admission for prettypretty. It might be possible to fold the
theme query into Terminal
’s constructor. But that query fails if the
input is being redirected. It also involves quite a bit of I/O, since it writes
18 ANSI escape sequences to the terminal and parses 18 ANSI escape sequences as
responses from the terminal. Consequently, making this operation an explicit one
seems the better interface design.
Prettypretty supports several more contextual operations, including for updating
the Terminal.window_title()
, using the
Terminal.alternate_screen()
, performing Terminal.batched_output()
,
and enabling Terminal.bracketed_paste()
. You can perform them
individually, each in its own with
statement, or you can fluently combine
them with each other in a single with
statement similar to the above
example.
Once the terminal has been set up, the progress bar script uses
is_dark_theme
to pick the right style and adjusts the style to the
terminal’s Terminal.fidelity
:
style = DARK_MODE_BAR if is_dark_theme() else LIGHT_MODE_BAR
style = style.prepare(term.fidelity)
Doing so once during startup avoids the not insubstantial overhead of color conversion on the critical path.
With that, the progress bar script is ready for turning progress reports into progress bar updates. Each update assembles the rich text for the progress bar, moves the (invisible) cursor to the beginning of the line, writes the rich text to terminal output, and flushes the output.
for percent in progress_reports():
bar = format_bar(percent, style)
term.column(0).rich_text(bar).flush()
time.sleep(random.uniform(1/60, 1/10))
After accomplishing so much with so little code, our progress bar script deserves some rest and so it sleeps for a spell.
Much of the rest of the progress bar script is not specific to prettypretty. Its line breakdown looks like this:
Function |
Lines of Code |
---|---|
Imports |
9 |
Argument parser |
18 |
Module constants |
7 |
Progress bar |
14 |
Progress reports |
7 |
main() |
23 |
Calling main() |
2 |
Total |
80 |
Note that only one line out of 14 for formatting the progress bar is specific to
prettypretty. Likewise, it takes only one line in main()
to write out the
progress bar. Startup is somewhat more hefty, comprising 8 prettypretty-specific
lines of code. Then again, 6 of them are very generously spaced.
Happy, happy, joy, joy!