Python Lesson 36: Your Script Broke In Their Email -- pyproject.toml Fixes That
š Full written solution: https://interview-kit-fe.vercel.app/python-lesson-36-packaging-and-distributing-projects š» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/python-lesson-36-packaging-and-distributing-projects Chapters: 0:00 The Broken Script Problem 0:13 Intro: Kai 0:33 Why pip Install Works 1:01 pyproject.toml Contract 1:18 src vs Flat Layout 1:39 Choosing A Package Name 1:53 Declaring Dependencies 2:11 Building The Wheel 2:29 Skipping Tests: The Risk 2:43 Testing With TestPyPI 2:58 twine Upload To PyPI 3:15 Wrap Up You built something great, emailed the .py file, and it broke on their machine. This lesson fixes that for good: writing pyproject.toml, choosing src vs flat project layout, picking a name you can actually keep, declaring real dependencies, and building a proper wheel. We test safely on TestPyPI before the real twine upload -- because PyPI never lets you re-upload a broken version under the same name. By the end you'll have a package anyone can pip install. #python #pypi #packaging #pyproject #softwareengineering Watch next: - Why Did Pharma Sales Drop in March? Python Project Tutorial: https://youtu.be/P-80ShVi-cs - Building My Own AI Developer Platform ā Ep 1: The idea: https://youtu.be/d-broARFYAY - Build a Unit Converter (Beginner, Web App) in Python ā Full Project Tutorial: https://youtu.be/0cAwwNRCkb8
Introduction
Every Python developer hits this wall eventually: you write something useful, you send the .py file to a friend or coworker, and it just doesn't run on their machine. Maybe it's ModuleNotFoundError. Maybe it's a hardcoded import path that only resolves on your filesystem. Either way, the problem isn't their setup ā it's that you never actually packaged your code, you just moved a file.
This matters far beyond "sharing a script with a friend." Every library you pip install ā requests, numpy, flask ā got to your machine through exactly the mechanism this lesson covers. Understanding packaging means understanding how the entire Python ecosystem distributes code, and it's a skill that separates someone who writes scripts from someone who ships software. It also comes up in real engineering work constantly: internal tools, shared utilities, anything more than one person needs to run.
By the end of this lesson you'll understand why pip install works at all, and you'll have built and published a package of your own.
Problem Overview
Here's the situation in plain terms: you have a folder of .py files that work fine when you run them yourself. You zip them up, or email one file, or push a folder to GitHub, and hand it to someone else. It doesn't run.
The reason is that your local success was an accident of context. Your imports resolve because you're standing in the right directory. Your dependencies are installed because you installed them at some point and forgot. None of that context travels with the file. pip ā the tool responsible for installing Python code ā has nothing to work with, because a folder of loose .py files carries no name, no version, and no dependency list. There's no contract for it to read.
Packaging is the act of writing that contract down, in a format every tool agrees on, so installation becomes mechanical instead of guesswork.
Example
Picture two versions of the same tiny library ā a couple of functions for formatting text.
Version A: a folder called textutils with two .py files inside, pushed to GitHub. To use it, someone has to clone the repo, cd into it, and hope their working directory lines up with your imports. If your code imports dateutil and they don't have it installed, they find out at runtime, mid-function, with a traceback.
Version B: the same code, but with a pyproject.toml declaring the name textutils, version 0.1.0, and dateutil>=2.8 as a dependency. Someone runs:
pip install textutils
pip reads the file, sees the dependency list, installs dateutil first, then installs your code into the right place on their system. No cloning, no working-directory guesswork, no missing-package surprise.
Same code. The difference is entirely the presence of that one file and what it declares.
Intuition
Think about how you'd solve this if no tooling existed at all. You'd need to answer three questions before anyone else could run your code: what is this thing called, what does it need to run, and where does the code actually live relative to everything else in the project.
An experienced engineer doesn't start by picking a build tool ā they start by asking what breaks first. And what breaks first is almost always dependencies: your code silently assumes libraries are present because you installed them once, months ago, and forgot. The second thing that breaks is structure: your imports work because your test scripts sit right next to your source files, and that adjacency is invisible until it's gone.
So the intuition is: packaging is just making those two invisible assumptions ā "what this needs" and "where this lives" ā explicit and machine-readable. Once they're explicit, any tool, on any machine, can reconstruct your working setup from scratch. That's the whole idea. Everything else ā pyproject.toml, src layout, wheels, TestPyPI ā is just the concrete mechanism for writing that down and verifying it before you commit to it publicly.
Brute Force Solution
The "brute force" version of packaging is what most people do without realizing it's a workaround: setup.py, plus a separate requirements.txt, plus README instructions telling people to pip install -r requirements.txt and hope the imports line up.
Idea: cobble together the contract using multiple files that different tools read differently ā setup.py for build metadata (often with arbitrary executable code in it), requirements.txt for dependencies, and tribal knowledge for everything else.
Algorithm:
- Write a
setup.pywith asetup()call describing the package. - Maintain a separate
requirements.txtby hand. - Tell users to run both
pip install -r requirements.txtandpip install .or similar. - Hope the two files stay in sync as the project evolves.
Advantages: it's the historical standard, and there's a lot of legacy tooling built around it.
Disadvantages: setup.py runs arbitrary Python during install ā slower and a real security concern, since installing a package means executing code someone else wrote. The dependency list lives in two places that can drift apart. There's no single, static, tool-agnostic source of truth.
Complexity: not a runtime complexity question, but a maintenance one ā every additional file describing your project is another place for the description to go stale. Time cost is mostly at install time (running setup code), and "space cost" is really cognitive: more files to keep consistent.
Optimal Solution
pyproject.toml replaces all of that fragmentation with one static, declarative file. It's not executed ā it's read. That alone removes the security and consistency problems of setup.py.
The core contract, under the [project] section, has three parts that matter most:
| Field | What it answers |
|---|---|
name |
What does someone type after pip install? |
version |
Which release is this? |
dependencies |
What has to be installed before this code runs? |
That's the whole thing any install tool needs to read.
Layout matters just as much as the file. The standard structure moves your actual package code into a src/ folder, separate from tests and project files sitting at the top level:
my-project/
āāā pyproject.toml
āāā src/
ā āāā mypackage/
ā āāā __init__.py
ā āāā core.py
āāā tests/
āāā test_core.py
This isn't cosmetic. In a flat layout ā package code sitting right next to your test scripts ā your tests can accidentally import the local source files directly, because they're right there in the same directory. That works, but it's testing the wrong thing: it proves your local files work, not that your installed package works. Move the code under src/, and there's no accidental path to it ā your tests are forced to import the version pip actually installed. That's the exact gap that lets a broken package pass all your tests and still fail for the first person who installs it for real.
Naming and versioning are permanent decisions. The name field is globally unique across all of PyPI ā pick something already taken and your upload just fails. And once a version is published, it's published; PyPI won't let you overwrite 0.1.0 with a fix. You bump to 0.1.1 instead. This is why testing before the real publish matters so much ā there's no undo button.
Dependencies close the loop. Everything your code imports that isn't the standard library goes in dependencies, ideally with a minimum version if your code relies on a feature from a particular release. When someone runs pip install, this list is what pip resolves and installs before your code executes ā which is precisely the step that was missing when you emailed a raw .py file.
Python Code
# pyproject.toml
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "textutils-yourname" # must be unique on PyPI
version = "0.1.0"
description = "Small text formatting helpers"
requires-python = ">=3.9"
dependencies = [
"python-dateutil>=2.8",
]
[tool.setuptools.packages.find]
where = ["src"]
# src/textutils/core.py
def title_case(text: str) -> str:
"""Return text with each word capitalized."""
return " ".join(word.capitalize() for word in text.split())
# src/textutils/__init__.py
from .core import title_case
__all__ = ["title_case"]
Code Walkthrough
[build-system]tells the build tool (python -m build) which backend to use to actually construct the wheel āsetuptoolshere, the most common choice.[project].nameis the literal string someone types intopip install. It has to be unique across all of PyPI, which is why real projects often suffix or prefix a name (textutils-yournamein the example).[project].versionfollows semantic-versioning-style conventions (major.minor.patch) and must increase on every publish ā PyPI rejects re-uploads of an existing version number.dependenciesis the listpipresolves and installs first, before your package's own code is placed. This is the line that directly fixesModuleNotFoundErrorfor anyone installing your package.[tool.setuptools.packages.find]withwhere = ["src"]tells setuptools to look insidesrc/for importable packages ā this is what enforces the src layout at build time, not just by convention.src/textutils/__init__.pyre-exportstitle_caseso users can writefrom textutils import title_caseinstead of reaching intotextutils.core.
Dry Run
Say you've written the files above and want to get textutils installable end to end.
python -m buildā readspyproject.toml, findssrc/textutils, and producesdist/textutils_yourname-0.1.0-py3-none-any.whlplus a source archive. The wheel is just a zip file in a shape every Python installer knows how to unpack ā no code execution required to install it.twine upload --repository testpypi dist/*ā uploads the wheel to TestPyPI, the practice index, using twine to handle authentication and the upload protocol so you don't hand-roll HTTP requests against PyPI's API.- In a fresh virtual environment:
pip install --index-url https://test.pypi.org/simple/ textutils-yourname. If it installs andfrom textutils import title_caseworks, your contract is correct. - Confident it works, run
twine upload dist/*to push the same wheel to the real PyPI index. Now anyone, anywhere, runspip install textutils-yournameand gets exactly what you tested.
Complexity Analysis
There's no algorithmic time/space complexity here ā packaging isn't an algorithm, it's a build and distribution process. The relevant "cost" is operational:
- Build time:
python -m builddoes one static pass over your source tree and metadata ā proportional to project size, and effectively instant for small projects. - Install time: dominated by dependency resolution and download, not your code. This is the cost you're paying to eliminate a much bigger cost ā a broken install that a user has to debug themselves.
- Irreversibility, not complexity, is the real constraint: a taken name or a published version number is permanent. That's why TestPyPI exists ā it converts an unrecoverable mistake into a recoverable one.
Alternative Solutions
Poetry and Hatch are alternative build backends that also read (their own extensions of) pyproject.toml but add their own dependency resolution, virtual environment management, and publishing commands. They're worth adopting if you want an opinionated, all-in-one workflow. For a single package with a short dependency list, plain setuptools plus build and twine ā what this lesson covers ā is simpler and has fewer moving parts, which matters more when you're learning what's actually happening under the hood.
Edge Cases
- No dependencies at all:
dependencies = []or omit the key ā still valid, still worth declaringnameandversionexplicitly. - Duplicate package name on PyPI: upload fails outright; you find out immediately, which is exactly why you should check availability (or just try the TestPyPI upload) before committing to a name in documentation.
- Version already published: re-uploading the same version number fails; you must bump the version even for a one-line fix.
- Missing
src/__init__.py: without it,setuptools.packages.findwon't discover your package, and the build silently produces an empty or broken wheel. - Unpinned dependency that changes behavior: omitting a minimum version on a dependency that introduced a breaking change in a later release can make your package fail for users who happen to get a newer version pip resolves.
Common Mistakes
- Publishing straight to real PyPI without testing on TestPyPI first ā and discovering the name is taken or the wheel is broken, permanently, on the record.
- Leaving code in a flat layout, so local tests pass against the working directory instead of the installed package, masking real install bugs.
- Forgetting to bump the version number after a fix and having the upload rejected.
- Missing a dependency in
pyproject.tomlbecause it "was already installed" in your own environment, reproducing the exactModuleNotFoundErroryou were trying to fix. - Treating
requirements.txtas the dependency source of truth instead ofpyproject.toml, letting the two drift out of sync. - Picking a package name without checking availability first, then having to rename mid-project.
- Assuming
python -m buildneeds asetup.pyā underpyproject.tomlit doesn't, and keeping a stale one around can cause backend conflicts.
Interview Questions
- What's the difference between
pyproject.tomlandsetup.py, and why did the ecosystem move toward the former? - Why does a
srclayout catch import bugs that a flat layout hides? - What's actually inside a
.whlfile, and why do installers prefer it over running arbitrary build scripts? - Why can't you overwrite a published version on PyPI, and what workflow does that constraint force?
- What role does
twineplay thatpiporbuilddon't already handle?
Similar Problems
- Understanding Python virtual environments ā the companion piece to packaging; isolates dependencies so
pip installresults are reproducible per project. - Semantic versioning in practice ā directly relevant, since PyPI's "no overwrite" rule only makes sense once you understand what major/minor/patch bumps are supposed to signal.
- Writing a
.gitignoreandMANIFEST.infor Python projects ā the adjacent problem of controlling exactly what ships inside your wheel versus your source repo.
Key Takeaways
Packaging is just making two things explicit that used to be invisible: what your code needs, and where it lives relative to everything else. pyproject.toml is the single file that answers both. The src layout forces you to test what you actually ship. TestPyPI exists because PyPI's permanence ā unique names, non-overwritable versions ā means mistakes there are forever, so you rehearse first. Once that contract is written, pip install your-project-name behaves exactly like pip install requests, because it's the same mechanism.
Watch the Video
This lesson is easier to follow end to end on video, where every step ā writing the file, running the build, testing on TestPyPI, and the real upload ā happens in order. Watch it here: {LINK}
About the Series
This is part of Fun with Learning Technology, a series that walks through the parts of software engineering that don't show up in algorithm practice but come up constantly in real work ā packaging, tooling, project structure, and the habits that separate a script from a shippable project. Each lesson builds toward being able to hand someone a working project, not just a file.
Call To Action
If this saved you from a future ModuleNotFoundError, the best way to support the series is simple: like the video on YouTube, subscribe so you don't miss the next one, and subscribe to the Substack for the written breakdown of every lesson. Drop a comment with what you're packaging next, and share this with anyone still emailing .py files around.
The solution
[project]
name = "myproject"
version = "0.1.0"
dependencies = [
"requests>=2.31",
]Ready to try it yourself? Solve Python problems with instant feedback in the practice sandbox.
Practice now ā


