Build a U.S. Code Browser with Python

Build a desktop app that fetches and displays U.S. federal law via a REST API.

Introduction

30 Second Summary

Federal laws affect everything from the music you stream to the software you write. But navigating the official U.S. Code online can feel like searching through an endless legal maze.

In this project, you will build a desktop application that fetches official U.S. federal law from a government API and displays it in a clean, navigable interface.

What You'll Build

A two-pane Python desktop app that browses Title 17 (Copyrights) of the United States Code, with a navigation tree on the left, full legal text on the right, and a local SQLite cache for instant startup.

By the end of this project, you'll have:

  • A working Python GUI built with Tkinter that runs on any platform.
  • A live connection to the GovInfo REST API that fetches official U.S. Code data on demand.
  • A local SQLite cache that stores fetched data and only re-fetches when the Code is updated.
  • Secret Mission: Add a real-time search bar that filters sections by keyword as you type.

Are there any prerequisites?

You need Python 3.14.6 and VS Code installed on your machine. No prior experience with GUI development or REST APIs is required.

Before We Start

You're about to build a desktop application that pulls live U.S. federal law from a government database and displays it in a browsable interface. Before writing any application code, you need a few tools and credentials in place.

The app calls the GovInfo REST API to fetch legal text on demand. That requires the requests library for HTTP calls and a free API key to authenticate with the service.

In this step, get ready to:
  • Install the requests library for making API calls.
  • Get a free API key from GovInfo.
  • Verify all dependencies import correctly.
Install the requests library

The requests library is the standard way to make HTTP calls in Python. You'll use it in every step that talks to the GovInfo API.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal by pressing Ctrl+` (macOS and Windows) or clicking Terminal in the top menu bar, then New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir uscode-browser
cd uscode-browser
  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the uscode-browser folder on your Desktop, then click Open.
  • Open a new terminal in the project folder by pressing Ctrl+`.
  • Install the requests library by running:
pip install requests==2.34.2
  • Verify the installation by running:
python -c "import requests; print(requests.__version__)"

You should see 2.34.2 printed in the terminal.

Seeing command not found?

If pip is not recognized, try pip3 instead. On some systems, Python 3 uses pip3 as the default command.

If python is not recognized, try python3 instead.

Help me fix my pip or python command.

Get a free GovInfo API key

The GovInfo API requires a free API key for authentication. The key is a 40-character string that you'll store in a local file so your app can read it.

  • Open https://www.govinfo.gov/api-signup in your browser.
  • Enter your name and email address in the signup form.
  • Click Signup.
  • Check your email for a message from api.data.gov containing your 40-character API key.

How long does the email take?

The API key email usually arrives within a few minutes. Check your spam folder if you do not see it in your inbox.

  • Back in VS Code, create a new file by clicking File in the top menu bar.
  • Click New File.
  • Name the file api_key.txt and save it in the uscode-browser folder.
  • Paste your 40-character API key into the file as the only content.
  • Save the file.

Why a separate file for the API key?

Storing the key in its own file keeps it out of your Python code. If you ever share your code, you can exclude api_key.txt to keep your credentials private.

  • Confirm the file exists by running:
ls

You should see api_key.txt listed in the output.

Verify your setup

Before building anything, you want to confirm that all three core libraries are importable. Tkinter and SQLite are bundled with Python, but a quick test catches any unexpected issues early.

  • Create a new file called main.py by clicking File, then New File.
  • Name it main.py and save it in the uscode-browser folder.
  • Add the following test code to main.py:
import tkinter
import requests
import sqlite3
print('All imports successful')

What does this code do?

  • import tkinter loads Python's built-in GUI toolkit, which you'll use to build the desktop interface.
  • import requests loads the HTTP library you just installed, which handles all API communication.
  • import sqlite3 loads Python's built-in database module, which you'll use for local caching.
  • The print confirms all three loaded without errors.
  • Save main.py.
  • Run the script by entering this command in your terminal:
python main.py

You should see All imports successful printed in the terminal.

Seeing an import error?

If you see ModuleNotFoundError: No module named 'requests', make sure you ran the pip install command in the same Python environment. Try running pip3 install requests==2.34.2 if you used python3 to run the script.

If tkinter fails to import, your Python installation may be missing the Tk component. Reinstall Python from python.org and ensure the installer includes Tcl/Tk.

Help me fix my import errors.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

import tkinter
import requests
import sqlite3
print('All imports successful')

Your environment is ready. Next up, you'll build the two-pane desktop interface that will display U.S. Code sections.

Build the Two-Pane GUI

Your environment is ready with requests installed, your API key saved, and Python confirming all imports work. Now it's time to build the actual application window.

A code browser needs two things side by side: a list of sections to pick from, and a reading area to display the selected section. Python's built-in Tkinter library gives us exactly this layout through a PanedWindow that splits the app into resizable left and right halves.

In this step, get ready to:
  • Create the application window with a horizontal split layout.
  • Add a Treeview navigation panel for browsing sections.
  • Add a content display panel for reading legal text.
Create the application window

Before building the GUI widgets, the app needs a foundation of imports, constants, and utility functions. These handle everything from reading the API key to parsing HTML responses.

  • Open main.py in VS Code.
  • Select all the existing code (the 3-line import test) and delete it.
  • Replace it with the following imports, constants, and HTML parsing utility:
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import requests
import threading
import os
from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs

API_BASE = "https://api.govinfo.gov"
DB_FILE = "uscode_cache.db"
TITLE_NUM = "17"


class HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.result = []

    def handle_data(self, data):
        self.result.append(data)

    def get_text(self):
        return "".join(self.result)


def strip_html(html_text):
    stripper = HTMLStripper()
    stripper.feed(html_text)
    return stripper.get_text()

What does this code do?

  • The imports bring in everything the app needs: tkinter for the GUI, sqlite3 for caching, requests for API calls, and threading for background tasks.
  • API_BASE, DB_FILE, and TITLE_NUM are constants that keep configuration in one place.
  • HTMLStripper extends Python's built-in HTML parser to extract plain text from HTML responses. The strip_html function wraps it for easy use.
  • Below the strip_html function, add the two data helper functions that read the API key and set up the database:
def get_api_key():
    key_file = "api_key.txt"
    if os.path.exists(key_file):
        with open(key_file, "r") as f:
            return f.read().strip()
    return None


def init_db():
    conn = sqlite3.connect(DB_FILE)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT
        )
    """)
    cur.execute("""
        CREATE TABLE IF NOT EXISTS granules (
            granule_id TEXT PRIMARY KEY,
            package_id TEXT,
            title TEXT,
            content TEXT,
            last_modified TEXT
        )
    """)
    conn.commit()
    return conn

What do these helpers do?

  • get_api_key() reads your GovInfo API key from api_key.txt. If the file is missing, it returns None so the app can show an error.
  • init_db() creates an SQLite database with two tables: metadata for storing package info, and granules for caching individual code sections.

Now for the main class that builds the window itself.

  • Below init_db, add the USCodeBrowser class and the entry point that launches the app:
class USCodeBrowser:
    def __init__(self, root):
        self.root = root
        self.root.title("U.S. Code Browser - Title 17 (Copyrights)")
        self.root.geometry("1100x700")

        self.api_key = get_api_key()
        self.conn = init_db()
        self.package_id = None
        self.all_granules = []

        self.create_widgets()

    def create_widgets(self):
        # Create a horizontal split layout
        self.paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        # Left pane frame
        left_frame = ttk.Frame(self.paned)
        self.paned.add(left_frame, weight=1)

        # Right pane frame
        right_frame = ttk.Frame(self.paned)
        self.paned.add(right_frame, weight=3)

        # Status bar at the bottom of the window
        self.status_var = tk.StringVar(value="Ready")
        status_bar = ttk.Label(
            self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor="w"
        )
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)


if __name__ == "__main__":
    root = tk.Tk()
    app = USCodeBrowser(root)
    root.mainloop()

What does this class do?

  • __init__ sets the window title to "U.S. Code Browser - Title 17 (Copyrights)" and sizes it at 1100x700 pixels. It also loads the API key and initializes the database connection.
  • ttk.PanedWindow creates a resizable horizontal split. The left pane gets weight=1 and the right pane gets weight=3, meaning the content area takes up three-quarters of the width.
  • The status bar at the bottom uses a StringVar so the app can update its text programmatically (e.g., showing "Fetching..." during API calls).
  • The if __name__ == "__main__" block at the bottom creates the Tk root window and starts the event loop.
  • Save main.py.
  • Run the app to confirm the window skeleton works by running:
python main.py
  • You should see a window titled "U.S. Code Browser - Title 17 (Copyrights)" with two blank panes and a status bar at the bottom showing "Ready".
  • Close the window to continue.

Window not appearing?

  • Check that you deleted all the old test code before pasting. The print('All imports successful') line should not be in the file anymore.
  • Make sure api_key.txt exists in the same folder as main.py. The app still launches without it, but you will see an error dialog.
  • If you see an indentation error, verify that the create_widgets method is indented one level inside the class.

Help me debug why my Tkinter window won't open.

Add the Treeview navigation panel

The left pane needs a navigable list of U.S. Code sections. Tkinter's Treeview widget displays hierarchical data and supports scrolling through long lists.

  • In main.py, find this line inside create_widgets:
        self.paned.add(left_frame, weight=1)
  • Directly below that line, add the Sections heading, Treeview, and scrollbar:
        ttk.Label(
            left_frame, text="Sections", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        tree_frame = ttk.Frame(left_frame)
        tree_frame.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(tree_frame, show="tree")
        tree_scroll = ttk.Scrollbar(
            tree_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=tree_scroll.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)

What does this code do?

  • The ttk.Label creates a bold "Sections" heading above the tree.
  • show="tree" tells the Treeview to display as a simple tree list without column headers.
  • The scrollbar is linked to the Treeview using yscrollcommand and command. When the user scrolls one, the other follows.
  • Save main.py and run python main.py again.
  • You should see a "Sections" heading in the left pane with an empty tree area below it.
  • Close the window to continue.

Left pane looks empty or misaligned?

  • Make sure the new code is placed AFTER self.paned.add(left_frame, weight=1) and BEFORE the right_frame line. Order matters in Tkinter layout.
  • Check that all lines are indented with 8 spaces (two levels: class method + method body).

Why is my Treeview not showing in the left pane?

Add the content display panel

The right pane displays the full legal text of a selected section. A Text widget handles multi-line content with word wrapping and its own scrollbar.

  • In create_widgets, find this line:
        self.paned.add(right_frame, weight=3)
  • Directly below that line, add the Content heading, Text widget, and scrollbar:
        ttk.Label(
            right_frame, text="Content", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        text_frame = ttk.Frame(right_frame)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.text = tk.Text(
            text_frame, wrap=tk.WORD, font=("Segoe UI", 11), padx=10, pady=10
        )
        text_scroll = ttk.Scrollbar(
            text_frame, orient=tk.VERTICAL, command=self.text.yview
        )
        self.text.configure(yscrollcommand=text_scroll.set)

        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        text_scroll.pack(side=tk.RIGHT, fill=tk.Y)

What does this code do?

  • wrap=tk.WORD ensures long lines of legal text break at word boundaries instead of mid-word.
  • The font is set to Segoe UI at size 11 for comfortable reading. The padx=10, pady=10 adds internal padding so text does not touch the edges.
  • The scrollbar pattern is the same as the Treeview: link the widget and scrollbar so they move in sync.
  • Save main.py and run the app one more time:
python main.py
  • You should see a full two-pane layout: "Sections" on the left with an empty tree, "Content" on the right with a blank text area, and "Ready" in the status bar at the bottom.
  • Try dragging the divider between the two panes. It should resize smoothly.

Don't see the Content pane?

  • Verify the new code is placed AFTER self.paned.add(right_frame, weight=3) and BEFORE the self.status_var line.
  • If the window launches but looks blank, try resizing it. A very small window can hide the panes.

My Tkinter Text widget isn't appearing in the right pane. What's wrong?

Why a PanedWindow instead of two frames side by side?

A PanedWindow gives users a draggable divider between panes. Legal text can be dense, so letting users resize the reading area makes the app more comfortable without extra code.

Now remove the inline comments you added to create_widgets (the lines starting with #) since the code structure speaks for itself at this point.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step. Compare your file against this:

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import requests
import threading
import os
from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs

API_BASE = "https://api.govinfo.gov"
DB_FILE = "uscode_cache.db"
TITLE_NUM = "17"


class HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.result = []

    def handle_data(self, data):
        self.result.append(data)

    def get_text(self):
        return "".join(self.result)


def strip_html(html_text):
    stripper = HTMLStripper()
    stripper.feed(html_text)
    return stripper.get_text()


def get_api_key():
    key_file = "api_key.txt"
    if os.path.exists(key_file):
        with open(key_file, "r") as f:
            return f.read().strip()
    return None


def init_db():
    conn = sqlite3.connect(DB_FILE)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT
        )
    """)
    cur.execute("""
        CREATE TABLE IF NOT EXISTS granules (
            granule_id TEXT PRIMARY KEY,
            package_id TEXT,
            title TEXT,
            content TEXT,
            last_modified TEXT
        )
    """)
    conn.commit()
    return conn


class USCodeBrowser:
    def __init__(self, root):
        self.root = root
        self.root.title("U.S. Code Browser - Title 17 (Copyrights)")
        self.root.geometry("1100x700")

        self.api_key = get_api_key()
        self.conn = init_db()
        self.package_id = None
        self.all_granules = []

        self.create_widgets()

    def create_widgets(self):
        self.paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        left_frame = ttk.Frame(self.paned)
        self.paned.add(left_frame, weight=1)

        ttk.Label(
            left_frame, text="Sections", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        tree_frame = ttk.Frame(left_frame)
        tree_frame.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(tree_frame, show="tree")
        tree_scroll = ttk.Scrollbar(
            tree_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=tree_scroll.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        right_frame = ttk.Frame(self.paned)
        self.paned.add(right_frame, weight=3)

        ttk.Label(
            right_frame, text="Content", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        text_frame = ttk.Frame(right_frame)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.text = tk.Text(
            text_frame, wrap=tk.WORD, font=("Segoe UI", 11), padx=10, pady=10
        )
        text_scroll = ttk.Scrollbar(
            text_frame, orient=tk.VERTICAL, command=self.text.yview
        )
        self.text.configure(yscrollcommand=text_scroll.set)

        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        text_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.status_var = tk.StringVar(value="Ready")
        status_bar = ttk.Label(
            self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor="w"
        )
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)


if __name__ == "__main__":
    root = tk.Tk()
    app = USCodeBrowser(root)
    root.mainloop()

Your GUI skeleton is running. The two panes, the scrollbars, and the status bar are all in place. Next up, you will connect this interface to the live GovInfo API and fill that empty Treeview with real U.S. Code sections.

Connect to the GovInfo API

Your two-pane GUI is ready to display content. The problem is that the Treeview on the left is empty.

In this step, you will connect the app to the official GovInfo REST API to discover the latest Title 17 package, fetch every section within it, and populate the Treeview with section titles.

In this step, get ready to:
  • Write helper functions that call the GovInfo collections and granules endpoints.
  • Build the fetch-and-populate logic that fills the Treeview with real U.S. Code sections.
  • Add background threading so the GUI stays responsive during network requests.
Write the API helper functions

You already have get_api_key() reading your key from api_key.txt. Now you need two more standalone functions: one to query the collections endpoint for USCODE packages, and one to fetch the list of granules (individual sections) inside a package.

  • In main.py, scroll down to the init_db() function.
  • Add this function directly below init_db() by pasting the following code:
def fetch_uscode_packages(api_key, start_date="2020-01-01T00:00:00Z"):
    url = f"{API_BASE}/collections/USCODE/{start_date}"
    params = {"offsetMark": "*", "pageSize": 100, "api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.json()

What does this code do?

This function queries the GovInfo collections endpoint for all USCODE packages modified since start_date. The default start date of 2020 ensures you find the most recent edition of Title 17.

The raise_for_status() call throws an error if the API returns a non-200 response. This gives you immediate feedback if the key is wrong or the endpoint is unavailable.

  • Save main.py.
  • Add the fetch_granules function directly below fetch_uscode_packages by pasting this code:
def fetch_granules(api_key, package_id):
    granules = []
    offset_mark = "*"
    while True:
        url = f"{API_BASE}/packages/{package_id}/granules"
        params = {
            "offsetMark": offset_mark,
            "pageSize": 100,
            "api_key": api_key,
        }
        response = requests.get(url, params=params)
        response.raise_for_status()
        data = response.json()
        if "granules" in data:
            granules.extend(data["granules"])
        next_page = data.get("nextPage")
        if not next_page:
            break
        parsed = urlparse(next_page)
        qs = parse_qs(parsed.query)
        offset_mark = qs.get("offsetMark", [""])[0]
        if not offset_mark:
            break
    return granules

What does this code do?

This function fetches every granule (individual section) inside a USCODE package. The GovInfo API paginates results using an offsetMark token instead of page numbers.

The while True loop keeps requesting pages until the API stops returning a nextPage URL. Each page's results are appended to the granules list. The function uses urlparse and parse_qs to extract the next offset token from the URL.

  • Save main.py.

Seeing a red underline on urlparse or parse_qs?

Both are already imported at the top of your file (from urllib.parse import urlparse, parse_qs). If you see an error, scroll up and confirm the import line is present.

Help me figure out why my imports aren't being recognized.

Fetch and populate the Treeview

Now you need methods on the USCodeBrowser class that orchestrate the full fetch workflow and insert results into the Treeview.

  • Inside the USCodeBrowser class, add the following initial_fetch method below create_widgets:
    def initial_fetch(self):
        try:
            # Update status bar via root.after (thread-safe GUI update)
            self.root.after(
                0, lambda: self.status_var.set("Finding latest Title 17 package...")
            )
            data = fetch_uscode_packages(self.api_key)

            # Filter packages to find Title 17
            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if not title_packages:
                self.root.after(
                    0,
                    lambda: messagebox.showerror(
                        "Error", "Could not find Title 17 package."
                    ),
                )
                return

            # Sort by lastModified to get the most recent edition
            title_packages.sort(
                key=lambda p: p.get("lastModified", ""), reverse=True
            )
            self.package_id = title_packages[0]["packageId"]
            last_modified = title_packages[0].get("lastModified", "")

            # Store package metadata in the database
            cur = self.conn.cursor()
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("package_id", self.package_id),
            )
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("last_modified", last_modified),
            )
            self.conn.commit()

            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Fetching sections for {self.package_id}..."
                ),
            )
            granules = fetch_granules(self.api_key, self.package_id)

            # Store each granule's metadata in the database
            cur = self.conn.cursor()
            for g in granules:
                cur.execute(
                    "INSERT OR REPLACE INTO granules "
                    "(granule_id, package_id, title, last_modified) "
                    "VALUES (?, ?, ?, ?)",
                    (
                        g["granuleId"],
                        self.package_id,
                        g.get("title", ""),
                        g.get("lastModified", ""),
                    ),
                )
            self.conn.commit()

            # Populate the Treeview from the newly cached data
            self.root.after(0, self.load_from_cache)
            count = len(granules)
            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Loaded {count} sections from {self.package_id}"
                ),
            )

        except Exception as e:
            self.root.after(0, lambda: self.status_var.set(f"Error: {e}"))
            self.root.after(
                0, lambda: messagebox.showerror("Fetch Error", str(e))
            )

What does this code do?

  • It calls fetch_uscode_packages to get all USCODE packages, then filters for ones containing title17 in their package ID.
  • It sorts by lastModified to pick the most recent edition, following the package ID format USCODE-{Year}-title17.
  • It saves the package ID and last-modified timestamp to the metadata table for future cache lookups.
  • It fetches all granules and stores each one's ID, title, and timestamp in the granules table.
  • Every GUI update uses self.root.after(0, ...) because this method will run on a background thread. Only the main thread can safely touch Tkinter widgets.
  • Save main.py.
  • You won't see a change yet because this method is not called anywhere. The next chunk connects it.
  • Add the load_from_cache method directly below initial_fetch:
    def load_from_cache(self):
        # Query all granules for the current package
        cur = self.conn.cursor()
        cur.execute(
            "SELECT granule_id, title FROM granules "
            "WHERE package_id = ? ORDER BY granule_id",
            (self.package_id,),
        )
        rows = cur.fetchall()

        # Store in memory for later use (e.g., search filtering)
        self.all_granules = rows

        # Clear existing Treeview entries
        for item in self.tree.get_children():
            self.tree.delete(item)

        # Insert each granule as a Treeview item
        for granule_id, title in rows:
            display_text = title if title else granule_id
            self.tree.insert("", tk.END, iid=granule_id, text=display_text)

        self.status_var.set(
            f"Showing {len(rows)} sections from {self.package_id}"
        )

What does this code do?

This method reads granule data from the SQLite cache and populates the Treeview. Each granule becomes a tree item where iid (item ID) stores the granule ID and text shows the human-readable title.

The method also stores the full list in self.all_granules for future use in the Secret Mission.

  • Save main.py.
Add threading so the GUI stays responsive

Network requests can take several seconds. If they run on the main thread, the entire window freezes until the request completes.

Python's threading module lets you run the fetch in the background while the GUI stays interactive. The load_data method will decide whether to load from cache or spawn a background thread for the first fetch.

  • Add the load_data method below create_widgets and above initial_fetch:
    def load_data(self):
        if not self.api_key:
            messagebox.showerror(
                "API Key Missing",
                "Create a file named 'api_key.txt' in the same folder "
                "as this script, containing your GovInfo API key.",
            )
            return

        # Check if we already have cached data
        cur = self.conn.cursor()
        cur.execute("SELECT value FROM metadata WHERE key = 'package_id'")
        row = cur.fetchone()

        if row:
            # Load from cache immediately (instant startup)
            self.package_id = row[0]
            self.load_from_cache()
        else:
            # First run: fetch from API in a background thread
            self.status_var.set("Fetching U.S. Code data (first run)...")
            threading.Thread(target=self.initial_fetch, daemon=True).start()

What does this code do?

  • If the API key is missing, it shows an error dialog and stops.
  • If the database already has a package_id in the metadata table, cached data exists. It loads directly from cache for instant startup.
  • If no cache exists (first run), it spawns a daemon thread running initial_fetch. The daemon=True flag ensures the thread dies when the main window closes.

Why daemon threads instead of asyncio?

Tkinter has its own event loop (mainloop()). Integrating Python's asyncio event loop alongside it adds complexity without extra benefit for this project. Daemon threads with root.after(0, callback) is the standard pattern for keeping a Tkinter app responsive during I/O.

  • Save main.py.
  • Now wire everything together. In the __init__ method, find this line:
        self.create_widgets()
  • Add the following line directly below it:
        self.load_data()
  • Next, inside create_widgets, find this line:
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
  • Add this event binding directly below it:
        self.tree.bind("<<TreeviewSelect>>", self.on_select)
  • Finally, add a placeholder on_select method at the end of the class (below load_from_cache):
    def on_select(self, event):
        pass

Why add on_select now?

The Treeview binding references self.on_select, so the method must exist even if it does nothing yet. You will fill it in during the next step when you display section content.

  • Save main.py.
  • Run your app by executing this command in the terminal:
python main.py

You should see the status bar update with progress messages. After a few seconds, the left-hand Treeview fills with section titles from Title 17 of the U.S. Code.

Treeview still empty after 10 seconds?

  • Check the status bar at the bottom of the window. If it says "API Key Missing", confirm your api_key.txt file is in the same folder as main.py and contains only the 40-character key.
  • If the status bar shows a "Fetch Error" dialog, your API key may be invalid. Double check it matches what you received in your confirmation email.
  • Confirm you placed self.load_data() after self.create_widgets() in the __init__ method. If it comes before, the widgets do not exist yet.

Help me debug why my GovInfo API fetch is failing.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and the Treeview is populated with section titles.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step:

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import requests
import threading
import os
from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs

API_BASE = "https://api.govinfo.gov"
DB_FILE = "uscode_cache.db"
TITLE_NUM = "17"


class HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.result = []

    def handle_data(self, data):
        self.result.append(data)

    def get_text(self):
        return "".join(self.result)


def strip_html(html_text):
    stripper = HTMLStripper()
    stripper.feed(html_text)
    return stripper.get_text()


def get_api_key():
    key_file = "api_key.txt"
    if os.path.exists(key_file):
        with open(key_file, "r") as f:
            return f.read().strip()
    return None


def init_db():
    conn = sqlite3.connect(DB_FILE)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT
        )
    """)
    cur.execute("""
        CREATE TABLE IF NOT EXISTS granules (
            granule_id TEXT PRIMARY KEY,
            package_id TEXT,
            title TEXT,
            content TEXT,
            last_modified TEXT
        )
    """)
    conn.commit()
    return conn


def fetch_uscode_packages(api_key, start_date="2020-01-01T00:00:00Z"):
    url = f"{API_BASE}/collections/USCODE/{start_date}"
    params = {"offsetMark": "*", "pageSize": 100, "api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.json()


def fetch_granules(api_key, package_id):
    granules = []
    offset_mark = "*"
    while True:
        url = f"{API_BASE}/packages/{package_id}/granules"
        params = {
            "offsetMark": offset_mark,
            "pageSize": 100,
            "api_key": api_key,
        }
        response = requests.get(url, params=params)
        response.raise_for_status()
        data = response.json()
        if "granules" in data:
            granules.extend(data["granules"])
        next_page = data.get("nextPage")
        if not next_page:
            break
        parsed = urlparse(next_page)
        qs = parse_qs(parsed.query)
        offset_mark = qs.get("offsetMark", [""])[0]
        if not offset_mark:
            break
    return granules


class USCodeBrowser:
    def __init__(self, root):
        self.root = root
        self.root.title("U.S. Code Browser - Title 17 (Copyrights)")
        self.root.geometry("1100x700")

        self.api_key = get_api_key()
        self.conn = init_db()
        self.package_id = None
        self.all_granules = []

        self.create_widgets()
        self.load_data()

    def create_widgets(self):
        self.paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        left_frame = ttk.Frame(self.paned)
        self.paned.add(left_frame, weight=1)

        ttk.Label(
            left_frame, text="Sections", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        tree_frame = ttk.Frame(left_frame)
        tree_frame.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(tree_frame, show="tree")
        tree_scroll = ttk.Scrollbar(
            tree_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=tree_scroll.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.tree.bind("<<TreeviewSelect>>", self.on_select)

        right_frame = ttk.Frame(self.paned)
        self.paned.add(right_frame, weight=3)

        ttk.Label(
            right_frame, text="Content", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        text_frame = ttk.Frame(right_frame)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.text = tk.Text(
            text_frame, wrap=tk.WORD, font=("Segoe UI", 11), padx=10, pady=10
        )
        text_scroll = ttk.Scrollbar(
            text_frame, orient=tk.VERTICAL, command=self.text.yview
        )
        self.text.configure(yscrollcommand=text_scroll.set)

        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        text_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.status_var = tk.StringVar(value="Ready")
        status_bar = ttk.Label(
            self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor="w"
        )
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)

    def load_data(self):
        if not self.api_key:
            messagebox.showerror(
                "API Key Missing",
                "Create a file named 'api_key.txt' in the same folder "
                "as this script, containing your GovInfo API key.",
            )
            return

        cur = self.conn.cursor()
        cur.execute("SELECT value FROM metadata WHERE key = 'package_id'")
        row = cur.fetchone()

        if row:
            self.package_id = row[0]
            self.load_from_cache()
        else:
            self.status_var.set("Fetching U.S. Code data (first run)...")
            threading.Thread(target=self.initial_fetch, daemon=True).start()

    def initial_fetch(self):
        try:
            self.root.after(
                0, lambda: self.status_var.set("Finding latest Title 17 package...")
            )
            data = fetch_uscode_packages(self.api_key)

            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if not title_packages:
                self.root.after(
                    0,
                    lambda: messagebox.showerror(
                        "Error", "Could not find Title 17 package."
                    ),
                )
                return

            title_packages.sort(
                key=lambda p: p.get("lastModified", ""), reverse=True
            )
            self.package_id = title_packages[0]["packageId"]
            last_modified = title_packages[0].get("lastModified", "")

            cur = self.conn.cursor()
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("package_id", self.package_id),
            )
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("last_modified", last_modified),
            )
            self.conn.commit()

            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Fetching sections for {self.package_id}..."
                ),
            )
            granules = fetch_granules(self.api_key, self.package_id)

            cur = self.conn.cursor()
            for g in granules:
                cur.execute(
                    "INSERT OR REPLACE INTO granules "
                    "(granule_id, package_id, title, last_modified) "
                    "VALUES (?, ?, ?, ?)",
                    (
                        g["granuleId"],
                        self.package_id,
                        g.get("title", ""),
                        g.get("lastModified", ""),
                    ),
                )
            self.conn.commit()

            self.root.after(0, self.load_from_cache)
            count = len(granules)
            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Loaded {count} sections from {self.package_id}"
                ),
            )

        except Exception as e:
            self.root.after(0, lambda: self.status_var.set(f"Error: {e}"))
            self.root.after(
                0, lambda: messagebox.showerror("Fetch Error", str(e))
            )

    def load_from_cache(self):
        cur = self.conn.cursor()
        cur.execute(
            "SELECT granule_id, title FROM granules "
            "WHERE package_id = ? ORDER BY granule_id",
            (self.package_id,),
        )
        rows = cur.fetchall()

        self.all_granules = rows

        for item in self.tree.get_children():
            self.tree.delete(item)

        for granule_id, title in rows:
            display_text = title if title else granule_id
            self.tree.insert("", tk.END, iid=granule_id, text=display_text)

        self.status_var.set(
            f"Showing {len(rows)} sections from {self.package_id}"
        )

    def on_select(self, event):
        pass


if __name__ == "__main__":
    root = tk.Tk()
    app = USCodeBrowser(root)
    root.mainloop()

Your app now pulls live data from the GovInfo API and displays it in a navigable list. Next up, you will make clicking a section actually show its full legal text in the right pane.

Display Section Content

Your left pane now shows a full list of Title 17 sections. Clicking one does nothing yet because the on_select method is just a pass placeholder.

In this step, you will wire up that click event so selecting a section fetches its legal text from the GovInfo API and displays it in the right pane. You will also cache every fetched section in SQLite so subsequent clicks load instantly.

In this step, get ready to:
  • Handle Treeview selection events and display cached content.
  • Fetch HTML content from the GovInfo API and strip it to plain text.
  • Run API calls in a background thread so the GUI stays responsive.
Handle Treeview selection events

The <<TreeviewSelect>> event binding already exists in create_widgets. The event fires every time the learner clicks a row in the left pane. Your job is to implement the callback so it reads the selection and shows the content.

  • In main.py, find the on_select method that currently contains just pass.
  • Replace the entire method with this code, and add the new display_content method directly below it:
    def on_select(self, event):
        selection = self.tree.selection()
        if not selection:
            return

        granule_id = selection[0]

        # Check cache for existing content
        cur = self.conn.cursor()
        cur.execute(
            "SELECT content FROM granules WHERE granule_id = ?", (granule_id,)
        )
        row = cur.fetchone()

        if row and row[0]:
            # Content already cached, display immediately
            self.display_content(row[0])
        else:
            # Not cached yet, fetch from API in background
            self.status_var.set(f"Fetching {granule_id}...")
            threading.Thread(
                target=self.fetch_content, args=(granule_id,), daemon=True
            ).start()

    def display_content(self, text):
        # Clear the text widget and insert new content
        self.text.delete("1.0", tk.END)
        self.text.insert("1.0", text)

What does this code do?

The on_select callback fires whenever the learner clicks a section in the Treeview.

  • self.tree.selection() returns a tuple of selected item IDs. If nothing is selected (e.g., the learner clicked empty space), we return early.
  • The method checks the SQLite cache first. If the content column already has text, it displays it instantly with no network request.
  • If the content is not cached, it launches a background thread to fetch it. This keeps the window responsive while the API call completes.
  • display_content clears any previously shown text, then inserts the new section content starting at position 1.0 (Tkinter's way of saying line 1, character 0).
  • Save main.py.
  • You won't see a change yet. The on_select method references fetch_content, which you will add in the next chunk.
Fetch and display the HTML content

When the cache is empty for a selected section, the app needs to hit the GovInfo API to retrieve the raw HTML. The /packages/{packageId}/granules/{granuleId}/htm endpoint returns the full legal text as an HTML document.

  • In main.py, scroll to the standalone functions at the top of the file. Find the closing line of the fetch_granules function (the final return granules line).
  • Add this new function directly below it, before the class definition:
def fetch_granule_htm(api_key, package_id, granule_id):
    # Fetch the raw HTML content for a single section
    url = f"{API_BASE}/packages/{package_id}/granules/{granule_id}/htm"
    params = {"api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.text

What does this function do?

This function constructs the GovInfo granule HTM endpoint URL using the package ID and granule ID. It returns the raw HTML string of the legal section content.

Unlike the granule list endpoint (which returns JSON), this endpoint returns plain HTML. That is why we use response.text instead of response.json().

  • Save main.py.
  • You won't see a change yet. The next chunk connects this function to the GUI.

Now you need a method inside the class that calls fetch_granule_htm, strips the HTML to plain text, caches the result, and updates the display.

  • In the USCodeBrowser class, find the display_content method you just added.
  • Add the following fetch_content method between on_select and display_content:
    def fetch_content(self, granule_id):
        try:
            # Fetch HTML from the GovInfo API
            htm = fetch_granule_htm(self.api_key, self.package_id, granule_id)
            plain_text = strip_html(htm)

            # Cache the plain text in SQLite
            cur = self.conn.cursor()
            cur.execute(
                "UPDATE granules SET content = ? WHERE granule_id = ?",
                (plain_text, granule_id),
            )
            self.conn.commit()

            # Update the GUI from the main thread
            self.root.after(0, lambda: self.display_content(plain_text))
            self.root.after(0, lambda: self.status_var.set("Ready"))

        except Exception as e:
            self.root.after(
                0, lambda: self.status_var.set(f"Error fetching content: {e}")
            )

What does this method do?

  • fetch_granule_htm retrieves the raw HTML for the selected section from the GovInfo API.
  • strip_html (already in your file) uses the HTMLStripper class to extract readable plain text from the HTML tags.
  • The UPDATE query saves the plain text into the content column of the granules table. Next time the learner clicks the same section, on_select finds it in the cache and skips the API call entirely.
  • root.after(0, callback) schedules the GUI update on Tkinter's main thread. This is required because background threads cannot safely modify widgets directly.

Why background threads instead of async?

Tkinter has its own event loop (mainloop()). Integrating Python's asyncio with Tkinter's loop adds significant complexity. Daemon threads with root.after() for GUI callbacks is the standard Tkinter pattern for keeping the interface responsive during network calls.

  • Save main.py.
  • Run your app by executing this command in the terminal:
python main.py
  • Click any section in the left pane. You should see the status bar flash a "Fetching..." message, then legal text appears in the right pane.
  • Click the same section a second time. This time it loads instantly from the cache with no status bar change.

Not seeing any text in the right pane?

  • Check the status bar at the bottom of the window. If it shows an error message, your API key may be invalid or your internet connection may be down.
  • Make sure fetch_granule_htm is placed outside the class (between fetch_granules and class USCodeBrowser). If it is inside the class, Python will not find it when fetch_content tries to call it.
  • Verify that fetch_content is indented inside the class (same level as on_select). A misaligned method becomes a standalone function that cannot access self.

Help me debug why my section content is not appearing in the right pane.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step:

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import requests
import threading
import os
from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs

API_BASE = "https://api.govinfo.gov"
DB_FILE = "uscode_cache.db"
TITLE_NUM = "17"


class HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.result = []

    def handle_data(self, data):
        self.result.append(data)

    def get_text(self):
        return "".join(self.result)


def strip_html(html_text):
    stripper = HTMLStripper()
    stripper.feed(html_text)
    return stripper.get_text()


def get_api_key():
    key_file = "api_key.txt"
    if os.path.exists(key_file):
        with open(key_file, "r") as f:
            return f.read().strip()
    return None


def init_db():
    conn = sqlite3.connect(DB_FILE)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT
        )
    """)
    cur.execute("""
        CREATE TABLE IF NOT EXISTS granules (
            granule_id TEXT PRIMARY KEY,
            package_id TEXT,
            title TEXT,
            content TEXT,
            last_modified TEXT
        )
    """)
    conn.commit()
    return conn


def fetch_uscode_packages(api_key, start_date="2020-01-01T00:00:00Z"):
    url = f"{API_BASE}/collections/USCODE/{start_date}"
    params = {"offsetMark": "*", "pageSize": 100, "api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.json()


def fetch_granules(api_key, package_id):
    granules = []
    offset_mark = "*"
    while True:
        url = f"{API_BASE}/packages/{package_id}/granules"
        params = {
            "offsetMark": offset_mark,
            "pageSize": 100,
            "api_key": api_key,
        }
        response = requests.get(url, params=params)
        response.raise_for_status()
        data = response.json()
        if "granules" in data:
            granules.extend(data["granules"])
        next_page = data.get("nextPage")
        if not next_page:
            break
        parsed = urlparse(next_page)
        qs = parse_qs(parsed.query)
        offset_mark = qs.get("offsetMark", [""])[0]
        if not offset_mark:
            break
    return granules


def fetch_granule_htm(api_key, package_id, granule_id):
    url = f"{API_BASE}/packages/{package_id}/granules/{granule_id}/htm"
    params = {"api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.text


class USCodeBrowser:
    def __init__(self, root):
        self.root = root
        self.root.title("U.S. Code Browser - Title 17 (Copyrights)")
        self.root.geometry("1100x700")

        self.api_key = get_api_key()
        self.conn = init_db()
        self.package_id = None
        self.all_granules = []

        self.create_widgets()
        self.load_data()

    def create_widgets(self):
        self.paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        left_frame = ttk.Frame(self.paned)
        self.paned.add(left_frame, weight=1)

        ttk.Label(
            left_frame, text="Sections", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        tree_frame = ttk.Frame(left_frame)
        tree_frame.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(tree_frame, show="tree")
        tree_scroll = ttk.Scrollbar(
            tree_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=tree_scroll.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.tree.bind("<<TreeviewSelect>>", self.on_select)

        right_frame = ttk.Frame(self.paned)
        self.paned.add(right_frame, weight=3)

        ttk.Label(
            right_frame, text="Content", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        text_frame = ttk.Frame(right_frame)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.text = tk.Text(
            text_frame, wrap=tk.WORD, font=("Segoe UI", 11), padx=10, pady=10
        )
        text_scroll = ttk.Scrollbar(
            text_frame, orient=tk.VERTICAL, command=self.text.yview
        )
        self.text.configure(yscrollcommand=text_scroll.set)

        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        text_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.status_var = tk.StringVar(value="Ready")
        status_bar = ttk.Label(
            self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor="w"
        )
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)

    def load_data(self):
        if not self.api_key:
            messagebox.showerror(
                "API Key Missing",
                "Create a file named 'api_key.txt' in the same folder "
                "as this script, containing your GovInfo API key.",
            )
            return

        cur = self.conn.cursor()
        cur.execute("SELECT value FROM metadata WHERE key = 'package_id'")
        row = cur.fetchone()

        if row:
            self.package_id = row[0]
            self.load_from_cache()
        else:
            self.status_var.set("Fetching U.S. Code data (first run)...")
            threading.Thread(target=self.initial_fetch, daemon=True).start()

    def initial_fetch(self):
        try:
            self.root.after(
                0, lambda: self.status_var.set("Finding latest Title 17 package...")
            )
            data = fetch_uscode_packages(self.api_key)

            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if not title_packages:
                self.root.after(
                    0,
                    lambda: messagebox.showerror(
                        "Error", "Could not find Title 17 package."
                    ),
                )
                return

            title_packages.sort(
                key=lambda p: p.get("lastModified", ""), reverse=True
            )
            self.package_id = title_packages[0]["packageId"]
            last_modified = title_packages[0].get("lastModified", "")

            cur = self.conn.cursor()
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("package_id", self.package_id),
            )
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("last_modified", last_modified),
            )
            self.conn.commit()

            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Fetching sections for {self.package_id}..."
                ),
            )
            granules = fetch_granules(self.api_key, self.package_id)

            cur = self.conn.cursor()
            for g in granules:
                cur.execute(
                    "INSERT OR REPLACE INTO granules "
                    "(granule_id, package_id, title, last_modified) "
                    "VALUES (?, ?, ?, ?)",
                    (
                        g["granuleId"],
                        self.package_id,
                        g.get("title", ""),
                        g.get("lastModified", ""),
                    ),
                )
            self.conn.commit()

            self.root.after(0, self.load_from_cache)
            count = len(granules)
            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Loaded {count} sections from {self.package_id}"
                ),
            )

        except Exception as e:
            self.root.after(0, lambda: self.status_var.set(f"Error: {e}"))
            self.root.after(
                0, lambda: messagebox.showerror("Fetch Error", str(e))
            )

    def load_from_cache(self):
        cur = self.conn.cursor()
        cur.execute(
            "SELECT granule_id, title FROM granules "
            "WHERE package_id = ? ORDER BY granule_id",
            (self.package_id,),
        )
        rows = cur.fetchall()

        self.all_granules = rows

        for item in self.tree.get_children():
            self.tree.delete(item)

        for granule_id, title in rows:
            display_text = title if title else granule_id
            self.tree.insert("", tk.END, iid=granule_id, text=display_text)

        self.status_var.set(
            f"Showing {len(rows)} sections from {self.package_id}"
        )

    def on_select(self, event):
        selection = self.tree.selection()
        if not selection:
            return

        granule_id = selection[0]

        cur = self.conn.cursor()
        cur.execute(
            "SELECT content FROM granules WHERE granule_id = ?", (granule_id,)
        )
        row = cur.fetchone()

        if row and row[0]:
            self.display_content(row[0])
        else:
            self.status_var.set(f"Fetching {granule_id}...")
            threading.Thread(
                target=self.fetch_content, args=(granule_id,), daemon=True
            ).start()

    def fetch_content(self, granule_id):
        try:
            htm = fetch_granule_htm(self.api_key, self.package_id, granule_id)
            plain_text = strip_html(htm)

            cur = self.conn.cursor()
            cur.execute(
                "UPDATE granules SET content = ? WHERE granule_id = ?",
                (plain_text, granule_id),
            )
            self.conn.commit()

            self.root.after(0, lambda: self.display_content(plain_text))
            self.root.after(0, lambda: self.status_var.set("Ready"))

        except Exception as e:
            self.root.after(
                0, lambda: self.status_var.set(f"Error fetching content: {e}")
            )

    def display_content(self, text):
        self.text.delete("1.0", tk.END)
        self.text.insert("1.0", text)


if __name__ == "__main__":
    root = tk.Tk()
    app = USCodeBrowser(root)
    root.mainloop()

Your app now fetches, caches, and displays real U.S. Code text. Next up, you will add update detection so the app checks whether a newer edition of Title 17 exists every time it launches.

Add SQLite Caching and Update Detection

Your app fetches and displays U.S. Code sections from the GovInfo API. The SQLite database you set up in earlier steps already stores fetched data locally.

In this step, you will verify that caching works across sessions and add smart update detection. The app will check whether the U.S. Code has been updated since your last fetch and prompt you to refresh only when needed.

In this step, get ready to:
  • Verify the SQLite database caches granule data across sessions.
  • Confirm that content caching avoids redundant API calls.
  • Add update detection that checks for newer versions of Title 17 on startup.
Verify SQLite caching on startup

Your app already stores the package ID and granule list in uscode_cache.db after the first fetch. On subsequent launches, load_data() checks the metadata table for a stored package_id and loads directly from cache if one exists.

  • Run your app by executing this command in the terminal:
python main.py
  • Wait for the sections to load in the Treeview, then close the app window.
  • Run python main.py again.

The second launch should populate the Treeview almost instantly. The status bar displays a message like "Showing X sections from USCODE-..." without the "Fetching" messages you saw on first run.

How does the cache-first startup work?

The load_data() method queries the metadata table for a stored package_id. If one exists, it calls load_from_cache() which reads the granules table and inserts each row into the Treeview.

If no cached package_id is found (first run), it launches initial_fetch() in a background thread, fetches from the API, and stores everything in the database for next time.

App still fetching on second launch?

Check that uscode_cache.db exists in your project folder. If it is missing, the first run may have crashed before writing to the database.

Make sure you waited for the first run to finish loading all sections before closing. The status bar should have shown "Loaded X sections" before you closed.

Help me figure out why my SQLite cache is not loading on app restart.

Verify content caching for individual sections

The Treeview loads section titles from cache, but the full text of each section is fetched on-demand. Once fetched, the fetch_content() method stores the stripped plain text in the content column of the granules table so it never needs to be fetched again.

  • Run the app and click any section in the Treeview.
  • Watch the status bar. It should briefly show "Fetching..." and then display the section text.
  • Click a different section, then click back to the first section.

The second time you click the same section, the text appears instantly with no "Fetching" message. The on_select() method checks if the content column is populated and skips the API call entirely.

Why cache on demand instead of all at once?

Title 17 has dozens of sections. Fetching the full HTML for every section on first launch would take many API calls and a long wait. By caching on demand, the app starts quickly and builds up its local store over time as the user browses.

Add update detection on startup

The U.S. Code gets updated periodically. Your app should detect when a newer version of Title 17 is available and offer to refresh the cached data.

  • In main.py, find the load_data() method. Locate the if row: block where the cache is loaded:
        if row:
            self.package_id = row[0]
            self.load_from_cache()
  • Add the following line directly after self.load_from_cache(), so the block looks like this:
        if row:
            self.package_id = row[0]
            self.load_from_cache()
            threading.Thread(target=self.check_for_updates, daemon=True).start()

What does this line do?

After loading from cache (so the user sees sections immediately), it spawns a background thread to check if a newer package exists. The daemon=True flag means the thread dies automatically when the app closes.

Now add the check_for_updates() method that runs in that background thread.

  • Add the following method to the USCodeBrowser class, after the display_content() method:
    def check_for_updates(self):
        try:
            # Read the stored last_modified timestamp from the database
            cur = self.conn.cursor()
            cur.execute("SELECT value FROM metadata WHERE key = 'last_modified'")
            row = cur.fetchone()

            if not row:
                return

            last_modified = row[0]
            self.root.after(
                0, lambda: self.status_var.set("Checking for updates...")
            )

            # Query the collections endpoint starting from our last known date
            data = fetch_uscode_packages(self.api_key, start_date=last_modified)
            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if title_packages:
                newest = title_packages[0]
                # If the newest package ID differs from ours, an update is available
                if newest["packageId"] != self.package_id:
                    self.root.after(
                        0,
                        lambda: self.notify_update(newest["packageId"]),
                    )
                else:
                    self.root.after(
                        0, lambda: self.status_var.set("Up to date")
                    )
            else:
                self.root.after(
                    0, lambda: self.status_var.set("Up to date")
                )

        except Exception as e:
            self.root.after(
                0,
                lambda: self.status_var.set(f"Update check failed: {e}"),
            )

What does this code do?

  • It reads the stored last_modified timestamp from the metadata table. This is the date the current cached package was last updated.
  • It calls the GovInfo collections endpoint with that timestamp as the start_date, which returns only packages modified after that date.
  • If a Title 17 package with a different ID is found, it means a newer edition exists and the user should be notified.
  • All GUI updates use root.after(0, ...) because Tkinter widgets can only be modified from the main thread.
  • Add the notify_update() method directly below check_for_updates():
    def notify_update(self, new_package_id):
        # Show a dialog asking the user if they want to download the update
        result = messagebox.askyesno(
            "Update Available",
            f"A newer version is available: {new_package_id}.\n"
            "Download now?",
        )
        if result:
            # Replace the package ID and re-fetch everything
            self.package_id = new_package_id
            threading.Thread(target=self.initial_fetch, daemon=True).start()

What does this code do?

The messagebox.askyesno() function shows a native yes/no dialog. If the user clicks Yes, it sets the new package ID and re-runs initial_fetch() in a background thread to download the updated data.

  • Save main.py.
  • Run the app again with python main.py. Watch the status bar. It should show "Checking for updates..." briefly after loading the cached sections, then settle on "Up to date".

Seeing an error about check_for_updates?

Make sure the new check_for_updates() and notify_update() methods are indented inside the USCodeBrowser class (one level of indentation from the class body).

Check that the line threading.Thread(target=self.check_for_updates, daemon=True).start() is inside the if row: block of load_data(), not outside it.

Help me debug why check_for_updates is causing an error in my Tkinter app.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that the status bar shows "Up to date" after launching.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point. Compare your file against it:

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import requests
import threading
import os
from html.parser import HTMLParser
from urllib.parse import urlparse, parse_qs

API_BASE = "https://api.govinfo.gov"
DB_FILE = "uscode_cache.db"
TITLE_NUM = "17"


class HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.result = []

    def handle_data(self, data):
        self.result.append(data)

    def get_text(self):
        return "".join(self.result)


def strip_html(html_text):
    stripper = HTMLStripper()
    stripper.feed(html_text)
    return stripper.get_text()


def get_api_key():
    key_file = "api_key.txt"
    if os.path.exists(key_file):
        with open(key_file, "r") as f:
            return f.read().strip()
    return None


def init_db():
    conn = sqlite3.connect(DB_FILE)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT
        )
    """)
    cur.execute("""
        CREATE TABLE IF NOT EXISTS granules (
            granule_id TEXT PRIMARY KEY,
            package_id TEXT,
            title TEXT,
            content TEXT,
            last_modified TEXT
        )
    """)
    conn.commit()
    return conn


def fetch_uscode_packages(api_key, start_date="2020-01-01T00:00:00Z"):
    url = f"{API_BASE}/collections/USCODE/{start_date}"
    params = {"offsetMark": "*", "pageSize": 100, "api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.json()


def fetch_granules(api_key, package_id):
    granules = []
    offset_mark = "*"
    while True:
        url = f"{API_BASE}/packages/{package_id}/granules"
        params = {
            "offsetMark": offset_mark,
            "pageSize": 100,
            "api_key": api_key,
        }
        response = requests.get(url, params=params)
        response.raise_for_status()
        data = response.json()
        if "granules" in data:
            granules.extend(data["granules"])
        next_page = data.get("nextPage")
        if not next_page:
            break
        parsed = urlparse(next_page)
        qs = parse_qs(parsed.query)
        offset_mark = qs.get("offsetMark", [""])[0]
        if not offset_mark:
            break
    return granules


def fetch_granule_htm(api_key, package_id, granule_id):
    url = f"{API_BASE}/packages/{package_id}/granules/{granule_id}/htm"
    params = {"api_key": api_key}
    response = requests.get(url, params=params)
    response.raise_for_status()
    return response.text


class USCodeBrowser:
    def __init__(self, root):
        self.root = root
        self.root.title("U.S. Code Browser - Title 17 (Copyrights)")
        self.root.geometry("1100x700")

        self.api_key = get_api_key()
        self.conn = init_db()
        self.package_id = None
        self.all_granules = []

        self.create_widgets()
        self.load_data()

    def create_widgets(self):
        self.paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        left_frame = ttk.Frame(self.paned)
        self.paned.add(left_frame, weight=1)

        ttk.Label(
            left_frame, text="Sections", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        tree_frame = ttk.Frame(left_frame)
        tree_frame.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(tree_frame, show="tree")
        tree_scroll = ttk.Scrollbar(
            tree_frame, orient=tk.VERTICAL, command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=tree_scroll.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.tree.bind("<<TreeviewSelect>>", self.on_select)

        right_frame = ttk.Frame(self.paned)
        self.paned.add(right_frame, weight=3)

        ttk.Label(
            right_frame, text="Content", font=("Segoe UI", 11, "bold")
        ).pack(anchor="w", padx=5, pady=(5, 2))

        text_frame = ttk.Frame(right_frame)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.text = tk.Text(
            text_frame, wrap=tk.WORD, font=("Segoe UI", 11), padx=10, pady=10
        )
        text_scroll = ttk.Scrollbar(
            text_frame, orient=tk.VERTICAL, command=self.text.yview
        )
        self.text.configure(yscrollcommand=text_scroll.set)

        self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        text_scroll.pack(side=tk.RIGHT, fill=tk.Y)

        self.status_var = tk.StringVar(value="Ready")
        status_bar = ttk.Label(
            self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor="w"
        )
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)

    def load_data(self):
        if not self.api_key:
            messagebox.showerror(
                "API Key Missing",
                "Create a file named 'api_key.txt' in the same folder "
                "as this script, containing your GovInfo API key.",
            )
            return

        cur = self.conn.cursor()
        cur.execute("SELECT value FROM metadata WHERE key = 'package_id'")
        row = cur.fetchone()

        if row:
            self.package_id = row[0]
            self.load_from_cache()
            threading.Thread(target=self.check_for_updates, daemon=True).start()
        else:
            self.status_var.set("Fetching U.S. Code data (first run)...")
            threading.Thread(target=self.initial_fetch, daemon=True).start()

    def initial_fetch(self):
        try:
            self.root.after(
                0, lambda: self.status_var.set("Finding latest Title 17 package...")
            )
            data = fetch_uscode_packages(self.api_key)

            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if not title_packages:
                self.root.after(
                    0,
                    lambda: messagebox.showerror(
                        "Error", "Could not find Title 17 package."
                    ),
                )
                return

            title_packages.sort(
                key=lambda p: p.get("lastModified", ""), reverse=True
            )
            self.package_id = title_packages[0]["packageId"]
            last_modified = title_packages[0].get("lastModified", "")

            cur = self.conn.cursor()
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("package_id", self.package_id),
            )
            cur.execute(
                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
                ("last_modified", last_modified),
            )
            self.conn.commit()

            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Fetching sections for {self.package_id}..."
                ),
            )
            granules = fetch_granules(self.api_key, self.package_id)

            cur = self.conn.cursor()
            for g in granules:
                cur.execute(
                    "INSERT OR REPLACE INTO granules "
                    "(granule_id, package_id, title, last_modified) "
                    "VALUES (?, ?, ?, ?)",
                    (
                        g["granuleId"],
                        self.package_id,
                        g.get("title", ""),
                        g.get("lastModified", ""),
                    ),
                )
            self.conn.commit()

            self.root.after(0, self.load_from_cache)
            count = len(granules)
            self.root.after(
                0,
                lambda: self.status_var.set(
                    f"Loaded {count} sections from {self.package_id}"
                ),
            )

        except Exception as e:
            self.root.after(0, lambda: self.status_var.set(f"Error: {e}"))
            self.root.after(
                0, lambda: messagebox.showerror("Fetch Error", str(e))
            )

    def load_from_cache(self):
        cur = self.conn.cursor()
        cur.execute(
            "SELECT granule_id, title FROM granules "
            "WHERE package_id = ? ORDER BY granule_id",
            (self.package_id,),
        )
        rows = cur.fetchall()

        self.all_granules = rows

        for item in self.tree.get_children():
            self.tree.delete(item)

        for granule_id, title in rows:
            display_text = title if title else granule_id
            self.tree.insert("", tk.END, iid=granule_id, text=display_text)

        self.status_var.set(
            f"Showing {len(rows)} sections from {self.package_id}"
        )

    def on_select(self, event):
        selection = self.tree.selection()
        if not selection:
            return

        granule_id = selection[0]

        cur = self.conn.cursor()
        cur.execute(
            "SELECT content FROM granules WHERE granule_id = ?", (granule_id,)
        )
        row = cur.fetchone()

        if row and row[0]:
            self.display_content(row[0])
        else:
            self.status_var.set(f"Fetching {granule_id}...")
            threading.Thread(
                target=self.fetch_content, args=(granule_id,), daemon=True
            ).start()

    def fetch_content(self, granule_id):
        try:
            htm = fetch_granule_htm(self.api_key, self.package_id, granule_id)
            plain_text = strip_html(htm)

            cur = self.conn.cursor()
            cur.execute(
                "UPDATE granules SET content = ? WHERE granule_id = ?",
                (plain_text, granule_id),
            )
            self.conn.commit()

            self.root.after(0, lambda: self.display_content(plain_text))
            self.root.after(0, lambda: self.status_var.set("Ready"))

        except Exception as e:
            self.root.after(
                0, lambda: self.status_var.set(f"Error fetching content: {e}")
            )

    def display_content(self, text):
        self.text.delete("1.0", tk.END)
        self.text.insert("1.0", text)

    def check_for_updates(self):
        try:
            cur = self.conn.cursor()
            cur.execute("SELECT value FROM metadata WHERE key = 'last_modified'")
            row = cur.fetchone()

            if not row:
                return

            last_modified = row[0]
            self.root.after(
                0, lambda: self.status_var.set("Checking for updates...")
            )

            data = fetch_uscode_packages(self.api_key, start_date=last_modified)
            packages = data.get("packages", [])
            title_packages = [
                p
                for p in packages
                if f"title{TITLE_NUM}" in p.get("packageId", "")
            ]

            if title_packages:
                newest = title_packages[0]
                if newest["packageId"] != self.package_id:
                    self.root.after(
                        0,
                        lambda: self.notify_update(newest["packageId"]),
                    )
                else:
                    self.root.after(
                        0, lambda: self.status_var.set("Up to date")
                    )
            else:
                self.root.after(
                    0, lambda: self.status_var.set("Up to date")
                )

        except Exception as e:
            self.root.after(
                0,
                lambda: self.status_var.set(f"Update check failed: {e}"),
            )

    def notify_update(self, new_package_id):
        result = messagebox.askyesno(
            "Update Available",
            f"A newer version is available: {new_package_id}.\n"
            "Download now?",
        )
        if result:
            self.package_id = new_package_id
            threading.Thread(target=self.initial_fetch, daemon=True).start()


if __name__ == "__main__":
    root = tk.Tk()
    app = USCodeBrowser(root)
    root.mainloop()

Your app now starts instantly from its local cache and silently checks for updates in the background. Next up, you will extend it with a search bar that filters sections in real time.

Secret mission

Add Search Functionality

Your U.S. Code Browser works great, but scrolling through dozens of sections to find a specific one is tedious. Add a real-time search bar that filters sections as you type, making it easy to jump straight to the copyright law you need.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally and the GovInfo API key is free, so there are no ongoing costs.

Resources you used:

  • main.py — your Python application script that launches the desktop GUI.
  • api_key.txt — contains your 40-character GovInfo API key. Keep this private.
  • uscode_cache.db — SQLite database file caching U.S. Code data locally. Takes disk space but costs nothing.

Keep everything running

No action needed. Choose this if you plan to keep using or extending the app.

  • The app works offline after the first fetch thanks to the SQLite cache.
  • Your GovInfo API key remains in api_key.txt. It is free to use with no expiration.
  • The uscode_cache.db file will grow as you browse more sections, but stays small (a few MB at most).

Pause - I'll come back to this later

Close the app window. Your cached data persists in the SQLite file for next time.

  • Close the U.S. Code Browser window if it is still open.
  • All fetched section data stays in uscode_cache.db. The next time you run python main.py, the app loads instantly from cache.
  • No background processes remain running after you close the window.

Delete - I don't want to use this again

Remove all project files from your computer. There are no cloud resources to clean up.

  • Close the U.S. Code Browser window if it is still open.
  • In your terminal, navigate to the folder containing your project files and delete them by running:
rm main.py api_key.txt uscode_cache.db

On Windows, use this command instead:

del main.py, api_key.txt, uscode_cache.db
  • If you created a dedicated project folder for this project, you can delete the entire folder instead.
  • The requests library remains installed in your Python environment. You can uninstall it by running pip uninstall requests if no other projects depend on it.

Nice Work!

Nice Work!

You just built a fully functional desktop application that fetches, caches, and displays official U.S. federal law from a live government API.

You've learned how to:

  • Build a cross-platform two-pane desktop GUI using Python's built-in Tkinter library with Treeview navigation and a text display panel.
  • Connect to the official GovInfo REST API to fetch live U.S. Code data using background threading for a responsive UI.
  • Implement SQLite caching for offline access and instant startup, with automatic update detection that only re-fetches when the Code changes.
  • Secret Mission: Added real-time search filtering across all sections using Tkinter event binding.

Ready to quiz yourself?