Building and Publishing Packages

Python 3.14 Β· βœ“ verified by execution on 2026-08-01

Sooner or later you write something worth sharing. Publishing it means answering one question for every other machine in the world: what is this, and what does it need to run? That answer lives in a single file β€” pyproject.toml.

The Two Tables That Matter

A pyproject.toml has two important tables. [build-system] says which tool turns your source into an installable package. You are not writing that tool β€” you are naming it.

Because Python 3.11+ ships tomllib in the standard library, you can read that file with no dependencies at all. Run this:

python
import tomllib

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

config = tomllib.loads(toml)
print(config['build-system']['build-backend'])
Output
hatchling.build

The second table, [project], is the metadata humans and pip both read β€” the name on the PyPI page, the version, and what must be installed alongside it:

python
import tomllib

toml = '''
[project]
name = "codeludo-demo"
version = "1.4.2"
dependencies = ["requests", "rich"]
'''

project = tomllib.loads(toml)['project']
print(project['name'], project['version'])
print(len(project['dependencies']), 'dependencies')
Output
codeludo-demo 1.4.2
2 dependencies

Most metadata is optional. Ask for a key that was never set and you get a KeyError, so reach for .get() with a default:

python
import tomllib

toml = '''
[project]
name = "demo"
version = "0.1.0"
'''

project = tomllib.loads(toml)['project']
print('license' in project)
print(project.get('description', '(none set)'))
Output
False
(none set)

What the Three Numbers Promise

1.4.2 is not decoration. Each number is a promise to everyone who installed your package.

1 . 4 . 2 MAJOR breaks existing code MINOR adds, safely PATCH fixes a bug
MAJOR breaks existing code, MINOR adds to it, PATCH fixes it. Only MAJOR is allowed to break someone's build.

Those rules are mechanical enough to write as code. Run it and watch each kind of change land differently:

python
def bump(version, kind):
    major, minor, patch = (int(p) for p in version.split('.'))
    if kind == 'major':
        return f'{major + 1}.0.0'
    if kind == 'minor':
        return f'{major}.{minor + 1}.0'
    return f'{major}.{minor}.{patch + 1}'

print(bump('1.4.2', 'patch'))
print(bump('1.4.2', 'minor'))
print(bump('1.4.2', 'major'))
Output
1.4.3
1.5.0
2.0.0

Notice that a MAJOR bump resets everything to the right back to zero. β€œWe rewrote the internals but nothing broke” is a MINOR release β€” size is not the signal, breakage is.

The Trap: Versions Are Not Strings

This one bites experienced developers. Sort a list of versions the obvious way and the answer is wrong:

python
versions = ['1.10.0', '1.9.0', '1.2.0']

print(sorted(versions))
print(sorted(versions, key=lambda v: tuple(int(p) for p in v.split('.')))) 
Output
['1.10.0', '1.2.0', '1.9.0']
['1.2.0', '1.9.0', '1.10.0']

The first line compares text character by character. '1', '.', then '1' versus '2' β€” and since '1' < '2', version 1.10.0 sorts before 1.2.0. Version ten is treated as older than version two.

The fix is the second line: turn '1.10.0' into the tuple (1, 10, 0) and let Python compare numbers. Any time you pick β€œthe latest version” from a list, this is the bug waiting for you.

Before You Publish

One more field decides whether pip will even try to install your package:

python
import tomllib

toml = '''
[project]
name = "demo"
version = "2.0.0"
requires-python = ">=3.11"
'''

project = tomllib.loads(toml)['project']
print(f"{project['name']} needs Python {project['requires-python']}")
Output
demo needs Python >=3.11

The publishing commands themselves are short β€” build a distribution, then upload it:

python -m build
python -m twine upload dist/*

Two things are permanent, so decide them deliberately. The name is claimed first-come, and a version number can never be reused β€” not even if you delete the release. Publish 1.0.0, find a typo a minute later, and the fix must go out as 1.0.1.

Watch Out

Myth: You still need a setup.py to publish. Reality: pyproject.toml replaced it. setup.py had to execute code just to read configuration; static data is safer and lets tools inspect a package without running anything.

Myth: tomllib.load() works like any other file read. Reality: It needs the file in binary mode β€” open('pyproject.toml', 'rb'). Text mode raises TypeError.

Check yourself

Which file does modern Python packaging use to declare build configuration?

Reveal answer

pyproject.toml β€” pyproject.toml is the standard. setup.py had to be executed just to read configuration; pyproject.toml is static data, so tools can inspect it safely.

You add a new optional argument to a function. No existing code breaks. Which part of 1.4.2 changes?

Reveal answer

MINOR, giving 1.5.0 β€” A backwards-compatible addition is a MINOR bump. MAJOR is reserved for changes that break existing code, PATCH for bug fixes.

What does sorted(['1.10.0', '1.2.0']) return?

Reveal answer

['1.10.0', '1.2.0'] β€” wrong, because it compares text β€” Sorting compares character by character, and '1' is less than '2', so '1.10.0' lands first. Sort on tuples of integers instead.

What does tomllib.load() require that trips people up?

Reveal answer

The file must be opened in binary mode ('rb') β€” tomllib.load() takes a binary file object. Passing a text-mode file raises TypeError β€” use open(path, 'rb').

Challenges

Challenge 1 +50 XP

Parse the pyproject.toml string and print the project name and version separated by a space (e.g. 'mytool 0.3.1').

python
  • Test 1 β€” expects "mytool 0.3.1\n"
Need a hint? (βˆ’25% XP)

tomllib.loads(toml) gives you a dict; the metadata lives under the 'project' key.

Show solution (0 XP)
import tomllib

toml = '''
[project]
name = "mytool"
version = "0.3.1"
'''

project = tomllib.loads(toml)['project']
print(project['name'], project['version'])

Challenge 2 +100 XP

Sort the version strings correctly (oldest first) and print the result. Plain string sorting gives the wrong answer.

python
  • Test 1 β€” expects "['1.9.12', '1.10.3', '2.0.1', '10.0.0']\n"
Need a hint? (βˆ’25% XP)

Use sorted() with a key that turns '1.10.3' into the tuple (1, 10, 3).

Show solution (0 XP)
versions = ['2.0.1', '1.10.3', '1.9.12', '10.0.0']
print(sorted(versions, key=lambda v: tuple(int(p) for p in v.split('.'))))