|
| 1 | +# 07 — Common Mistakes in Python Packaging |
| 2 | + |
| 3 | +These are real-world mistakes that trip up both beginners and experienced developers. |
| 4 | +When reviewing or writing package code, check for these actively. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Table of Contents |
| 9 | + |
| 10 | +- [Project Structure](#project-structure) |
| 11 | +- [pyproject.toml Configuration](#pyprojecttoml-configuration) |
| 12 | +- [Imports](#imports) |
| 13 | +- [Version Management](#version-management) |
| 14 | +- [Dependencies](#dependencies) |
| 15 | +- [Files Missing from Builds](#files-missing-from-builds) |
| 16 | +- [Publishing](#publishing) |
| 17 | +- [Testing](#testing) |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## Project Structure |
| 22 | + |
| 23 | +### Using flat layout instead of src layout |
| 24 | +``` |
| 25 | +# BAD — source is importable from project root |
| 26 | +my-package/ |
| 27 | +├── my_package/ ← Python imports this instead of installed version |
| 28 | +├── tests/ |
| 29 | +└── pyproject.toml |
| 30 | +
|
| 31 | +# GOOD — src layout prevents accidental imports |
| 32 | +my-package/ |
| 33 | +├── src/ |
| 34 | +│ └── my_package/ |
| 35 | +├── tests/ |
| 36 | +└── pyproject.toml |
| 37 | +``` |
| 38 | +**Why:** The current working directory is first on `sys.path`. Without `src/`, tests pass |
| 39 | +locally but the installed package may be broken (missing files, wrong imports). |
| 40 | + |
| 41 | +### Including tests/ in the wheel |
| 42 | +**Mistake:** Build includes a top-level `tests/` in the wheel. |
| 43 | +**Why:** Writes to `site-packages/tests/`, colliding with other packages. |
| 44 | +**Fix:** With hatchling, use `[tool.hatch.build.targets.wheel] exclude = ["tests"]`. |
| 45 | + |
| 46 | +### Putting `__init__.py` in tests/ |
| 47 | +**Why:** Confuses package discovery. Tests should never be importable. |
| 48 | +**Fix:** Remove it. Use `conftest.py` for shared fixtures. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## pyproject.toml Configuration |
| 53 | + |
| 54 | +### Missing `[build-system]` table |
| 55 | +**Why:** Build tools fall back to legacy setuptools behavior, which is unpredictable. |
| 56 | +**Fix:** Always include: |
| 57 | +```toml |
| 58 | +[build-system] |
| 59 | +requires = ["hatchling"] |
| 60 | +build-backend = "hatchling.build" |
| 61 | +``` |
| 62 | + |
| 63 | +### Deprecated license table format |
| 64 | +**Mistake:** `license = {text = "MIT"}` or `license = {file = "LICENSE"}`. |
| 65 | +**Why:** PEP 639 deprecated the table format. |
| 66 | +**Fix:** Use `license = "MIT"` (SPDX string) + `license-files = ["LICENSE"]`. |
| 67 | + |
| 68 | +### Not reinstalling after changing pyproject.toml |
| 69 | +**Why:** `pyproject.toml` is read at install time. Changes are ignored until reinstall. |
| 70 | +**Fix:** Run `uv sync` or `uv pip install -e .` after changes. |
| 71 | + |
| 72 | +--- |
| 73 | + |
| 74 | +## Imports |
| 75 | + |
| 76 | +### Running modules directly instead of `python -m` |
| 77 | +**Mistake:** `python src/mypackage/cli.py` |
| 78 | +**Why:** Sets `__package__` to `None`, breaking all relative imports. |
| 79 | +**Fix:** Use `python -m mypackage.cli`, or define entry points: |
| 80 | +```toml |
| 81 | +[project.scripts] |
| 82 | +mycli = "mypackage.cli:main" |
| 83 | +``` |
| 84 | + |
| 85 | +### "Import the world" in `__init__.py` |
| 86 | +```python |
| 87 | +# BAD |
| 88 | +from .models import * |
| 89 | +from .utils import * |
| 90 | +from .core import * |
| 91 | + |
| 92 | +# GOOD |
| 93 | +from my_package.core import read_data, write_data |
| 94 | +from my_package.errors import MyPackageError |
| 95 | + |
| 96 | +__all__ = ["read_data", "write_data", "MyPackageError"] |
| 97 | +``` |
| 98 | +**Why:** Slows import time, wastes memory, pollutes namespace. |
| 99 | + |
| 100 | +### Missing `__all__` for re-exports |
| 101 | +**Why:** Type checkers (mypy, pyright) treat imports without `__all__` as private. |
| 102 | +Users get "module has no attribute" type errors. |
| 103 | + |
| 104 | +### `TYPE_CHECKING` guard without string annotations |
| 105 | +```python |
| 106 | +# BAD — crashes at runtime |
| 107 | +from typing import TYPE_CHECKING |
| 108 | +if TYPE_CHECKING: |
| 109 | + from .models import User |
| 110 | + |
| 111 | +def get_user() -> User: ... # NameError! |
| 112 | + |
| 113 | +# GOOD — use string annotation or future annotations |
| 114 | +from __future__ import annotations |
| 115 | + |
| 116 | +from typing import TYPE_CHECKING |
| 117 | +if TYPE_CHECKING: |
| 118 | + from .models import User |
| 119 | + |
| 120 | +def get_user() -> User: ... # works |
| 121 | +``` |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +## Version Management |
| 126 | + |
| 127 | +### Version in multiple places |
| 128 | +**Mistake:** Hardcoding in both `pyproject.toml` and `__init__.py`. |
| 129 | +**Fix:** Single source: |
| 130 | +```python |
| 131 | +# src/my_package/__init__.py |
| 132 | +from importlib.metadata import version |
| 133 | +__version__ = version("my-package") |
| 134 | +``` |
| 135 | + |
| 136 | +### Dynamic version that isn't a string literal |
| 137 | +**Mistake:** `__version__ = f"{major}.{minor}.{patch}"` or `__version__ = get_version()`. |
| 138 | +**Why:** Build backends parse source statically — they won't execute your code. |
| 139 | +**Fix:** Always a plain string: `__version__ = "1.2.3"` (if not using importlib.metadata). |
| 140 | + |
| 141 | +### importlib.metadata with no fallback |
| 142 | +**Mistake:** Fails when code is copied without installing (Lambda, Docker, vendoring). |
| 143 | +**Fix:** |
| 144 | +```python |
| 145 | +try: |
| 146 | + from importlib.metadata import version |
| 147 | + __version__ = version("my-package") |
| 148 | +except Exception: |
| 149 | + __version__ = "0.0.0" |
| 150 | +``` |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +## Dependencies |
| 155 | + |
| 156 | +### No version bounds |
| 157 | +**Mistake:** `dependencies = ["requests", "pydantic"]` |
| 158 | +**Fix:** Lower bounds: `dependencies = ["requests>=2.28", "pydantic>=2.0"]` |
| 159 | + |
| 160 | +### Overly strict upper bounds |
| 161 | +**Mistake:** `dependencies = ["requests>=2.28,<3"]` |
| 162 | +**Why:** When a compatible release comes out, your package blocks users from upgrading. |
| 163 | +This is the single biggest cause of dependency resolution conflicts in Python. |
| 164 | +**Fix:** Lower bounds only for libraries. Reserve upper bounds for *known* incompatibilities. |
| 165 | + |
| 166 | +### Upper bound on `requires-python` |
| 167 | +**Mistake:** `requires-python = ">=3.10,<3.13"` |
| 168 | +**Why:** pip/uv will refuse to install on Python 3.13 even though it almost certainly works. |
| 169 | +**Fix:** `requires-python = ">=3.10"` |
| 170 | + |
| 171 | +### Pinned exact versions in library deps |
| 172 | +**Mistake:** `dependencies = ["requests==2.31.0"]` |
| 173 | +**Why:** Makes it impossible to install alongside other packages needing a different version. |
| 174 | +**Fix:** Pins are for applications (lock files), not libraries. |
| 175 | + |
| 176 | +### Dev deps in `[project] dependencies` |
| 177 | +**Mistake:** pytest, ruff, mypy in `dependencies`. |
| 178 | +**Why:** Users installing your library get all your dev tools as transitive deps. |
| 179 | +**Fix:** Use `[dependency-groups]` (PEP 735): |
| 180 | +```toml |
| 181 | +[dependency-groups] |
| 182 | +dev = ["pytest>=8.0", "ruff>=0.4", "mypy>=1.0"] |
| 183 | +``` |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## Files Missing from Builds |
| 188 | + |
| 189 | +### `py.typed` not shipping in wheel |
| 190 | +**Why:** Type checkers treat your package as untyped and ignore all annotations. |
| 191 | +**Fix:** Verify: `unzip -l dist/*.whl | grep py.typed` |
| 192 | + |
| 193 | +### Non-Python data files missing |
| 194 | +**Mistake:** JSON schemas, templates, SQL files at project root instead of inside package dir. |
| 195 | +**Fix:** Put data files inside `src/my_package/`. Hatchling includes them by default. |
| 196 | + |
| 197 | +### Confusing MANIFEST.in with wheel contents |
| 198 | +**Why:** `MANIFEST.in` only controls sdist (source distribution). Zero effect on wheels. |
| 199 | +**Fix:** Use build backend config for wheel contents. |
| 200 | + |
| 201 | +--- |
| 202 | + |
| 203 | +## Publishing |
| 204 | + |
| 205 | +### Not building both sdist and wheel |
| 206 | +**Why:** Without wheel: slow source builds. Without sdist: users can't audit source. |
| 207 | +**Fix:** `uv build` produces both by default. |
| 208 | + |
| 209 | +### Classifiers not matching `requires-python` |
| 210 | +**Mistake:** `requires-python = ">=3.10"` but classifiers only list 3.10, 3.11. |
| 211 | +**Why:** PyPI uses classifiers for search filtering. Not auto-generated. |
| 212 | +**Fix:** Keep classifiers in sync with every Python version you test against. |
| 213 | + |
| 214 | +### Not using Trusted Publishing |
| 215 | +**Mistake:** Long-lived API tokens in CI secrets. |
| 216 | +**Why:** Tokens can leak. Compromised CI = malicious package versions. |
| 217 | +**Fix:** Use PyPI Trusted Publishing (OIDC) with GitHub Actions. |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## Testing |
| 222 | + |
| 223 | +### Testing source checkout instead of installed package |
| 224 | +**Why:** Missing files, wrong `__init__.py`, broken entry points — none caught. |
| 225 | +**Fix:** src layout + editable install (`uv pip install -e .`) + pytest. |
| 226 | + |
| 227 | +### Importing from conftest.py explicitly |
| 228 | +**Mistake:** `from conftest import some_fixture` |
| 229 | +**Why:** conftest.py is auto-loaded by pytest. Explicit imports cause double-loading. |
| 230 | +**Fix:** Just use fixture names — pytest injects them automatically. |
0 commit comments