Pro Track project · about 3 to 5 hours
A book log with SQLite
A small program that keeps a log of the books you've read in a real database, with Python's own sqlite3 module. You write the class that creates the table and adds, finds, changes, removes and totals the books with SQL, and pytest tests check it against a fresh database in memory.
Download the project (zip, 19 KB) The folder on GitHub Your checklist (0 of 9 ticked)
Unzip it on your computer and open its README.md. The zip isn't saved for offline use, so downloading it needs a connection. New to virtual environments and pytest? The Pro Track page shows how to set up your computer.
What it practices
- a class over a SQLite database, with sqlite3 (from module 34: Defining classes)
- raising ValueError for wrong values, and catching it in the menu (from module 25: Handling errors)
- a text menu in a module of its own, apart from the data (from module 44: Developing a larger application)
- exporting the books to a CSV file with the csv module (from module 24: Writing files)
- the pages of each author in a dictionary (from module 21: Dictionary)
What you'll build
A book log: each line of it is one book you've read, with its title, its author and how many pages it has. The program is a text menu over a database in a file, books.db, so your books are still there the next time you start it. A run looks like this (what you type comes after each :):
Book log. Commands: add, list, search, change, remove, totals, export, quit
Command: add
Title: The Left Hand of Darkness
Author: Ursula K. Le Guin
Pages: 304
Added book 1.
Command: add
Title: The Summer Book
Author: Tove Jansson
Pages: 170
Added book 2.
Command: add
Title: A Wizard of Earthsea
Author: Ursula K. Le Guin
Pages: 183
Added book 3.
Command: search
Title or author contains: le guin
#1 The Left Hand of Darkness by Ursula K. Le Guin, 304 pages
#3 A Wizard of Earthsea by Ursula K. Le Guin, 183 pages
Command: totals
Pages in all: 657
Tove Jansson: 170
Ursula K. Le Guin: 487
Command: export
File (empty for books.csv):
Wrote 3 books to books.csv.
Command: quit
Bye!
export writes the books into a CSV file that a spreadsheet opens:
id,title,author,pages
1,The Left Hand of Darkness,Ursula K. Le Guin,304
2,The Summer Book,Tove Jansson,170
3,A Wizard of Earthsea,Ursula K. Le Guin,183
The menu, the class Book and the start of the program are ready. Your part is the class that talks to the database, BookDatabase. The menu only calls its methods and never sees any SQL, as in module 44, "Developing a larger application": the user interface in one class, the data in another.
| File | What it is | Who writes it |
|---|---|---|
booklog/bookdatabase.py |
The class BookDatabase: the table and every query. |
You (steps 1 to 6) |
booklog/book.py |
The class Book: one book, with its id, title, author and pages. |
Given ready |
booklog/menu.py |
The class BookMenu: the text menu. |
Given ready |
booklog/__main__.py |
Starts the program when you run python -m booklog. |
Given ready |
tests/ |
The tests, one file for each step, and conftest.py, which gives each test a new database. |
Given ready |
What you need
- Python 3.14 (3.12 or newer works too). Check it in a terminal with
python3 --version(on Windows,py --version). The Pro Track page shows how to install Python and set up a project, step by step. - No database to install. SQLite is a database that keeps everything in one file, and the module
sqlite3that uses it comes with Python. - A code editor. Visual Studio Code with its Python extension is the one the course uses; PyCharm works just as well.
- pytest, the tool that runs the tests. pip installs it from
requirements.txtin step 0, so that step needs an internet connection. - Git, if you want to put the project on GitHub from the terminal (optional). Get it from git-scm.com, or with your package manager (
brew install git,sudo apt install git). - What the course taught, in particular: classes and methods (modules 34 to 36), encapsulation (39), handling errors, raising them too (25), writing files and CSV (24 and 30), your own modules (31), and splitting a program into a user interface and the rest (44).
About 3 to 5 hours of work.
The project's folder
book-log/
requirements.txt what pip installs: pytest
pytest.ini pytest's settings: the tests are in tests/
booklog/ the program, a package: a folder of modules
tests/ the tests, a file for each step
.github/ the workflow that runs the tests on GitHub
A package is a folder of modules with a file __init__.py in it. from booklog.book import Book imports the class Book from the module book.py in the folder booklog, and python -m booklog runs the package's __main__.py.
SQL in two minutes
The course didn't use databases, so here is what this project needs. A database keeps tables. A table has named columns, and each row is one record: here, one book. You talk to the database in SQL, a language of its own:
CREATE TABLE IF NOT EXISTS Books (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, ...)
INSERT INTO Books (title, author, pages) VALUES (?, ?, ?)
SELECT id, title, author, pages FROM Books WHERE id = ?
SELECT id, title, author, pages FROM Books ORDER BY id
UPDATE Books SET title = ?, author = ?, pages = ? WHERE id = ?
DELETE FROM Books WHERE id = ?
SELECT SUM(pages) FROM Books
In Python, self.connection.execute(sql, values) runs one SQL statement. Each ? in the SQL is a place for a value, and the values come as a tuple, in the same order. A tuple of one value needs a comma: (book_id,). execute returns a cursor, which holds the answer:
cursor.fetchone()gives the next row as a tuple, such as(1, "Dune", "Frank Herbert", 412), orNonewhen there are no more rows.cursor.fetchall()gives all the rows, as a list of tuples.cursor.lastrowidis the id of the row anINSERTadded, andcursor.rowcountis how many rows anUPDATEor aDELETEchanged.
Never put a value into the SQL text yourself, with an f-string or +. A title like The Hitchhiker's Guide would break the SQL, and a title written on purpose could run SQL of its own. With ?, SQLite always treats the value as plain text, and some tests check exactly that.
A change to the database (INSERT, UPDATE, DELETE) is saved only when it is committed. Run it inside with self.connection:, and the connection commits at the end of the block, or undoes the change if an error happens inside it. It's like with open(...), except that it doesn't close the connection:
with self.connection:
cursor = self.connection.execute("INSERT INTO Books (title, author, pages) VALUES (?, ?, ?)", (title, author, pages))
return cursor.lastrowid
Python's sqlite3 documentation has the rest, with a tutorial.
pytest in two minutes
A test is a function whose name starts with
test_, in a file whose name starts withtest_.python -m pytestfinds all of them intests/and runs them.A test checks with a plain
assert. When the condition is false, pytest shows both sides, and the message after the comma if there is one:assert db.remove(5) is False.pytest.raiseschecks that code raises an exception:with pytest.raises(ValueError): db.add("", "Jane Austen", 474)Fixtures give a test what it needs: pytest sees a parameter's name and passes the right thing in.
tests/conftest.pymakes the fixturedb: aBookDatabase(":memory:"), a new, empty database that lives only in memory, so every test starts from nothing and no file is left behind. pytest's own fixturetmp_pathis an empty folder for a test that needs real files.
Some tests also look into the table with plain SQL through db.connection, past your methods. So your class must really keep the books in the database, not in a list of its own.
Steps
Each step names the # TODOs to fill in, all in booklog/bookdatabase.py, and the test file that checks them. Do them in order: later steps use the earlier ones. After each step, run that step's tests (see Run the tests), and go on when they pass.
Step 0: get the project running
Unzip the project and open a terminal in its folder (the one with requirements.txt). Make a virtual environment, a folder .venv that keeps the project's packages apart from the rest of your computer, install pytest into it, and run the tests:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m pytestOn Windows, the first two lines are py -m venv .venv and .venv\Scripts\activate. Activate the environment again each time you open a new terminal for the project: the prompt then starts with (.venv).
pytest runs 69 tests, and all of them fail: that's the starting point, and the failing tests are your to-do list. If instead it says No module named pytest, the virtual environment isn't active.
Step 1: create the table
At the end of __init__, after the connection is made, create the table Books if it doesn't exist yet. Its columns:
| Column | SQL type | What it holds |
|---|---|---|
id |
INTEGER PRIMARY KEY AUTOINCREMENT |
The book's id. The database hands out 1, 2, 3... and never hands out a removed book's id again. |
title |
TEXT NOT NULL |
The title, such as Dune: text, never missing. |
author |
TEXT NOT NULL |
Who wrote it. |
pages |
INTEGER NOT NULL |
How many pages it has. |
The SQL starts with CREATE TABLE IF NOT EXISTS Books ( and lists the columns with commas between them. A string in triple quotes ("""...""") can go over several lines, which keeps it readable. IF NOT EXISTS matters: when you start the program the second time, the table is already there with your books in it, and __init__ must leave it as it is.
Why AUTOINCREMENT? SQLite gives a new row the biggest id so far plus 1. Without AUTOINCREMENT, if you removed the last book and added another, the new one would get the removed one's id, and an id you wrote down somewhere would suddenly mean another book. With it, an id is never used twice.
Checked by tests/test_schema.py.
Step 2: add and find_by_id
add(title, author, pages) stores a new book and returns its id.
- First check the values. Raise a
ValueErrorwith a message that says what's wrong if the title or the author is empty or only spaces, or ifpagesis less than 1. Check before you touch the database, so a refused book is never stored and doesn't use up an id. The menu catches theValueErrorand shows its message:Not added: a book needs a title. - Store the title and the author without the spaces at their start and end (
strip()). - Run the
INSERTinsidewith self.connection:, as in "SQL in two minutes" above, and returncursor.lastrowid. There's no id in theINSERT: the database chooses it.
find_by_id(book_id) returns the book with that id, or None if there's none. The SQL is SELECT id, title, author, pages FROM Books WHERE id = ?, and fetchone() gives the row or None. Make a Book of the row: Book(row[0], row[1], row[2], row[3]), or Book(*row), which passes the tuple's items as the arguments. A SELECT changes nothing, so it needs no with self.connection:.
The checks are needed again in step 4, so a private method that checks the values and returns them stripped, used by both, saves writing them twice.
Checked by tests/test_add_and_find.py.
Step 3: list and search
list_all() returns every book as a Book, smallest id first: SELECT id, title, author, pages FROM Books ORDER BY id, then a Book of each row of fetchall(). SQL promises no particular order unless you ask for one with ORDER BY. SQLite often gives the rows in id order anyway, but don't count on it: one test turns on a setting that makes SQLite give them in the opposite order whenever there's no ORDER BY.
search(text) returns the books whose title or author contains the text, ignoring upper and lower case, smallest id first. dUNE finds Dune and Children of Dune, le guin finds Ursula K. Le Guin's books, and an empty text finds every book. Do it in Python: go through list_all() and keep the books where text.lower() is in the title or the author in lower case. A book whose title and author both match is in the list once.
(Why not in SQL? SQLite's LOWER() changes only the letters A to Z, so LOWER('HALLDÓR') is halldÓr, and a search for HALLDÓR wouldn't find Halldór Laxness. Python's lower() knows every alphabet. SQL's LIKE has the same problem, and in it % and _ mean "anything" and "any one character", so a search for _ would find every book. A test checks both.)
Checked by tests/test_list_and_search.py.
Step 4: update and remove
update(book) changes the title, the author and the pages of the row with the book's id: UPDATE Books SET title = ?, author = ?, pages = ? WHERE id = ?. It checks the values and strips the text exactly as add does. It returns True when cursor.rowcount is more than 0, and False when there was no book with that id.
remove(book_id) deletes the row with that id, DELETE FROM Books WHERE id = ?, and returns True if a row went away, False if there was none.
Both change the database, so both run inside with self.connection:. The tests check that the change is committed: they open a second connection to the same file and look.
Checked by tests/test_update_and_remove.py. One of its tests removes the last book and adds a new one: the new one gets a new id, not the removed one's. AUTOINCREMENT from step 1 does that for you.
Step 5: totals
total_pages() returns how many pages all the books have together. SELECT SUM(pages) FROM Books gives one row with one value. Careful: the sum of no rows is NULL in SQL, which comes to Python as None, and the method must return 0 then.
pages_by_author() returns a dictionary from each author to the pages of their books together, such as {"Frank Herbert": 856, "Jane Austen": 723}. An author is written exactly as it is stored, so Jane Austen and jane austen are two different keys. In SQL, GROUP BY makes one row of each group of rows with the same value, and SUM adds a column up within each group:
SELECT author, SUM(pages) FROM Books GROUP BY author
Each row of the answer is a tuple (author, pages): put them in a dictionary. Or go through list_all() and add the pages up in Python, as in module 21.
Checked by tests/test_totals.py.
Step 6: export to CSV
export_csv(path) writes every book into a CSV file, smallest id first, after a header line, and returns how many books it wrote:
id,title,author,pages
1,Dune,Frank Herbert,412
Write it with Python's csv module, which the starter already imports. Module 30 read CSV files with csv.reader; csv.writer is the other half:
with open(path, "w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(["id", "title", "author", "pages"])
Then one writer.writerow([...]) for each book. Why not join the values with commas yourself? A title like Eats, Shoots & Leaves has a comma in it, and a spreadsheet would split it in two. csv.writer puts quotes around such a value, and doubles any quotes inside it. newline="" lets the csv module choose the line breaks itself, as its documentation asks, and encoding="utf-8" keeps letters like ó right on every computer. Opening with "w" replaces a file that's already there.
Checked by tests/test_export.py. When all 69 tests pass, you're done: run the program and log your own books.
Ideas to go further, each with a test of your own first: a column for the date you finished a book (module 29) and a command that lists the books of one year, a rating from 1 to 5 with the average per author (AVG in SQL), or an import command that reads books back from a CSV file.
Run the tests
In the project's folder, with the virtual environment active:
python -m pytestThe tests fail at first: that's expected, and each step makes more of them pass. pytest shows each failed test with the line that failed and the values it compared, and ends with a summary of the failed tests and a count, such as 12 failed, 57 passed. When every test passes, the last line says 69 passed.
To run one step's tests only, name the test file, and to run one test, add :: and its name:
python -m pytest tests/test_schema.py
python -m pytest tests/test_add_and_find.py::test_find_by_idpython -m pytest -x stops at the first test that fails, and python -m pytest -q prints less. Your editor can run the tests too: VS Code's Testing panel (the flask icon) finds them once you choose pytest, and PyCharm shows a run button next to each test.
Run the program
In the project's folder:
python -m booklogThe program makes the file books.db in the folder you run it from the first time, and uses it again every time after. Type quit to stop (or press Ctrl+D, or Ctrl+Z and Enter on Windows). To start over with an empty log, delete books.db while the program isn't running. The .gitignore keeps books.db and books.csv out of your repository.
Before you've written the steps, a command whose method isn't written yet stops the program with NotImplementedError. Run it again after each step to see what works.
To look into books.db without the program, the free DB Browser for SQLite opens it and shows the table, or runs SQL of your own.
Put it on GitHub (optional)
The project comes with a GitHub Actions workflow, .github/workflows/test.yml. In your own GitHub repository, it installs Python 3.14 and pytest on GitHub's computers and runs python -m pytest every time you push, so you can see that the tests pass somewhere other than your own computer. The tests use databases in memory, so GitHub's computers need nothing else.
- On github.com, create a new repository (the + at the top, then New repository). Give it a name, like
book-log. Leave it empty: no README, no .gitignore. - Put the project's files at the top of the repository, so that
requirements.txtand the.githubfolder are at its root. Without the.githubfolder, nothing runs.With git (in the project's folder):
Commands for your own computer git init git add . git commit -m "Start the book log" git branch -M main git remote add origin https://github.com/YOUR-NAME/book-log.git git push -u origin mainThe
.gitignorekeeps.venv, Python's own files and your database out of the repository.The first time you use git on a computer, tell it your name and email, or
git commitstops with "Author identity unknown":Commands for your own computer git config --global user.name "Your Name" git config --global user.email you@example.comThe first
git pushasks you to sign in, and GitHub doesn't accept your account password there. Git for Windows opens a browser window to sign you in. On macOS and Linux, sign in first with GitHub CLI (gh auth login, and let it set up git), or paste a personal access token when git asks for the password. GitHub Desktop signs you in for you, too.In the browser: on the new repository's page, choose uploading an existing file and drag the project's files and folders into it, then Commit changes. Don't upload
.venv,__pycache__,.pytest_cacheorbooks.db. Folders whose names start with a dot are hidden on macOS and Linux: pressCmd+Shift+.in macOS's Finder, orCtrl+Hin most Linux file managers, to see.github.
- Open the repository's Actions tab. The workflow Tests runs after each push: a red cross means some tests fail (open the run to see which), a green check means they all pass. Each push runs it again, so push after each step and watch the red turn green.
The GitHub mobile app shows the Actions tab and each run's result, so you can follow your tests from a phone. Writing the code, running the tests and the first upload need the computer.
Python Arena can't see your repository or your test results. They are yours.
Your checklist
python -m pytestruns, and the tests fail- Step 1, creating the table, passes
test_schema.py - Step 2, add and find_by_id, passes
test_add_and_find.py - Step 3, list and search, passes
test_list_and_search.py - Step 4, update and remove, passes
test_update_and_remove.py - Step 5, the totals, passes
test_totals.py - Step 6, export to CSV, passes
test_export.py - I ran the program, quit, and found my books still there the next time
- The tests pass in my GitHub repository's Actions tab