The other day I wanted to get serious with the awesome Rich library.
As we always say: you have to build to really learn + need ideas? Scratch your own itch.
If you’re struggling for ideas, see what takes you long and/or is cumbersome and see if it’s a good candidate to automate it with code. Here is a more elaborate email we sent about this a while ago.
Well, I had an itch for a while … I was always Googling color hex codes when styling web apps.
What if I could do this from the command line?
And if we talk command line, what if we can make one that looks really nice? That’s when I remembered I had to learn Rich …
I ended up with this tool:
Let’s see how I got to this result …
Getting the data
The code for this project is here.
I first looked around to get a mapping of color names -> hex codes. I found this resource (thanks codebrainz).
To make sure this data would not change I forked the repo (I believe forks will survive original repos being deleted).
Then I made a function to download the data:
from pathlib import Path
from urllib.request import urlretrieve
from rich.console import Console
console = Console()
colors_csv_url = "https://raw.githubusercontent.com/bbelderbos/color-names/master/output/colors.csv"
colors_csv_file = Path(colors_csv_url).name
def download_data():
console.print("Grabbing colors.csv from GitHub")
urlretrieve(colors_csv_url, colors_csv_file)
console.print("Adding header to the file")
with open(colors_csv_file, 'r') as f:
data = f.read()
with open(colors_csv_file, 'w') as f:
header = "name,name2,hex,r,g,b"
f.write(f"{header}\n" + data)
And put this in a data.py module. A few things to notice:
- I use Rich’s
console
object for printing. - I did not need the
requests
library to download a file per se, so I just used the Standard Library’surllib.request.urlretrieve
. - I had to add a header myself, hence the reading in of the csv file and writing back.
I just wanted to get something working, so I did not care about packaging / project structure yet so the rest of the code I just wrote in a script.py.
I made two functions in script.py: get_hex_colors()
and show_colors()
.
The first one opens the csv file we created with download_data()
and loops through the colors:
def get_hex_colors(search_term):
if not Path(colors_csv_file).exists():
download_data()
with open(colors_csv_file) as f:
rows = csv.DictReader(f)
for row in rows:
hex_, name = row["hex"], row["name"]
if len(hex_) != FULL_COLOR_HEX_LEN:
continue
if search_term.lower() not in name.lower():
continue
hls = Hls(*colorsys.rgb_to_hls(
int(row["r"]), int(row["g"]), int(row["b"])
))
yield Color(hex_, name, hls)
A few things to notice here:
- If the colors csv file is not found (I love
pathlib
!), we download it first. - I like to read in a csv file as an sequence of dicts,
csv.DictReader()
lets you do that. - I use
hex_
as a variable name to disambiguate thehex()
built-in function. - I only want to take into account full color hex length of 7 chars (so
#ff0000
, but not the shorter#f00
). - The use of
continue
here leads to less nested code (flat is better than nested as per the Zen of Python). - I track the
Hls()
(it’s probably HSL actually) for sorting, I will explain why later … yield
turns this into a generator.- I use a
Color()
namedtuple for the colors (which thanks totyping.NamedTuple
you can now define with type hints)
Under the if __name__ == "__main__":
block (which evaluates to True
when we call the script directly), I have the simplest command line app ever 😉
Of course I can (should?) use argparse, or even better Typer, but at this point I really need only one positional argument, the search string, so sys.argv
was good enough 🙂
It then nicely calls the two functions in order:
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} search_term")
sys.exit(1)
search_term = sys.argv[1]
colors = list(get_hex_colors(search_term))
if colors:
show_colors(search_term, colors)
else:
error_console.print(f"No matches for {search_term}")
Note I use Rich’s console API defining two Console
instances called console
and error_console
to print to stdout and stderr respectively:
console = Console()
error_console = Console(stderr=True, style="bold red")
Showing a nice table of color search matches
Let’s move onto a really cool Rich feature: tables.
I use one to display the search results in show_colors()
:
def show_colors(seach_term, colors, num_column_pairs=3, order_by_hls=True):
if order_by_hls:
colors.sort(key=lambda x: x.hls.L, reverse=True)
table = Table(title=f"Matching colors for {search_term}")
for _ in range(num_column_pairs):
table.add_column("Hex")
table.add_column("Name")
def _color(hex_, string):
return f"[{hex_}]{string}"
row = []
for i, color in enumerate(colors, start=1):
row.extend([
_color(color.hex_, color.hex_),
_color(color.hex_, color.name)
])
is_last_row = i == len(colors) # in case < num_column_pairs results
if i % num_column_pairs == 0 or is_last_row:
table.add_row(*row)
row = []
console.print(table)
Making a table in Rich is as simple as making a Table()
instance and adding columns with the add_column()
method.
Then you loop through the rows and use the add_row()
method to add them.
The .extend()
list method is handy for adding multiple items to a list at once.
And *row
means we tuple unpack the row list values passing them as arguments to add_row()
. One of those things I really love about Python!
I needed to check for the last row because in case of two results and a num_column_pairs
of 3
it would not add any rows yielding an empty table:
With the is_last_row
in place it works:
Ordering of results
As promised I would get back to the sorting aspect.
Why do we have the order_by_hls
as an optional arg to show_colors()
? And what the heck is colors.sort(key=lambda x: x.hls.L, reverse=True)
for?
Here is something cool that happened when I shared this project on Twitter.
Initially the table showed the matching colors in a pretty random order:
So when I showed this on Twitter the creator of Rich (Will McGugan) chimed in asking if I “could convert the RGB to HSL and sort by the L component?”
Which goes to show you should share your work. Getting a 2nd, 3rd, Nth pair of eyes to look at it is very insightful.
People will highlight things you wouldn’t have thought about, giving you an opportunity (challenge) to make your code better, more functional and often more robust.
So share your work, it will make you a better developer.
I had never heard about HSL but it turned out Python’s Standard Library had us (yet again covered): colorsys
(which I think I found reading through the Rich source code. I highly recommend getting into the habit of reading more source code, you’ll learn a lot!)
I defined a new namedtuple to keep track of the HSL per color, nesting it into the Color
object (again, not sure why I called it Hls
and not Hsl
):
class Hls(NamedTuple):
H: float
L: float
S: float
class Color(NamedTuple):
hex_: str
name: str
hls: Hls
And then I could use the colorsys
module (in the get_hex_colors()
function) to get the HSL from a RGB color (again using tuple unpacking):
hls = Hls(*colorsys.rgb_to_hls(
int(row["r"]), int(row["g"]), int(row["b"])
))
That should explain the sorting in show_colors()
:
colors.sort(key=lambda x: x.hls.L, reverse=True)
The colors are sorted on the Color namedtuple’s hls
attribute, and particularly on its L
attribute which is what Will suggested.
(By the way, .sort()
sorts in-place, sorted()
would return a new list.)
Result: a much nicer output:
(Shell) Alias everything
One last thing I did was setup a shell alias so I can just use this tool from anywhere in the terminal:
function cos {
(cd $HOME/code/color-searcher && source venv/bin/activate && python script.py "$1")
}
So now I would get the above output by typing “cos green”. The extra ()
inside the function runs this in a sub-shell (a trick I learned from Gary Bernhardt) so there are no side effects like the project’s virtual environment remaining enabled after running the command. Pretty convenient.
Of course when you see an opportunity to make enhancements, be my guest (I probably will go back and fix the hls / hsl naming). Again the code is here.
I hope you learned some new Python and coding tricks from this article. Reach out to me on Twitter if you want to further discuss this project or anything else Python developer related …
Keep calm and code in Python!
– Bob