Skip to content

Latest commit

 

History

History
1564 lines (1168 loc) · 38.9 KB

File metadata and controls

1564 lines (1168 loc) · 38.9 KB

Python Wheel Distributions Guide

A brief guide to creating wheeled Python distributions.

Contents

Overview

What Are Wheels and Why Use Them

Python wheel is a modern standard for Python binary package distribution, replacing the older egg format.

Key Advantages

  • Faster Installation: Pre-built packages install immediately without compilation
  • Improved Security: Digital signatures and hash verification
  • Better Consistency: Same binary works across compatible platforms
  • No Build Tools Required: End users don’t need compilers or build dependencies
  • Smaller Size: Excludes tests and documentation, reducing download size

Distribution formats

Wheel Types

Pure Python Wheels

Pure Python wheels contain only Python code (no compiled extensions). They use the “py3-none-any” tag indicating compatibility with any platform.

Filename format: {name}-{version}-py3-none-any.whl

Example: mypackage-1.0.0-py3-none-any.whl

Platform Wheels

Platform wheels contain compiled extensions and are specific to an operating system, architecture, and sometimes Python implementation.

Filename format: {name}-{version}-{python}-{abi}-{platform}.whl

Examples:

my_package-1.0.0-cp39-cp39-win_amd64.whl             # Windows 64-bit, Python 3.9
my_package-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl    # macOS, Python 3.9
my_package-1.0.0-cp39-cp39-manylinux_2_17_x86_64.whl # Linux, Python 3.9

Where:

  • cp39 means CPython 3.9
  • cp39 (second) is the ABI tag
  • win_amd64, macosx_10_9_x86_64, manylinux_2_17_x86_64 are platform tags

Which Wheel Type Should I Use?

Question: Does your package have C extensions or compiled code?

YES: Use Platform Wheel. Build separately for each platform (examples: numpy, pandas) NO: Use Pure Python Wheel (py3-none-any). Single wheel works on all platforms.

Setting Up Your Environment

Instead of calling python, you may need to call python3

Required Tools

Install these tools before starting:

python -m pip install --upgrade pip
pip install build wheel setuptools pytest requests

Using python3-venv

python3-venv - The built-in virtual environment module in Python 3 (successor to pyvenv).

Installation

# Debian/Ubuntu
sudo apt install python3-venv

# Fedora
sudo dnf install python3-venv

Creating a Virtual Environment

# Basic usage (.venv - This is the standard convention for naming Virtual Environments)
python -m venv .venv

# With system-site-packages (gives access to system packages)
python -m venv --system-site-packages .venv

Activating, Deactivate the Environment

# On Linux/macOS
source .venv/bin/activate

# On Windows (cmd.exe)
.venv\Scripts\activate.bat

# On Windows (PowerShell)
.venv\Scripts\Activate.ps1

# Deactivate when done
deactivate

Recommended Development Workflow

Here’s a streamlined workflow that will make package development much easier:

  • One-Time Setup
    • Create venv
    • Activate
    • Install package in editable mode
  • Daily Development Loop
    • Edit src/
    • pytest
    • Test command
  • Release
    • Update version
    • Build
    • Test wheel
Step-by-Step Commands
# =====================================
# ONE-TIME SETUP (run once per project)
# =====================================

# STEP 1: Create a Virtual Environment
python -m venv .venv

# STEP 2: Activate the Virtual Environment
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# STEP 3: Install Development Dependencies
python -m pip install --upgrade pip
pip install build pytest pytest-cov requests
# OR better yet (if you've set up dev extras in pyproject.toml):
pip install -e ".[dev]"

# STEP 4: Install Your Package in Editable Mode
pip install -e .

# =====================================
# DAILY DEVELOPMENT (repeat as needed)
# =====================================

# Edit your code in src/...
# Changes take effect immediately - no reinstall needed!

# Run tests directly
pytest

# Try your CLI command or import your package
your-command --help  # Works with your latest code!
python -c "import yourpackage; print(yourpackage.something())"

# =====================================
# PREPARING A RELEASE (occasional)
# =====================================

# STEP 5: Update version in pyproject.toml or __init__.py

# STEP 6: Clean Build Artifacts
rm -rf build/ dist/ *.egg-info

# STEP 7: Build Distribution Packages
python -m build

# STEP 8: Test the Built Wheel (recommended)
# Create temporary environment to test the actual wheel
python -m venv test_env
source test_env/bin/activate
pip install dist/package_name-0.1.0-py3-none-any.whl
# Test it works
python -c "import package_name; print(package_name.__version__)"
deactivate
rm -rf test_env

# If you modify dependencies in pyproject.toml, refresh editable install:
pip install -e . --force-reinstall
Editable Mode (-e)

pip install -e . (the -e stands for “editable”):

  • Instant feedback: When edit code, changes available immediately
  • No reinstall loop: Avoid the tedious edit-uninstall-reinstall-test cycle
  • System-wide access: Your package/CLI becomes importable everywhere
  • Real source testing: Tests run against your actual source code
  • Fast iteration: Perfect for active development

Testing

# Run all tests
pytest

# Run tests with coverage report
pytest --cov=your_package_name --cov-report=html

# Run specific test file
pytest tests/test_module.py

# Run tests in verbose mode
pytest -v

# Run tests and stop at first failure
pytest -x

# Run tests matching a pattern
pytest -k "test_something"

Additional Useful Commands

# Version Bumping
# Update version in pyproject.toml or __init__.py, then:
pip install -e .  # Refresh the editable install

# Complete Clean - Remove all generated and cached files
rm -rf build/ dist/ *.egg-info __pycache__/ .pytest_cache/ .coverage htmlcov/
find . -type d -name "__pycache__" -exec rm -rf {} +
find . -type f -name "*.pyc" -delete

# Checking Installed Package (to see installed version and location)
pip show your_package_name

# List all files in the installed package
pip show -f your_package_name

# Check what packages are installed
pip list

# Create requirements.txt from current environment
pip freeze > requirements.txt

Recommended Layout

my-package/                  # Project root
├── pyproject.toml           # Modern build configuration (preferred)
├── setup.py                 # Legacy build configuration (if needed)
├── setup.cfg                # Additional configuration (optional)
├── README.md                # Project documentation
├── LICENSE                  # License file
├── src/                     # Source directory (recommended)
│   └── my_package/          # Package directory
│       ├── __init__.py      # Makes it a Python package
│       ├── module.py        # Package code
│       └── cli.py           # Command-line interface
└── tests/                   # Test directory
    └── test_module.py       # Test files

Why Use the =src= Layout?

The src layout (also called “src layout”) is considered best practice for several reasons:

  • Prevents accidental imports: Can’t accidentally import from project root
  • Clear separation: Distinguishes installed code from development code
  • Better testing: Ensures tests run against installed package, not source
  • Catches packaging errors: Missing files in setup will cause import errors
  • Simpler editable installs: More predictable behavior with pip install -e .

Configuration Files

Modern Approach: pyproject.toml (Recommended)

This is the modern, preferred approach defined by PEP 517/518.

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "1.0.0"
description = "A sample Python package"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]
keywords = ["example", "tutorial"]

[project.urls]
"Homepage" = "https://github.com/yourusername/hello"

# Runtime dependencies
dependencies = [
    "requests>=2.28.0",
    "click>=8.0.0",
]

# This section is REQUIRED for pip install -e ".[dev]"
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=3.0",
    "mypy>=1.0",
    "build>=1.0",
]

docs = [
    "sphinx>=4.0",
    "sphinx-rtd-theme>=1.0",
]

# Define command-line scripts
# Format: command-name = "package.module:function"
# This creates an executable script called 'my-command'
[project.scripts]
my-command = "my_package.cli:main"

# Setuptools-specific configuration
[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]

# Pytest configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --strict-markers"

# Mypy type checking configuration
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true

# Coverage configuration
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/test_*.py"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

Version Management Strategy

Version Management Strategy

Controlling Package Contents

Controlling Package Contents

Building Wheels

Using build (Recommended)

The modern, PEP 517-compliant way:

python -m build

This creates both:

  • A wheel file: dist/my_package-1.0.0-py3-none-any.whl
  • A source distribution: dist/my_package-1.0.0.tar.gz

Build only wheel or sdist

# Build only wheel
python -m build --wheel

# Build only source distribution
python -m build --sdist

Using setuptools directly (Legacy)

# Build wheel
python setup.py bdist_wheel

# Build source distribution
python setup.py sdist

# Build both
python setup.py sdist bdist_wheel

Note: Direct setup.py invocation is deprecated. Use python -m build instead.

Install and uninstall package

Install and uninstall your package as system-wide CLI tool

Use pipx for installing CLI applications in isolated environments:

# Install pipx (if not already installed)
# Debian/Ubuntu
sudo apt install pipx
# Or via pip
python -m pip install --user pipx
python -m pipx ensurepath

# Install your package
pipx install dist/package_name-0.1.0-py3-none-any.whl

# Or install directly from PyPI
pipx install package_name

# Uninstall
pipx uninstall package_name

# List installed packages
pipx list

# Upgrade a package
pipx upgrade package_name

Why pipx?

  • Each application gets its own virtual environment
  • No dependency conflicts between tools
  • Commands available system-wide
  • Perfect for CLI tools like black, pytest, flake8, etc.

Install and uninstall your package in an environment

# Install from PyPI
pip install package-name

# Install from local wheel
pip install dist/package_name-0.1.0-py3-none-any.whl

# Install from source (current directory)
pip install .

# Install in editable/development mode
pip install -e .

# Install with extra dependencies
pip install "package-name[dev]"
pip install -e ".[dev,docs]"

# Uninstall
pip uninstall package-name

# Upgrade to latest version
pip install --upgrade package-name

# Force reinstall (useful for testing)
pip install --force-reinstall package-name

Managing dependencies

# Create requirements.txt from current environment
pip freeze > requirements.txt

# Install from requirements.txt
pip install -r requirements.txt

# Show installed package details
pip show package-name

# List all installed packages
pip list

# List outdated packages
pip list --outdated

Advanced Topics

Advanced Topics

Troubleshooting Guide

Troubleshooting Guide

Examples

Example1, ‘hellolib’ (package/library)

A simple Python package for greeting messages.

Project Structure

hellolib/
├── Makefile
├── pyproject.toml
├── README.md
├── src/
│   └── hellolib/
│       ├── __init__.py
│       └── hello.py
└── tests/
    ├── __init__.py
    ├── test_version.py
    └── test_hellolib.py

Source code

# Hellolib

A simple Python package for greeting messages.

## Installation
```bash
pip install hellolib
```

## Usage
```python
from hellolib.hello import say_hello, say_goodbye

print(say_hello())              # Hello, World!
print(say_hello("Python"))      # Hello, Python!
print(say_goodbye("Friend"))    # Goodbye, Friend!
```

## Development
```bash
# Create and activate environment
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install in development mode with dev dependencies
pip install -e ".[dev]"

# Run tests
pytest

# Run tests with coverage
pytest --cov=hellolib --cov-report=html

# Build distribution
python -m build

# Clean up
deactivate
rm -rf .venv
```

## Testing the Built Package
```bash
# Create test environment
python -m venv test_env
source test_env/bin/activate  # On Windows: test_env\Scripts\activate

# Install the wheel
pip install ./dist/hellolib-1.1.0-py3-none-any.whl

# Test it
python -c "from hellolib.hello import say_hello; print(say_hello('Wheel'))"

# Clean up
deactivate
rm -rf test_env
```
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "hellolib"
version = "1.1.0"
description = "A simple greeting library"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]

[project.urls]
"Homepage" = "https://github.com/yourusername/hello"

# Optional dependencies for development
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=3.0",
    "mypy>=1.0",
    "build>=1.0",
]

# Setuptools-specific configuration
[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]

# Pytest configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v"

# Mypy type checking configuration
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true
"""Simple greeting library."""

try:
    from importlib.metadata import version, PackageNotFoundError
    __version__ = version(__name__)
except (ImportError, PackageNotFoundError):
    __version__ = "unknown"

from hellolib.hello import (
    say_hello,
    say_goodbye,
    greet_multiple,
    format_greeting,
)

# Define public interface
__all__ = [
    "__version__",
    "say_hello",
    "say_goodbye",
    "greet_multiple",
    "format_greeting",
]
"""Greeting functions demonstrating Python 3.10+ features."""


def say_hello(name: str = "World") -> str:
    """Return a greeting message.

    Args:
        name: Name to greet. Defaults to "World".

    Returns:
        Greeting message

    Examples:
        >>> say_hello()
        'Hello, World!'
        >>> say_hello("Python")
        'Hello, Python!'
    """
    return f"Hello, {name}!"


def say_goodbye(name: str = "World") -> str:
    """Return a goodbye message.

    Args:
        name: Name to say goodbye to. Defaults to "World".

    Returns:
        Goodbye message

    Examples:
        >>> say_goodbye()
        'Goodbye, World!'
        >>> say_goodbye("Python")
        'Goodbye, Python!'
    """
    return f"Goodbye, {name}!"


def greet_multiple(
    names: list[str] | tuple[str, ...],
    greeting: str = "Hello"
) -> list[str]:
    """Greet multiple people with modern type hints.

    Demonstrates Python 3.10+ features:
    - Union types with | operator (PEP 604)
    - Built-in generics like list[str], tuple[str, ...] (PEP 585)

    Args:
        names: List or tuple of names to greet
        greeting: Greeting word to use. Defaults to "Hello".

    Returns:
        List of greeting messages

    Examples:
        >>> greet_multiple(["Alice", "Bob"])
        ['Hello, Alice!', 'Hello, Bob!']
        >>> greet_multiple(("Charlie",), "Hi")
        ['Hi, Charlie!']
        >>> greet_multiple([])
        []
    """
    return [f"{greeting}, {name}!" for name in names]


def format_greeting(
    name: str,
    *,
    style: str | None = None,
    suffix: str = "!"
) -> str:
    """Format a greeting with various styles using pattern matching.

    Demonstrates Python 3.10+ features:
    - Union with None using | operator
    - Keyword-only arguments with *
    - Structural pattern matching (PEP 634)

    Args:
        name: Name to greet
        style: Greeting style ('formal', 'casual', or None for default)
        suffix: Punctuation to end with. Defaults to "!".

    Returns:
        Formatted greeting message

    Examples:
        >>> format_greeting("Alice")
        'Hello, Alice!'
        >>> format_greeting("Bob", style="formal")
        'Good day, Bob!'
        >>> format_greeting("Charlie", style="casual")
        'Hey, Charlie!'
        >>> format_greeting("Dave", style="casual", suffix=".")
        'Hey, Dave.'
    """
    match style:
        case "formal":
            greeting = "Good day"
        case "casual":
            greeting = "Hey"
        case None:
            greeting = "Hello"
        case _:
            greeting = "Hello"

    return f"{greeting}, {name}{suffix}"
"""Tests package for hellolib."""
# This file can be empty - it just indicates tests is a package
"""Tests for the hellolib package."""

from hellolib.hello import (
    say_hello,
    say_goodbye,
    greet_multiple,
    format_greeting,
)


def test_say_hello_default() -> None:
    """Test say_hello with default arguments."""
    assert say_hello() == "Hello, World!"


def test_say_hello_custom() -> None:
    """Test say_hello with custom name."""
    assert say_hello("Python") == "Hello, Python!"


def test_say_hello_empty_string() -> None:
    """Test say_hello with empty string."""
    assert say_hello("") == "Hello, !"


def test_say_goodbye_default() -> None:
    """Test say_goodbye with default arguments."""
    assert say_goodbye() == "Goodbye, World!"


def test_say_goodbye_custom() -> None:
    """Test say_goodbye with custom name."""
    assert say_goodbye("Python") == "Goodbye, Python!"


def test_return_types() -> None:
    """Test that functions return strings."""
    assert isinstance(say_hello(), str)
    assert isinstance(say_goodbye(), str)


# Tests for Python 3.10+ features
def test_greet_multiple_list() -> None:
    """Test greet_multiple with list (demonstrates list[str] type hint)."""
    result = greet_multiple(["Alice", "Bob"])
    assert result == ["Hello, Alice!", "Hello, Bob!"]


def test_greet_multiple_tuple() -> None:
    """Test greet_multiple with tuple (demonstrates tuple[str, ...] type hint)."""
    result = greet_multiple(("Charlie",))
    assert result == ["Hello, Charlie!"]


def test_greet_multiple_empty() -> None:
    """Test greet_multiple with empty list."""
    assert greet_multiple([]) == []


def test_greet_multiple_custom_greeting() -> None:
    """Test greet_multiple with custom greeting."""
    result = greet_multiple(["Dave"], greeting="Hi")
    assert result == ["Hi, Dave!"]


def test_format_greeting_default() -> None:
    """Test format_greeting with default style."""
    assert format_greeting("Alice") == "Hello, Alice!"


def test_format_greeting_formal() -> None:
    """Test format_greeting with formal style (demonstrates pattern matching)."""
    assert format_greeting("Bob", style="formal") == "Good day, Bob!"


def test_format_greeting_casual() -> None:
    """Test format_greeting with casual style (demonstrates pattern matching)."""
    assert format_greeting("Charlie", style="casual") == "Hey, Charlie!"


def test_format_greeting_custom_suffix() -> None:
    """Test format_greeting with custom suffix."""
    assert format_greeting("Dave", style="casual", suffix=".") == "Hey, Dave."


def test_format_greeting_unknown_style() -> None:
    """Test format_greeting with unknown style (demonstrates default case)."""
    assert format_greeting("Eve", style="unknown") == "Hello, Eve!"
"""Test version information."""

from hellolib import __version__


def test_version_exists() -> None:
    """Test that version is defined."""
    assert __version__ is not None
    assert isinstance(__version__, str)
    assert __version__ != "unknown"


def test_version_format() -> None:
    """Test version follows semantic versioning."""
    parts = __version__.split(".")
    assert len(parts) >= 2, "Version should have at least major.minor"
    assert all(part.isdigit() for part in parts), "Version parts should be numeric"
#!/usr/bin/env python3
"""Example usage of hellolib package."""

from hellolib.hello import say_hello, say_goodbye

# Default greeting
print(say_hello())  # Outputs: Hello, World!

# Custom greeting
print(say_hello("Python"))  # Outputs: Hello, Python!

# Goodbye message
print(say_goodbye("Friend"))  # Outputs: Goodbye, Friend!

Building and Testing

Development Workflow

For development workflow, see hellolib/README.md

Running the Example

# After installing in dev mode (pip install -e .)
python ./use_hellolib_example.py

Expected output:

Hello, World!
Hello, Python!
Goodbye, Friend!

Example2, ‘hello’ (CLI application)

A Python CLI package for various greeting commands.

Project Structure

hello/
├── pyproject.toml
├── README.md
├── src/
│   └── hello/
│       ├── __init__.py
│       ├── cli.py
│       ├── handlers/
│       │   ├── __init__.py
│       │   ├── hi_handler.py
│       │   ├── bay_handler.py
│       │   ├── default_handler.py
│       │   └── info_handler.py
└── tests/
    ├── __init__.py
    ├── test_version.py
    └── test_hello.py

Source code

# Hello

A simple Python CLI application for greeting commands.

## Installation

### System-wide Installation (Recommended for CLI tools)
```bash
pipx install hello
```

### Regular Installation
```bash
pip install hello
```

## Usage

After installation, you can use the package from the command line:
```bash
hello hi         # Outputs: Hello
hello bay        # Outputs: Good-bay
hello anything   # Outputs: Have a nice day
hello --version  # Shows version information
hello --help     # Shows help information
```

## Development

```bash
# Create and activate environment
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install in development mode
pip install -e ".[dev]"

# Run all tests
pytest

# Run tests with coverage
pytest --cov=hello --cov-report=html

# Run specific test
pytest tests/test_hello.py -v

# Clean previous builds
rm -rf build/ dist/ *.egg-info

# Build distribution
python -m build

# Clean up
deactivate
rm -rf .venv
```

## Testing the Built Package

###  Test environment
```bash
# Create test environment
python -m venv test_env
source test_env/bin/activate  # On Windows: test_env\Scripts\activate

# Install the wheel
pip install ./dist/hello-1.0.0-py3-none-any.whl

# Test commands
hello hi
hello bay
hello --version

# Clean up
deactivate
rm -rf test_env
```

### System-wide Installation
```bash
# Install with pipx for testing
pipx install ./dist/hello-1.0.0-py3-none-any.whl

# Test commands
hello hi
hello bay
hello --version

# Uninstall
pipx uninstall hello
```
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "hello"
version = "1.0.0"
description = "A CLI greeting application"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [
    {name = "Your Name", email = "your.email@example.com"}
]

[project.urls]
"Homepage" = "https://github.com/yourusername/hello"

# This section is REQUIRED for pip install -e ".[dev]"
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=3.0",
    "mypy>=1.0",
    "build>=1.0",
]

# This creates an executable command called 'hello'
# that calls the main() function in hello.cli module
[project.scripts]
hello = "hello.cli:main"

# Setuptools-specific configuration
[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]
include = ["hello*"]

# Pytest configuration
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-v"

# Mypy type checking configuration
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true
"""Hello CLI application package."""

try:
    from importlib.metadata import version, PackageNotFoundError
    __version__ = version(__name__)
except (ImportError, PackageNotFoundError):
    __version__ = "unknown"

# Define public interface
__all__ = ["__version__"]
"""Command-line interface for the hello package."""

import sys
import argparse
from hello.handlers.hi_handler import handle_hi
from hello.handlers.bay_handler import handle_bay
from hello.handlers.default_handler import handle_default
from hello import __version__


def create_parser() -> argparse.ArgumentParser:
    """Create and configure argument parser.

    Returns:
        argparse.ArgumentParser: Configured ArgumentParser instance
    """
    parser = argparse.ArgumentParser(
        prog="hello",  # Use command name, not module name (__name__ would be "hello.cli")
        description="A simple greeting CLI application",
        epilog="Examples:\n"
               "  hello              # Default greeting\n"
               "  hello hi           # Say hello\n"
               "  hello bay          # Say goodbye\n"
               "  hello --version    # Show version",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "--version",
        "-v",
        action="version",
        version=f"%(prog)s {__version__}",
        help="Show program version and exit"
    )

    parser.add_argument(
        "command",
        nargs="?",
        default="default",
        help="Command to execute: 'hi', 'bay', or any other text for default message"
    )

    return parser


def main() -> int:
    """Main entry point for CLI.

    Returns:
        Exit code (0 for success, non-zero for error)
    """
    parser = create_parser()
    args = parser.parse_args()

    try:
        match args.command:
            case "hi":
                result = handle_hi()
            case "bay":
                result = handle_bay()
            case _:
                result = handle_default()

        print(result)
        return 0
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
"""Handler modules for the hello package."""

from hello.handlers.hi_handler import handle_hi
from hello.handlers.bay_handler import handle_bay
from hello.handlers.default_handler import handle_default
from hello.handlers.info_handler import handle_info

__all__ = ["handle_hi", "handle_bay", "handle_default", "handle_info"]
"""Handler for the 'hi' command."""


def handle_hi() -> str:
    """Return the 'Hello' greeting.

    Returns:
        The greeting message
    """
    return "Hello"
"""Handler for the 'bay' command."""


def handle_bay() -> str:
    """Return the 'Good-bay' message.

    Returns:
        The farewell message
    """
    return "Good-bay"
"""Default handler for unrecognized commands."""


def handle_default() -> str:
    """Return the default message.

    Returns:
        The default friendly message
    """
    return "Have a nice day"
"""Handler demonstrating advanced Python 3.10+ features.

This handler showcases structural pattern matching capabilities
introduced in Python 3.10 (PEP 634).
"""


def handle_info(args: list[str] | None = None) -> str:
    """Process info command with structural pattern matching.

    Demonstrates Python 3.10+ features:
    - Union types with | operator (PEP 604)
    - Built-in generics like list[str] (PEP 585)
    - Structural pattern matching (PEP 634)

    Args:
        args: List of arguments to process. Defaults to None.

    Returns:
        Information message based on the arguments

    Examples:
        >>> handle_info(None)
        'No information requested'
        >>> handle_info([])
        'No information requested'
        >>> handle_info(['version'])
        'Version: 1.0.0'
        >>> handle_info(['python'])
        'Python: 3.10+'
        >>> handle_info(['author', 'email'])
        'Contact: author at email'
    """
    match args:
        # Match None or empty list
        case None | []:
            return "No information requested"

        # Match single item patterns
        case ["version"]:
            return "Version: 1.0.0"

        case ["python"]:
            return "Python: 3.10+"

        case ["help"]:
            return "Available info: version, python, author, help"

        # Match two items for contact info
        case ["author", email]:
            return f"Contact: author at {email}"

        # Match list with specific first element
        case ["config", *rest]:
            config_items = ", ".join(rest) if rest else "none"
            return f"Configuration: {config_items}"

        # Catch-all with guard (if clause)
        case [first, *_] if len(args) > 3:
            return f"Too many arguments (got {len(args)}, expected ≤ 3)"

        # Final catch-all
        case _:
            items = ", ".join(args) if isinstance(args, list) else str(args)
            return f"Unknown info request: {items}"
"""Tests package for hello CLI application."""
# This file can be empty - it just indicates tests is a package
"""Tests for the hello package."""

from unittest.mock import patch
import pytest
from hello.cli import main
from hello.handlers.hi_handler import handle_hi
from hello.handlers.bay_handler import handle_bay
from hello.handlers.default_handler import handle_default
from hello.handlers.info_handler import handle_info


def test_handle_hi() -> None:
    """Test the hi handler."""
    assert handle_hi() == "Hello"


def test_handle_bay() -> None:
    """Test the bay handler."""
    assert handle_bay() == "Good-bay"


def test_handle_default() -> None:
    """Test the default handler."""
    assert handle_default() == "Have a nice day"


@patch("sys.argv", ["hello", "hi"])
def test_cli_hi(capsys: pytest.CaptureFixture[str]) -> None:
    """Test CLI with 'hi' command."""
    result = main()
    captured = capsys.readouterr()
    assert captured.out.strip() == "Hello"
    assert result == 0


@patch("sys.argv", ["hello", "bay"])
def test_cli_bay(capsys: pytest.CaptureFixture[str]) -> None:
    """Test CLI with 'bay' command."""
    result = main()
    captured = capsys.readouterr()
    assert captured.out.strip() == "Good-bay"
    assert result == 0


@patch("sys.argv", ["hello", "unknown"])
def test_cli_default(capsys: pytest.CaptureFixture[str]) -> None:
    """Test CLI with an unknown command."""
    result = main()
    captured = capsys.readouterr()
    assert captured.out.strip() == "Have a nice day"
    assert result == 0


@patch("sys.argv", ["hello"])
def test_cli_no_command(capsys: pytest.CaptureFixture[str]) -> None:
    """Test CLI with no command (should use default)."""
    result = main()
    captured = capsys.readouterr()
    assert captured.out.strip() == "Have a nice day"
    assert result == 0


# Tests for info_handler demonstrating Python 3.10+ pattern matching
def test_info_handler_none() -> None:
    """Test info handler with None."""
    assert handle_info(None) == "No information requested"


def test_info_handler_empty() -> None:
    """Test info handler with empty list."""
    assert handle_info([]) == "No information requested"


def test_info_handler_version() -> None:
    """Test info handler requesting version."""
    assert handle_info(["version"]) == "Version: 1.0.0"


def test_info_handler_python() -> None:
    """Test info handler requesting Python version."""
    assert handle_info(["python"]) == "Python: 3.10+"


def test_info_handler_help() -> None:
    """Test info handler requesting help."""
    assert handle_info(["help"]) == "Available info: version, python, author, help"


def test_info_handler_author() -> None:
    """Test info handler with author contact (demonstrates pattern with capture)."""
    result = handle_info(["author", "test@example.com"])
    assert result == "Contact: author at test@example.com"


def test_info_handler_config() -> None:
    """Test info handler with config (demonstrates wildcard pattern)."""
    result = handle_info(["config", "debug", "verbose"])
    assert result == "Configuration: debug, verbose"


def test_info_handler_config_empty() -> None:
    """Test info handler with config but no items."""
    result = handle_info(["config"])
    assert result == "Configuration: none"


def test_info_handler_too_many_args() -> None:
    """Test info handler with too many arguments (demonstrates guard clause)."""
    result = handle_info(["a", "b", "c", "d"])
    assert result == "Too many arguments (got 4, expected ≤ 3)"


def test_info_handler_unknown() -> None:
    """Test info handler with unknown request (demonstrates catch-all)."""
    result = handle_info(["unknown", "request"])
    assert result == "Unknown info request: unknown, request"
"""Test version information."""

import subprocess
import sys
from hello import __version__


def test_version_exists() -> None:
    """Test that version can be imported."""
    assert __version__ is not None
    assert isinstance(__version__, str)
    assert __version__ != "unknown"


def test_version_format() -> None:
    """Test version follows semantic versioning."""
    parts = __version__.split(".")
    assert len(parts) >= 2, "Version should have at least major.minor"
    assert all(part.isdigit() for part in parts), "Version parts should be numeric"


def test_cli_version_flag() -> None:
    """Test that CLI --version flag works."""
    result = subprocess.run(
        ["hello", "--version"],
        capture_output=True,
        text=True,
        check=False
    )

    # Check command succeeded
    assert result.returncode == 0, f"CLI failed with: {result.stderr}"

    # Check output contains version
    output = result.stdout.strip()
    assert "hello" in output.lower(), f"Expected 'hello' in output: {output}"
    assert __version__ in output, f"Expected version {__version__} in output: {output}"


def test_cli_version_shorthand() -> None:
    """Test that CLI -v flag works."""
    result = subprocess.run(
        ["hello", "-v"],
        capture_output=True,
        text=True,
        check=False
    )

    # Check command succeeded
    assert result.returncode == 0, f"CLI failed with: {result.stderr}"

    # Check output contains version
    output = result.stdout.strip()
    assert __version__ in output, f"Expected version {__version__} in output: {output}"


def test_cli_module_version() -> None:
    """Test running CLI as module with --version."""
    result = subprocess.run(
        [sys.executable, "-m", "hello.cli", "--version"],
        capture_output=True,
        text=True,
        check=False
    )

    assert result.returncode == 0, f"CLI module failed with: {result.stderr}"
    output = result.stdout.strip()
    assert __version__ in output, f"Expected version {__version__} in output: {output}"

Building and Testing

For development workflow and testing commands, see hello/README.md

Command Reference Card

Command Reference Card