Writing a Python CLI to complete the DigitalOcean Functions Challenge
Tags: python, digitalocean, serverless, api
Published: 2022-06-13 19:38:19

---
title: Writing a Python CLI to complete the DigitalOcean Functions Challenge
published: true
description: A quick write-up on writing a Python CLI to complete the DigitalOcean Functions Challenge
tags: python, digitalocean, serverless, api
series: DigitalOcean Functions
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
---

# Prelude

To continue from my [Go CLI write-up](https://dev.to/j4ng5y/writing-a-go-cli-to-complete-the-digital-ocean-functions-challenge-3372), I will go in to detail about writing a Python CLI to perform the same [DigitalOcean Functions Challenge](https://functionschallenge.digitalocean.com)

## 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](https://pipenv.pypa.io/en/latest/index.html) like so:

`pipenv install requests structlog click`

* [Requests](https://requests.readthedocs.io/en/latest/) I always use when I have to interface with the web in Python applications.
* [Structlog](https://www.structlog.org/en/stable/) is my structured logging package of choice for Python applications.
* [Click](https://click.palletsprojects.com/en/8.1.x/) 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.

```python
#!/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"])
```

**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:

```python
...

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(),
    )
```

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

```python
@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()
```

**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