BeginnerChallenge

Reusable Package and CLI

Turn a working script into an installable package with a proper src layout, a pyproject.toml, an editable install in a virtual environment, a terminal command, and tests that run against the installed package rather than a loose file.

Skills you’ll practice

  • modules and packages
  • packaging layout
  • virtual environments
  • documentation
  • testing

The Beginner tier’s third and last project. Every project so far has been “a script you run with python something.py.” This one is about the gap between that and something you could hand to someone else to install and run as its own command — the exact gap Modules, Packages, and Environments opens with a package layout, a virtual environment, and an executable-module pattern, and closes here by actually building one.

Scenario

Pick a working program to package: your CLI Quiz or Habit Tracker, your Expense Analyzer, or any other script of comparable size you’ve already written and tested. Restructure it into an installable package, give it a real terminal command, and prove — by actually running that command from a different directory — that it works as something installed, not just as a file you happen to be standing next to.

Requirements

Stage 1: package layout

  • Restructure the code into a src/ layout: src/<your_package_name>/__init__.py, plus at least two modules — one holding the program’s actual logic, one holding whatever runs when the command is invoked (commonly named cli.py or __main__.py).
  • Imports between your own modules must be absolute (from your_package_name.core import ...), not relative to the current working directory.
  • The entry-point function that starts the program takes no required arguments — it reads whatever it needs (files, input()) itself, the same way it did as a standalone script.

Stage 2: packaging metadata

  • Write a pyproject.toml declaring the project’s name, version, description, and requires-python.
  • Declare a console-script entry point ([project.scripts]) mapping a command name to your entry-point function.
  • List any third-party dependencies your program actually imports — if it only uses the standard library, this list is empty, and that’s worth stating explicitly rather than leaving ambiguous.

Stage 3: virtual environment and install

  • Create a virtual environment (uv venv, or python -m venv) and activate it.
  • Install your package into it in editable mode (uv pip install -e ., or pip install -e .).
  • From a directory that is not your project directory, with the virtual environment active, run the command name you declared in Stage 2 and confirm it works — this is the actual proof the package is installed correctly, not just that the source files happen to be importable from wherever you’re standing.

Stage 4: documentation

  • Write a README.md covering: what the program does, how to create the virtual environment and install it, how to run the installed command, and anything it expects to find (a config file, an environment variable) to run correctly.
  • Someone who has never seen your code should be able to follow the README alone to get the command running.

Stage 5: tests

  • Tests import from your package by its installed name (from your_package_name.core import ...), not by a relative file path — that’s what proves the tests are exercising the installed package, the same thing a real user will run.
  • Run pytest with the virtual environment active and confirm it discovers and passes the tests from the package’s installed location.

Fixtures

No fixture data is needed if you’re packaging an earlier project — its own fixtures still apply. Packaging a fresh script instead, this minimal starting point demonstrates the same restructuring:

Pythonword_count.py (a single-file script, before packaging)
import sys
from pathlib import Path


def count_words(text: str) -> int:
    return len(text.split())


def main() -> None:
    if len(sys.argv) < 2:
        print("Usage: python word_count.py <file>")
        return

    path = Path(sys.argv[1])
    text = path.read_text(encoding="utf-8")
    print(f"{path.name}: {count_words(text)} words")


if __name__ == "__main__":
    main()

Acceptance checklist

  • The package uses a src/ layout with at least two modules and absolute internal imports.
  • pyproject.toml declares name, version, requires-python, dependencies (even if empty), and a [project.scripts] entry point.
  • pip install -e . (or the uv equivalent) succeeds in a fresh virtual environment.
  • The installed command runs successfully from a directory other than the project directory.
  • README.md documents installation and usage clearly enough to follow without reading the source.
  • pytest passes, importing from the installed package rather than a loose script file.
  • ruff format --check and ruff check both pass on your source files.

Common places to get stuck

  • A stray script of the same name in the current directory shadows the installed package. Python resolves imports from the current directory before site-packages in some setups — if import your_package_name unexpectedly picks up an old loose file instead of the installed one, run the command from a directory that doesn’t contain any leftover copy of your original script.
  • Forgetting __init__.py in a package directory. Without it, the directory may not be recognized as a package at all, producing a ModuleNotFoundError that names the package itself, not anything inside it — a strong hint to check for the missing file specifically.
  • A [project.scripts] entry pointing at a function that requires arguments. A console-script entry point is called with no arguments; if your function’s signature requires one, installation succeeds but running the command fails immediately with a TypeError.
  • Editing the source after installing, then wondering why nothing changed. A regular (non-editable) install copies the code at install time — an editable install (-e .) is what makes further edits show up immediately without reinstalling, which is why Stage 3 specifically asks for it.

Stuck? Reveal one hint at a time.

  1. Hint 1

    If the installed command runs the wrong (old) code, check whether you installed with -e — a non-editable install snapshots your source at install time, so an edit afterward has no effect until you reinstall.

  2. Hint 2

    If import your_package_name raises ModuleNotFoundError naming the package itself (not something inside it), check for a missing __init__.py in that directory, or that pyproject.toml's package discovery actually points at your src/ layout.

  3. Hint 3

    If the installed command's TypeError complains about a missing required argument, check your [project.scripts] target — it must point at a function that runs with zero arguments, since that's how a console-script entry point is always invoked.

Extension ideas

  • Add a second console-script entry point (a package can expose more than one command).
  • Add a --version style option that prints the version declared in pyproject.toml.
  • Publish the package to a private or test package index and install it from there instead of from a local path, to see the same install step work against a real distribution.
  • Add a CHANGELOG.md and bump the version deliberately the next time you change behavior.

Debugging journal

Before moving on: describe one error you hit specifically because of the restructuring (an import error, a missing entry point, a stale non-editable install) rather than because of a bug in the program’s actual logic. What made it clear the problem was packaging-related and not a logic bug? That distinction — is this broken because of what the code does, or because of how it’s installed and imported — is exactly the kind of debugging judgment Errors and Debugging introduced and this project applies to a new category of failure.

See one reference approach (not the only correct one)

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

A minimal but complete layout for the word_count.py fixture above, restructured as a package:

Outputdirectory layout
word-counter/
├── pyproject.toml
├── README.md
├── src/
│   └── word_counter/
│       ├── __init__.py
│       ├── core.py
│       └── cli.py
└── tests/
    └── test_core.py
Outputpyproject.toml
[project]
name = "word-counter"
version = "0.1.0"
description = "Count words in a text file."
requires-python = ">=3.13"
dependencies = []

[project.scripts]
word-counter = "word_counter.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Pythonsrc/word_counter/core.py
def count_words(text: str) -> int:
    return len(text.split())
Pythonsrc/word_counter/cli.py
import sys
from pathlib import Path

from word_counter.core import count_words


def main() -> None:
    if len(sys.argv) < 2:
        print("Usage: word-counter <file>")
        return

    path = Path(sys.argv[1])
    text = path.read_text(encoding="utf-8")
    print(f"{path.name}: {count_words(text)} words")

core.py holds logic with no dependency on sys.argv or the terminal, which is what makes tests/test_core.py able to call count_words directly with a plain string — the same separation-of-concerns habit the earlier two projects’ solution notes also relied on for testability. cli.py is deliberately the only module that touches sys.argv, and it’s also the only thing pyproject.toml’s entry point ever calls.

Rubric

Dimension What it’s assessing
Correctness The installed command runs correctly from outside the project directory.
Clarity Logic and CLI/entry-point code are separated into different modules.
Resilience The package installs cleanly into a fresh virtual environment with declared dependencies.
Usability The README lets someone else install and run the command without reading the source first.

Beginner tier complete

Finishing this project closes out all three Beginner projects and, with them, the Beginner tier itself — eleven lessons and three projects covering reliable scripts, core language fluency, and now packaging a program well enough for someone else to use it.