Python: Zero to Hero
Home/Advanced Projects
Share

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.toml is the single config file.
  • [project] in pyproject.toml defines name, version, description, author, dependencies, and classifiers.
  • [project.scripts] creates CLI commands — after installation, users can run texttools from their terminal.
  • [project.optional-dependencies] defines groups like dev, nlp that users install with pip install mypackage[dev].
  • pip install -e ".[dev]" installs in editable mode — changes take effect immediately.
  • Semantic versioning: MAJOR.MINOR.PATCH. Start at 0.1.0.
  • python -m build creates a .whl and .tar.gz in dist/.
  • 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.