Reading Bible Passages from the Terminal

Years ago I had the idea to build a command-line interface for reading Bible passages. I put together an early prototype but it was left unfinished, recently I decided to revisit it and turn it into something more complete...

Data Source and Storage Layer

One of the first decisions I needed to make was which data source to use. This choice would shape the entire project moving forward because it:

In the end I settled with API.Bible because it offers free API keys, access to a wide selection of Bibles and an intuitive RESTful API.

Hand in hand with data source is the storage layer, because API requests are not free and results should be cached. This is not only allowed but encouraged, although, the API.Bible T&C explicitly state that cached content should not be older than 30 days. For this project I went with SQLite. It's simple, local and fast enough to meet the requirements of this project.


CLI or TUI?

I knew going in that I wanted to create a command-line interface but many tools nowadays offer GUI-like interfaces in the form of TUIs. I'm generally more experienced in writing CLIs but this project seemed to lend itself towards an interactive user interface.

However, many python package managers allow you to define multiple entry points for precisely this purpose. For example:

scripts.bible-cli = "bible.cli.cli:main"
scripts.bible-tui = "bible.tui.tui:main"

Textual seemed like the obvious choice for the TUI and after some consideration I went with Clypi for the CLI.


Application Internals

API Layer

The first step was to push raw API calls behind an interface, neither the TUI or the CLI should know about API endpoints.

class BibleAPI:
    async def __aenter__(self):
        self.session = AsyncSession(
            base_url=settings.shared.ENDPOINT,
            headers={'api-key': settings.shared.API_KEY},
        )
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.session.close()

    async def get_bibles(self):
        return await self.get('/v1/bibles')

    async def get_books(self, bible_id: str):
        return await self.get(f'/v1/bibles/{bible_id}/books')

    ...

Storage Layer

Then do the same with the storage layer, notice that if the record is older than CACHE_EXPIRY_DAYS then get_cached returns None, which in turn causes the application to do a raw API fetch.

def get_cached(key: str):
    with sqlite3.connect(settings.shared.DB_PATH) as conn:
        cursor = conn.cursor()
        cursor.execute(
            'SELECT value, last_updated FROM cache WHERE key = ?', 
            (key,)
        )
        row = cursor.fetchone()

    if not row:
        return None

    value, last_updated = row
    if datetime.fromisoformat(last_updated) < datetime.now() - timedelta(
        days=settings.shared.CACHE_EXPIRY_DAYS
    ):
        logger.debug(f'Cache expired for key: {key}')
        return None

    return value


def set_cached(key: str, value: str):
    with sqlite3.connect(settings.shared.DB_PATH) as conn:
        conn.execute(
            'REPLACE INTO cache (key, value, last_updated) VALUES (?, ?, ?)',
            (key, value, datetime.now().isoformat()),
        )
        conn.commit()

Rendering

I discovered that the API.Bible endpoints return HTML content but we are dealing with command-line interfaces so this would require parsing the HTML and producing terminal renderable content. For this I reached to Beautiful Soup. Parsing the HTML was unglamorous and laborious work so I won't explore it in this blog but here is the generic renderer.

One thing I noticed in building the renderers was that each book had it's own HTML quirks and not all markup was equal, so for the final application I ended up implementing book-specific renderers.


Application Interfaces

CLI

I wanted to keep the CLI readable as though you were asking it directly for a chapter/verse. For this reason I decided to build the following CLI tree:

Usage: bible-cli book [BOOK_NAME] [OPTIONS] COMMAND

┏━ Subcommands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ list      List all available Bible books               ┃
┃ chapter   Retrieve a Bible chapter                     ┃
┃ chapters  Retrieve multiple chapters of a Bible book   ┃
┃ verse     Retrieve a single Bible verse                ┃
┃ verses    Retrieve multiple Bible verses               ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Chapters/Verses subcommands accept requests for multiple responses, for example, bible-cli book verses Luke 1 1-8:

Verses

Some requests, for example, an entire book, can return a lot of results so we fetch them asynchronously:

async with Spinner('Fetching chapters...'):
    tasks = [fetch_chapter(ch) for ch in self.chapters]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    results = [r for r in results if isinstance(r, dict)]

    if not results:
        raise ValueError('No chapters retrieved.')

This dramatically speeds up loading times.

TUI

Although we are able to use the same api and storage layers the renderers have to be switchable at runtime in case we change Bibles or book and have to be settable by a modal window.

As well as that it's a very good idea to implement navigation binds:

BINDINGS: typing.ClassVar[list[tuple[str, str, str]]] = [
    ('n', 'next_chapter', 'Next chapter'),
    ('p', 'prev_chapter', 'Previous chapter'),
    ('j', 'next_chapter', 'Next chapter'),
    ('k', 'prev_chapter', 'Previous chapter'),
]

This makes mouse-free reading easy and uninterrupted.

I decided to take a read-first approach by keeping the TUI as uncluttered as possible:

TUI


Conclusion

I'm happy with how it turned out. In the end, I implemented renderers for three of the many Bibles available through API.Bible, partly because they were the ones I could access with my free API key, and partly because the project had already grown into enough work.

The TUI could still use a bit more polish. For example, it would be nice to replace the list of books with clickable buttons, perhaps laid out in a grid.

You can find the full source code at onyx-and-iris/bible.

Subscribe to this blog's RSS feed