The Python packaging article makes a distinction that changes how you write code: a script is a file you run, and a package is something other people can install and import. Getting from one to the other is not one big step but ten small ones, and this workbook walks all ten by building a single real package from an empty folder to something installable.
You will build tempkit, a unit converter for temperature and distance. It is deliberately small, because the point is the structure around the code rather than the code itself, and every command below has been run so the outputs are what you will actually see. Work through the stages in order, since each one builds on the folder the last one left behind.
Stage 1: from a script to something importable
Start with the problem. A loose .py file works when you run it, and fails the moment someone else wants to import it. The fix is a folder with an __init__.py inside it.
Create the project and the first module:
tempkit-project/└── tempkit/ ├── __init__.py <- this file is what makes it importable └── temperature.py
tempkit/temperature.py:
def celsius_to_fahrenheit(value): return value * 9 / 5 + 32
Leave __init__.py completely empty for now, then from tempkit-project/ run:
python3 -c "from tempkit.temperature import celsius_to_fahrenheit; print(celsius_to_fahrenheit(100))"
212.0
The empty __init__.py is doing all the work here. Without it, Python does not treat the folder as a package you can import from, and you get ModuleNotFoundError even though the file is right there. With it, tempkit becomes a namespace and tempkit.temperature becomes a module inside it. That single empty file is the entire difference between a folder of scripts and a package.
Stage 2: separate the internals from the interface
One module quickly becomes a tangle of helpers and public functions. Splitting each area into core.py for the internal logic and api.py for what users call keeps the boundary explicit.
tempkit/├── __init__.py└── temperature/ ├── __init__.py ├── core.py <- internal: the conversion mechanics └── api.py <- public: what users actually call
tempkit/temperature/core.py:
VALID_UNITS = ("celsius", "fahrenheit", "kelvin")def to_celsius(value, unit): if unit == "celsius": return value if unit == "fahrenheit": return (value - 32) * 5 / 9 if unit == "kelvin": return value - 273.15 raise ValueError(f"unknown unit: {unit}")def from_celsius(value, unit): if unit == "celsius": return value if unit == "fahrenheit": return value * 9 / 5 + 32 if unit == "kelvin": return value + 273.15 raise ValueError(f"unknown unit: {unit}")
tempkit/temperature/api.py:
from tempkit.temperature.core import to_celsius, from_celsiusdef convert(value, from_unit, to_unit): return from_celsius(to_celsius(value, from_unit), to_unit)
Notice the shape of core.py, which is the hub pattern the article describes. Rather than writing a function for every pair of units, which would be six functions for three units and twelve for four, everything converts into one hub unit and then back out. Celsius is the hub here, so adding a fourth scale means adding two lines rather than six functions. The api.pylayer then exposes a single convert function, and users never need to know a hub exists.
Stage 3: sub-packages for separate domains
Distance conversion is the same idea with a different hub, and it belongs in its own sub-package rather than crowding the temperature one.
tempkit/├── __init__.py├── temperature/│ ├── __init__.py│ ├── core.py│ └── api.py└── distance/ ├── __init__.py ├── core.py └── api.py
tempkit/distance/core.py:
TO_METRES = {"metres": 1.0, "km": 1000.0, "miles": 1609.344, "feet": 0.3048}def to_metres(value, unit): if unit not in TO_METRES: raise ValueError(f"unknown unit: {unit}") return value * TO_METRES[unit]def from_metres(value, unit): if unit not in TO_METRES: raise ValueError(f"unknown unit: {unit}") return value / TO_METRES[unit]
tempkit/distance/api.py:
from tempkit.distance.core import to_metres, from_metresdef convert(value, from_unit, to_unit): return from_metres(to_metres(value, from_unit), to_unit)
Every sub-package needs its own __init__.py, which is the most common thing people forget when nesting. The distance hub is metres, held in a dictionary rather than an if-chain, which is the cleaner form once the conversions are simple multipliers, and it is worth seeing both styles since temperature needs the if-chain because Kelvin and Fahrenheit involve offsets rather than pure scaling.
Stage 4: import from the package root, always
Internal imports can be written two ways, and one of them will break on you.
# FRAGILE: relative import, breaks if the file moves or is run directlyfrom .core import to_celsius# ROBUST: full path from the package rootfrom tempkit.temperature.core import to_celsius
Verify the whole thing still assembles from the project directory:
python3 -c "from tempkit.temperature.api import convert as tfrom tempkit.distance.api import convert as dprint('212 F in C:', t(212, 'fahrenheit', 'celsius'))print('5 km in miles:', round(d(5, 'km', 'miles'), 4))"
212 F in C: 100.05 km in miles: 3.1069
The full-path form costs a few more characters and saves a category of bug. Relative imports resolve against wherever Python thinks the current module sits, which changes depending on how the code was invoked, so a file that imports fine through the package fails when run directly as a script. Writing from tempkit.temperature.core import ... means the import resolves the same way from anywhere, including from tests, which is where the difference usually bites first.
Stage 5: the __init__.py as a storefront
An empty __init__.py makes a folder importable. A populated one decides what users see first, and it is where you shorten a long import path into a short one.
tempkit/__init__.py:
from tempkit.temperature.api import convert as convert_temperaturefrom tempkit.distance.api import convert as convert_distance__version__ = "0.1.0"
python3 -c "import tempkitprint(tempkit.__version__)print(tempkit.convert_temperature(20, 'celsius', 'fahrenheit'))"
0.1.068.0
The user now writes tempkit.convert_temperature(...) rather than from tempkit.temperature.api import convert, which is a considerable improvement in how the package reads. The renaming with as is doing real work too, since both sub-packages export a function called convert and the storefront is where you resolve that collision. Keeping __version__ here is the convention that lets anyone check which version they have loaded at runtime.
Stage 6: docstrings that describe the contract
Documentation lives next to the code so the two cannot drift apart. NumPy style is the common convention in data work.
tempkit/temperature/api.py, documented:
"""Public interface for temperature conversion."""from tempkit.temperature.core import to_celsius, from_celsiusdef convert(value, from_unit, to_unit): """Convert a temperature between scales. Parameters ---------- value : float The temperature to convert. from_unit : str Scale of the input: 'celsius', 'fahrenheit' or 'kelvin'. to_unit : str Scale to convert into, same options. Returns ------- float The converted temperature. Raises ------ ValueError If either unit is not recognised. Examples -------- >>> convert(100, "celsius", "fahrenheit") 212.0 """ return from_celsius(to_celsius(value, from_unit), to_unit)
python3 -c "from tempkit.temperature.api import convert; help(convert)"
The docstring is not a comment, it is an object attached to the function, which is why help() can print it and why editors show it on hover. The three sections that matter are Parameters, Returns and Raises, because together they state the contract: what goes in, what comes out, and how it fails. The Examples block earns its place too, since it doubles as a test that doctest can run, so the example cannot silently go stale.
Stage 7: setup.py, the file that makes it installable
Metadata turns a folder into something pip understands.
setup.py, at the project root beside the tempkit/ folder:
from setuptools import setup, find_packagessetup( name="tempkit", version="0.1.0", author="Your Name", description="Unit conversions for temperature and distance", long_description=open("README.md").read(), long_description_content_type="text/markdown", packages=find_packages(exclude=["tests"]), python_requires=">=3.9", install_requires=[], # runtime dependencies go here)
Two supporting files beside it:
requirements.txt, for development tools users do not need:
pytestflake8tox
MANIFEST.in, so non-code files travel with the package:
include README.mdinclude LICENSE
find_packages() is the line worth understanding, since it walks the directory tree and collects every folder containing an __init__.py, which is why forgetting one in a sub-package silently drops it from the distribution. The split between install_requires and requirements.txt matters as well: the first is what your users are forced to install, so keep it minimal, and the second is what you need to develop and test, which is nobody else’s problem. The version number follows major.minor.patch, and starting at 0.1.0 signals honestly that the interface may still change.
Stage 8: install it in editable mode
Editable installs let you import the package from anywhere while still editing the source in place.
python3 -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\activatepip install -e .
Then from any directory:
python3 -c "import tempkitprint('version:', tempkit.__version__)print('20 C in F:', tempkit.convert_temperature(20, 'celsius', 'fahrenheit'))print('loaded from:', tempkit.__file__)"
version: 0.1.020 C in F: 68.0loaded from: .../tempkit-project/tempkit/__init__.py
The -e flag is what makes this a development workflow rather than a snapshot. A normal pip install . copies the code into site-packages, so every edit needs a reinstall, whereas -e installs a link back to your source folder, meaning a change to core.py is live on the next import with no reinstall at all. The virtual environment around it is not optional advice either, since installing your own half-finished package into the system Python is how environments become unreproducible.
Stage 9: tests that mirror the structure
Tests live in their own folder that mirrors the package, so finding the test for a module is never a search.
tempkit-project/├── tempkit/└── tests/ ├── test_temperature.py └── test_distance.py
tests/test_temperature.py:
import pytestfrom tempkit.temperature.api import convertdef test_celsius_to_fahrenheit(): assert convert(100, "celsius", "fahrenheit") == 212.0def test_fahrenheit_to_celsius(): assert convert(212, "fahrenheit", "celsius") == pytest.approx(100.0)def test_celsius_to_kelvin(): assert convert(0, "celsius", "kelvin") == pytest.approx(273.15)def test_same_unit_is_unchanged(): assert convert(21.5, "celsius", "celsius") == 21.5def test_unknown_unit_raises(): with pytest.raises(ValueError): convert(10, "celsius", "rankine")
tests/test_distance.py:
import pytestfrom tempkit.distance.api import convertdef test_km_to_miles(): assert convert(5, "km", "miles") == pytest.approx(3.1069, abs=0.0001)def test_miles_to_feet(): assert convert(1, "miles", "feet") == pytest.approx(5280.0)def test_round_trip(): assert convert(convert(42, "metres", "feet"), "feet", "metres") == pytest.approx(42.0)
pytest -q
........ [100%]8 passed in 0.01s
Three habits are on display. pytest.approx handles the floating-point comparisons, which matter here because (212 - 32) * 5 / 9does not land on exactly 100.0 in binary arithmetic and a plain == would fail unpredictably. The error case is tested as deliberately as the happy path, since a function that accepts nonsense silently is worse than one that raises. And the round-trip test in the distance file is a pattern worth stealing for any converter, because it checks the two directions against each other without you having to look up a single expected value.
Stage 10: style, versions, and publishing
Three final tools take the package from working to shareable.
Style checking with flake8:
flake8 tempkit/
Silence means clean. Where a rule is wrong for your code rather than your code being wrong, suppress that one line rather than disabling the rule globally:
l = compute_length() # noqa: E741
Testing across Python versions with tox, in tox.ini:
[tox]envlist = py39, py310, py311, py312
[testenv]
deps = pytest commands = pytest
tox
Building and publishing:
python3 -m build # creates dist/twine upload --repository testpypi dist/* # practise here FIRSTtwine upload dist/* # the real thing
python -m build produces two files in dist/, a .tar.gz source distribution and a .whl wheel, and the wheel is what pip prefers because it installs without running any build step. The TestPyPI upload is the step to take seriously, because PyPI does not let you overwrite or reuse a version number once published, so a mistake means burning 0.1.0 and releasing 0.1.1 to fix it. Practise on TestPyPI, install from there to confirm it works, and only then upload for real.
The finished project
tempkit-project/├── tempkit/│ ├── __init__.py # the storefront│ ├── temperature/│ │ ├── __init__.py│ │ ├── core.py # internal, hub pattern│ │ └── api.py # public│ └── distance/│ ├── __init__.py│ ├── core.py│ └── api.py├── tests/│ ├── test_temperature.py│ └── test_distance.py├── setup.py # metadata and dependencies├── requirements.txt # dev tools only├── MANIFEST.in # non-code files to include├── tox.ini # multi-version testing├── README.md└── LICENSE
Work through these and you have the whole article in practice: __init__.py as the thing that makes a folder importable, the core-and-api split, sub-packages, absolute imports, the storefront pattern, NumPy docstrings, setup.py metadata and the dependency split, editable installs, a mirrored test suite, and the style, version and publishing tools. The habit worth carrying is the one that motivates all of it: the moment code is worth reusing, it is worth structuring, and the ten stages above are cheaper to do at the start than to retrofit once three projects already import your scripts by file path.
Hope this helps.
[…] Turning Your Python Code into a Real Package: 10 Code-Along Examples […]