1

Learning Rich by making a color searcher command line app

 1 year ago
source link: https://pybit.es/articles/learning-rich-by-making-a-color-searcher-command-line-app/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Learning Rich by making a color searcher command line app

By Bob Belderbos on 6 July 2022

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:

color searcher

Searching for color hex codes from the command line.

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’s urllib.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 the hex() 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 to typing.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:

Screenshot 2022 07 06 at 11.45.10

Oops …

With the is_last_row in place it works:

Screenshot 2022 07 06 at 11.45.17

Better!

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:

color searcher

Colors were initially sorted pretty randomly

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:

color searcher2

New output after the sorting.

(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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK