Chapter 50: Package Your Code and Share It
You've built something useful. Now how do you share it?
Right now your code lives on your computer. If someone else wants it, they'd have to find your files, figure out the dependencies, and set everything up manually. That's a terrible experience.
Python's packaging ecosystem solves this. You create a package, publish it to PyPI (the Python Package Index), and anyone in the world can install it with:
pip install your-package-name
This chapter walks you through every step — from structuring your project to uploading it live.
What is a Package?
A Python package is a directory containing Python modules, structured so pip can install and manage it.
When you run pip install requests, pip downloads a package from PyPI, extracts it, and puts the files where Python can find them. You can do the exact same thing with your own code.
Project Structure — The src Layout
There are two common layouts. The src layout is the modern standard because it prevents accidental imports of your development code:
my-package/
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── core.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
├── docs/
├── pyproject.toml <- the single config file (replaces setup.py)
├── README.md
├── LICENSE
└── .gitignore
The key rule: your actual package code lives inside src/mypackage/, not in the root.
pyproject.toml — The Modern Config File
pyproject.toml replaced setup.py and setup.cfg. It's the single file that configures your package, build tools, and quality tools.
Let's build a small package called texttools — utilities for cleaning and analysing text:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "texttools"
version = "0.1.0"
description = "Simple text cleaning and analysis utilities"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [
{ name = "Alice Smith", email = "alice@example.com" }
]
keywords = ["text", "nlp", "utilities", "cleaning"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Topic :: Text Processing",
]
# Runtime dependencies (installed automatically with your package)
dependencies = [
"requests>=2.28",
]
# Optional dependency groups
[project.optional-dependencies]
dev = [
"pytest>=7",
"pytest-cov",
"ruff",
"mypy",
]
nlp = [
"nltk>=3.8",
"spacy>=3.6",
]
# CLI entry points — creates a command-line script
[project.scripts]
texttools = "texttools.cli:main"
# Links shown on PyPI
[project.urls]
Homepage = "https://github.com/alice/texttools"
Repository = "https://github.com/alice/texttools"
Documentation = "https://texttools.readthedocs.io"
"Bug Tracker" = "https://github.com/alice/texttools/issues"
# ── Tool configuration ────────────────────────────────────────────────────────
[tool.hatch.build.targets.wheel]
packages = ["src/texttools"]
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP"]
[tool.mypy]
python_version = "3.11"
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
Write the Package Code
src/texttools/__init__.py:
"""texttools — simple text cleaning and analysis utilities."""
from .core import clean, word_count, sentence_count, readability_score
from .utils import slugify, truncate, extract_emails, extract_urls
__version__ = "0.1.0"
__all__ = [
"clean", "word_count", "sentence_count", "readability_score",
"slugify", "truncate", "extract_emails", "extract_urls",
]
src/texttools/core.py:
"""Core text analysis functions."""
import re
def clean(text: str, lowercase: bool = False, remove_punctuation: bool = False) -> str:
"""
Clean a text string.
Args:
text: The input string.
lowercase: Convert to lowercase if True.
remove_punctuation: Strip punctuation if True.
Returns:
Cleaned string.
Examples:
>>> clean(" Hello, World! ")
'Hello, World!'
>>> clean("Hello, World!", lowercase=True)
'hello, world!'
"""
text = text.strip()
if lowercase:
text = text.lower()
if remove_punctuation:
text = re.sub(r"[^\w\s]", "", text)
# Normalise whitespace
text = re.sub(r"\s+", " ", text)
return text
def word_count(text: str) -> int:
"""Return the number of words in text."""
return len(text.split()) if text.strip() else 0
def sentence_count(text: str) -> int:
"""Return the approximate number of sentences."""
sentences = re.split(r"[.!?]+", text.strip())
return len([s for s in sentences if s.strip()])
def readability_score(text: str) -> float:
"""
Compute a simple readability score (0--100, higher = easier to read).
Based on average sentence length and average word length.
"""
words = text.split()
sentences = sentence_count(text)
if not words or not sentences:
return 0.0
avg_sentence_len = len(words) / sentences
avg_word_len = sum(len(w) for w in words) / len(words)
# Simplified Flesch-like formula
score = 206.835 - (1.015 * avg_sentence_len) - (84.6 * avg_word_len / 4.5)
return round(max(0.0, min(100.0, score)), 1)
src/texttools/utils.py:
"""Text utility functions."""
import re
def slugify(text: str) -> str:
"""
Convert text to a URL-friendly slug.
Examples:
>>> slugify("Hello, World!")
'hello-world'
>>> slugify("Python 3.12 is GREAT!")
'python-312-is-great'
"""
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def truncate(text: str, max_length: int = 100, suffix: str = "...") -> str:
"""Truncate text to max_length characters, appending suffix if truncated."""
if len(text) <= max_length:
return text
return text[: max_length - len(suffix)].rstrip() + suffix
def extract_emails(text: str) -> list[str]:
"""Extract all email addresses from text."""
pattern = r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Z|a-z]{2,}\b"
return re.findall(pattern, text)
def extract_urls(text: str) -> list[str]:
"""Extract all URLs from text."""
pattern = r"https?://[^\s]+"
return re.findall(pattern, text)
src/texttools/cli.py:
"""Command-line interface for texttools."""
import sys
from texttools import clean, word_count, sentence_count, readability_score
def main() -> None:
"""Entry point for the `texttools` CLI command."""
if len(sys.argv) < 2:
print("Usage: texttools <text>")
print(" texttools --file <path>")
sys.exit(1)
if sys.argv[1] == "--file":
if len(sys.argv) < 3:
print("Error: --file requires a path argument")
sys.exit(1)
with open(sys.argv[2], encoding="utf-8") as f:
text = f.read()
else:
text = " ".join(sys.argv[1:])
cleaned = clean(text)
print(f"Words: {word_count(cleaned)}")
print(f"Sentences: {sentence_count(cleaned)}")
print(f"Readability: {readability_score(cleaned)}/100")
Write Tests
tests/test_core.py:
import pytest
from texttools.core import clean, word_count, sentence_count, readability_score
def test_clean_strips_whitespace():
assert clean(" hello ") == "hello"
def test_clean_normalises_whitespace():
assert clean("hello world") == "hello world"
def test_clean_lowercase():
assert clean("Hello World", lowercase=True) == "hello world"
def test_clean_remove_punctuation():
assert clean("Hello, World!", remove_punctuation=True) == "Hello World"
def test_word_count():
assert word_count("hello world") == 2
assert word_count("") == 0
assert word_count(" ") == 0
def test_sentence_count():
assert sentence_count("Hello. World.") == 2
assert sentence_count("One sentence") == 1
def test_readability_score_range():
score = readability_score("The cat sat on the mat. It was a fat cat.")
assert 0.0 <= score <= 100.0
# Parametrize tests to cover many cases at once
@pytest.mark.parametrize("text,expected", [
("hello", 1),
("hello world", 2),
("one two three", 3),
])
def test_word_count_parametrized(text, expected):
assert word_count(text) == expected
tests/test_utils.py:
from texttools.utils import slugify, truncate, extract_emails, extract_urls
def test_slugify_basic():
assert slugify("Hello World") == "hello-world"
def test_slugify_special_chars():
assert slugify("Hello, World!") == "hello-world"
def test_truncate_short():
assert truncate("hi", max_length=10) == "hi"
def test_truncate_long():
result = truncate("Hello, World!", max_length=8)
assert len(result) <= 8
assert result.endswith("...")
def test_extract_emails():
text = "Contact alice@example.com or bob@test.org for help."
emails = extract_emails(text)
assert "alice@example.com" in emails
assert "bob@test.org" in emails
def test_extract_urls():
text = "Visit https://python.org and https://pypi.org for more."
urls = extract_urls(text)
assert "https://python.org" in urls
Install in Editable Mode
Before publishing, install your own package locally so you can test it:
pip install -e ".[dev]"
-e = editable mode. Changes to src/texttools/ take effect immediately without reinstalling.
[dev] installs the optional dev dependencies (pytest, ruff, mypy).
Now test it:
# Run the test suite
pytest
# Use the CLI you defined
texttools "Python is a great language for beginners and experts alike."
# Import it like any other package
python -c "import texttools; print(texttools.slugify('Hello World!'))"
Semantic Versioning
Version numbers follow the format MAJOR.MINOR.PATCH:
| Change | Bump |
|---|---|
| Backwards-incompatible API change | MAJOR: 1.0.0 -> 2.0.0 |
| New feature, backwards-compatible | MINOR: 1.0.0 -> 1.1.0 |
| Bug fix, no new features | PATCH: 1.0.0 -> 1.0.1 |
Start at 0.1.0 while the API is still settling. Release 1.0.0 when you're confident in the public API.
Write a Good README
Your README.md is the first thing people see on PyPI and GitHub. Include:
# texttools
Simple text cleaning and analysis utilities for Python.
## Install
```bash
pip install texttools
```
## Quick start
```python
import texttools
text = " Hello, World! This is a GREAT day. "
print(texttools.clean(text, lowercase=True))
# -> "hello, world! this is a great day."
print(texttools.word_count(text)) # -> 9
print(texttools.slugify("Hello World!")) # -> "hello-world"
```
## CLI
```bash
texttools "Hello, World! This is a test."
# Words: 6
# Sentences: 2
# Readability: 74.3/100
```
## API Reference
| Function | Description |
|---|---|
| `clean(text, lowercase, remove_punctuation)` | Clean and normalise text |
| `word_count(text)` | Count words |
| `sentence_count(text)` | Count sentences |
| `slugify(text)` | Convert to URL slug |
| `truncate(text, max_length)` | Truncate with ellipsis |
| `extract_emails(text)` | Find all email addresses |
| `extract_urls(text)` | Find all URLs |
## License
MIT
Build the Distribution Files
Install the build tool:
pip install build
Build:
python -m build
This creates:
dist/
├── texttools-0.1.0-py3-none-any.whl <- wheel (fast install)
└── texttools-0.1.0.tar.gz <- source distribution
The .whl file is a zip archive — unzip it and you'll see exactly what gets installed.
Publish to TestPyPI First
Always test on TestPyPI before publishing to the real PyPI:
pip install twine
# Upload to TestPyPI
twine upload --repository testpypi dist/*
You'll need an account at test.pypi.org. Create one, then generate an API token under Account Settings.
Install from TestPyPI to verify it works:
pip install --index-url https://test.pypi.org/simple/ texttools
Publish to PyPI
When you're happy:
twine upload dist/*
You'll need an account at pypi.org and an API token.
After uploading, anyone in the world can install your package:
pip install texttools
GitHub Actions — Automate Publishing
Create .github/workflows/publish.yml:
name: Publish to PyPI
on:
push:
tags:
- "v*" # trigger when you push a version tag like v0.1.0
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest --tb=short
publish:
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # required for trusted publishing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
# Uses PyPI's Trusted Publisher — no API token needed
Now publishing is:
git tag v0.1.0
git push origin v0.1.0
GitHub Actions runs your tests, builds the package, and uploads it to PyPI automatically.
CI — Test on Every Push
Create .github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: ruff check src/ tests/
- run: mypy src/
- run: pytest --cov=texttools --cov-report=term-missing
Every pull request is automatically linted, type-checked, and tested on Python 3.11 and 3.12.
The Full Publishing Workflow
# 1. Make your changes
# 2. Run tests
pytest
# 3. Lint and type check
ruff check src/ tests/
mypy src/
# 4. Bump version in pyproject.toml
# (or use hatch-vcs for automatic versioning from git tags)
# 5. Commit
git add .
git commit -m "feat: add extract_urls function"
# 6. Tag the release
git tag v0.2.0
# 7. Push (triggers CI + publish workflow)
git push origin main --tags
What You Learned in This Chapter
- Python packages live in a
src/mypackage/directory.pyproject.tomlis the single config file. [project]inpyproject.tomldefines name, version, description, author, dependencies, and classifiers.[project.scripts]creates CLI commands — after installation, users can runtexttoolsfrom their terminal.[project.optional-dependencies]defines groups likedev,nlpthat users install withpip install mypackage[dev].pip install -e ".[dev]"installs in editable mode — changes take effect immediately.- Semantic versioning:
MAJOR.MINOR.PATCH. Start at0.1.0. python -m buildcreates a.whland.tar.gzindist/.twine upload --repository testpypi dist/*publishes to TestPyPI for testing.twine upload dist/*publishes to the real PyPI.- GitHub Actions automates testing on every push and publishing on version tags.
What's Next?
Chapter 51 covers Git and Version Control — the workflow every professional Python developer uses: branches, commits, pull requests, and working with GitHub from the command line.