Dear Diary, an Encrypted Command-Line Diary with Python
In my last post, I wrote about how to work with encrypted SQLite databases with Python. As an example application of these libraries, I showed some code fragments for a fictional diary program. Because I was thinking the examples directory of the peewee repo was looking a little thin, I decided to flesh out the diary program and include it as an example.
In this post, I'll go over the diary code in the hopes that you may find it interesting or useful. The code shows how to use the peewee SQLCipher extension. I've also implemented a simple command-line menu loop. All told, the code is less than 100 lines!
To follow along at home, you'll need to install a few libraries. The encryption is handled by SQLCipher, an open-source library that securely encrypts SQLite databases using AES-256 with cipher-block chaining. There are also python bindings which expose a db-api 2.0 compatible API. For detailed instructions on installation, refer to my previous post, but if you want things to just work, you can run the following:
$ pip install pysqlcipher
You will also need to install peewee:
$ pip install peewee
The database layer
We'll be using peewee to securely store and manage the database of diary entries. To get started, we will define our database connection and a model class representing the table of entries.
#!/usr/bin/env python from peewee import * from playhouse.sqlcipher_ext import SqlCipherDatabase # Defer initialization of the database until the script is executed from the # command-line. db = SqlCipherDatabase(None) class Entry(Model): content = TextField() timestamp = DateTimeField(default=datetime.datetime.now) class Meta: database = db def initialize(passphrase): db.init('diary.db', passphrase=passphrase, kdf_iter=64000) Entry.create_table(fail_silently=True)
The python standard library includes a module for reading passwords from
stdin without echoing the characters. We will use that module to securely accept the passphrase for unlocking the diary.
In the application's entry-point, we'll collect the passphrase, initialize the database, and enter the main menu loop (defined later in the post).
#!/usr/bin/env python from getpass import getpass import sys from peewee import * from playhouse.sqlcipher_ext import SqlCipherDatabase # ... Database and model code from previous code snippet ... if __name__ == '__main__': # Collect the passphrase using a secure method. passphrase = getpass('Enter password: ') if not passphrase: sys.stderr.write('Passphrase required to access diary.\n') sys.stderr.flush() sys.exit(1) # Initialize the database. initialize(passphrase) menu_loop()
Interactive menu loop
The diary will feature an interactive menu loop. Having interactive menus in your scripts is typically frowned-upon, because it hurts composability and is not unix-like. For the diary, though, it seemed like a good idea to me, as it allows you to enter your password once and then have many interactions with the app.
For simplicity, the menu will allow us to perform three operations:
- Add a new entry
- List entries, ordered newest to oldest.
- Search for entries using simple partial-string matching.
Here is the structure for the menu loop and the functions the menu will delegate to:
#!/usr/bin/env python from collections import OrderedDict import datetime from getpass import getpass import sys from peewee import * from playhouse.sqlcipher_ext import SqlCipherDatabase # ... Database definition and model code ... def menu_loop(): choice = None while choice != 'q': for key, value in menu.items(): print('%s) %s' % (key, value.__doc__)) choice = raw_input('Action: ').lower().strip() if choice in menu: menu[choice]() def add_entry(): """Add entry""" def view_entries(search_query=None): """View previous entries""" def search_entries(): """Search entries""" menu = OrderedDict([ ('a', add_entry), ('v', view_entries), ('s', search_entries), ]) if __name__ == '__main__': # ... Application entry-point code ...
The menu loop is called after the database is initialized, then runs in a loop, displaying the menu and delegating to one of the three menu functions. The program ends when the user types
Let's start by defining the
add_entry function. This function will accept multiple lines of input from the user, reading until an
EOF is received (Ctrl+d on my computer). After the user has entered their text, the program will prompt the user whether they wish to save the entry and automatically return to the menu loop.
def add_entry(): """Add entry""" print('Enter your entry. Press ctrl+d when finished.') data = sys.stdin.read().strip() if data and raw_input('Save entry? [Yn] ') != 'n': Entry.create(content=data) print('Saved successfully.')
Impressively, that's all the code it takes! The call to
sys.stdin.read() will automatically read up to an EOF. Since the
menu_loop delegated to the function, when the function exits we will be back in the loop, so no additional code is required.
Now let's define the
view_entries function which will display previously-written entries. We have a couple options here -- we could get fancy and implement a less-style paging system, or we could go simple and just print every single entry in a massive wall-of-text. I chose to take the middle road -- display one entry at a time, and let the user continue to the next one or break out of the loop.
To accomplish this,
view_entries will query entries ordered newest-to-oldest, then in a loop, each entry will be displayed and the user will then be prompted to continue to the next, or to quit. As with the
add_entry function, as soon as the function exits, we will be back in the menu loop.
In order to re-use this logic for the search action, I've written
view_entries to accept an optional search query.
def view_entries(search_query=None): """View previous entries""" query = Entry.select().order_by(Entry.timestamp.desc()) if search_query: query = query.where(Entry.content.contains(search_query)) for entry in query: timestamp = entry.timestamp.strftime('%A %B %d, %Y %I:%M%p') print(timestamp) print('=' * len(timestamp)) print(entry.content) print('n) next entry') print('q) return to main menu') if raw_input('Choice? (Nq) ') == 'q': break
As eluded to in the previous paragraph,
search_entries will simply delegate to the
view_entries function after collecting the search query from the user. Here is the code:
def search_entries(): """Search entries""" view_entries(raw_input('Search query: '))
And with that, our secret diary program is complete!
Ideas for improving the diary
As an exercise, here are some features that might be cool to add:
- Option to delete an entry.
- Smarter pagination for lists of entries.
- Use terminal colors to make the app more visually appealing. Blessings looks pretty cool for this.
- Option to edit entries.
- Calendar-type view, or way to query entries by date.
- Web front-end?
Thanks for reading
If you'd like to browse the complete code, you can find it here.
I hope you enjoyed reading this post! Feel free to leave any comments or questions using the form below.
- SQLCipher and Python overview
- SQLCipher project page
- pysqlcipher source code
- Peewee SQLCipher extension.
- Peewee ORM documentation
Here are some blog posts on related topics you might be interested in:
- Using SQLite's full-text search engine with Python
- Using the SQLite JSON1 and FTS5 extensions with Python
- Guide to extending SQLite with Python through user-defined functions and aggregates, and even building your own SQLite extension modules
Commenting has been closed, but please feel free to contact me