paint-brush
How to Create a Python CLI Program for Trello Board Management (Part 1)by@elainechan01
2,031 reads
2,031 reads

How to Create a Python CLI Program for Trello Board Management (Part 1)

by Elaine Yun Ru ChanAugust 15th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

As stated on Wikipedia, “A command-line interface (CLI) is a means of interacting with a device or computer program with commands from a user or client, and responses from the device or program, in the form of lines of text.” In other words, a CLI program is a program whereby the user uses the command line to interact with the program by providing instructions to execute. Many day-to-day software is wrapped as a CLI program. Take the vim text editor for example - a tool shipped with any UNIX system which can be activated simply by running vim <FILE> in the terminal. Concerning the Google Cloud CLI, let’s dive into the anatomy of a CLI program.
featured image - How to Create a Python CLI Program for Trello Board Management (Part 1)
Elaine Yun Ru Chan HackerNoon profile picture

Disclaimer: This tutorial assumes that the readers have a foundational knowledge of Python, APIs, Git, and Unit Tests.

I’ve come across various CLI software with the coolest animations, and it got me wondering - could I ever upgrade my ‘minimalistic’ rock-paper-scissors school project?


Hi, let’s play! Choose your fighter (rock,paper,scissors): rock

What is a CLI program?

As stated on Wikipedia, “A command-line interface (CLI) is a means of interacting with a device or computer program with commands from a user or client, and responses from the device or program, in the form of lines of text.”


In other words, a CLI program is a program whereby the user uses the command line to interact with the program by providing instructions to execute.


Many day-to-day software is wrapped as a CLI program. Take the vim text editor for example - a tool shipped with any UNIX system which can be activated simply by running vim <FILE> in the terminal.


Concerning the Google Cloud CLI, let’s dive into the anatomy of a CLI program.

Arguments

Arguments (Parameters) are items of information provided to a program. It is often referred to as positional arguments because they are identified by their position.


For example, when we want to set the project property in the core section, we run gcloud config set project <PROJECT_ID>


Notably, we can translate this into

Argument

Content

Arg 0

gcloud

Arg 1

config

Commands

Commands are an array of arguments that provide instructions to the computer.


Based on the previous example, we set the project property in the core section by running gcloud config set project <PROJECT_ID>


In other words, set is a command.

Optional Commands

Usually, commands are required but we can make exceptions. Based on the program’s use case, we can define optional commands.


Referring back to the gcloud config command, as stated in their official documentation, gcloud config is a command group that lets you modify properties. The usage is as such:

gcloud config GROUP | COMMAND [GCLOUD_WIDE_FLAG … ]

whereby COMMAND can be either set, list, and so on… (Note that GROUP is config)

Options

Options are documented types of parameters that modify the behavior of a command. They’re key-value pairs that are denoted by ‘-’ or ‘--’.


Circling back to the usage of the gcloud config command group, the option(s), in this case, is the GCLOUD_WIDE_FLAG.


For example, say that we wanted to display the detailed usage and description of the command, we run gcloud config set –help. In other words, --help is the option.


Another example is when we want to set the zone property in the compute section of a specific project, we run gcloud config set compute <ZONE_NAME> –project=<PROJECT_ID>. In other words, --project is an option that holds the value <PROJECT_ID>.


It’s also important to note that their positions usually don’t matter.

Mandatory Options

Options, like its name, are usually optional, but can also be tailored to be mandatory.


For example, when we want to create a dataproc cluster, we run gcloud dataproc clusters create <CLUSTER_NAME> –region=<REGION>. And as stated in their usage documentation:

gcloud dataproc clusters create (CLUSTER: –region=REGION)

The --region flag is mandatory if it hasn’t been previously configured.

Short Options vs. Long Options

Short options begin with - followed by a single alphanumeric character, whereas long options begin with -- followed by multiple characters. Think of short options as shortcuts when the user is sure of what they want whereas long options are more readable.


You chose rock! The computer will now make its selection.

What will we achieve through this tutorial?

So I lied… We won’t be attempting to upgrade the staple rock-paper-scissors CLI program.

Instead, let’s take a look at a real-world scenario:

Outline and Goals

Your team uses Trello to keep track of the project’s issues and progress. Your team is looking for a more simplified way to interact with the board - something similar to creating a new GitHub repository through the terminal. The team turned to you to create a CLI program with this basic requirement of being able to add a new card to the ‘To Do’ column of the board.


Based on the mentioned requirement, let’s draft out our CLI program by defining its requirements:


Functional Requirements

  • User can add a new card to a column on the board
    • Required inputs: column, card name
    • Optional inputs: card description, card labels (select from existing)

Non-Functional Requirements

  • Program to prompt user to provide access to Trello account (authorization)
  • Program to prompt user to set which Trello board to work on (configuration)

Optional Requirements

  • User can add a new column to the board
  • User can add a new label to the board
  • User can see a simplified/detailed view of all columns


Based on the above, we can formalize the commands and options of our CLI program as such:

Detailed table view of CLI structure based on requirements


P.s. Don’t worry about the last two columns, we’ll learn about it later…


As for our tech stack, we’ll be sticking to this:


Unit Tests

  • pytest
  • pytest-mock
  • cli-test-helpers

Trello

  • py-trello (Python wrapper for the Trello SDK)

CLI

  • typer
  • rich
  • simple-term-menu

Utils (Misc)

  • python-dotenv

Timeline

We’ll be tackling this project in parts and here’s a snippet of what you can expect:


Part 1

  • Implementation of py-trello business logic

Part 2

  • Implementation of CLI business logic
  • Distributing the CLI program as a package

Part 3

  • Implementation of optional functional requirements
  • Package update


The computer chose scissors! Let’s see who wins this battle…

Let’s get started

Folder Structure

The goal is to distribute the CLI program as a package on PyPI. Thus, such a setup is needed:

trellocli/
	__init__.py
	__main__.py
	models.py
	cli.py
	trelloservice.py
tests/
	test_cli.py
	test_trelloservice.py
README.md
pyproject.toml
.env
.gitignore


Here’s a deep dive into each file and/or directory:

  • trellocli: acts as the package name to be used by users e.g., pip install trellocli
    • __init__.py: represents the root of the package, conforms the folder as a Python package
    • __main__.py: defines the entry point, and allows users to run modules without specifying the file path by using the -m flag e.g., python -m <module_name> to replace python -m <parent_folder>/<module_name>.py
    • models.py: stores globally used classes e.g., models that API responses are expected to conform to
    • cli.py: stores the business logic for CLI commands and options
    • trelloservice.py: stores the business logic to interact with py-trello
  • tests: stores unit tests for the program
    • test_cli.py: stores unit tests for the CLI implementation
    • test_trelloservice.py: stores unit tests for the interaction with py-trello
  • README.md: stores documentation for the program
  • pyproject.toml: stores the configurations and requirements of the package
  • .env: stores environment variables
  • .gitignore: specifies the files to be ignored (not tracked) during version control


For a more detailed explanation of publishing Python packages, here’s a great article to check out: How to Publish an Open-Source Python Package to PyPI by Geir Arne Hjelle

Setup

Before we get started, let’s touch base on setting up the package.


Starting with the __init__.py file in our package, which would be where package constants and variables are stored, such as app name and version. In our case, we want to initialize the following:

  • app name
  • version
  • SUCCESS and ERROR constants
# trellocli/__init__.py

__app_name__ = "trellocli"
__version__ = "0.1.0"

(
  SUCCESS,
  TRELLO_WRITE_ERROR,
  TRELLO_READ_ERROR
) = range(3)

ERRORS = {
  TRELLO_WRITE_ERROR: "Error when writing to Trello",
  TRELLO_READ_ERROR: "Error when reading from Trello"
}


Moving on to the __main__.py file, the main flow of your program should be stored here. In our case, we will store the CLI program entry point, assuming that there will be a callable function in cli.py.

# trellocli/__main__.py

from trellocli import cli

def main():
  # we'll modify this later - after the implementation of `cli.py`
  pass

if __name__ == "__main__":
  main()


Now that the package has been set up, let’s take a look at updating our README.md file (main documentation). There isn’t a specific structure that we must follow, but a good README would consist of the following:

  • Overview
  • Installation and Requirements
  • Getting Started and Usage

Another great post to read up on if you’d like to dive deeper: How to Write a Good README by merlos


Here’s how I’d like to structure the README for this project

<!---
README.md
-->

# Overview

# Getting Started

# Usage

# Architecture
## Data Flow
## Tech Stack

# Running Tests

# Next Steps

# References


Let’s leave the skeleton as it is for now - we’ll return to this later.


Moving on, let’s configure our package’s metadata based on the official documentation

# pyproject.toml

[project]
name = "trellocli_<YOUR_USERNAME>"
version = "0.1.0"
authors = [
  { name = "<YOUR_NAME>", email = "<YOUR_EMAIL>" }
]
description = "Program to modify your Trello boards from your computer's command line"
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
  "Programming Language :: Python :: 3",
  "License :: OSI Approved :: MIT License",
  "Operating System :: OS Independent",
]
dependencies = []

[project.urls]
"Homepage" = ""


Notice how there are placeholders that you have to modify e.g., your username, your name…


On another note, we’ll be leaving the homepage URL empty for now. We’ll make changes after having published it to GitHub. We’ll also be leaving the dependencies portion empty for now, and adding as we go.


Next on the list would be our .env file where we store our environment variables such as API secrets and keys. It’s important to note that this file shouldn’t be tracked by Git as it contains sensitive information.


In our case, we’ll be storing our Trello credentials here. To create a Power-Up in Trello, follow this guide. More specifically, based on the usage by py-trello, as we intend to use OAuth for our application, we’ll need the following to interact with Trello:

  • API Key (for our application)
  • API Secret (for our application)
  • Token (user’s token to grant access to their data)


Once you’ve retrieved your API Key and Secret, store them in the .env file as such

# .env

TRELLO_API_KEY=<your_api_key>
TRELLO_API_SECRET=<your_api_secret>


Last but not least, let’s use the template Python .gitignore that can be found here. Note that this is crucial to ensure that our .env file is never tracked - if at some point, our .env file was tracked, even if we removed the file in later steps, the damage is done and malicious actors can trace down the previous patches for sensitive information.


Now that the setup is complete, let’s push our changes up to GitHub. Depending on the metadata as specified in pyproject.toml, do remember to update your LICENSE and homepage URL accordingly. For reference on how to write better commits: Write Better Commits, Build Better Projects by Victoria Dye


Other notable steps:

Unit Tests

Before we get started with writing our tests, it’s important to note that because we’re working with an API, we’ll be implementing mock tests to be able to test our program without the risk of API downtime. Here’s another great article on mock testing by Real Python: Mocking External APIs in Python


Based on the functional requirements, our main concern is to allow users to add a new card. Referencing the method in py-trello: add_card. To be able to do so, we must call the add_card method from the List class, of which can be retrieved from the get_list function from the Board class, of which can be retrieved…


You get the gist - we’ll need a lot of helper methods to reach our final destination, let’s put it in words:

  • Test to retrieve client’s token
  • Test to retrieve boards
  • Test to retrieve a board
  • Test to retrieve lists from board
  • Test to retrieve a list
  • Test to retrieve labels from board
  • Test to retrieve a label
  • Test to add card
  • Test to add label to card


It’s also important to note that when writing unit tests, we want our tests to be as extensive as possible - Does it handle errors well? Does it cover every aspect of our program?


However, just for the purpose of this tutorial, we’ll be simplifying things by only checking for success cases.


Before diving into the code, let’s modify our pyproject.toml file to include the dependencies needed for writing/running unit tests.

# pyproject.toml

[project]
dependencies = [
  "pytest==7.4.0",
  "pytest-mock==3.11.1"
]


Next, let’s activate our virtualenv and run pip install . to install the dependencies.


Once that’s done, let’s finally write some tests. In general, our tests should include a mocked response to be returned, a patch to the function we’re attempting to test by fixing the return value with the mocked response, and finally a call to the function. A sample test to retrieve the user’s access tokens would like the following:

# tests/test_trelloservice.py

# module imports
from trellocli import SUCCESS
from trellocli.trelloservice import TrelloService
from trellocli.models import *

# dependencies imports

# misc imports

def test_get_access_token(mocker):
  """Test to check success retrieval of user's access token"""
  mock_res = GetOAuthTokenResponse(
    token="test",
    token_secret="test",
    status_code=SUCCESS
  )
  mocker.patch(
    "trellocli.trelloservice.TrelloService.get_user_oauth_token",
    return_value=mock_res
  )
  trellojob = TrelloService()
  res = trellojob.get_user_oauth_token()

  assert res.status_code == SUCCESS


Notice in my sample code that GetOAuthTokenResponse is a model that has yet to be set in models.py. It provides structure to writing cleaner code, we’ll see this in action later.


To run our tests, simply run python -m pytest. Notice how our tests will fail, but that’s okay - it’ll work out in the end.


Challenge Corner 💡 Can you try to write more tests on your own? Feel free to refer to this patch to see what my tests look like


For now, let’s build out our trelloservice. Starting with adding a new dependency, that is the py-trello wrapper.

# pyproject.toml

dependencies = [
  "pytest==7.4.0",
  "pytest-mock==3.11.1",
  "py-trello==0.19.0"
]


Once again, run pip install . to install the dependencies.

Models

Now, let’s start by building out our models - to regulate the responses we’re expecting in trelloservice. For this portion, it’s best to refer to our unit tests and the py-trello source code to understand the type of return value we can expect.


For example, say that we want to retrieve the user’s access token, referring to py-trello’s create_oauth_token function (source code), we know to expect the return value to be something like this

# trellocli/models.py

# module imports

# dependencies imports

# misc imports
from typing import NamedTuple

class GetOAuthTokenResponse(NamedTuple):
  token: str
  token_secret: str
  status_code: int


On the other hand, be aware of conflicting naming conventions. For example, the py-trello module has a class named List. A workaround for this would be to provide an alias during import.

# trellocli/models.py

# dependencies imports
from trello import List as Trellolist


Feel free to also use this opportunity to tailor the models to your program’s needs. For example, say that you only require one attribute from the return value, you could refactor your model to expect to extract the said value from the return value rather than storing it as a whole.

# trellocli/models.py

class GetBoardName(NamedTuple):
  """Model to store board id

  Attributes
    id (str): Extracted board id from Board value type
  """
  id: str


Challenge Corner 💡 Can you try to write more models on your own? Feel free to refer to this patch to see what my models look like

Business Logic

Setup

Models down, let’s officially start coding the trelloservice. Again, we should refer to the unit tests that we created - say that the current list of tests doesn’t provide full coverage for the service, always return and add more tests when needed.


Per usual, include all import statements towards the top. Then create the TrelloService class and placeholder methods as expected. The idea is that we’ll initialize a shared instance of the service in cli.py and call its methods accordingly. Furthermore, we’re aiming for scalability, thus the need for extensive coverage.

# trellocli/trelloservice.py

# module imports
from trellocli import TRELLO_READ_ERROR, TRELLO_WRITE_ERROR, SUCCESS
from trellocli.models import *

# dependencies imports
from trello import TrelloClient

# misc imports

class TrelloService:
  """Class to implement the business logic needed to interact with Trello"""
  def __init__(self) -> None:
    pass

  def get_user_oauth_token() -> GetOAuthTokenResponse:
    pass

  def get_all_boards() -> GetAllBoardsResponse:
    pass

  def get_board() -> GetBoardResponse:
    pass

  def get_all_lists() -> GetAllListsResponse:
    pass

  def get_list() -> GetListResponse:
    pass

  def get_all_labels() -> GetAllLabelsResponse:
    pass

  def get_label() -> GetLabelResponse:
    pass

  def add_card() -> AddCardResponse:
    pass


P.s. notice how this time round when we run our tests, our tests will pass. In fact, this will help us ensure that we stick to the right track. The workflow should be to extend our functions, run our tests, check for pass/fail and refactor accordingly.

Authorization and Initializing TrelloClient

Let’s start with the __init__ function. The idea is to call the get_user_oauth_token function here and initialize the TrelloClient. Again, emphasizing the need of storing such sensitive information only in the .env file, we’ll be using the python-dotenv dependency to retrieve sensitive information. After modifying our pyproject.toml file accordingly, let’s start implementing the authorization steps.

# trellocli/trelloservice.py

class TrelloService:
  """Class to implement the business logic needed to interact with Trello"""
  def __init__(self) -> None:
    self.__load_oauth_token_env_var()
    self.__client = TrelloClient(
      api_key=os.getenv("TRELLO_API_KEY"),
      api_secret=os.getenv("TRELLO_API_SECRET"),
      token=os.getenv("TRELLO_OAUTH_TOKEN")
    )

  def __load_oauth_token_env_var(self) -> None:
    """Private method to store user's oauth token as an environment variable"""
    load_dotenv()
    if not os.getenv("TRELLO_OAUTH_TOKEN"):
      res = self.get_user_oauth_token()
      if res.status_code == SUCCESS:
        dotenv_path = find_dotenv()
        set_key(
          dotenv_path=dotenv_path,
          key_to_set="TRELLO_OAUTH_TOKEN",
          value_to_set=res.token
        )
      else:
        print("User denied access.")
        self.__load_oauth_token_env_var()

  def get_user_oauth_token(self) -> GetOAuthTokenResponse:
    """Helper method to retrieve user's oauth token

    Returns
      GetOAuthTokenResponse: user's oauth token
    """
    try:
      res = create_oauth_token()
      return GetOAuthTokenResponse(
        token=res["oauth_token"],
        token_secret=res["oauth_token_secret"],
        status_code=SUCCESS
      )
    except:
      return GetOAuthTokenResponse(
        token="",
        token_secret="",
        status_code=TRELLO_AUTHORIZATION_ERROR
      )


In this implementation, we created a helper method to handle any foreseeable errors e.g., when the user clicks Deny during authorization. Moreover, it’s set up to recursively ask for the user’s authorization until a valid response was returned, because the fact is that we can’t continue unless the user authorizes our app to access their account data.


Challenge Corner 💡 Notice TRELLO_AUTHORIZATION_ERROR? Can you declare this error as a package constant? Refer to Setup for more information

Helper Functions

Now that the authorization part is done, let’s move on to the helper functions, starting with retrieving the user’s Trello boards.

# trellocli/trelloservice.py

  def get_all_boards(self) -> GetAllBoardsResponse:
    """Method to list all boards from user's account

    Returns
      GetAllBoardsResponse: array of user's trello boards
    """
    try:
      res = self.__client.list_boards()
      return GetAllBoardsResponse(
        res=res,
        status_code=SUCCESS
      )
    except:
      return GetAllBoardsResponse(
        res=[],
        status_code=TRELLO_READ_ERROR
      )

  def get_board(self, board_id: str) -> GetBoardResponse:
    """Method to retrieve board

    Required Args
      board_id (str): board id

    Returns
      GetBoardResponse: trello board
    """
    try:
      res = self.__client.get_board(board_id=board_id)
      return GetBoardResponse(
        res=res,
        status_code=SUCCESS
      )
    except:
      return GetBoardResponse(
        res=None,
        status_code=TRELLO_READ_ERROR
      )


As for retrieving the lists (columns), we’ll have to check out the Board class of py-trello, or in other words, we must accept a new parameter of the Board value type.

# trellocli/trelloservice.py

  def get_all_lists(self, board: Board) -> GetAllListsResponse:
    """Method to list all lists (columns) from the trello board

    Required Args
      board (Board): trello board

    Returns
      GetAllListsResponse: array of trello lists
    """
    try:
      res = board.all_lists()
      return GetAllListsResponse(
        res=res,
        status_code=SUCCESS
      )
    except:
      return GetAllListsResponse(
        res=[],
        status_code=TRELLO_READ_ERROR
      )

  def get_list(self, board: Board, list_id: str) -> GetListResponse:
    """Method to retrieve list (column) from the trello board

    Required Args
      board (Board): trello board
      list_id (str): list id

    Returns
      GetListResponse: trello list
    """
    try:
      res = board.get_list(list_id=list_id)
      return GetListResponse(
        res=res,
        status_code=SUCCESS
      )
    except:
      return GetListResponse(
        res=None,
        status_code=TRELLO_READ_ERROR
      )


Challenge Corner 💡 Could you implement the get_all_labels and get_label function on your own? Revise the Board class of py-trello. Feel free to refer to this patch to see what my implementation looks like

Function to Add a New Card

Last but not least, we’ve finally reached what we’ve been aiming for this whole time - adding a new card. Keep in mind that we won’t be using all of the previously declared functions here - the goal of the helper functions is to increase scalability.

# trellocli/trelloservice.py

  def add_card(
    self,
    col: Trellolist,
    name: str,
    desc: str = "",
    labels: List[Label] = []
  ) -> AddCardResponse:
    """Method to add a new card to a list (column) on the trello board

    Required Args
      col (Trellolist): trello list
      name (str): card name

    Optional Args
      desc (str): card description
      labels (List[Label]): list of labels to be added to the card

    Returns
      AddCardResponse: newly-added card
    """
    try:
      # create new card
      new_card = col.add_card(name=name)
      # add optional description
      if desc:
        new_card.set_description(description=desc)
      # add optional labels
      if labels:
        for label in labels:
          new_card.add_label(label=label)
      return AddCardResponse(
        res=new_card,
        status_code=SUCCESS
      )
    except:
      return AddCardResponse(
        res=new_card,
        status_code=TRELLO_WRITE_ERROR
      )


🎉 Now that’s done and dusted, remember to update your README accordingly and push your code to GitHub.


Congratulations! You won. Play again (y/N)?

Wrap-Up

Thanks for bearing with me:) Through this tutorial, you successfully learned to implement mocking when writing unit tests, structure models for cohesiveness, read through source code to find key functionalities, and implement business logic using a third-party wrapper.


Keep an eye out for Part 2, where we’ll do a deep dive on implementing the CLI program itself.


In the meantime, let’s stay in touch 👀