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

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

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

Too Long; Didn't Read

Part 2 of the tutorial series on How to Create a Python CLI Program for Trello Board Management focusing on how to write business logic for CLI commands and Python package distribution

People Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - How to Create a Python CLI Program for Trello Board Management (Part 2)
Elaine Yun Ru Chan HackerNoon profile picture

We’re way beyond the staple rock-paper-scissors school project by now - let’s dive right into it.


What will we achieve through this tutorial?

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:

Detailed table view of CLI structure based on requirements


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.


Let’s Get Started


Folder Structure

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:


  • config
  • access
  • list


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.


Setup

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:


  • Read error: Raised when there’s an error reading from Trello
  • Write error: Raised when there’s an error writing to Trello
  • Authorization error: Raised when authorization is not granted for Trello
  • Invalid user input error: Raised when user’s CLI input is not recognized


# trellocli/shared/custom_exceptions.py

class TrelloReadError(BaseException):
	pass

class TrelloWriteError(BaseException):
	pass

class TrelloAuthorizationError(BaseException):
	pass

class InvalidUserInputError(BaseException):
	pass


Unit Tests

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:


  • Test to configure access
  • Test to configure the trello board
  • Test to create a new trello card
  • Test to display trello board details
  • Test to display trello board details (detailed view)


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)


Business Logic


Main CLI Module

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:


  • board_name: required if config board was not previously set
  • detailed: display in a detailed view


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:


  • Data type
  • Helper text
  • Default value


For example, to create the detailed option with the following conditions:


  • Data type: bool
  • Helper text: “Enable detailed view”
  • Default value: None


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:


  • Check authorization
  • Configure to use the appropriate board (check if the board_name option was provided)
  • Set up Trello board to be read from
  • Retrieve appropriate Trello card data and categorize based on Trello list
  • Display data (check if the detailed option was selected)


A few things to note…


  • We want to raise Exceptions when the trellojob produces a status code other than SUCCESS. By using try-catch blocks, we can prevent our program from fatal crashes.
  • When configuring the appropriate board to use, we’ll be attempting to set up the Trello board for use based on the retrieved board_id. Thus, we want to cover the following use cases
    • Retrieving the board_id if the board_name was explicitly provided by checking for a match using the get_all_boards function in the trellojob
    • Retrieving the board_id as stored as an environment variable if the board_name option wasn’t used
  • The data we’ll be displaying will be formatted using the Table functionality from the rich package. For more information on rich, please refer to their official docs
    • Detailed: Display a summary of the number of Trello lists, number of cards, and defined labels. For each Trello list, display all cards and their corresponding name, descriptions, and associated labels
    • Non-detailed: Display a summary of the number of Trello lists, number of cards, and defined labels


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


Subcommands

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:


  • Check for authorization
  • Retrieve all Trello boards from user’s account
  • Display a single-selection terminal menu of Trello boards
  • Set selected Trello board ID as an environment variable


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


  • Trello list to be added to: Single-selection
  • Card name: Text
  • [Optional] Card description: Text
  • [Optional] Labels: Multi-selection
  • Confirmation: y/N


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:


  • Users can skip on providing a description
  • Users can provide an empty selection for Labels


# 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


Package Distribution

Here comes the fun part - officially distributing our software on PyPI. We’ll be following this pipeline to do so:


  • Configure metadata + update README
  • Upload to Test PyPI
  • Configure GitHub Actions
  • Push code to Tag v1.0.0
  • Distribute code to PyPI 🎉


For a detailed explanation, check out this great tutorial on Python Packaging by Ramit Mittal.


Metadata Configuration

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>


TestPyPI

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:


  • username: token
  • password: <your TestPyPI token>


Once that’s completed, you should be able to head over to TestPyPI to check out your newly-distributed package!


GitHub Setup

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.


Wrap-Up

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?