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 internalservercheck
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 installclick
:$ 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 acli
module within theservercheck
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 startersetup.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 theREQUIRED
list and create ourconsole_script
. We’re
also going to remove theUploadCommand
: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.