In part 1 we built taskcli, a command-line task tracker, using Typer for argument parsing and subcommands. It works, but every task vanishes the moment the process exits, and there’s no way to install taskcli as a real command on your system. This part fixes both: we’ll persist tasks to a proper data file and package the project with pyproject.toml.

Where should data live?

The instinct is to write a tasks.json file next to the script. That breaks the moment someone runs taskcli from a different directory, since relative paths resolve against the current working directory, not the tool’s location.

The correct answer depends on the OS. On Linux, config and data belong under ~/.config/ and ~/.local/share/ respectively (per the XDG Base Directory spec). On macOS it’s ~/Library/Application Support/. On Windows it’s somewhere under %APPDATA%. Hand-rolling this logic with os.name checks is exactly the kind of thing you shouldn’t write yourself.

Install platformdirs

pip install platformdirs

platformdirs (the actively maintained fork of the older appdirs) gives you the correct, OS-appropriate path with one function call:

# src/taskcli/storage.py
from pathlib import Path
from platformdirs import user_data_dir

APP_NAME = "taskcli"


def data_file() -> Path:
    data_dir = Path(user_data_dir(APP_NAME))
    data_dir.mkdir(parents=True, exist_ok=True)
    return data_dir / "tasks.json"

On macOS this resolves to ~/Library/Application Support/taskcli/tasks.json. On Linux, ~/.local/share/taskcli/tasks.json. Your code never has to know the difference.

A minimal storage layer

Tasks are simple enough that a JSON file beats reaching for SQLite. Keep the read/write logic in one module so the CLI commands never touch the filesystem directly:

# src/taskcli/storage.py
import json
from pathlib import Path
from platformdirs import user_data_dir

from taskcli.models import Task

APP_NAME = "taskcli"


def data_file() -> Path:
    data_dir = Path(user_data_dir(APP_NAME))
    data_dir.mkdir(parents=True, exist_ok=True)
    return data_dir / "tasks.json"


def load_tasks() -> list[Task]:
    path = data_file()
    if not path.exists():
        return []
    raw = json.loads(path.read_text())
    return [Task(**item) for item in raw]


def save_tasks(tasks: list[Task]) -> None:
    path = data_file()
    payload = [task.__dict__ for task in tasks]
    path.write_text(json.dumps(payload, indent=2))
# src/taskcli/models.py
from dataclasses import dataclass


@dataclass
class Task:
    id: int
    text: str
    done: bool = False

Real-world pattern: atomic writes so a crash never corrupts your data

path.write_text(...) looks safe, but it isn’t atomic: it opens the file, truncates it, and writes the new content in place. If the process is killed (or the machine loses power) mid-write, tasks.json is left half-written and every subsequent json.loads call throws. For a task list this is annoying; for anything that matters more, it’s a real bug.

The standard fix is write-to-temp-then-rename. os.replace (and Path.replace) is atomic on both POSIX and Windows, so the file on disk is always either the old complete version or the new complete version, never something in between:

# src/taskcli/storage.py
import json
import os
import tempfile
from pathlib import Path


def save_tasks(tasks: list[Task]) -> None:
    path = data_file()
    payload = [task.__dict__ for task in tasks]

    fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            f.write(json.dumps(payload, indent=2))
        os.replace(tmp_path, path)
    except BaseException:
        os.unlink(tmp_path)
        raise

Writing the temp file in the same directory as the target (dir=path.parent) matters: os.replace is only atomic when source and destination are on the same filesystem, and /tmp is often a separate mount from your home directory.

Wiring storage into the CLI

With storage.py in place, the commands from part 1 become genuinely useful:

# src/taskcli/cli.py
import typer

from taskcli.models import Task
from taskcli.storage import load_tasks, save_tasks

app = typer.Typer(help="A simple command-line task tracker.")


@app.command()
def add(text: str) -> None:
    """Add a new task."""
    tasks = load_tasks()
    next_id = max((t.id for t in tasks), default=0) + 1
    tasks.append(Task(id=next_id, text=text))
    save_tasks(tasks)
    typer.echo(f"Added task {next_id}: {text}")


@app.command(name="list")
def list_tasks() -> None:
    """List all open tasks."""
    tasks = [t for t in load_tasks() if not t.done]
    if not tasks:
        typer.echo("No open tasks.")
        return
    for task in tasks:
        typer.echo(f"[{task.id}] {task.text}")


@app.command()
def done(task_id: int) -> None:
    """Mark a task as complete."""
    tasks = load_tasks()
    for task in tasks:
        if task.id == task_id:
            task.done = True
            save_tasks(tasks)
            typer.echo(f"Completed: {task.text}")
            return
    typer.echo(f"Error: No task with id {task_id}", err=True)
    raise typer.Exit(code=1)


if __name__ == "__main__":
    app()

Note the command function is renamed to list_tasks with name="list" passed explicitly. list shadows the builtin, which is harmless here but worth avoiding as a habit once a module has more than a couple of commands.

Packaging with pyproject.toml

Everything so far runs via python -m taskcli.cli, which is fine for development but not something you’d hand to another person. Packaging turns the project into something installable with pip install taskcli, and gives you a real taskcli command on the shell PATH.

Modern Python packaging is centered entirely on pyproject.toml, no setup.py needed for a pure-Python package like this one:

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "taskcli"
version = "0.1.0"
description = "A simple command-line task tracker."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [
    { name = "Your Name", email = "you@example.com" },
]
dependencies = [
    "typer>=0.12",
    "platformdirs>=4.0",
]

[project.scripts]
taskcli = "taskcli.cli:app"

[tool.hatch.build.targets.wheel]
packages = ["src/taskcli"]

Two sections matter most:

  • [project.scripts] creates the console entry point. After install, taskcli on your PATH resolves to calling app from taskcli.cli — this is what turns a Python function into a shell command.
  • dependencies pins the packages the tool needs at runtime. Anyone who pip installs taskcli gets typer and platformdirs pulled in automatically.

hatchling is a solid default build backend: minimal configuration, fast, and it understands the src/ layout without extra setup. setuptools and poetry-core work too if you already have a preference.

Real-world pattern: editable installs for development

While developing, you want changes to src/taskcli/ to take effect immediately without reinstalling. An editable install does exactly that, it links the package into your environment rather than copying it:

python -m venv .venv
source .venv/bin/activate
pip install -e .

Now taskcli add "buy milk" runs your local, in-progress code directly. Combine this with the [project.optional-dependencies] table to keep test tooling out of the default install:

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov"]
pip install -e ".[dev]"
pytest

This is the setup you want in CI too: install once with dev extras, then both the tool and its test suite are available from the same environment.

Verifying the install

After pip install -e ., confirm the entry point actually resolved:

which taskcli
# .venv/bin/taskcli

taskcli add "ship the CLI series"
# Added task 1: ship the CLI series

taskcli list
# [1] ship the CLI series

If taskcli isn’t found, the most common cause is an unactivated virtual environment. pip install -e . places the script inside .venv/bin/ (or Scripts/ on Windows), not anywhere global.

What’s next

taskcli now persists data safely and installs as a real command. Part 3 covers publishing it to PyPI: versioning, building a wheel and sdist, and pushing a release with twine so anyone can pip install taskcli without cloning the repo.