This chapter covers
In chapter 2, I showed you some of the best practices around separation of concerns in Python. Separating concerns means creating boundaries between code that deals with distinct activities to make the code more understandable. You learned how functions, classes, modules, and packages are useful in decomposing code into pieces that are easier to reason about. Although chapter 2 covered several of the tools available for separating concerns, it’s helpful to get some experience applying them.
As is true for many, I learn best by doing. As I work through a real project, I often discover connections I didn’t see before or find new questions to explore. In this chapter, you’ll work through a real application that exhibits a good use case for separating concerns. You’ll improve upon it in the chapters to come, and you’ll end up with something you can extend for your own personal use.
This and future chapters make light use of structured query language (SQL), a domain-specific language for manipulating and retrieving data from databases. If you haven’t used SQL before, or need a refresher, you might want to run through a tutorial before continuing. Ben Brumm’s SQL in Motion course (www.manning.com/livevideo/sql-in-motion) is a good primer.
In this chapter, you’ll develop an application for saving and organizing bookmarks (more specifics on that in a minute).
I’m not a great notetaker. Throughout school and my career, I’ve struggled to find a way of writing things down for myself that helps me learn and retain information. When I find a great resource that goes through a concept in a novel way or with insightful examples, I’ve struck gold, but I usually need to dedicate time to read and practice the information in that resource. As a result, I’ve amassed a great number of bookmarks over the last few years. Maybe I’ll have the time to read through them someday!
The default bookmark feature in most browsers is lacking. Although things can be nested in folders and given a title, it’s often pretty difficult to recall why you saved something in the first place. A bunch of my bookmarks are code-related articles about testing, performance, new programming languages, and the like. When I find an interesting repository on GitHub, I also use GitHub’s “star” feature to save it for later. But GitHub stars are limited too; at the time of writing, they’re one big flat list that you can filter only by programming language. Whatever bookmark implementations you might use, they’re mostly built on the same foundational principles.
Bookmarks are an example of a small CRUD workflow: create, read, update, and delete (figure 6.1). These four operations make up a lot of data-driven tools in the world. You can create a bookmark to save for later, and then read its information to get the URL. You may want to update a bookmark’s title if the one you gave it originally was confusing, and you usually delete them when you’re done with them. This is a pretty good place to start your application.
Because a long description is one of the features missing from some existing bookmark tools, your application will include that off the bat. You’ll add a few more features in the following chapters, and do so in a way that will enable you to keep adding features you want.
You’re going to develop Bark, a command-line bookmarking application. Bark allows you to create bookmarks that, for now, will be made up of a few pieces of information:
Bark will also let you list all bookmarks that have been added and then delete a specific bookmark by its ID. This is all managed through a command-line interface (CLI)—an application you interact with in your terminal. On startup, Bark’s CLI will present you with a menu of options. Each option, when selected, will trigger an action that will read or modify a database.
You won’t develop a feature to cover the update portion of CRUD for bookmarking in this chapter; you’ll get to that in chapter 7.
Even though the CRUD-like operations Bark supports are fairly common for this kind of application, there’s a sizable amount of stuff happening. For an application this big, it’s important to remember what benefits separation of concerns will offer:
Keep these ideas in mind as you work through the exercise in this chapter. My goal is for you to come out of this chapter with something you can continue developing and adding features to. To do this, you’ll first think about and then implement a high-level architecture that will support that outcome.
I try to start developing applications like Bark with a concise explanation of how it does what it does. This tends to lead me toward an initial architecture.
For example, how does Bark work? What is its concise description? Perhaps the following statement comprises the answers to these questions: Using a command-line interface, a user chooses options for adding, removing, and listing bookmarks stored in a database.
Now let’s break that down a bit:
These points represent the high-level layers of abstraction for Bark. The CLI is the presentation layer of the application. The database is the persistence layer. The actions and business logic are kind of like the glue that connects the presentation and persistence layers. Each is a fairly separate concern, as shown in figure 6.2. This kind of multitier architecture, where each layer (tier) of an application has freedom to evolve, is used by many organizations. Teams can assemble around each tier based on areas of expertise, and each layer can potentially be reused with other applications if desired.
You’ll develop each of these layers of Bark as you work through the chapter. Because each is a separate concern, it makes sense to think of them as separate Python modules:
We’ll start from the persistence layer and work our way up.
Separating applications into layers of presentation, persistence, and actions or rules is a common pattern. Some variations on this approach are so common, they’ve been given names. Model-view-controller (MVC) is a way of modeling data for persistence, providing users with a view into that data, and allowing them to control changes to that data with some set of actions. Model-view-viewmodel (MVVM) puts an emphasis on allowing the view and data model to communicate freely. These and other multitier architectures are great examples of separation of concerns.
The persistence layer is the lowest level of Bark (figure 6.3). This layer will be concerned with taking information it receives and communicating it to the database.
You’ll be using SQLite, a portable database that stores data in a single file by default (www.sqlite.org/index.html). This is convenient, compared to more complex database systems, because you can start from scratch by deleting the file if something goes wrong.
Despite being one of the most widely used databases, SQLite is installed on only some operating systems by default. I recommend downloading a precompiled binary for your operating system from the official download page (https://sqlite.org/download.html).
Starting in the database module, you’ll create a Database-Manager class for manipulating data in the database. Python provides a built-in sqlite3 module, which you can use to get a connection to the database, make queries, and iterate over results. SQLite databases are usually a single file with a .db extension; if you make a sqlite3 connection to a file that doesn’t exist, the module will create it for you.
The database module provides most of what you need to manage bookmark data, including the following:
How can these tasks be broken down further? Each seems somewhat separate, from the business logic perspective described earlier, but what about at the persistence layer? Most of the activities described can be achieved by constructing an appropriate SQL statement and executing it. Executing it requires a connection to the database, which requires the path to the database file.
Whereas managing the persistence is a high-level concern, these individual concerns are what you get when you peel open the persistence layer. They should each be separate as well. First things first, though—you need a connection to the database.
Many smart people have produced wonderful and robust packages that make working with databases in Python easier. SQLAlchemy (www.sqlalchemy.org) is a widely used tool for not only interacting with databases, but abstracting data models via an object-relational mapping (ORM). An ORM allows you to treat database records as objects in languages like Python, without worrying much about the details of a database at all. The Django web framework also provides an ORM for writing data models.
In the spirit of learning by doing, you’ll write the database interaction code yourself in this chapter. It’s limited to the scope of Bark, but it can be added to or replaced if you’d like to do more with the rest of the application. If you need to use a database in future projects, consider whether you want to write your database code from scratch or use one of these third-party packages instead.
While Bark is running, it needs only one connection to the database—it can reuse this connection for all its operations. To make this connection, you can use sqlite3 .connect, which accepts the path of the database file to which it should connect. Again, if the file does not exist, it will be created.
The __init__ for DatabaseManager should
It’s good practice to close the connection to the SQLite database when the program finishes, to limit the possibility of data corruption. For symmetry, the __del__ for DatabaseManager should close the connection with the connection’s .close() method.
This will serve as the foundation for executing statements.
import sqlite3 class DatabaseManager: def __init__(self, database_filename): self.connection = sqlite3.connect(database_filename) 1 def __del__(self): self.connection.close() 2
Your DatabaseManager will need a way to execute statements. These statements have a couple of things in common, so encapsulating those aspects into a reusable method will reduce the likelihood of errors from rewriting the same code each time you want to execute a new kind of statement.
Some SQL statements return data; these statements are called queries. Sqlite3 manages query results with a concept called a cursor. Using a cursor to execute a statement lets you iterate over the results it returns. Statements that aren’t queries (INSERT, DELETE, and so on) don’t return any results, but the cursor manages this by returning an empty list.
Write an _execute method on DatabaseManager that you can use to execute all statements using a cursor, returning a result that you can choose to use where you need to. The _execute method should
def _execute(self, statement): cursor = self.connection.cursor() 1 cursor.execute(statement) 2 return cursor 3
Statements that aren’t queries usually manipulate data, and if anything bad happens while they’re executing, the data could become corrupted. Databases guard against this with a feature called a transaction. If a statement executing within a transaction fails or is otherwise interrupted, the database will roll back to its last known working state. Sqlite3 lets you use the connection object to create a transaction via a context manager, a Python block using the with keyword that provides some special behavior when the code enters and exits the block.
Update _execute to put the cursor creation, execution, and return inside a transaction, like the following:
def _execute(self, statement): with self.connection: 1 cursor = self.connection.cursor() cursor.execute(statement) 2 return cursor
Using .execute inside a transaction will get you where you need to go, functionally speaking. But it’s a good security practice to use placeholders for real values in SQL statements to prevent users from doing malicious things with specially crafted queries.[1] Update _execute to accept two things:
See the Wikipedia article on SQL injection: https://en.wikipedia.org/wiki/SQL_injection.
The method should then execute the statement by passing both arguments to the cursor’s execute, which accepts the same arguments. It should look something like the following snippet:
def _execute(self, statement, values=None): 1 with self.connection: cursor = self.connection.cursor() cursor.execute(statement, values or []) 2 return cursor
Now you have a database connection and the ability to execute arbitrary statements on that connection. Remember that the connection is managed for you automatically when you create a DatabaseManager instance, so you don’t need to think about how it’s opened and closed, unless you want to change it. Now, statement execution is managed within the _execute method, so you also don’t need to think about how a statement is executed; you only need to tell it what statement to execute. This is the power of separating your concerns.
Now that you’ve got these building blocks, it’s time to develop some database interactions.
One of the first things you’ll need is a database table in which to store your bookmark data. You’ll have to create this table using a SQL statement. Because the concerns of connecting to the database and executing statements are now abstracted, the work of creating a table includes the following:
Remember that each bookmark has an ID, title, URL, optional notes, and the date it was added. The data type and constraints for each column follow:
A table creation statement in SQLite uses the CREATE TABLE keywords, followed by the table name, the list of columns, and their data type information in parentheses. Because you’ll want Bark to create the table on startup if it doesn’t already exist, you can use CREATE TABLE IF NOT EXISTS.
Based on the previous descriptions of the bookmark columns, what would the SQL statement look like for creating a bookmarks table? See if you can write it out, then come back to check your work against the following listing.
CREATE TABLE IF NOT EXISTS bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, 1 title TEXT NOT NULL, 2 url TEXT NOT NULL, notes TEXT, date_added TEXT NOT NULL );
You can now write your method for creating tables. Each column is identified by a name, like title, that maps to a data type and constraints, like TEXT NOT NULL, so a dictionary seems like an appropriate Python type for representing columns. The method needs to
Try writing the create_table method now, and then check back to see how it compares to the following listing.
def create_table(self, table_name, columns): columns_with_types = [ 1 f'{column_name} {data_type}' for column_name, data_type in columns.items() ] self._execute( 2 f''' CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(columns_with_types)}); ''' )
Right now, you need only the bookmarks table for Bark. I’ve already argued in this book that early optimization is a no-no, and the same is true for generalization. So why make a general-use create_table method?
When I start building a method with hardcoded values, I check to see if it’s much work to parameterize those values with arguments to the method. For example, replacing the string 'bookmarks' with a table_name string argument isn’t much work. The columns and their data types follow similarly. Using this approach, the create_table method can be made general enough to create most any table you’ll need.
You’ll use this method later on to create a bookmarks table, which is what Bark will interact with to manage bookmarks as you develop the application.
Now that you can create a table, you need to be able to add bookmark records to it. This is the “C” in CRUD (figure 6.4).
SQLite expects the INSERT INTO keyword, followed by the table name, to indicate the intent to add a new record to the table. This is followed by the list of columns you’re supplying values for in parentheses, the VALUES keyword, and then the values you’re supplying in parentheses. A record insert statement in SQLite looks like this:
INSERT INTO bookmarks (title, url, notes, date_added) VALUES ('GitHub', 'https://github.com', 'A place to store repositories of code', '2019-02-01T18:46:32.125467');
Remember that it’s a good practice to use placeholders instead, as in the _execute method earlier. What parts of the preceding query should use placeholders?
Only places where literal values go can use placeholders in statements, so 3 is the correct answer. An INSERT statement for the bookmarks table, with placeholders, looks like this:
INSERT INTO bookmarks (title, url, notes, date_added) VALUES (?, ?, ?, ?);
To construct this statement, you’ll need to write an add method in DatabaseManager that
Write the add method now, and check back with the following listing to see how it compares.
def add(self, table_name, data): placeholders = ', '.join('?' * len(data)) column_names = ', '.join(data.keys()) 1 column_values = tuple(data.values()) 2 self._execute( f''' INSERT INTO {table_name} ({column_names}) VALUES ({placeholders}); ''', column_values, 3 )
To insert records into a database, all you need is the info to be inserted, but some database statements are used in tandem with one or more additional clauses. Clauses affect which records the statement will operate on. Using a DELETE statement without a clause, for example, could end up deleting all the records in the table. You don’t want that.
WHERE clauses can be appended to several kinds of statements to limit the statement’s effect to records matching that criteria. You can combine multiple WHERE criteria using AND or OR. In Bark, for example, each bookmark record has an ID, so you can limit a statement to acting on a particular record by its ID with a clause like WHERE id = 3.
This kind of limiting is useful both for queries (to search for specific records) and for regular statements. Clauses will be useful when you need to delete specific records.
After a bookmark has outlived its usefulness, you need a way to delete it (figure 6.5). To delete a bookmark, you can issue a DELETE statement to the database, using a WHERE clause to specify a bookmark by its ID.
In SQLite, the statement to delete the bookmark with an ID of 3 looks like this:
DELETE FROM bookmarks WHERE ID = 3;
As in the create_table and add methods, you can represent the criteria as a dictionary that maps column names to the values you want to match. Write a delete method that
Check your results against the following listing.
def delete(self, table_name, criteria): 1 placeholders = [f'{column} = ?' for column in criteria.keys()] delete_criteria = ' AND '.join(placeholders) self._execute( f''' DELETE FROM {table_name} WHERE {delete_criteria}; ''', tuple(criteria.values()), 2 )
You can add and remove records from a table now, but how can you retrieve them? Aside from creating and deleting information, you’ll want to be able to read what you’ve already stored (figure 6.6).
You can create a query statement in SQLite using SELECT * FROM bookmarks (the * means “all columns”) and some criteria:
SELECT * FROM bookmarks WHERE ID = 3;
Additionally, you can sort these results by a specific column using an ORDER BY clause:
SELECT * FROM bookmarks WHERE ID = 3 ORDER BY title; 1
Again, you should use placeholders where there are literal values in the query:
SELECT * FROM bookmarks WHERE ID = ? ORDER BY title;
Your select method will look somewhat similar to the delete method, except that criteria can be optional. (It will fetch all records by default.) It should also accept an optional order_by argument that specifies a column to sort the results by (the default is the primary key of the table). Using delete as a guide, write select now and come back to compare with the following listing when you’re done.
def select(self, table_name, criteria=None, order_by=None): criteria = criteria or {} 1 query = f'SELECT * FROM {table_name}' if criteria: 2 placeholders = [f'{column} = ?' for column in criteria.keys()] select_criteria = ' AND '.join(placeholders) query += f' WHERE {select_criteria}' if order_by: 3 query += f' ORDER BY {order_by}' return self._execute( 4 query, tuple(criteria.values()), )
You’ve now created a database connection; written an _execute method for executing arbitrary SQL statements with placeholders in a transaction; and written methods to add, query, and delete records. This is about all you’ll need for manipulating a SQLite database for the moment. You just finished a database manager in fewer than 100 lines of code. Nice work.
Next, you’ll develop the business logic that interacts with the persistence layer.
Now that the persistence layer for Bark is in place, you can work on the layer that figures out what to put in and get out of the persistence layer (figure 6.7).
When a user interacts with something in the presentation layer of Bark, Bark needs to trigger something to happen in the business logic and ultimately in the persistence layer. It might be tempting to do something like the following:
if user_input == 'add bookmark' # add bookmark elif user_input == 'delete bookmark #4': # delete bookmark
But this would couple the text presented to the user with the actions that need to be triggered. You would have new conditions for each menu option, and if you wanted multiple options to trigger the same command, or you wanted to change the text, you would have to refactor some code. It would be nice if the presentation layer were the only place that knows about the menu option text displayed to the user.
Each action is kind of like a command that needs to be executed in response to a user’s menu choice. By encapsulating the logic of each action as a command object, and providing a consistent way to trigger them via an execute method, these actions can be decoupled from the presentation layer. The presentation layer can then point menu options to commands without worrying about how those commands work. This is called the command pattern.[2]
See Wikipedia’s “Command pattern” article for more on this pattern: https://en.wikipedia.org/wiki/Command _pattern.
You’ll develop each of the CRUD actions and some peripheral functionality as commands in the business logic layer.
Now that you’re working in the business logic layer, create a new “commands” module to house all the commands you’re going to write. Because most of the commands will need to make use of the DatabaseManager, import it from the database module and create an instance of it (called db) to be used throughout the commands module. Remember that its __init__ method requires the file path to a SQLite database; I suggest calling it bookmarks.db. Leaving out any leading path will create the database file in the same directory as the Bark code.
Because you’ll need to initialize the bookmarks database table if it doesn’t already exist, start by writing a CreateBookmarksTableCommand class whose execute method creates the table for your bookmarks. You can make use of the db.create_table method you wrote earlier to create your bookmarks table. Later in the chapter, you’ll trigger this command to run when Bark starts up. Check your work against the following listing.
db = DatabaseManager('bookmarks.db') 1 class CreateBookmarksTableCommand: def execute(self): 2 db.create_table('bookmarks', { 3 'id': 'integer primary key autoincrement', 'title': 'text not null', 'url': 'text not null', 'notes': 'text', 'date_added': 'text not null', })
Notice that the command is only aware of its duties (calling persistence layer logic) and the interface of its dependency (DatabaseManager.create_table). This is loose coupling, thanks in part to separating the persistence logic and (eventually) the presentation logic. You should be seeing the benefits of separation of concerns more and more clearly as you work through these exercises.
To add a bookmark, you’ll need to pass data received from the presentation layer on to the persistence layer. The data will be passed as a dictionary mapping column names to values. This is a great example of code relying on a shared interface rather than the specifics of an implementation. If the persistence layer and the business logic layer agree on a data format, they can each do what they need to, as long as the data format stays consistent.
Write an AddBookmarkCommand class that will perform this operation. This class will
See Wikipedia’s article on “ISO 8601” for more information on this time format: https://en.wikipedia.org/ wiki/ISO_8601.
Check your work against the following listing.
from datetime import datetime ... class AddBookmarkCommand: def execute(self, data): data['date_added'] = datetime.utcnow().isoformat() 1 db.add('bookmarks', data) 2 return 'Bookmark added!' 3
You’ve now written all the business logic needed for creating bookmarks. Next, you’ll want to be able to list the bookmarks you’ve added.
Bark needs to be able to show you the bookmarks you’ve saved—without that, Bark wouldn’t be of much use. You’re going to write a ListBookmarksCommand that will provide the logic for displaying the bookmarks in the database.
You’ll want to make use of the DatabaseManager.select method to get the bookmarks from the database. By default, SQLite sorts records by their order of creation (that is, by the primary key of the table), but it might also be useful to sort bookmarks by date or title. In Bark, bookmarks’ IDs and dates sort identically because they both strictly increase as you add bookmarks, but it’s good practice to sort explicitly by the column of interest in case that changes.
ListBookmarksCommand should do the following:
Write the command to list bookmarks, and come back to check your work against the following listing.
class ListBookmarksCommand: def __init__(self, order_by='date_added'): 1 self.order_by = order_by def execute(self): return db.select('bookmarks', order_by=self.order_by).fetchall() 2
Now you’ve got enough functionality to add bookmarks and view existing ones. The last step for managing bookmarks is a command for deleting them.
Similar to adding a new bookmark, the deletion of a bookmark requires some data to be passed from the presentation layer. This time, though, the data is simply an integer value representing the ID of the bookmark to delete.
Write a DeleteBookmarkCommand command that accepts this information in its execute method and passes it to the DatabaseManager.delete method. Remember that delete accepts a dictionary mapping column names to values to match against; here, you’ll want to match the given value in the id column. Once the record is deleted, return a success message for use in the presentation layer.
Come back and check your work against the following listing.
class DeleteBookmarkCommand : def execute(self, data): db.delete('bookmarks', {'id': data}) 1 return 'Bookmark deleted!'
There’s one piece of polish left: a command for exiting Bark. A user could use the usual Ctrl-C method of stopping the Python program, but an option to exit is a little nicer.
Python provides the sys.exit function for stopping program execution. Write a QuitCommand whose execute method exits the program using this approach, then come back and check your work against the following listing.
import sys ... class QuitCommand : def execute(self): sys.exit() 1
Now you can wipe the sweat from your brow . . . not because you’re done, but because you’ll be developing the presentation layer next.
Bark uses a command-line interface (CLI). Its presentation layer (the part the user sees, as shown in figure 6.8) is text in a terminal. Depending on the application, a CLI can run until the completion of a specific task, or it can keep running until the user explicitly exits. Because you wrote QuitCommand, you might guess that you’ll be doing the latter.
The presentation layer of Bark contains an infinite loop:
Now that you’re working on the presentation layer, you’ll need to create a new bark module. It’s a good practice to put code for command-line applications into an if name == 'main': block; this will make sure you don’t unintentionally execute the code in the module by importing the bark module somewhere. If you start with a Hello, World! type of program, you can do a quick check to make sure things are set up properly.
Start with the following in your bark module:
if __name__ == '__main__': print('Welcome to Bark!')
Try running python bark.py in your terminal; you should see Welcome to Bark! as a result. Now you can start hooking up the presentation layer to some business logic.
Remember that Bark needs to initialize the database, creating the bookmarks table if it doesn’t already exist. Import the commands module and update your code to execute the CreateBookmarksTableCommand, as shown in the following snippet. After making this update and running python bark.py, you won’t see any text output, but you should see that a bookmarks.db file is created.
import commands if __name__ == '__main__': commands.CreateBookmarksTableCommand().execute()
It may seem small, but you’ve just accomplished something pretty remarkable. This represents a full pass through all the layers of your multitier architecture. The presentation layer (the act of running bark.py, so far) has triggered a command in the business logic, which, in turn, set up a table in the persistence layer fit for storing bookmarks. Each layer knows just enough about its surroundings to do its job; things are well separated and loosely coupled. You’ll experience this a few more times as you start adding menu options to Bark that trigger more commands.
When you start Bark, it should present you with a menu of options that looks something like this:
(A) Add a bookmark (B) List bookmarks by date (T) List bookmarks by title (D) Delete a bookmark (Q) Quit
Each option has a keyboard shortcut and a descriptive title. If you look carefully, each of these options corresponds to one of the commands you wrote earlier. Because you wrote the commands using the command pattern, each command can be triggered the same way as the others—using its execute method. Commands differ only in what setup and input they require, and then from the presentation layer’s perspective they do whatever they do.
Based on what you’ve learned about encapsulation, how would you go about hooking up the items in the presentation layer to the business logic they control?
I recommend choice 2. To hook each menu option up to the command it should trigger, you can create an Option class. The class’s __init__ method can accept the name to display to the user in the menu, an instance of the command to execute when chosen by the user, and an optional preparation step (to get additional input from the user, for example). All of these can be stored as instance attributes.
When chosen, an Option instance needs to
An Option instance should be represented as its text description when shown to the user; you can use __str__ to override the default behavior. Abstracting this work from the rest of the code that gets and validates user input allows you to keep your concerns separate.
Try writing the Option class, then check the following listing to see how you’ve done.
class Option: def __init__(self, name, command, prep_call=None): self.name = name 1 self.command = command 2 self.prep_call = prep_call 3 def choose(self): 4 data = self.prep_call() if self.prep_call else None 5 message = self.command.execute(data) if data else self.command.execute() 6 print(message) def __str__(self): 7 return self.name
With the Option class in place, now is a good time to start hooking up more of the business logic you created earlier. Remember that you need to do a few things with each option:
What Python data structure would work well to hold all your options?
Each keyboard key maps to a menu option, and you need to check the user’s input against the available options, so you need to keep those pairings stored somehow. Choice 3 is a good one because a dict can provide keyboard key and option pairs that you can also iterate over, with the dictionary’s .items() method, for printing the option text. I also recommend using collections.OrderedDict specifically, to ensure that your menu options will always be printed in the order you specify.
Add your options dictionary after CreateBookmarksTableCommand now, adding an item for each menu option. Once the dictionary is in place, create a print_options function that iterates over the options and prints them in the format you saw earlier:
(A) Add a bookmark (B) List bookmarks by date (T) List bookmarks by title (D) Delete a bookmark (Q) Quit
Check your work with the following listing.
def print_options(options): for shortcut, option in options.items(): print(f'({shortcut}) {option}') print() ... if __name__ == '__main__': ... options = { 'A': Option('Add a bookmark', commands.AddBookmarkCommand()), 'B': Option('List bookmarks by date', commands.ListBookmarksCommand()), 'T': Option('List bookmarks by title', commands.ListBookmarksCommand(order_by='title')), 'D': Option('Delete a bookmark', commands.DeleteBookmarkCommand()), 'Q': Option('Quit', commands.QuitCommand()), } print_options(options)
After you’ve added the menu options, running Bark should print all of the options you added. You can’t yet trigger them; for that, you’ll need to get some user input.
With our overall goal of threading presentation to business logic to persistence, what remains to be added is a bit of interactivity with Bark users. The approach for getting the user’s desired option goes like this:
What approach might you use in Python to get this repeating behavior?
Because there isn’t a definitive end state for getting the user’s input (they might enter an invalid choice four billion times), a while loop (option 1) makes the most sense. While the user’s choice is invalid, keep prompting them. You can take it easy on them by accepting the upper- and lowercase versions of each option if you like.
Write a get_option_choice function, and use it after printing the options to get the user’s choice. Then call that option’s choose method. Try it out, then compare your work with the following listing.
def option_choice_is_valid(choice, options): 1 return choice in options or choice.upper() in options def get_option_choice(options): choice = input('Choose an option: ') 2 while not option_choice_is_valid(choice, options): 3 print('Invalid choice') choice = input('Choose an option: ') return options[choice.upper()] 4 if __name__ == '__main__': ... chosen_option = get_option_choice(options) chosen_option.choose()
At this point, you can run Bark, and some of the commands, like listing bookmarks and quitting, will respond to your input. But a couple of options require some additional preparation, as I alluded to earlier. You need to supply a title, description, and so on to add a bookmark, and you need to specify the ID of a bookmark to delete it. Much like you got user input for the menu option to choose, you’ll need to prompt the user for this bookmark data.
Here’s another opportunity to encapsulate some behavior. For each piece of information you need, you should
Write three functions—one to provide the repeating prompt behavior, and two that use it to get information for adding or deleting a bookmark. Then add each information-fetching function as the prep_call to the appropriate Option instance. Check your results against the following listing to see how you did, or if you get stuck.
def get_user_input(label, required=True): 1 value = input(f'{label}: ') or None 2 while required and not value: 3 value = input(f'{label}: ') or None return value def get_new_bookmark_data(): 4 return { 'title': get_user_input('Title'), 'url': get_user_input('URL'), 'notes': get_user_input('Notes', required=False), 5 } def get_bookmark_id_for_deletion(): 6 return get_user_input('Enter a bookmark ID to delete') if __name__ == '__main__': ... 'A': Option('Add a bookmark', commands.AddBookmarkCommand(), prep_call=get_new_bookmark_data), ... 'D': Option('Delete a bookmark', commands.DeleteBookmarkCommand(), prep_call=get_bookmark_id_for_deletion),
If all is well, you should now be able to run Bark and add, list, or delete bookmarks! Congratulations on a job well done.
We just covered a heck of a lot of stuff, but I want to point out something I find exciting. Because of the way you’ve built Bark, if you want to add new functionality, there’s a clear roadmap:
How cool is that? Separating concerns allows you to clearly see which areas of code you need to augment when adding new functionality.
Before finishing up this chapter, there are a couple of remaining pieces of polish to attend to.
Clearing the screen just before printing the menu or executing a command will make it easier to see the current context the user is in. To clear the screen, you can defer to your operating system’s command-line program for clearing the terminal text. The command for clearing the screen is clear on many operating systems, but it’s cls on Windows. You can figure out if you’re on Windows by checking os.name—on Windows this is 'nt'. (Windows NT is to Windows 10 as macOS is to Mojave.)
Write a clear_screen function that makes the appropriate call using os.system, as in the following code:
import os def clear_screen(): clear = 'cls' if os.name == 'nt' else 'clear' os.system(clear)
Call this just before calling print_options, and just before calling the .choose() method of the user’s selected option:
if __name__ == '__main__': ... clear_screen() print_options(options) chosen_option = get_option_choice(options) clear_screen() chosen_option.choose()
This will be most helpful when the menu and command results get printed over and over again, which is the final piece of this puzzle.
The last step is to run Bark in a loop so that users can perform several actions in a row. To do this, create a loop method and move everything but the database initialization from the if __name__ == '__main__' block into it. Back in the if __name__ == '__main__' block, call loop inside a while True: block. At the end of loop, add a line to pause and wait for the user to press Enter before proceeding.
def loop(): 1 # All the steps for showing/selecting options ... _ = input('Press ENTER to return to menu') 2 if __name__ == '__main__': commands.CreateBookmarksTableCommand().execute() while True: 3 loop()
Now Bark will give the user a way to return to the menu after each interaction, and the menu gives them an option to exit. This covers all the bases. What do you think? I think it’s about time to start using Bark.
3.145.87.161