Authoring a package¶
Once you have more than one .py file you want to share — even just
between two of your own projects — it's worth turning your code into
a proper installable package. This tutorial walks through the modern,
pyproject.toml-driven approach using hatchling as the build
backend.
Time commitment: 20 minutes
Prerequisites:
- You've worked through the earlier Learn pages, especially Packages and namespaces
- You're comfortable creating a virtual environment
Learning objectives¶
By the end of this tutorial, you will be able to:
- Lay a project out in the canonical
src/shape - Write a working
pyproject.tomlwith metadata and dependencies - Install your package in editable mode for development
!!! note "Browser note" The commands below run in your terminal, not in this page's browser kernel. Pyodide doesn't expose a shell, so the bash blocks here are for reference — copy them into a real terminal to follow along.
We'll still use the page's virtual filesystem to write files
and demonstrate structure — only the `pip install -e .` step
and similar shell commands need a real terminal.
The src/ layout¶
The recommended layout for a new package looks like this:
my-package/
pyproject.toml
README.md
LICENSE
src/
my_package/
__init__.py
core.py
tests/
test_core.py
Two things to note:
- The distribution name (
my-package) and the import name (my_package) can differ. The distribution name is what you publish to PyPI; the import name is the directory undersrc/. Hyphens are common in distribution names; the import name must be a valid Python identifier, so it uses underscores. - The package itself lives under
src/, not at the top level. This is the most common layout question for new authors — the next section explains why.
Project layout has its own reference page with the trade-offs in detail.
Building the layout on the page¶
We'll create the file tree in Pyodide's virtual filesystem so the rest of the notebook can refer to real files.
import os, textwrap
os.makedirs("my-package/src/my_package", exist_ok=True)
os.makedirs("my-package/tests", exist_ok=True)
open("my-package/src/my_package/__init__.py", "w").write(
'__version__ = "0.1.0"\n'
'from .core import greet\n'
)
open("my-package/src/my_package/core.py", "w").write(textwrap.dedent('''
def greet(name: str) -> str:
# Return a friendly greeting.
return f"Hello, {name}!"
''').lstrip())
open("my-package/tests/test_core.py", "w").write(textwrap.dedent('''
from my_package import greet
def test_greet():
assert greet("Ada") == "Hello, Ada!"
''').lstrip())
os.system("ls -R my-package") # Pyodide may or may not have `ls`; fine either way.
pyproject.toml¶
pyproject.toml is the single configuration file that describes
your package — its name, version, dependencies, build system, and
any tool-specific configuration. It's defined by PEP 621 (project
metadata) and PEP 517 (build system).
Here's a minimal one for our example:
pyproject = textwrap.dedent('''
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-package"
version = "0.1.0"
description = "A friendly greeting library."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
license = { text = "MIT" }
dependencies = []
[project.optional-dependencies]
test = ["pytest"]
''').lstrip()
open("my-package/pyproject.toml", "w").write(pyproject)
print(pyproject)
Two tables do the work.
[build-system] tells pip how to build your package. We've
chosen hatchling — a modern, lightweight
build backend that needs no configuration for the standard src/
layout. Setuptools is the classic alternative; flit, poetry-core,
and pdm-backend are others.
[project] carries the metadata. name, version, and at
least a description are the essentials. requires-python
constrains which Python versions your package supports.
dependencies is a list of strings using the same syntax as pip install arguments ("requests>=2.30").
See the pyproject.toml field reference for the full set of fields.
Editable installs¶
For a package you're actively developing, you don't want to
pip install it after every change. Editable installs solve that:
# From inside the project root, with your venv activated:
pip install -e .
The -e (editable) flag installs a thin link rather than a copy. Any
edit to your source files is picked up next time you run Python — no
re-install needed. This is the standard development workflow once
a project is package-shaped.
After pip install -e . you can import my_package from anywhere
in the venv.
Adding dependencies¶
Dependencies live in the [project] table:
[project]
# ...
dependencies = [
"requests>=2.30",
"rich",
]
After editing pyproject.toml, run pip install -e . again to pick
up the new dependencies. From this point on, anyone who installs
your package gets requests and rich automatically.
Optional dependencies¶
Tooling that's only needed in development — pytest, mypy,
black — belongs in [project.optional-dependencies]:
[project.optional-dependencies]
test = ["pytest>=7", "pytest-cov"]
dev = ["mypy", "ruff"]
Install a group with the bracket syntax:
pip install -e ".[test]" # base deps + the test group
pip install -e ".[test,dev]" # base deps + both groups
This is also how libraries offer optional integrations — for example,
pandas[excel] pulls in the bits needed for Excel support, and only
those.
Versioning¶
The version field is a string that other tools — and humans —
will read. Most Python packages follow Semantic Versioning:
MAJOR.MINOR.PATCH, where breaking changes bump MAJOR, new features
bump MINOR, and fixes bump PATCH.
For now, set it manually in pyproject.toml and bump it when you
release. Tools like hatch version or bump-my-version automate
this if it gets tedious.
Recap and next steps¶
- The standard layout has
pyproject.tomlat the root and the code undersrc/<import_name>/. - Use hatchling as the build backend unless you have a reason to choose otherwise.
pip install -e .is the developer's friend — install once, edit freely.
With your package working locally, the last step is to share it — Building and publishing.