How long would it take a new joiner to reproduce this? A delivery manager asked me that about a churn notebook I had spent six weeks on, and I answered, confidently, about a day. It took her four. Nothing in that notebook was wrong. It simply carried nine unwritten assumptions about where files sat on disk, which cells had already been executed, and which packages happened to be installed in one particular environment on one particular laptop.
Key takeaways
Put your code under src and declare it in pyproject.toml. Everything else in this part depends on that one decision, because a src layout makes it impossible to import code you have not actually installed.
Anchor every path to the package file, never to the working directory. The whole churn package below runs identically from the project root, from a notebooks folder, or from the filesystem root.
Stamp a version into every saved artifact and refuse to load a mismatch. Six modules, 175 lines, five tests running in 0.23 seconds, and a full training run in 0.32 seconds is the entire cost of doing this properly.
Cost of a notebook nobody else can run
Where the project stands: across Parts 4 to 20 we loaded the Telco churn data, engineered features from it, fitted linear and logistic models, tuned them, rebuilt one as a network and backtested another. All of that lives in notebook cells. This part changes none of the modelling and all of the plumbing, turning those cells into an installable package with a command line entry point, a test suite and versioned artifacts. Nothing after this point in the series assumes a notebook.
Notebooks are excellent at exploration and terrible at handover, and it is worth being precise about why rather than treating it as taste. Four failure modes account for nearly all of it. Execution order is invisible, so a notebook that runs top to bottom today may depend on a cell you deleted an hour ago. State is hidden, so a variable defined in cell 4 and mutated in cell 31 has no declared lifetime. Paths are relative to wherever the kernel was started, which is usually not where the reader will start theirs. And there is no dependency declaration at all, so the environment is whatever the author accumulated.
Packaging fixes each of those with a specific mechanism, and it helps to hold the mapping in mind while reading the rest of this part. Import order replaces execution order. Function arguments replace hidden state. Paths derived from the module file replace paths derived from the shell. A dependency table in pyproject.toml replaces whatever was lying around. None of these are advanced techniques and all four take an afternoon.
One objection comes up every time I propose this, and it deserves an answer rather than a dismissal: packaging feels like overhead on work that might get thrown away. My rule is a threshold rather than a principle. If the analysis will be run more than twice, or by more than one person, or on a schedule, it goes in a package. Everything else stays in a notebook and is allowed to be messy. Roughly a third of my exploratory work never crosses that line, and that is fine.
Layout that survives contact with a second person
Two layouts are in common use. Flat layout puts your package directory at the project root, next to pyproject.toml. Src layout puts it one level down, inside a folder named src. That distinction sounds cosmetic and is not, because Python adds the current directory to the import path. Under a flat layout, running Python from the project root imports your source folder whether or not the package is installed, which means your tests can pass against code that would fail entirely for anybody who installed the wheel. A src layout makes that impossible: nothing imports until you have installed it, so the thing you test is the thing you ship. Python packaging documentation recommends src for exactly this reason, and so does pytest.
Here is the shape of the churn project as I would hand it over. Six modules, one test file, one configuration file, and a data folder that is not tracked in version control.
Metadata goes in pyproject.toml, a single file that replaces setup.py, setup.cfg and most tool specific configuration. PEP 621 standardised the project table inside it, and setuptools reads that table directly. Three sections carry the weight: build-system says which tool builds your package, project describes it, and project.scripts creates command line entry points.
Two entries in there repay a closer look. Under project.scripts, the string maps a command name to a module and a function, so installing this package puts an executable called churn on the path that calls main in cli.py. That is how you give somebody a tool rather than a folder of scripts. And tool.setuptools.packages.find with where set to src is what tells the build backend to look one level down; leave it out with a src layout and you will build an empty wheel that installs cleanly and imports nothing.
Splitting modules by responsibility rather than by convenience is what makes the package readable later. My default split has held up across a decade of projects, and each module answers one question.
| Module | Lines | Question it answers | Imports pandas |
|---|---|---|---|
| config.py | 9 | Where does everything live | No |
| data.py | 39 | How do rows get in, and are they valid | Yes |
| features.py | 18 | How do rows become a matrix | Yes |
| model.py | 45 | How is it fitted, scored, saved and loaded | No |
| cli.py | 32 | What can a human ask for | No |
| tests/test_features.py | 31 | Which assumptions must hold | Yes |
Configuration, paths and a working directory trap
Relative paths are the single most common reason a handed over project fails on the first attempt, and they fail in a way that looks like the recipient made a mistake. A notebook sitting in a notebooks folder reads its data with two dots and a slash, which resolves correctly from that folder and nowhere else. Move the same line into a module and it breaks the moment anybody runs it from the project root, which is where a package is normally run from.
Fix it by deriving paths from the module file rather than from the shell. Every Python module knows its own location through the dunder file attribute, so a config module can resolve the project root once and everything else imports from there. Adding an environment variable override on top of that costs two lines and buys you the ability to point the same package at a different dataset without editing anything.
Loading gets the same treatment. Missing data should produce a message that tells the reader what to do next, not a raw pandas traceback, and required columns should be checked once at the boundary rather than discovered halfway through feature engineering.
CHURN_DATA_DIR=/tmp/alt CHURN_ARTIFACT_DIR=/tmp/alt churn train --holdout 0.3 from the filesystem root printed rows 7043 features 6, then train auc 0.8144 holdout auc 0.8039, then saved /tmp/alt/model.joblib. That is the whole mechanism behind running one package across development, staging and production without branching on hostnames.Tests worth writing for a data science package
Testing model quality is a trap, and it is where most data scientists start. An assertion that accuracy exceeds some threshold breaks every time the data shifts, which trains everybody to ignore the test suite. Test the assumptions instead: shapes, dtypes, column order, boundary values and the handling of the specific ugliness you know lives in your dataset. Model quality belongs in monitoring, which Part 25 covers, not in a unit test.
Telco churn supplies three concrete assumptions worth pinning. TotalCharges arrives as text with eleven blank strings in it, tenure can be zero which makes any per month average a division hazard, and feature column order must be stable or a saved model will silently score the wrong columns. Each becomes one short test.
Run those before installing the package and you meet the src layout doing its job, which surprises people the first time.
Installing the package in editable mode fixes it. Editable means the interpreter is pointed at your source folder rather than a copy, so edits take effect without reinstalling, while imports still go through the real installed package metadata. That is the mode you develop in.
Two details in that pyproject test configuration are worth defending. Setting import-mode to importlib is now the recommendation in pytest documentation, and it stops pytest from mutating the import path, which means your tests import the installed package rather than a folder that happened to be adjacent. It also removes the rule that every test file needs a globally unique name, which becomes irritating the moment you have two subdirectories both wanting a test_features file. Setting testpaths keeps a bare pytest invocation from wandering into your data folder.
War story
A propensity model I built for a retail client passed every test in CI for eleven weeks and then failed on the first production deploy with ModuleNotFoundError on a helper module. Cause: flat layout. A folder named utils sat at the project root, was importable from the repository during testing because Python adds the working directory to the import path, and was never listed in the packaging configuration, so it was simply absent from the wheel. Nothing in eleven weeks of green builds could have caught it, because CI ran from the repository root and production did not.
Rebuilding under a src layout took about ninety minutes, most of it moving files and fixing import statements. Three more modules turned out to be in the same state and were also missing from the wheel. My rule since: the CI job installs the built wheel into a clean environment and runs the tests from a directory that is not the repository. Any import that resolves by accident fails there immediately.
Giving your package a command line
An entry point is what converts a package into a tool. Whoever installs telco-churn gets a churn command, and every parameter that used to be a variable somebody had to edit becomes a flag they can pass. Keeping the entry function thin matters: it parses arguments, calls into the modules, prints, and returns an exit code. No modelling logic belongs here, because nothing in this file is easy to test.
One note on the estimator. Fitting here is roughly forty lines of gradient descent written directly in NumPy, so the package installs and runs with only pandas, NumPy and joblib as dependencies. Swapping in the LogisticRegression class from Part 10 touches one function and adds one dependency line; every structural decision in this part is unchanged by that choice, which is rather the point of separating the model module from everything else.
Now that holdout size is a flag rather than a hardcoded number, sweeping it becomes a shell loop instead of a code edit, and the result is a genuinely reproducible experiment because the seed lives in config.
| Holdout fraction | Training rows | Train AUC | Holdout AUC |
|---|---|---|---|
| 0.10 | 6,338 | 0.8150 | 0.7776 |
| 0.20 | 5,634 | 0.8168 | 0.7890 |
| 0.30 | 4,930 | 0.8144 | 0.8039 |
| 0.40 | 4,225 | 0.8124 | 0.8095 |
| 0.50 | 3,521 | 0.8125 | 0.8097 |
Read that chart carefully, because it makes an argument for the packaging work rather than about model quality. Train AUC barely moves across the sweep, sitting between 0.8124 and 0.8168, while holdout AUC climbs 0.032 as the evaluation set grows. Nothing about the model changed. What changed is measurement noise from evaluating on 705 rows instead of 3,522. Anybody reading a single number from a notebook would have taken 0.7776 as a property of the model, and the only reason this is visible at all is that the sweep cost one shell loop. Cross validation, covered in Part 11, is the proper answer here; cheap reproducibility is what made the problem noticeable.
Artifacts, versions and what joblib will not tell you
Saving a fitted model with joblib takes one line, which is exactly the problem. A pickle file records object state and nothing about the code that produced it, so a model saved by version 0.3.0 of your package will load happily under 0.9.0 even if the feature list changed underneath. Predictions come back, no warning appears, and the numbers are wrong in a way that no test catches.
Guard against it by stamping the package version and the feature list into the object at fit time and refusing to load a mismatch. Five lines, and it converts a silent wrong answer into a loud stop.
Library versions deserve the same suspicion. Scikit-learn does raise InconsistentVersionWarning when an estimator is unpickled under a different version than it was pickled with, which is more than joblib gives you by itself, but its own documentation is blunt that loading across versions is unsupported and may produce different results. Treat that warning as an error in any serving path. And never load a pickle you did not create, because unpickling executes code by construction.
Building a distributable wheel is the last step and takes one command. Inspecting what came out is the habit worth forming, because a wheel is a zip file and reading its contents catches packaging mistakes in seconds.
Packaging order I would follow on Monday
Do it in this sequence, because each step makes the next one cheaper. Create the src directory and move your code into it first, even before writing pyproject.toml, since that move is what surfaces every hidden import. Write pyproject.toml second with a name, a version, three or four dependencies and the packages find entry. Install editable third and fix the import errors that appear, which will be more than you expect and all of which are real. Pull hardcoded paths into a config module fourth. Write four or five assumption tests fifth. Add the command line entry point last, once you know what the tool actually needs to do.
On the choices where reasonable people disagree, here is what I would pick and what I would avoid. Take src layout over flat layout without hesitation; flat is the one I would avoid, and my eleven weeks of green builds shipping a broken wheel is the reason. Take setuptools as your build backend unless you have a specific reason not to, because it is the most widely documented and nothing in this part needs anything more. Take a plain pyproject.toml over a Makefile full of PYTHONPATH exports; that pattern papers over exactly the import problems a src layout is designed to expose. Avoid putting model quality thresholds in your unit tests, and avoid writing a README that explains how to set environment variables that your config module should be deriving on its own.
One measurable target is worth holding yourself to: a colleague with your repository URL and nothing else should reach a trained model in five commands and under ten minutes, most of which is dependency download. Everything in this part exists to make that true. Try it on your current project this week by cloning your own repository into a fresh directory, creating a clean virtual environment, and following your own README literally without using any knowledge you have in your head. Whatever breaks first is the highest value thing you will fix all month.
Part 22 takes this installable package and asks how it should be served, comparing batch scoring, real time endpoints and streaming, along with the latency and cost consequences of each.
References
- Writing your pyproject.toml, Python Packaging User Guide
- Configuring setuptools using pyproject.toml files, setuptools 83.0.0
- PEP 621, Storing project metadata in pyproject.toml
- Good Integration Practices, pytest documentation
- Model persistence, scikit-learn documentation


DrJha