We’re way beyond the staple rock-paper-scissors school project by now - let’s dive right into it.
In How to Create a Python CLI Program for Trello Board Management (Part 1), we successfully created the business logic to interact with the Trello SDK.
Here is a quick recap of the architecture of our CLI program:
In this tutorial, we’ll be looking at how to transform our project into a CLI program, focusing on the functional and non-functional requirements.
On the other hand, we’ll also be learning how to distribute our program as a package on PyPI.
Previously, we managed to set up a skeleton to host our trelloservice
module. This time around, we want to implement a cli
folder with modules for different functionalities, namely:
The idea is that, for each command group, its commands will be stored in its own module. As for the list
command, we’ll store it in the main CLI file as it doesn’t belong to any command group.
On the other hand, let’s look into cleaning up our folder structure. More specifically, we should start accounting for the software’s scalability by ensuring that directories aren’t cluttered.
Here’s a suggestion on our folder structure:
trellocli/
__init__.py
__main__.py
trelloservice.py
shared/
models.py
custom_exceptions.py
cli/
cli.py
cli_config.py
cli_create.py
tests/
test_cli.py
test_trelloservice.py
assets/
images/
README.md
pyproject.toml
.env
.gitignore
Notice how there’s the assets
folder? This will be used to store related assets for our README
whereas there being a newly-implemented shared
folder in trellocli
, we’ll use it to store modules to be used across the software.
Let’s start by modifying our entry point file, __main__.py
. Looking at the import itself, because we’ve decided to store related modules in their own subfolders, we’ll have to accommodate such changes. On the other hand, we’re also assuming that the main CLI module, cli.py
, has an app
instance we can run.
# trellocli/__main__.py
# module imports
from trellocli import __app_name__
from trellocli.cli import cli
from trellocli.trelloservice import TrelloService
# dependencies imports
# misc imports
def main():
cli.app(prog_name=__app_name__)
if __name__ == "__main__":
main()
Fast forward to our cli.py
file; we’ll be storing our app
instance here. The idea is to initialize a Typer
object to be shared across the software.
# trellocli/cli/cli.py
# module imports
# dependencies imports
from typer import Typer
# misc imports
# singleton instances
app = Typer()
Moving forward with this concept, let’s modify our pyproject.toml
to specify our command line scripts. Here, we’ll provide a name for our package and define the entry point.
# pyproject.toml
[project.scripts]
trellocli = "trellocli.__main__:main"
Based on the sample above, we’ve defined trellocli
as the package name and the main
function in the __main__
script, which is stored in the trellocli
module will be executed during runtime.
Now that the CLI portion of our software is set up, let’s modify our trelloservice
module to better serve our CLI program. As you recall, our trelloservice
module is set up to recursively ask for the user’s authorization until it is approved. We’ll be modifying this such that the program will exit if authorization isn’t given and urge the user to run the config access
command. This will ensure that our program is cleaner and more descriptive in terms of instructions.
To put this into words, we’ll be modifying these functions:
__init__
__load_oauth_token_env_var
authorize
is_authorized
Starting with the __init__
function, we’ll be initializing an empty client instead of handling the client setup here.
# trellocli/trelloservice.py
class TrelloService:
def __init__(self) -> None:
self.__client = None
Challenge Corner💡Can you modify our __load_oauth_token_env_var
function so that it won’t recursively prompt for the user’s authorization? Hint: A recursive function is a function that calls itself.
Moving onto the authorize
and is_authorized
helper functions, the idea is that authorize
will carry out the business logic of setting up the client by utilizing the __load_oauth_token_env_var
function whereas the is_authorized
function merely returns a boolean value of whether authorization was granted.
# trellocli/trelloservice.py
class TrelloService:
def authorize(self) -> AuthorizeResponse:
"""Method to authorize program to user's trello account
Returns
AuthorizeResponse: success / error
"""
self.__load_oauth_token_env_var()
load_dotenv()
if not os.getenv("TRELLO_OAUTH_TOKEN"):
return AuthorizeResponse(status_code=TRELLO_AUTHORIZATION_ERROR)
else:
self.__client = TrelloClient(
api_key=os.getenv("TRELLO_API_KEY"),
api_secret=os.getenv("TRELLO_API_SECRET"),
token=os.getenv("TRELLO_OAUTH_TOKEN")
)
return AuthorizeResponse(status_code=SUCCESS)
def is_authorized(self) -> bool:
"""Method to check authorization to user's trello account
Returns
bool: authorization to user's account
"""
if not self.__client:
return False
else:
return True
Understand that the difference between __load_oauth_token_env_var
and authorize
is that __load_oauth_token_env_var
is an internal facing function that serves to store the authorization token as an environment variable whereas authorize
being the publicly facing function, it attempts to retrieve all the necessary credentials and initialize a Trello Client.
Challenge Corner💡Notice how our authorize
function returns an AuthorizeResponse
data type. Can you implement a model that has the status_code
attribute? Refer to Part 1 of How to Create a Python CLI Program for Trello Board Management (Hint: Look at how we created Models)
Lastly, let’s instantiate a singleton TrelloService
object towards the bottom of the module. Feel free to refer to this patch to see how the full code looks like: trello-cli-kit
# trellocli/trelloservice.py
trellojob = TrelloService()
Finally, we want to initialize some custom exceptions to be shared across the program. This is different from the ERRORS
defined in our initializer as these exceptions are subclasses from BaseException
and act as the typical user-defined exceptions, whereas the ERRORS
serve more as constant values starting from 0.
Let’s keep our exceptions to the minimum and go with some of the common use cases, most notably:
# trellocli/shared/custom_exceptions.py
class TrelloReadError(BaseException):
pass
class TrelloWriteError(BaseException):
pass
class TrelloAuthorizationError(BaseException):
pass
class InvalidUserInputError(BaseException):
pass
As mentioned in Part I, we won’t be extensively covering Unit Tests in this tutorial, so let’s work with only the necessary elements:
The idea is to mock a command line interpreter, like a shell
to test for expected results. What’s great about the Typer
module is that it comes with its own runner
object. As for running the tests, we’ll be pairing it with the pytest
module. For more information, view the official docs by Typer.
Let’s work through the first test together, that is, to configure access. Understand that we’re testing if the function properly executes. To do so, we’ll be checking the system response and whether the exit code is success
a.k.a 0. Here’s a great article by RedHat on what exit codes are and how the system uses them to communicate processes.
# trellocli/tests/test_cli.py
# module imports
from trellocli.cli.cli import app
# dependencies imports
from typer.testing import CliRunner
# misc imports
runner = CliRunner()
def test_config_access():
res = runner.invoke(app, ["config", "access"])
assert result.exit_code == 0
assert "Go to the following link in your browser:" in result.stdout
Challenge Corner💡Now that you’ve got the gist of it, can you implement other test cases on your own? (Hint: you should also consider testing for failure cases)
Understand that this will be our main cli
module - for all command groups (config, create), their business logic will be stored in its own separate file for better readability.
In this module, we’ll store our list
command. Diving deeper into the command, we know that we want to implement the following options:
config board
was not previously set
Starting with the board_name required option, there are a few ways to achieve this, one of them being by using the callback function (For more information, here are the official docs) or by simply using a default environment variable. However, for our use case, let’s keep it straightforward by raising our InvalidUserInputError
custom exception if conditions aren’t met.
To build out the command, let’s start with defining the options. In Typer, as mentioned in their official docs, the key ingredients to defining an option would be:
For example, to create the detailed
option with the following conditions:
Our code would look like this:
detailed: Annotated[bool, typer.Option(help=”Enable detailed view)] = None
Overall, to define the list
command with the needed options, we’ll treat list
as a Python Function and its options as required parameters.
# trellocli/cli/cli.py
@app.command()
def list(
detailed: Annotated[bool, Option(help="Enable detailed view")] = None,
board_name: Annotated[str, Option(help="Trello board to search")] = ""
) -> None:
pass
Note that we’re adding the command to the app
instance initialized towards the top of the file. Feel free to navigate through the official Typer codebase to modify the Options to your liking.
As for the workflow of the command, we’re going for something like this:
board_name
option was provided)detailed
option was selected)
A few things to note…
SUCCESS
. By using try-catch
blocks, we can prevent our program from fatal crashes.board_id
. Thus, we want to cover the following use cases
board_id
if the board_name
was explicitly provided by checking for a match using the get_all_boards
function in the trellojobboard_id
as stored as an environment variable if the board_name
option wasn’t usedTable
functionality from the rich
package. For more information on rich
, please refer to their official docs
Putting everything together, we get something as follows. Disclaimer: there may be some missing functions from TrelloService
that we have yet to implement. Please refer to this patch if you need help implementing them: trello-cli-kit
# trellocli/cli/cli.py
# module imports
from trellocli.trelloservice import trellojob
from trellocli.cli import cli_config, cli_create
from trellocli.misc.custom_exceptions import *
from trellocli import SUCCESS
# dependencies imports
from typer import Typer, Option
from rich import print
from rich.console import Console
from rich.table import Table
from dotenv import load_dotenv
# misc imports
from typing_extensions import Annotated
import os
# singleton instances
app = Typer()
console = Console()
# init command groups
app.add_typer(cli_config.app, name="config", help="COMMAND GROUP to initialize configurations")
app.add_typer(cli_create.app, name="create", help="COMMAND GROUP to create new Trello elements")
@app.command()
def list(
detailed: Annotated[
bool,
Option(help="Enable detailed view")
] = None,
board_name: Annotated[str, Option()] = ""
) -> None:
"""COMMAND to list board details in a simplified (default)/detailed view
OPTIONS
detailed (bool): request for detailed view
board_name (str): board to use
"""
try:
# check authorization
res_is_authorized = trellojob.is_authorized()
if not res_is_authorized:
print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`")
raise AuthorizationError
# if board_name OPTION was given, attempt to retrieve board id using the name
# else attempt to retrieve board id stored as an env var
board_id = None
if not board_name:
load_dotenv()
if not os.getenv("TRELLO_BOARD_ID"):
print("[bold red]Error![/] A trello board hasn't been configured to use. Try running `trellocli config board`")
raise InvalidUserInputError
board_id = os.getenv("TRELLO_BOARD_ID")
else:
res_get_all_boards = trellojob.get_all_boards()
if res_get_all_boards.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving boards from trello")
raise TrelloReadError
boards_list = {board.name: board.id for board in res_get_all_boards.res} # retrieve all board id(s) and find matching board name
if board_name not in boards_list:
print("[bold red]Error![/] An invalid trello board name was provided. Try running `trellocli config board`")
raise InvalidUserInputError
board_id = boards_list[board_name]
# configure board to use
res_get_board = trellojob.get_board(board_id=board_id)
if res_get_board.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when configuring the trello board to use")
raise TrelloReadError
board = res_get_board.res
# retrieve data (labels, trellolists) from board
res_get_all_labels = trellojob.get_all_labels(board=board)
if res_get_all_labels.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving data from board")
raise TrelloReadError
labels_list = res_get_all_labels.res
res_get_all_lists = trellojob.get_all_lists(board=board)
if res_get_all_lists.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving data from board")
raise TrelloReadError
trellolists_list = res_get_all_lists.res
# store data on cards for each trellolist
trellolists_dict = {trellolist: [] for trellolist in trellolists_list}
for trellolist in trellolists_list:
res_get_all_cards = trellojob.get_all_cards(trellolist=trellolist)
if res_get_all_cards.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving cards from trellolist")
raise TrelloReadError
cards_list = res_get_all_cards.res
trellolists_dict[trellolist] = cards_list
# display data (lists count, cards count, labels)
# if is_detailed OPTION is selected, display data (name, description, labels) for each card in each trellolist
print()
table = Table(title="Board: "+board.name, title_justify="left", show_header=False)
table.add_row("[bold]Lists count[/]", str(len(trellolists_list)))
table.add_row("[bold]Cards count[/]", str(sum([len(cards_list) for cards_list in trellolists_dict.values()])))
table.add_row("[bold]Labels[/]", ", ".join([label.name for label in labels_list if label.name]))
console.print(table)
if detailed:
for trellolist, cards_list in trellolists_dict.items():
table = Table("Name", "Desc", "Labels", title="List: "+trellolist.name, title_justify="left")
for card in cards_list:
table.add_row(card.name, card.description, ", ".join([label.name for label in card.labels if label.name]))
console.print(table)
print()
except (AuthorizationError, InvalidUserInputError, TrelloReadError):
print("Program exited...")
To see our software in action, simply run python -m trellocli --help
in the terminal. By default, the Typer module will populate the output for the --help
command on its own. And notice how we can call trellocli
as the package name - recall how this was previously defined in our pyproject.toml
?
Let’s fast forward a little and initialize the create
and config
command groups as well. To do so, we’ll simply use the add_typer
function on our app
object. The idea is that the command group will have its own app
object, and we’ll just add that into the main app
in cli.py
, along with the name of the command group and helper text. It should look something like this
# trellocli/cli/cli.py
app.add_typer(cli_config.app, name="config", help="COMMAND GROUP to initialize configurations")
Challenge Corner💡Could you import the create
command group on your own? Feel free to refer to this patch for help: trello-cli-kit
To set up a command group for create
, we’ll be storing its respective commands in its own module. The setup is similar to that of cli.py
with the need to instantiate a Typer object. As for the commands, we would also want to adhere to the need to use custom exceptions. An additional topic we want to cover is when the user presses Ctrl + C
, or in other words, interrupts the process. The reason why we didn’t cover this for our list
command is because the difference here is that the config
command group consists of interactive commands. The main difference between interactive commands is that they require ongoing user interaction. Of course, say that our direct command takes a long time to execute. It’s also best practice to handle potential keyboard interrupts.
Starting with the access
command, we’ll finally be using the authorize
function as created in our TrelloService
. Since the authorize
function handles the configuration all on its own, we’ll only have to verify the execution of the process.
# trellocli/cli/cli_config.py
@app.command()
def access() -> None:
"""COMMAND to configure authorization for program to access user's Trello account"""
try:
# check authorization
res_authorize = trellojob.authorize()
if res_authorize.status_code != SUCCESS:
print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`")
raise AuthorizationError
except KeyboardInterrupt:
print("[yellow]Keyboard Interrupt.[/] Program exited...")
except AuthorizationError:
print("Program exited...")
As for the board
command, we’ll be utilizing various modules to provide a good user experience, including Simple Terminal Menu to display a terminal GUI for user interaction. The main idea is as follows:
# trellocli/cli/cli_config.py
@app.command()
def board() -> None:
"""COMMAND to initialize Trello board"""
try:
# check authorization
res_is_authorized = trellojob.is_authorized()
if not res_is_authorized:
print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`")
raise AuthorizationError
# retrieve all boards
res_get_all_boards = trellojob.get_all_boards()
if res_get_all_boards.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving trello boards")
raise TrelloReadError
boards_list = {board.name: board.id for board in res_get_all_boards.res} # for easy access to board id when given board name
# display menu to select board
boards_menu = TerminalMenu(
boards_list.keys(),
title="Select board:",
raise_error_on_interrupt=True
)
boards_menu.show()
selected_board = boards_menu.chosen_menu_entry
# set board ID as env var
dotenv_path = find_dotenv()
set_key(
dotenv_path=dotenv_path,
key_to_set="TRELLO_BOARD_ID",
value_to_set=boards_list[selected_board]
)
except KeyboardInterrupt:
print("[yellow]Keyboard Interrupt.[/] Program exited...")
except (AuthorizationError, TrelloReadError):
print("Program exited...")
Finally, we’re moving into the core functional requirement of our software - Add a new card to a list on the Trello board. We’ll be using the same steps from our list
command up until retrieving data from the board.
Apart from that, we’ll be interactively requesting for user input to properly configure the new card:
For all prompts needing the user to select from a list, we’ll be using the Simple Terminal Menu
package like before. As for other prompts and miscellaneous items like needing text input or the user’s confirmation, we will simply be using the rich
package. It’s also important to note that we have to properly handle the optional values:
# trellocli/cli/cli_create.py
@app.command()
def card(
board_name: Annotated[str, Option()] = ""
) -> None:
"""COMMAND to add a new trello card
OPTIONS
board_name (str): board to use
"""
try:
# check authorization
res_is_authorized = trellojob.is_authorized()
if not res_is_authorized:
print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`")
raise AuthorizationError
# if board_name OPTION was given, attempt to retrieve board id using the name
# else attempt to retrieve board id stored as an env var
board_id = None
if not board_name:
load_dotenv()
if not os.getenv("TRELLO_BOARD_ID"):
print("[bold red]Error![/] A trello board hasn't been configured to use. Try running `trellocli config board`")
raise InvalidUserInputError
board_id = os.getenv("TRELLO_BOARD_ID")
else:
res_get_all_boards = trellojob.get_all_boards()
if res_get_all_boards.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving boards from trello")
raise TrelloReadError
boards_list = {board.name: board.id for board in res_get_all_boards.res} # retrieve all board id(s) and find matching board name
if board_name not in boards_list:
print("[bold red]Error![/] An invalid trello board name was provided. Try running `trellocli config board`")
raise InvalidUserInputError
board_id = boards_list[board_name]
# configure board to use
res_get_board = trellojob.get_board(board_id=board_id)
if res_get_board.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when configuring the trello board to use")
raise TrelloReadError
board = res_get_board.res
# retrieve data (labels, trellolists) from board
res_get_all_labels = trellojob.get_all_labels(board=board)
if res_get_all_labels.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving the labels from the trello board")
raise TrelloReadError
labels_list = res_get_all_labels.res
labels_dict = {label.name: label for label in labels_list if label.name}
res_get_all_lists = trellojob.get_all_lists(board=board)
if res_get_all_lists.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when retrieving the lists from the trello board")
raise TrelloReadError
trellolists_list = res_get_all_lists.res
trellolists_dict = {trellolist.name: trellolist for trellolist in trellolists_list} # for easy access to trellolist when given name of trellolist
# request for user input (trellolist, card name, description, labels to include) interactively to configure new card to be added
trellolist_menu = TerminalMenu(
trellolists_dict.keys(),
title="Select list:",
raise_error_on_interrupt=True
) # Prompt: trellolist
trellolist_menu.show()
print(trellolist_menu.chosen_menu_entry)
selected_trellolist = trellolists_dict[trellolist_menu.chosen_menu_entry]
selected_name = Prompt.ask("Card name") # Prompt: card name
selected_desc = Prompt.ask("Description (Optional)", default=None) # Prompt (Optional) description
labels_menu = TerminalMenu(
labels_dict.keys(),
title="Select labels (Optional):",
multi_select=True,
multi_select_empty_ok=True,
multi_select_select_on_accept=False,
show_multi_select_hint=True,
raise_error_on_interrupt=True
) # Prompt (Optional): labels
labels_menu.show()
selected_labels = [labels_dict[label] for label in list(labels_menu.chosen_menu_entries)] if labels_menu.chosen_menu_entries else None
# display user selection and request confirmation
print()
confirmation_table = Table(title="Card to be Added", show_header=False)
confirmation_table.add_row("List", selected_trellolist.name)
confirmation_table.add_row("Name", selected_name)
confirmation_table.add_row("Description", selected_desc)
confirmation_table.add_row("Labels", ", ".join([label.name for label in selected_labels]) if selected_labels else None)
console.print(confirmation_table)
confirm = Confirm.ask("Confirm")
# if confirm, attempt to add card to trello
# else, exit
if confirm:
res_add_card = trellojob.add_card(
col=selected_trellolist,
name=selected_name,
desc=selected_desc,
labels=selected_labels
)
if res_add_card.status_code != SUCCESS:
print("[bold red]Error![/] A problem occurred when adding a new card to trello")
raise TrelloWriteError
else:
print("Process cancelled...")
except KeyboardInterrupt:
print("[yellow]Keyboard Interrupt.[/] Program exited...")
except (AuthorizationError, InvalidUserInputError, TrelloReadError, TrelloWriteError):
print("Program exited...")
Challenge Corner💡Can you display a progress bar for the add
process? Hint: Take a look at using rich
’s status feature
Here comes the fun part - officially distributing our software on PyPI. We’ll be following this pipeline to do so:
For a detailed explanation, check out this great tutorial on Python Packaging by Ramit Mittal.
The last detail that we need for our pyproject.toml
is to specify which module stores the package itself. In our case, that’ll be trellocli
. Here’s the metadata to add:
# pyproject.toml
[tool.setuptools]
packages = ["trellocli"]
As for our README.md
, it’s great practice to provide some sort of guide, be it usage guidelines or how to get started. If you included images in your README.md
, you should use its absolute URL, that’s usually of the following format
https://raw.githubusercontent.com/<user>/<repo>/<branch>/<path-to-image>
We’ll be using the build
and twine
tools to build and publish our package. Run the following command in your terminal to create a source archive and a wheel for your package:
python -m build
Ensure that you’ve already got an account set up on TestPyPI, and run the following command
twine upload -r testpypi dist/*
You’ll be prompted to type in your username and password. Due to having two factor authentication enabled, you’ll be required to use an API Token (For more information on how to acquire a TestPyPI API token: link to documentation). Simply put in the following values:
Once that’s completed, you should be able to head over to TestPyPI to check out your newly-distributed package!
The goal is to utilize GitHub as a means to continuously update new versions of your package based on tags.
First, head over to the Actions
tab on your GitHub workflow and select a new workflow. We’ll be using the Publish Python Package
workflow that was created by GitHub Actions. Notice how the workflow requires reading from the repository secrets? Ensure that you’ve stored your PyPI token under the specified name (Acquiring a PyPI API token is similar to that of TestPyPI).
Once the workflow is created, we’ll be pushing our code to tag v1.0.0. For more information on version naming syntax, here’s a great explanation by Py-Pkgs: link to documentation
Simply run the usual pull
, add
and commit
commands. Next, create a tag for your latest commit by running the following command (For more information on tags: link to documentation)
git tag <tagname> HEAD
Finally, push your new tag to the remote repository
git push <remote name> <tag name>
Here’s a great article by Karol Horosin on Integrating CI/CD with your Python Package if you’d like to learn more. But for now, sit back and enjoy your latest achievement 🎉. Feel free to watch the magic unravel as a GitHub Actions workflow as it distributes your package to PyPI.
This was a lengthy one 😓. Through this tutorial, you learned to transform your software into a CLI program using the Typer
module and distribute your package to PyPI. To dive deeper, you learned to define commands and command groups, develop an interactive CLI session, and dabble with common CLI scenarios like keyboard interruption.
You’ve been an absolute wizard for toughing it out through it all. Will you not join me for Part 3, where we implement the optional functionalities?