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 , we successfully created the business logic to interact with the Trello SDK. How to Create a Python CLI Program for Trello Board Management (Part 1) 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 Let’s Get Started Folder Structure Previously, we managed to set up a skeleton to host our module. This time around, we want to implement a folder with modules for different functionalities, namely: trelloservice cli config access list The idea is that, for each command group, its commands will be stored in its own module. As for the command, we’ll store it in the main CLI file as it doesn’t belong to any command group. list 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 folder? This will be used to store related assets for our whereas there being a newly-implemented folder in , we’ll use it to store modules to be used across the software. assets README shared trellocli Setup Let’s start by modifying our entry point file, . 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, , has an instance we can run. __main__.py cli.py app # 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 file; we’ll be storing our instance here. The idea is to initialize a object to be shared across the software. cli.py app Typer # 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 to specify our command line scripts. Here, we’ll provide a name for our package and define the entry point. pyproject.toml # pyproject.toml [project.scripts] trellocli = "trellocli.__main__:main" Based on the sample above, we’ve defined as the package name and the function in the script, which is stored in the module will be executed during runtime. trellocli main __main__ trellocli Now that the CLI portion of our software is set up, let’s modify our module to better serve our CLI program. As you recall, our 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 command. This will ensure that our program is cleaner and more descriptive in terms of instructions. trelloservice trelloservice config access To put this into words, we’ll be modifying these functions: __init__ __load_oauth_token_env_var authorize is_authorized Starting with the function, we’ll be initializing an empty client instead of handling the client setup here. __init__ # trellocli/trelloservice.py class TrelloService: def __init__(self) -> None: self.__client = None 💡Can you modify our function so that it won’t recursively prompt for the user’s authorization? Hint: A recursive function is a function that calls itself. Challenge Corner __load_oauth_token_env_var Moving onto the and helper functions, the idea is that will carry out the business logic of setting up the client by utilizing the function whereas the function merely returns a boolean value of whether authorization was granted. authorize is_authorized authorize __load_oauth_token_env_var is_authorized # 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 and is that is an that serves to store the authorization token as an environment variable whereas being the publicly facing function, it attempts to retrieve all the necessary credentials and initialize a Trello Client. __load_oauth_token_env_var authorize __load_oauth_token_env_var internal facing function authorize 💡Notice how our function returns an data type. Can you implement a model that has the attribute? Refer to (Hint: Look at how we created Models) Challenge Corner authorize AuthorizeResponse status_code Part 1 of How to Create a Python CLI Program for Trello Board Management Lastly, let’s instantiate a singleton object towards the bottom of the module. Feel free to refer to this patch to see how the full code looks like: TrelloService 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 defined in our initializer as these exceptions are subclasses from and act as the typical user-defined exceptions, whereas the serve more as constant values starting from 0. ERRORS BaseException ERRORS 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 to test for expected results. What’s great about the module is that it comes with its own object. As for running the tests, we’ll be pairing it with the module. For more information, view the . shell Typer runner pytest 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 a.k.a 0. Here’s a great article by RedHat on . success 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 💡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) Challenge Corner Business Logic Main CLI Module Understand that this will be our main module - for all command groups (config, create), their business logic will be stored in its own separate file for better readability. cli In this module, we’ll store our command. Diving deeper into the command, we know that we want to implement the following options: list board_name: required if was not previously set config board 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 ) or by simply using a default environment variable. However, for our use case, let’s keep it straightforward by raising our custom exception if conditions aren’t met. official docs InvalidUserInputError To build out the command, let’s start with defining the options. In Typer, as mentioned in their , the key ingredients to defining an option would be: official docs Data type Helper text Default value For example, to create the option with the following conditions: detailed 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 command with the needed options, we’ll treat as a Python Function and its options as required parameters. list list # 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 instance initialized towards the top of the file. Feel free to navigate through the to modify the Options to your liking. app official Typer codebase 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 option was provided) board_name Set up Trello board to be read from Retrieve appropriate Trello card data and categorize based on Trello list Display data (check if the option was selected) detailed A few things to note… We want to raise Exceptions when the trellojob produces a status code other than . By using blocks, we can prevent our program from fatal crashes. SUCCESS try-catch When configuring the appropriate board to use, we’ll be attempting to set up the Trello board for use based on the retrieved . Thus, we want to cover the following use cases board_id Retrieving the if the was explicitly provided by checking for a match using the function in the trellojob board_id board_name get_all_boards Retrieving the as stored as an environment variable if the option wasn’t used board_id board_name The data we’ll be displaying will be formatted using the functionality from the package. For more information on , please refer to their Table rich rich 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 that we have yet to implement. Please refer to this patch if you need help implementing them: TrelloService 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 in the terminal. By default, the Typer module will populate the output for the command on its own. And notice how we can call as the package name - recall how this was previously defined in our ? python -m trellocli --help --help trellocli pyproject.toml Let’s fast forward a little and initialize the and command groups as well. To do so, we’ll simply use the function on our object. The idea is that the command group will have its own object, and we’ll just add that into the main in , along with the name of the command group and helper text. It should look something like this create config add_typer app app app cli.py # trellocli/cli/cli.py app.add_typer(cli_config.app, name="config", help="COMMAND GROUP to initialize configurations") 💡Could you import the command group on your own? Feel free to refer to this patch for help: Challenge Corner create trello-cli-kit Subcommands To set up a command group for , we’ll be storing its respective commands in its own module. The setup is similar to that of 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 , or in other words, interrupts the process. The reason why we didn’t cover this for our command is because the difference here is that the 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. create cli.py Ctrl + C list config Starting with the command, we’ll finally be using the function as created in our . Since the function handles the configuration all on its own, we’ll only have to verify the execution of the process. access authorize TrelloService authorize # 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 command, we’ll be utilizing various modules to provide a good user experience, including to display a terminal GUI for user interaction. The main idea is as follows: board Simple Terminal Menu 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 command up until retrieving data from the board. list 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 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 package. It’s also important to note that we have to properly handle the optional values: Simple Terminal Menu rich 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...") 💡Can you display a progress bar for the process? Hint: Take a look at using ’s Challenge Corner add rich 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 is to specify which module stores the package itself. In our case, that’ll be . Here’s the metadata to add: pyproject.toml trellocli # pyproject.toml [tool.setuptools] packages = ["trellocli"] As for our , 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 , you should use its absolute URL, that’s usually of the following format README.md README.md https://raw.githubusercontent.com/<user>/<repo>/<branch>/<path-to-image> TestPyPI We’ll be using the and 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: build twine 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: ). Simply put in the following values: link to documentation 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 tab on your GitHub workflow and select a new workflow. We’ll be using the 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). Actions Publish Python Package 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 , and commands. Next, create a tag for your latest commit by running the following command (For more information on tags: ) pull add commit 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 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. Karol Horosin on Integrating CI/CD with your Python Package Wrap-Up This was a lengthy one 😓. Through this tutorial, you learned to transform your software into a CLI program using the 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. Typer 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?