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
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 (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 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.
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 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.
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 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.
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:
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
Non-Functional Requirements
Optional Requirements
Based on the above, we can formalize the commands and options of our CLI program as such:
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
Trello
CLI
Utils (Misc)
We’ll be tackling this project in parts and here’s a snippet of what you can expect:
Part 1
py-trello
business logicPart 2
Part 3
The computer chose scissors! Let’s see who wins this battle…
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 tocli.py
: stores the business logic for CLI commands and optionstrelloservice.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 implementationtest_trelloservice.py
: stores unit tests for the interaction with py-trello
README.md
: stores documentation for the programpyproject.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
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:
# 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:
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:
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:
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:
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.
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
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.
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
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
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)?
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 👀
GitHub source code: https://github.com/elainechan01/trellocli