visit
Hi, let’s play! Choose your fighter (rock,paper,scissors): rock
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.
For example, when we want to set the project
property in the core section, we run gcloud config set project <PROJECT_ID>
Argument | Content |
---|---|
Arg 0 | gcloud |
Arg 1 | config |
… | … |
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.
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
)
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>
.
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.
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.
Functional Requirements
Non-Functional Requirements
Optional Requirements
P.s. Don’t worry about the last two columns, we’ll learn about it later…
Unit Tests
Trello
CLI
Utils (Misc)
Part 1
py-trello
business logicPart 2
Part 3
The computer chose scissors! Let’s see who wins this battle…
trellocli/
__init__.py
__main__.py
models.py
cli.py
trelloservice.py
tests/
test_cli.py
test_trelloservice.py
README.md
pyproject.toml
.env
.gitignore
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
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:
<!---
README.md
-->
# Overview
# Getting Started
# Usage
# Architecture
## Data Flow
## Tech Stack
# Running Tests
# Next Steps
# References
# 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" = ""
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 . 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 . 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:
Based on the functional requirements, our main concern is to allow users to add a new card. Referencing the method in py-trello
: . 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…
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.
# 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 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 (), 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
# 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 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
# 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 to see what my implementation looks like
# 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
)
Congratulations! You won. Play again (y/N)?