DEV Community

Jordan Gregory
Jordan Gregory

Posted on

Writing a Python CLI to complete the DigitalOcean Functions Challenge

Prelude

To continue from my Go CLI write-up, I will go in to detail about writing a Python CLI to perform the same DigitalOcean Functions Challenge

The Code

Like the Go code I wrote earlier, I just threw everything into main.py for this example, so to get started, I simply created a main.py file and installed the first few dependencies I knew I wanted to use with Pipenv like so:

pipenv install requests structlog click

  • Requests I always use when I have to interface with the web in Python applications.
  • Structlog is my structured logging package of choice for Python applications.
  • Click is my CLI framework of choice for Python applications.

In doing this, now I have a Pipfile and a Pipfile.lock and can go about doing my actually development.

First, I knew I wanted to have a Request and a Response class defined, and knowing what I know from the previous example, I was able to just write the classes.

#!/usr/bin/env python3
import logging
import sys

import requests
import structlog
import click


class Request:
    """
    The Request class will contain all of our methods to handle a request to the DigitalOcean Function Challenge api.

    Attributes
    ----------
    API_URL : str
        The API_URL attribute is a static string to hold the URL of the API to access.

    name: str
        The name attribute is the name of the Sammy that we wish to create.

    t : str
        The "t" attribute is the type of the Sammy that we wish to create. This attribute is named "t" to avoid
        conflicting with the "type" keywork.

    log : structlog.BoundLogger
        The log attribute is not an attribute to be dealt with directly, rather the _get_log(self) method will retrieve
        the bound logger for us.

    Methods
    -------
    _set_name(name: str)
        This method sets the value of the name attribute.

    _set_type(t: str)
        This method sets the value of the t attribute.

    _set_log()
        This method gets the application logger and sets it to the log attribute.

    _get_url() -> str
        This method returns the value of the API_URL attribute.

    _get_name() -> str
        This method returns the value of the name attribute.

    _get_type() -> str
        This method returns the value of the t attribute.

    _build_headers() -> dict
        This static method returns a dictionary to be used as a request header.

    _build_request_body() -> dict
        This method returns a dictionary to be used as the request body.

    do() -> requests.Response
        This method is the primary class method and is used to perform the HTTP POST request.
    """
    def __init__(self, name: str, t: str):
        """
        Parameters
        ----------
        :param name: str
            The name to give to your new Sammy.
        :param t: str
            The type to give to your new Sammy.
        """
        self.API_URL = "https://functionschallenge.digitalocean.com/api/sammy"
        self.name = None
        self.t = None
        self.log: structlog.BoundLogger = None
        self._set_name(name)
        self._set_type(t)
        self._set_log()

    def _set_name(self, name: str):
        """This method sets the value of the name attribute.

        Parameters
        ----------
        :param name: str
            The name of the new sammy to create.
        """
        self.name = name

    def _set_type(self, t: str):
        """This method sets the value of the t attribute.

        Input validation is handled at the CLI layer. We will need to do further input validation if we don't do the
        validation at the CLI layer.

        Parameters
        ----------
        :param t: str
            The type of the new sammy to create.
        """
        self.t = t

    def _set_log(self):
        """This method gets the application logger and sets it to the log attribute."""
        self.log = structlog.stdlib.get_logger()

    def _get_url(self) -> str:
        """This method returns the value of the API_URL attribute."""
        return self.API_URL

    def _get_name(self) -> str:
        """This method returns the value of the name attribute."""
        return self.name

    def _get_type(self) -> str:
        """This method returns the value of the t attribute."""
        return self.t

    def _build_request_body(self) -> dict:
        """This method returns a dictionary to be used as the request body."""
        return {
            "name": self.name,
            "type": self.t,
        }

    @staticmethod
    def _build_headers() -> dict:
        """This static method returns a dictionary to be used as a request header."""
        return {
            "Accept": "application/json",
            "Content-Type": "application/json"
        }

    def do(self) -> requests.Response:
        """This method is the primary class method and is used to perform the HTTP POST request."""
        return requests.post(url=self._get_url(), json=self._build_request_body(), headers=self._build_headers())


class Response:
    """
    The Response class will contain all of our methods to handle a response from the DigitalOcean Functions Challenge
    api.

    Attributes
    ----------
    resp : requests:Response
        The actual response that is retrieved from performing the do() function from our Request class.
    log: : structlog.BoundLogger
        The log attribute is not an attribute to be dealt with directly, rather the _get_log(self) method will retrieve
        the bound logger for us.

    Methods
    -------
    _set_log() -> None
        Gets the application logger.

    _get_status_code() -> int
        Gets the HTTP status code from the requests.Response object the class was initialized with.

    _has_errors() -> bool
        A helper to let us know if errors exist in the requests.Response object the class was initialized with.

    do() -> None
        The primary method of this class. Do is used to print the information from the response to the terminal.
    """
    def __init__(self, resp: requests.Response):
        """
        Parameters
        ----------
        :param resp: requests.Response
            A response object from our Request class do() method.
        """
        self.resp = resp
        self.log: structlog.BoundLogger = None

        self._set_log()

    def _set_log(self):
        """Gets the application logger."""
        self.log = structlog.stdlib.get_logger()

    def _get_status_code(self) -> int:
        """Gets the HTTP status code from the requests.Response object the class was initialized with."""
        return self.resp.status_code

    def _has_errors(self) -> bool:
        """A helper to let us know if errors exist in the requests.Response object the class was initialized with."""
        respj: dict = self.resp.json()
        if "errors" in respj:
            return True
        else:
            return False

    def do(self) -> None:
        """The primary method of this class. Do is used to print the information from the response to the terminal."""
        if self._has_errors():
            for e in self.resp.json()["errors"]:
                for ee in self.resp.json()["errors"][e]:
                    self.log.error(ee)
                return
        else:
            self.log.info(self.resp.json()["message"])
Enter fullscreen mode Exit fullscreen mode

Note: Notice the shebang at the top of the file. I personally like to do this with Python applications so I can call the file directly. In doing this though, you have to provide the file with the appropriate permissions to run. In my case (on a unix-like OS), I just had to run chmod +x main.py to allow it to run.

Now we have a few classes to handle our request and our response.

I wanted structlog to handle things a little more json-y for me, so I needed to add a logger configuration function as well like the following:

...

def configure_logger():
    """Basic application level logging configuration"""
    logging.basicConfig(
        format="%(message)s",
        stream=sys.stdout,
        level=logging.INFO,
    )
    structlog.configure(
        processors=[
            structlog.processors.add_log_level,
            structlog.processors.JSONRenderer(),
        ],
        logger_factory=structlog.stdlib.LoggerFactory(),
    )
Enter fullscreen mode Exit fullscreen mode

Now that our logger is configured, all we had to do pull it all together with a CLI:

@click.command()
@click.option('-n', '--name', "name", help='The name to give to your new Sammy.')
@click.option('-t', '--type', "t", type=click.Choice([
    "sammy",
    "punk",
    "dinosaur",
    "retro",
    "pizza",
    "robot",
    "pony",
    "bootcamp",
    "xray"
], case_sensitive=False), help='The type to give to your new Sammy.')
def main(name, t):
    configure_logger()
    req = Request(name, t)
    response = req.do()
    resp = Response(response)
    resp.do()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Note: Again, notice the if __name__ == "__main__" statement, this is just the line that says "Do this if the file is called directly".

Really all this main() function does (with the help of some Click decorators), is to set the name flag, the t flag for the type, and then run our functions as we have designed them.

Running our new app is a simple as ./main.py --name <my name> --type <my type>.

Like usual, if you want to see the full file, feel free to check it out on the repository here:
https://github.com/j4ng5y/digitalocean-functions-challenge/tree/main/python

Top comments (0)