Modern Python Basics #7: Modules/packages and pyproject.toml

4 min read

For six posts we wrote code in a single file. This final post covers how to split code across many files and tie them together as a project — the import system, modules and packages, and the pyproject.toml that brings it all together.

  • #1 Getting started and uv setup
  • #2 Variables, basic types, and type hints
  • #3 Control flow
  • #4 Collections and comprehensions
  • #5 Functions — argument patterns
  • #6 Errors and exception handling
  • #7 Modules/packages and pyproject.toml ← this post

Modules — one file is a module #

In Python, a single .py file is a module. You bring it in elsewhere with import.

math_utils.py
def add(a: int, b: int) -> int:
    return a + b

def multiply(a: int, b: int) -> int:
    return a * b

PI = 3.141592
main.py
import math_utils

print(math_utils.add(2, 3))     # 5
print(math_utils.PI)            # 3.141592

Four import forms #

import variations
# 1. The whole module
import math_utils
math_utils.add(1, 2)

# 2. Alias
import math_utils as mu
mu.add(1, 2)

# 3. Specific names
from math_utils import add, PI
add(1, 2)

# 4. Alias + specific
from math_utils import add as plus
plus(1, 2)

from m import * is rarely used #

🚫 Usually avoided
from math_utils import *

Name collisions, untraceable origins, confused static analyzers. Almost never used in new code. The exception is brief use in interactive shells.

Packages — a directory is a package #

To bundle multiple modules, use a directory.

Package structure
my_app/
├── __init__.py
├── auth.py
├── db.py
└── handlers/
    ├── __init__.py
    ├── user.py
    └── post.py

In this structure:

Package import
from my_app import auth
from my_app.db import connect
from my_app.handlers.user import create_user

__init__.py — two roles #

__init__.py does two things.

  1. Marks this directory as a package (historical role — since Python 3.3, even without it, directories are treated as namespace packages)
  2. Code that runs when the package is imported — to organize the public API or initialize
my_app/__init__.py
"""my_app — 예제 애플리케이션."""

from my_app.auth import login, logout
from my_app.db import connect

__all__ = ["login", "logout", "connect"]
__version__ = "0.1.0"

Now callers can:

Shorter import
from my_app import login, connect

The internal structure (auth module, db module) isn’t exposed. This is the pattern of defining the public API in one place.

__all__ is the whitelist of names for from my_app import *, but in practice it’s used more as documentation that declares the public API.

Namespace package — when __init__.py is missing #

From 3.3+, a directory without __init__.py is also a package (implicit namespace package). Drawbacks:

  • No place to put initialization code
  • Some (older) tools may not recognize it

When making a new package, keeping an __init__.py is safer. It can be empty (no pass needed — an empty file is fine).

Absolute vs relative imports #

Two ways to import another module from the same package.

my_app/handlers/user.py
# Absolute import
from my_app.db import connect
from my_app.auth import current_user

# Relative import
from ..db import connect
from ..auth import current_user

.. is “one level up”; . is “same directory.”

Both work, but absolute imports are recommended (PEP 8). The source is visible at a glance and they’re less likely to break when files move. Relative imports are fine over short distances inside a package.

__main__ — when running the module directly #

For code that runs only when executed as a script.

cli.py
def main():
    print("hello!")

if __name__ == "__main__":
    main()

You can use this file two ways.

Run directly
$ uv run cli.py
hello!         ← __name__ is '__main__'
Import elsewhere
import cli
cli.main()    # explicit call still works
# main() doesn't run on import

Without if __name__ == "__main__":, just importing the file would run main(), causing side effects. Always include this guard as best practice.

Package-level execution — __main__.py #

To make a package itself executable:

Executable package
my_cli/
├── __init__.py
├── __main__.py     ← main entry point here
└── core.py
my_cli/__main__.py
from my_cli.core import run

if __name__ == "__main__":
    run()

Then:

Run as a package
$ uv run python -m my_cli

A common pattern when building CLI tools.

Standard library — same import system #

The standard library uses the same import system. No install needed.

Common standard modules
import os                # filesystem
import sys               # interpreter, argv
import json              # JSON
import re                # regex
import datetime          # date/time
import pathlib           # path objects
from collections import Counter, defaultdict
from itertools import chain, groupby
from functools import lru_cache, partial

The fastest reference is the official docs.

pyproject.toml — single source of truth for the project #

The file uv init made in #1. Now we look at it properly.

pyproject.toml
[project]
name = "my-app"
version = "0.1.0"
description = "예제 애플리케이션"
readme = "README.md"
requires-python = ">=3.14"
authors = [
    { name = "Curtis", email = "you@example.com" },
]
license = { text = "MIT" }
dependencies = [
    "httpx>=0.28",
    "pydantic>=2.10",
]

[project.optional-dependencies]
docs = ["mkdocs>=1.6"]

[dependency-groups]
dev = [
    "pytest>=8.0",
    "ruff>=0.7",
    "pyright>=1.1",
]

[project.scripts]
my-app = "my_app.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Key sections, one by one.

[project] — PEP 621 metadata #

Name, version, description, Python version requirement, authors, license, runtime dependencies.

[project.optional-dependencies] — optional dependencies #

Bundles users can choose to install with pip install 'my-app[docs]'. Common when distributing libraries.

[dependency-groups] — dev/test #

Standardized as PEP 735. Bundles tools needed for development but excluded from distribution. uv adds them automatically with uv add --dev.

[project.scripts] — CLI entrypoints #

This section makes installing the package also create a console command automatically.

[project.scripts]
my-app = "my_app.cli:main"

After install:

$ my-app
# runs my_app.cli's main function

If you build CLI tools, this section is the key.

[build-system] — build backend #

Needed if uploading to PyPI. Options like hatchling, setuptools, pdm-backend. For new projects, hatchling is light and a reasonable choice.

Tool config also lives in pyproject.toml #

Linter/formatter/type checker configs go in the same file.

Tool config
[tool.ruff]
line-length = 100
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B"]

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.14"

[tool.pytest.ini_options]
testpaths = ["tests"]

Scattered files like setup.cfg, .flake8, mypy.ini, pytest.ini collapse into one.

uv daily commands — once more #

In addition to what we saw in #1, the common flows:

Daily flows
# New project
uv init my-app --python 3.14

# Dependencies
uv add httpx pydantic
uv add --dev pytest ruff pyright
uv remove old-package

# Sync (on another machine / fresh checkout)
uv sync

# Run
uv run python main.py
uv run pytest
uv run ruff check
uv run pyright

# Run once (a tool not in your project deps)
uvx ruff check .          # runs once, doesn't pollute the env

# Upgrade Python itself
uv python install 3.14

uvx is the command for a one-off run in an isolated environment. Same role as pipx; doesn’t install the tool globally — safer.

Distributing a package to PyPI #

Once you’re set up here, distribution is one or two commands.

Build + publish
uv build                          # creates wheel + sdist in dist/
uvx twine upload dist/*           # upload to PyPI

(There’s also uv publish instead of twine, but twine is still the standard for stability.)

Wrapping up the series #

What this post covered:

  • One file is a module; a directory is a package
  • The four import forms — import x, import x as y, from x import y, from x import y as z
  • __init__.py — package marker + initialization + organizing the public API (__all__)
  • Prefer absolute imports; relative imports only over short distances
  • Always include the if __name__ == "__main__": guard
  • Run a package with __main__.py + python -m my_pkg
  • One file (pyproject.toml) holds metadata + dependencies + scripts + tool config
  • Separate dev dependencies with [dependency-groups]
  • Auto-generate console commands with [project.scripts]
  • uv sync, uv run, uvx make the daily workflow

Looking back at the whole series #

Across seven posts we set up the basic infrastructure of modern Python. By now, you can write and read code like this:

One-line summary of what we can do now
from collections.abc import Callable

type UserId = int

def find_users(
    ids: list[UserId],
    *,
    transform: Callable[[UserId], str] = str,
    skip_invalid: bool = True,
) -> dict[UserId, str]:
    """ID 리스트에서 변환된 사용자 맵을 만든다."""
    result: dict[UserId, str] = {}
    for uid in ids:
        try:
            result[uid] = transform(uid)
        except ValueError:
            if not skip_invalid:
                raise
    return result

Type hints, | union, built-in generics, keyword-only, Callable, exception handling, docstring — every tool covered in this series.

Next series #

Next is the Modern Python Intermediate series. The following topics come in.

  1. dataclass and __slots__
  2. typing in earnest — Generic, Protocol, TypedDict, Literal
  3. Context managers (with, contextlib) — the tool we mentioned often around finally in this series
  4. iterables/generators/yield from
  5. Decorator patterns
  6. Pattern matching in depth (the next step from this series’ #3 match-case)
  7. Async intro (asyncio basics)

This basics series is designed to flow directly into the intermediate, so you can step in without a break.

X