Modern Python Basics #7: Modules/packages and pyproject.toml
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.
def add(a: int, b: int) -> int:
return a + b
def multiply(a: int, b: int) -> int:
return a * b
PI = 3.141592import math_utils
print(math_utils.add(2, 3)) # 5
print(math_utils.PI) # 3.141592Four import forms #
# 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
#
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.
my_app/
├── __init__.py
├── auth.py
├── db.py
└── handlers/
├── __init__.py
├── user.py
└── post.pyIn this structure:
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.
- Marks this directory as a package (historical role — since Python 3.3, even without it, directories are treated as namespace packages)
- Code that runs when the package is imported — to organize the public API or initialize
"""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:
from my_app import login, connectThe 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.
# 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.
def main():
print("hello!")
if __name__ == "__main__":
main()You can use this file two ways.
$ uv run cli.py
hello! ← __name__ is '__main__'import cli
cli.main() # explicit call still works
# main() doesn't run on importWithout 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:
my_cli/
├── __init__.py
├── __main__.py ← main entry point here
└── core.pyfrom my_cli.core import run
if __name__ == "__main__":
run()Then:
$ uv run python -m my_cliA common pattern when building CLI tools.
Standard library — same import system #
The standard library uses the same import system. No install needed.
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, partialThe 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.
[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 functionIf 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.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:
# 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.14uvx 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.
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
importforms —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,uvxmake 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:
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 resultType 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.
- dataclass and
__slots__ - typing in earnest — Generic, Protocol, TypedDict, Literal
- Context managers (
with,contextlib) — the tool we mentioned often aroundfinallyin this series - iterables/generators/
yield from - Decorator patterns
- Pattern matching in depth (the next step from this series’ #3
match-case) - Async intro (asyncio basics)
This basics series is designed to flow directly into the intermediate, so you can step in without a break.