ServerCheck: Building a Python CLI with Click

45 minutes
  • 3 Learning Objectives

About this Hands-on Lab

We frequently have to check whether one of our servers has access to other servers on our internal network. To make this a little easier for ourselves, we’ve decided to use Python to write a CLI that can either take a JSON file with servers and ports to check or a list of host/port combinations to make requests to. In this hands-on lab, we’re going to outline our CLI using the [click](https://click.palletsprojects.com/en/7.x/) library.

Learning Objectives

Successfully complete this lab by achieving the following learning objectives:

Set Up a Project and Virtualenv Using Pipenv

To set up our project, we’re going to create a new directory with an internal directory of the same name (servercheck) to hold our Python package:

$ mkdir -p servercheck/servercheck
$ cd servercheck

We also need to add an __init__.py to the internal servercheck directory to mark it as a package:

$ touch servercheck/__init__.py

Next, let’s make sure that pipenv is installed and then use it to create our virtualenv and install click:

$ pip3.7 install --user -U pipenv
...
$ pipenv --python python3.7 install click
...

Once the virtualenv is created, we need to activate it while working on this project:

$ pipenv shell
(servercheck) $
Define the CLI Function

With click installed, we’re ready to start creating the command line function. Let’s put this in a cli module within the servercheck package:

servercheck/cli.py

import click

@click.command()
def cli():
    pass

if __name__ == "__main__":
    cli()

Now we can run our cli by passing this file to the Python interpreter:

(servercheck) $ python servercheck/cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --help  Show this message and exit

Our final result requires us to handle two different types of options but not positional arguments. To add these, we’ll use the @click.option decorator. Let’s also handle the case when both of the options are empty.

servercheck/cli.py

import click

@click.command()
@click.option("--filename", "-f", default=None)
@click.option("--server", "-s", default=None, multiple=True)
def cli(filename, server):
    if not filename and not server:
        raise click.UsageError("must provide a JSON file or servers")

if __name__ == "__main__":
    cli()

Since we’re running the file without any arguments, we should see an expected error:

(servercheck) $ python servercheck/cli.py
Usage: cli.py [OPTIONS]

Error: must provide a JSON file or servers

Next, we’ll create a set to hold on to all of the server/IP combinations, and then we’ll add anything from the JSON file if given and also the values passed using the --server or -s flags.

servercheck/cli.py

import click
import json
import sys

@click.command()
@click.option("--filename", "-f", default=None)
@click.option("--server", "-s", default=None, multiple=True)
def cli(filename, server):
    if not filename and not server:
        raise click.UsageError("must provide a JSON file or servers")

    # Create a set to prevent duplicate server/port combinations
    servers = set()

    # If --filename or -f option is used then attempt to read
    # the file and add all values to the `servers` set.
    if filename:
        try:
            with open(filename) as f:
                json_servers = json.load(f)
                for s in json_servers:
                    servers.add(s)
        except:
            print("Error: Unable to open or read JSON file")
            sys.exit(1)

    # If --server or -s option are used then add those values
    # to the set.
    if server:
        for s in server:
            servers.add(s)

    print(servers)

if __name__ == "__main__":
    cli()

To test this, let’s create an example JSON file to parse and use it in combination with the --server option.

example.json

[
    "JSONIP:PORT",
    "JSONIP:PORT",
    "JSONIP2:PORT2"
]

Notice that we put a duplicate in the JSON file to make sure that it isn’t listed twice in our output.

Now let’s test our tool:

$ python servercheck/cli.py -s "TEST:4000" --server "Other:3000" -f example.json
{'JSONIP2:PORT2', 'TEST:4000', 'JSONIP:PORT', 'Other:3000'}
Create `setup.py` with `console_scripts` for `servercheck`

We know that we’re able to take in the user’s information so we should create our setup.py so that we can generate a console script when installing our package. We’re going to use the starter setup.py that Kennith Reitz maintains so that we don’t have to

(servercheck) $ curl -O https://raw.githubusercontent.com/kennethreitz/setup.py/master/setup.py

Next, let’s edit this file to add click as a dependency in the REQUIRED list and create our console_script. We’re
also going to remove the UploadCommand:

setup.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import io
import os
import sys
from shutil import rmtree

from setuptools import find_packages, setup, Command

# Package meta-data.
NAME = "servercheck"
DESCRIPTION = (
    "CLI to ensure that HTTP requests can be made to various server/port combinations"
)
URL = "https://github.com/me/myproject"
EMAIL = "me@example.com"
AUTHOR = "Awesome Soul"
REQUIRES_PYTHON = ">=3.7.0"
VERSION = "0.1.0"

# What packages are required for this module to be executed?
REQUIRED = ["click"]

# What packages are optional?
EXTRAS = {
    # 'fancy feature': ['django'],
}

# The rest you shouldn't have to touch too much :)
# ------------------------------------------------
# Except, perhaps the License and Trove Classifiers!
# If you do change the License, remember to change the Trove Classifier for that!

here = os.path.abspath(os.path.dirname(__file__))

# Import the README and use it as the long-description.
# Note: this will only work if 'README.md' is present in your MANIFEST.in file!
try:
    with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f:
        long_description = "n" + f.read()
except FileNotFoundError:
    long_description = DESCRIPTION

# Load the package's __version__.py module as a dictionary.
about = {}
if not VERSION:
    project_slug = NAME.lower().replace("-", "_").replace(" ", "_")
    with open(os.path.join(here, project_slug, "__version__.py")) as f:
        exec(f.read(), about)
else:
    about["__version__"] = VERSION

# Where the magic happens:
setup(
    name=NAME,
    version=about["__version__"],
    description=DESCRIPTION,
    long_description=long_description,
    long_description_content_type="text/markdown",
    author=AUTHOR,
    author_email=EMAIL,
    python_requires=REQUIRES_PYTHON,
    url=URL,
    packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]),
    # If your package is a single module, use this instead of 'packages':
    # py_modules=['mypackage'],
    entry_points={"console_scripts": ["servercheck=servercheck.cli:cli"]},
    install_requires=REQUIRED,
    extras_require=EXTRAS,
    include_package_data=True,
    license="MIT",
    classifiers=[
        # Trove classifiers
        # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.7",
        "Programming Language :: Python :: Implementation :: CPython",
        "Programming Language :: Python :: Implementation :: PyPy",
    ],
)

Let’s install our tool to be editable using pip and then use it by name:

(servercheck) $ pip install -e .
...
(servercheck) $ servercheck -s "SERVER:1000" -s "SERVER:2000"
{'SERVER:1000', 'SERVER:2000'}

We’ve successfully implemented the foundational UI for our CLI.

Additional Resources

We frequently have to check whether one of our servers has access to other servers on our internal network. To make this a little easier for ourselves, we've decided to use Python to write a CLI that can either take a JSON file with servers and ports to check or a list of host/port combinations to make requests to. The team has decided that we should use click to create our CLI, so we're going to kick off the project and lay out the CLI using click.

By the time the tool is completed (not in this lab) we'd like it to work like this with a configuration file:

$ servercheck -f servers.json
Successful Connections
----------------------
IP1:port
IP2:port

Failed Connections
------------------
IP3:port

Or it can be used like this if we pass in host/port combination(s):

$ servercheck -s IP1:port -s IP2:port -s IP3:port
Successful Connections
----------------------
IP1:port
IP2:port

Failed Connections
------------------
IP3:port

If both styles are used, then combine the results but make sure not to allow duplicates.

If neither option is provided, then an error should be shown like this:

$ servercheck
Usage: servercheck [OPTIONS]

Error: must provide a JSON file or servers

For this hands-on lab, we're going to be building the CLI so that it handles the options properly, but we won't concern ourselves with the logic to make the requests just yet. With that in mind, we simply need to make sure that we can read a configuration file and also parse out multiple servers when passed using the -s flag. For now, we'll create the CLI so that it shows us what was parsed, and we can verify that we've collected the right information. By the end of this hands-on lab, we should have a CLI that works like this:

$ servercheck -s IP1:port -s IP2:port -s IP3:port
{'IP1:port', 'IP2:port', 'IP3:port'}

Or like this:

$ servercheck -f servers.json
{'IP1:port', 'IP1:port2', 'IP2:port'}

In this case, the servers.json file would look like this:

[
    "IP1:port",
    "IP1:port2",
    "IP2:port"
]

To feel comfortable completing this lab, you'll need to know how to do the following:

  • Create a Python project with a setup.py and a virtualenv. Watch "Project Overview and Setup: Load-Testing CLI" from the Programming Use Cases with Python course if you're unfamiliar with how to do this.
  • Build a CLI using the click library. Watch "Designing the CLI: argparse or click" from the Programming Use Cases with Python course if you're unfamiliar with how to do this.

What are Hands-on Labs

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Sign In
Welcome Back!

Psst…this one if you’ve been moved to ACG!

Get Started
Who’s going to be learning?