Disclaimer: This tutorial assumes that the readers have a foundational knowledge of Python, APIs, Git, and Unit Tests. I’ve come across various CLI software with the coolest animations, and it got me wondering - could I ever upgrade my ‘minimalistic’ rock-paper-scissors school project? Hi, let’s play! Choose your fighter (rock,paper,scissors): rock What is a CLI program? As stated on Wikipedia, “A command-line interface (CLI) is a means of interacting with a device or computer program with commands from a user or client, and responses from the device or program, in the form of lines of text.” In other words, a CLI program is a program whereby the user uses the command line to interact with the program by providing instructions to execute. Many day-to-day software is wrapped as a CLI program. Take the text editor for example - a tool shipped with any UNIX system which can be activated simply by running in the terminal. vim vim <FILE> Concerning the , let’s dive into the anatomy of a CLI program. Google Cloud CLI Arguments Arguments (Parameters) are items of information provided to a program. It is often referred to as positional arguments because they are identified by their position. For example, when we want to set the property in the core section, we run project gcloud config set project <PROJECT_ID> Notably, we can translate this into Argument Content Arg 0 gcloud Arg 1 config … … Commands Commands are an array of arguments that provide instructions to the computer. Based on the previous example, we set the property in the core section by running project gcloud config set project <PROJECT_ID> In other words, is a command. set Optional Commands Usually, commands are required but we can make exceptions. Based on the program’s use case, we can define optional commands. Referring back to the command, as stated in their official documentation, is a command group that lets you modify properties. The usage is as such: gcloud config gcloud config gcloud config GROUP | COMMAND [GCLOUD_WIDE_FLAG … ] whereby COMMAND can be either , , and so on… (Note that GROUP is ) set list config Options Options are documented types of parameters that modify the behavior of a command. They’re key-value pairs that are denoted by ‘-’ or ‘--’. Circling back to the usage of the command group, the option(s), in this case, is the . gcloud config GCLOUD_WIDE_FLAG For example, say that we wanted to display the detailed usage and description of the command, we run . In other words, is the option. gcloud config set –help --help Another example is when we want to set the zone property in the compute section of a specific project, we run . In other words, is an option that holds the value . gcloud config set compute <ZONE_NAME> –project=<PROJECT_ID> --project <PROJECT_ID> It’s also important to note that their positions usually don’t matter. Mandatory Options Options, like its name, are usually optional, but can also be tailored to be mandatory. For example, when we want to create a dataproc cluster, we run . And as stated in their usage documentation: gcloud dataproc clusters create <CLUSTER_NAME> –region=<REGION> gcloud dataproc clusters create (CLUSTER: –region=REGION) The flag is mandatory if it hasn’t been previously configured. --region Short Options vs. Long Options 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. What will we achieve through this tutorial? So I lied… We won’t be attempting to upgrade the staple rock-paper-scissors CLI program. Instead, let’s take a look at a real-world scenario: Outline and Goals Your team uses Trello to keep track of the project’s issues and progress. Your team is looking for a more simplified way to interact with the board - something similar to creating a new GitHub repository through the terminal. The team turned to you to create a CLI program with this basic requirement of being able to add a new card to the ‘To Do’ column of the board. Based on the mentioned requirement, let’s draft out our CLI program by defining its requirements: Functional Requirements User can add a new card to a column on the board Required inputs: column, card name Optional inputs: card description, card labels (select from existing) Non-Functional Requirements Program to prompt user to provide access to Trello account (authorization) Program to prompt user to set which Trello board to work on (configuration) Optional Requirements User can add a new column to the board User can add a new label to the board User can see a simplified/detailed view of all columns Based on the above, we can formalize the commands and options of our CLI program as such: P.s. Don’t worry about the last two columns, we’ll learn about it later… As for our tech stack, we’ll be sticking to this: Unit Tests pytest pytest-mock cli-test-helpers Trello py-trello (Python wrapper for the Trello SDK) CLI typer rich simple-term-menu Utils (Misc) python-dotenv Timeline We’ll be tackling this project in parts and here’s a snippet of what you can expect: Part 1 Implementation of business logic py-trello Part 2 Implementation of CLI business logic Distributing the CLI program as a package Part 3 Implementation of optional functional requirements Package update The computer chose scissors! Let’s see who wins this battle… Let’s get started Folder Structure The goal is to distribute the CLI program as a package on . Thus, such a setup is needed: PyPI trellocli/ __init__.py __main__.py models.py cli.py trelloservice.py tests/ test_cli.py test_trelloservice.py README.md pyproject.toml .env .gitignore Here’s a deep dive into each file and/or directory: : acts as the package name to be used by users e.g., trellocli pip install trellocli : represents the root of the package, conforms the folder as a Python package __init__.py : defines the entry point, and allows users to run modules without specifying the file path by using the flag e.g., to replace __main__.py -m python -m <module_name> python -m <parent_folder>/<module_name>.py : stores globally used classes e.g., models that API responses are expected to conform to models.py : stores the business logic for CLI commands and options cli.py : stores the business logic to interact with trelloservice.py py-trello : stores unit tests for the program tests : stores unit tests for the CLI implementation test_cli.py : stores unit tests for the interaction with test_trelloservice.py py-trello : stores documentation for the program README.md : stores the configurations and requirements of the package pyproject.toml : stores environment variables .env : specifies the files to be ignored (not tracked) during version control .gitignore For a more detailed explanation of publishing Python packages, here’s a great article to check out: How to Publish an Open-Source Python Package to PyPI by Geir Arne Hjelle Setup Before we get started, let’s touch base on setting up the package. Starting with the 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: __init__.py app name version SUCCESS and ERROR constants # 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 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 . __main__.py 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 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 Installation and Requirements Getting Started and Usage Another great post to read up on if you’d like to dive deeper: How to Write a Good README by merlos Here’s how I’d like to structure the README for this project <!--- README.md --> # Overview # Getting Started # Usage # Architecture ## Data Flow ## Tech Stack # Running Tests # Next Steps # References Let’s leave the skeleton as it is for now - we’ll return to this later. Moving on, let’s configure our package’s metadata based on the official documentation # pyproject.toml [project] name = "trellocli_<YOUR_USERNAME>" version = "0.1.0" authors = [ { name = "<YOUR_NAME>", email = "<YOUR_EMAIL>" } ] description = "Program to modify your Trello boards from your computer's command line" readme = "README.md" requires-python = ">=3.7" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [] [project.urls] "Homepage" = "" Notice how there are placeholders that you have to modify e.g., your username, your name… On another note, we’ll be leaving the homepage URL empty for now. We’ll make changes after having published it to GitHub. We’ll also be leaving the dependencies portion empty for now, and adding as we go. Next on the list would be our 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. .env 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 , as we intend to use OAuth for our application, we’ll need the following to interact with Trello: this guide py-trello API Key (for our application) API Secret (for our application) Token (user’s token to grant access to their data) Once you’ve retrieved your API Key and Secret, store them in the file as such .env # .env TRELLO_API_KEY=<your_api_key> TRELLO_API_SECRET=<your_api_secret> Last but not least, let’s use the template Python that can be found . Note that this is crucial to ensure that our file is never tracked - if at some point, our 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. .gitignore here .env .env Now that the setup is complete, let’s push our changes up to GitHub. Depending on the metadata as specified in , do remember to update your LICENSE and homepage URL accordingly. For reference on how to write better commits: pyproject.toml Write Better Commits, Build Better Projects by Victoria Dye Other notable steps: Build a virtualenv for the project Unit Tests Before we get started with writing our tests, it’s important to note that because we’re working with an API, we’ll be implementing mock tests to be able to test our program without the risk of API downtime. Here’s another great article on mock testing by Real Python: Mocking External APIs in Python Based on the functional requirements, our main concern is to allow users to add a new card. Referencing the method in : . To be able to do so, we must call the method from the class, of which can be retrieved from the function from the class, of which can be retrieved… py-trello add_card add_card List get_list Board You get the gist - we’ll need a lot of helper methods to reach our final destination, let’s put it in words: Test to retrieve client’s token Test to retrieve boards Test to retrieve a board Test to retrieve lists from board Test to retrieve a list Test to retrieve labels from board Test to retrieve a label Test to add card Test to add label to card It’s also important to note that when writing unit tests, we want our tests to be as extensive as possible - Does it handle errors well? Does it cover every aspect of our program? However, just for the purpose of this tutorial, we’ll be simplifying things by only checking for success cases. Before diving into the code, let’s modify our file to include the dependencies needed for writing/running unit tests. pyproject.toml # pyproject.toml [project] dependencies = [ "pytest==7.4.0", "pytest-mock==3.11.1" ] Next, let’s activate our virtualenv and run to install the dependencies. pip install . Once that’s done, let’s finally write some tests. In general, our tests should include a mocked response to be returned, a patch to the function we’re attempting to test by fixing the return value with the mocked response, and finally a call to the function. A sample test to retrieve the user’s access tokens would like the following: # tests/test_trelloservice.py # module imports from trellocli import SUCCESS from trellocli.trelloservice import TrelloService from trellocli.models import * # dependencies imports # misc imports def test_get_access_token(mocker): """Test to check success retrieval of user's access token""" mock_res = GetOAuthTokenResponse( token="test", token_secret="test", status_code=SUCCESS ) mocker.patch( "trellocli.trelloservice.TrelloService.get_user_oauth_token", return_value=mock_res ) trellojob = TrelloService() res = trellojob.get_user_oauth_token() assert res.status_code == SUCCESS Notice in my sample code that is a model that has yet to be set in . It provides structure to writing cleaner code, we’ll see this in action later. GetOAuthTokenResponse models.py To run our tests, simply run . Notice how our tests will fail, but that’s okay - it’ll work out in the end. python -m pytest 💡 Can you try to write more tests on your own? Feel free to refer to to see what my tests look like Challenge Corner this patch For now, let’s build out our . Starting with adding a new dependency, that is the wrapper. trelloservice py-trello # pyproject.toml dependencies = [ "pytest==7.4.0", "pytest-mock==3.11.1", "py-trello==0.19.0" ] Once again, run to install the dependencies. pip install . Models Now, let’s start by building out our models - to regulate the responses we’re expecting in . For this portion, it’s best to refer to our unit tests and the source code to understand the type of return value we can expect. trelloservice py-trello For example, say that we want to retrieve the user’s access token, referring to ’s function ( ), we know to expect the return value to be something like this py-trello create_oauth_token source code # 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 module has a class named . A workaround for this would be to provide an alias during import. py-trello List # trellocli/models.py # dependencies imports from trello import List as Trellolist Feel free to also use this opportunity to tailor the models to your program’s needs. For example, say that you only require one attribute from the return value, you could refactor your model to expect to extract the said value from the return value rather than storing it as a whole. # trellocli/models.py class GetBoardName(NamedTuple): """Model to store board id Attributes id (str): Extracted board id from Board value type """ id: str 💡 Can you try to write more models on your own? Feel free to refer to to see what my models look like Challenge Corner this patch Business Logic Setup Models down, let’s officially start coding the . 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. trelloservice Per usual, include all import statements towards the top. Then create the class and placeholder methods as expected. The idea is that we’ll initialize a shared instance of the service in and call its methods accordingly. Furthermore, we’re aiming for scalability, thus the need for extensive coverage. TrelloService cli.py # 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. Authorization and Initializing TrelloClient Let’s start with the function. The idea is to call the function here and initialize the . Again, emphasizing the need of storing such sensitive information only in the file, we’ll be using the dependency to retrieve sensitive information. After modifying our file accordingly, let’s start implementing the authorization steps. __init__ get_user_oauth_token TrelloClient .env python-dotenv pyproject.toml # 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 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. Deny 💡 Notice ? Can you declare this error as a package constant? Refer to Setup for more information Challenge Corner TRELLO_AUTHORIZATION_ERROR Helper Functions Now that the authorization part is done, let’s move on to the helper functions, starting with retrieving the user’s Trello boards. # trellocli/trelloservice.py def get_all_boards(self) -> GetAllBoardsResponse: """Method to list all boards from user's account Returns GetAllBoardsResponse: array of user's trello boards """ try: res = self.__client.list_boards() return GetAllBoardsResponse( res=res, status_code=SUCCESS ) except: return GetAllBoardsResponse( res=[], status_code=TRELLO_READ_ERROR ) def get_board(self, board_id: str) -> GetBoardResponse: """Method to retrieve board Required Args board_id (str): board id Returns GetBoardResponse: trello board """ try: res = self.__client.get_board(board_id=board_id) return GetBoardResponse( res=res, status_code=SUCCESS ) except: return GetBoardResponse( res=None, status_code=TRELLO_READ_ERROR ) As for retrieving the lists (columns), we’ll have to check out the class of , or in other words, we must accept a new parameter of the value type. Board py-trello Board # 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 ) 💡 Could you implement the and function on your own? Revise the class of . Feel free to refer to to see what my implementation looks like Challenge Corner get_all_labels get_label Board py-trello this patch Function to Add a New Card Last but not least, we’ve finally reached what we’ve been aiming for this whole time - adding a new card. Keep in mind that we won’t be using all of the previously declared functions here - the goal of the helper functions is to increase scalability. # trellocli/trelloservice.py def add_card( self, col: Trellolist, name: str, desc: str = "", labels: List[Label] = [] ) -> AddCardResponse: """Method to add a new card to a list (column) on the trello board Required Args col (Trellolist): trello list name (str): card name Optional Args desc (str): card description labels (List[Label]): list of labels to be added to the card Returns AddCardResponse: newly-added card """ try: # create new card new_card = col.add_card(name=name) # add optional description if desc: new_card.set_description(description=desc) # add optional labels if labels: for label in labels: new_card.add_label(label=label) return AddCardResponse( res=new_card, status_code=SUCCESS ) except: return AddCardResponse( res=new_card, status_code=TRELLO_WRITE_ERROR ) 🎉 Now that’s done and dusted, remember to update your README accordingly and push your code to GitHub. Congratulations! You won. Play again (y/N)? Wrap-Up Thanks for bearing with me:) Through this tutorial, you successfully learned to implement mocking when writing unit tests, structure models for cohesiveness, read through source code to find key functionalities, and implement business logic using a third-party wrapper. Keep an eye out for Part 2, where we’ll do a deep dive on implementing the CLI program itself. In the meantime, let’s stay in touch 👀 GitHub source code: https://github.com/elainechan01/trellocli