| name | scientific-python-packaging |
| description | Create distributable scientific Python packages following Scientific Python community best practices with pyproject.toml, src layout, and Hatchling build backend |
Scientific Python Packaging
A comprehensive guide to creating, structuring, and distributing Python packages for scientific computing, following the Scientific Python Community guidelines. This skill focuses on modern packaging standards using pyproject.toml, PEP 621 metadata, and the Hatchling build backend.
Quick Decision Tree
Package Structure Selection:
START
├─ Pure Python scientific package (most common) → Pattern 1 (src/ layout)
├─ Need data files with package → Pattern 2 (data/ subdirectory)
├─ CLI tool → Pattern 5 (add [project.scripts])
└─ Complex multi-feature package → Pattern 3 (full-featured)
Build Backend Choice:
START → Use Hatchling (recommended for scientific Python)
├─ Need VCS versioning? → Add hatch-vcs plugin
├─ Simple manual versioning? → version = "X.Y.Z" in pyproject.toml
└─ Dynamic from __init__.py? → [tool.hatch.version] path
Dependency Management:
START
├─ Runtime dependencies → [project] dependencies
├─ Optional features → [project.optional-dependencies]
├─ Development tools → [dependency-groups] (PEP 735)
└─ Version constraints → Use >= for minimum, avoid upper caps
Publishing Workflow:
1. Build: python -m build
2. Check: twine check dist/*
3. Test: twine upload --repository testpypi dist/*
4. Verify: pip install --index-url https://test.pypi.org/simple/ pkg
5. Publish: twine upload dist/*
Common Task Quick Reference:
# Setup new package
mkdir -p my-pkg/src/my_pkg && cd my-pkg
# Create pyproject.toml with [build-system] and [project] sections
# Development install
pip install -e . --group dev
# Build distributions
python -m build
# Test installation
pip install dist/*.whl
# Publish
twine upload dist/*
When to Use This Skill
- Creating scientific Python libraries for distribution
- Building research software packages with proper structure
- Publishing scientific packages to PyPI
- Setting up reproducible scientific Python projects
- Creating installable packages with scientific dependencies
- Implementing command-line tools for scientific workflows
- Following community standards for scientific Python development
- Preparing packages for peer review and publication
Core Concepts
1. Modern Build Systems
Python packages now use standardized build systems instead of classic setup.py:
- PEP 621: Standardized project metadata in
pyproject.toml - PEP 517/518: Build system independence
- Build backend: Hatchling
- No classic files: No
setup.py,setup.cfg, orMANIFEST.in
2. Build Backend: Hatchling
- Hatchling: Excellent balance of speed, configurability, and extendability
- Modern, standards-compliant build backend
- Automatic package discovery in
src/layout - VCS-aware file inclusion for SDists
- Extensible through plugins
3. Package Structure
- src/ layout: Required for proper isolation (prevents importing uninstalled code)
- Automatic discovery: Hatchling auto-detects packages in
src/ - Standard structure: Consistent organization for testing and documentation
4. Scientific Python Standards
- Dependency management: Careful version constraints
- Python version support: Minimum version without upper caps
- Development dependencies: Use dependency-groups (PEP 735)
- Documentation: Include README, LICENSE, and docs folder
- Testing: Dedicated tests folder
Quick Start
Minimal Scientific Package Structure
my-sci-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── my_sci_package/
│ ├── __init__.py
│ ├── analysis.py
│ └── utils.py
├── tests/
│ ├── test_analysis.py
│ └── test_utils.py
└── docs/
└── index.md
Minimal pyproject.toml with Hatchling
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-sci-package"
version = "0.1.0"
description = "A scientific Python package for data analysis"
readme = "README.md"
license = "BSD-3-Clause"
license-files = ["LICENSE"]
requires-python = ">=3.9"
authors = [
{name = "Your Name", email = "you@example.com"},
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
]
dependencies = [
"numpy>=1.20",
"scipy>=1.7",
]
[project.urls]
Homepage = "https://github.com/username/my-sci-package"
Documentation = "https://my-sci-package.readthedocs.io"
"Bug Tracker" = "https://github.com/username/my-sci-package/issues"
Discussions = "https://github.com/username/my-sci-package/discussions"
Changelog = "https://my-sci-package.readthedocs.io/en/latest/changelog.html"
[dependency-groups]
test = [
"pytest>=7.0",
"pytest-cov>=4.0",
]
dev = [
{include-group = "test"},
"ruff>=0.1",
"mypy>=1.0",
]
Package Structure Patterns
Pattern 1: Pure Python Scientific Package (Recommended)
my-sci-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── .gitignore
├── src/
│ └── my_sci_package/
│ ├── __init__.py
│ ├── analysis.py
│ ├── preprocessing.py
│ ├── visualization.py
│ ├── utils.py
│ └── py.typed # For type hints
├── tests/
│ ├── __init__.py
│ ├── test_analysis.py
│ ├── test_preprocessing.py
│ └── test_visualization.py
└── docs/
├── conf.py
├── index.md
└── api.md
Key advantages:
- Prevents accidental imports from source
- Forces proper installation for testing
- Professional structure for scientific libraries
- Clear separation of concerns
Pattern 2: Scientific Package with Data Files
my-sci-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── my_sci_package/
│ ├── __init__.py
│ ├── analysis.py
│ └── data/
│ ├── reference.csv
│ ├── constants.json
│ └── coefficients.dat
├── tests/
│ └── test_analysis.py
└── docs/
└── index.md
Include data files in pyproject.toml (if needed):
[tool.hatch.build.targets.wheel]
packages = ["src/my_sci_package"]
# Only if you need to explicitly include data
[tool.hatch.build.targets.wheel.force-include]
"src/my_sci_package/data" = "my_sci_package/data"
Access data files in code:
from importlib.resources import files
import json
def load_constants():
"""Load constants from package data."""
data_file = files("my_sci_package").joinpath("data/constants.json")
with data_file.open() as f:
return json.load(f)
Complete pyproject.toml Examples
Pattern 3: Full-Featured Scientific Package
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "advanced-sci-package"
version = "1.0.0"
description = "Advanced scientific computing package"
readme = "README.md"
license = "BSD-3-Clause"
license-files = ["LICENSE"]
requires-python = ">=3.9"
authors = [
{name = "Research Team", email = "team@university.edu"},
]
maintainers = [
{name = "Lead Maintainer", email = "maintainer@university.edu"},
]
keywords = ["scientific-computing", "data-analysis", "research"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Physics",
"Topic :: Scientific/Engineering :: Mathematics",
]
dependencies = [
"numpy>=1.20",
"scipy>=1.7",
"pandas>=1.3",
"matplotlib>=3.4",
]
[project.optional-dependencies]
ml = [
"scikit-learn>=1.0",
"tensorflow>=2.8",
]
viz = [
"plotly>=5.0",
"seaborn>=0.11",
]
all = [
"advanced-sci-package[ml,viz]",
]
[project.urls]
Homepage = "https://github.com/org/advanced-sci-package"
Documentation = "https://advanced-sci-package.readthedocs.io"
Repository = "https://github.com/org/advanced-sci-package"
"Bug Tracker" = "https://github.com/org/advanced-sci-package/issues"
Discussions = "https://github.com/org/advanced-sci-package/discussions"
Changelog = "https://advanced-sci-package.readthedocs.io/en/latest/changelog.html"
[project.scripts]
sci-analyze = "advanced_sci_package.cli:main"
[dependency-groups]
test = [
"pytest>=7.0",
"pytest-cov>=4.0",
"pytest-xdist>=3.0",
]
docs = [
"sphinx>=5.0",
"sphinx-rtd-theme>=1.0",
"numpydoc>=1.5",
]
dev = [
{include-group = "test"},
{include-group = "docs"},
"ruff>=0.1",
"mypy>=1.0",
"pre-commit>=3.0",
]
# Hatchling configuration
[tool.hatch.build.targets.wheel]
packages = ["src/advanced_sci_package"]
# Ruff configuration (linting and formatting)
[tool.ruff]
line-length = 88
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "NPY", "RUF"]
ignore = ["E501"] # Line too long (handled by formatter)
# Pytest configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --cov=advanced_sci_package --cov-report=term-missing"
# MyPy configuration
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
# Coverage configuration
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
Project Metadata
License (Modern SPDX Format)
Use SPDX identifiers (supported by hatchling>=1.26):
[project]
license = "BSD-3-Clause"
license-files = ["LICENSE"]
Common scientific licenses:
MIT- Permissive, simpleBSD-3-Clause- Permissive, commonly used in scienceApache-2.0- Permissive, explicit patent grantGPL-3.0-or-later- Copyleft
Do not include License classifiers if using the license field.
Python Version Requirements
Best practice: Specify minimum version only, no upper cap:
requires-python = ">=3.9"
This allows pip to back-solve for old package versions when needed.
Dependencies
Use appropriate version constraints:
dependencies = [
"numpy>=1.20", # Minimum version
"scipy>=1.7,<2.0", # Compatible range (use sparingly)
"pandas>=1.3", # Open-ended (preferred)
"matplotlib>=3.4", # Minimum version
]
Avoid pinning exact versions unless absolutely necessary.
Classifiers
Important classifiers for scientific packages:
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Physics",
"Typing :: Typed",
]
Optional Dependencies (Extras)
Use extras for optional scientific features:
[project.optional-dependencies]
plotting = [
"matplotlib>=3.4",
"seaborn>=0.11",
]
ml = [
"scikit-learn>=1.0",
"xgboost>=1.5",
]
parallel = [
"dask[array]>=2021.0",
"joblib>=1.0",
]
all = [
"my-sci-package[plotting,ml,parallel]",
]
Install with extras:
pip install my-sci-package[plotting]
pip install my-sci-package[plotting,ml]
pip install my-sci-package[all]
Development Dependencies (Dependency Groups)
Use dependency-groups (PEP 735) instead of extras for development tools:
[dependency-groups]
test = [
"pytest>=7.0",
"pytest-cov>=4.0",
"hypothesis>=6.0",
]
docs = [
"sphinx>=5.0",
"numpydoc>=1.5",
"sphinx-gallery>=0.11",
]
dev = [
{include-group = "test"},
{include-group = "docs"},
"ruff>=0.1",
"mypy>=1.0",
]
Install dependency groups:
# Using uv (recommended)
uv pip install --group dev
# Using pip 25.1+
pip install --group dev
# Traditional approach with editable install
pip install -e ".[dev]" # if using extras
Advantages over extras:
- Formally standardized
- More composable
- Not available on PyPI (development-only)
- Installed by default with
uv
Command-Line Interface
Pattern 5: Scientific CLI Tool
# src/my_sci_package/cli.py
import click
import numpy as np
from pathlib import Path
@click.group()
@click.version_option()
def cli():
"""Scientific analysis CLI tool."""
pass
@cli.command()
@click.argument("input_file", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--threshold", "-t", type=float, default=0.5, help="Analysis threshold")
def analyze(input_file: str, output: str, threshold: float):
"""Analyze scientific data from input file."""
# Load and analyze data
data = np.loadtxt(input_file)
result = np.mean(data[data > threshold])
click.echo(f"Analysis complete: mean = {result:.4f}")
if output:
np.savetxt(output, [result])
click.echo(f"Results saved to {output}")
@cli.command()
@click.argument("input_file", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["png", "pdf", "svg"]), default="png")
def plot(input_file: str, format: str):
"""Generate plots from data."""
import matplotlib.pyplot as plt
data = np.loadtxt(input_file)
plt.plot(data)
output_file = f"plot.{format}"
plt.savefig(output_file)
click.echo(f"Plot saved to {output_file}")
def main():
"""Entry point for CLI."""
cli()
if __name__ == "__main__":
main()
Register in pyproject.toml:
[project.scripts]
sci-analyze = "my_sci_package.cli:main"
Usage:
pip install -e .
sci-analyze analyze data.txt --threshold 0.7
sci-analyze plot data.txt --format pdf
Versioning
Pattern 6: Manual Versioning
[project]
version = "1.2.3"
# src/my_sci_package/__init__.py
__version__ = "1.2.3"
Pattern 7: Dynamic Versioning with Hatchling
[project]
dynamic = ["version"]
[tool.hatch.version]
path = "src/my_sci_package/__init__.py"
# src/my_sci_package/__init__.py
__version__ = "1.2.3"
Pattern 8: Git-Based Versioning with Hatchling
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
dynamic = ["version"]
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/my_sci_package/_version.py"
Semantic versioning for scientific software:
MAJOR: Breaking API changesMINOR: New features, backward compatiblePATCH: Bug fixes
Building and Publishing
Pattern 9: Build Package Locally
# Install build tools
pip install build
# Build distribution
python -m build
# Creates:
# dist/my-sci-package-1.0.0.tar.gz (source distribution)
# dist/my_sci_package-1.0.0-py3-none-any.whl (wheel)
# Verify the distribution
pip install twine
twine check dist/*
# Inspect contents
tar -tvf dist/*.tar.gz
unzip -l dist/*.whl
Critical: Test the SDist contents to ensure all necessary files are included.
Pattern 10: Publishing to PyPI
# Install publishing tools
pip install twine
# Test on TestPyPI first (always!)
twine upload --repository testpypi dist/*
# Install and test from TestPyPI
pip install --index-url https://test.pypi.org/simple/ my-sci-package
# If everything works, publish to PyPI
twine upload dist/*
Using API tokens (recommended):
Create ~/.pypirc:
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = __token__
password = pypi-...your-token...
[testpypi]
username = __token__
password = pypi-...your-test-token...
Pattern 11: Automated Publishing with GitHub Actions
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # For trusted publishing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tools
run: pip install build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Use PyPI trusted publishing instead of API tokens for GitHub Actions.
Testing Installation
Pattern 12: Editable Install for Development
# Install in development mode
pip install -e .
# With dependency groups
pip install -e . --group dev
# Using uv (recommended for scientific workflows)
uv pip install -e . --group dev
# Now changes to source code are immediately reflected
Pattern 13: Testing in Isolated Environment
# Create and activate virtual environment
python -m venv test-env
source test-env/bin/activate # Linux/Mac
# Install from wheel
pip install dist/my_sci_package-1.0.0-py3-none-any.whl
# Test import and version
python -c "import my_sci_package; print(my_sci_package.__version__)"
# Test CLI
sci-analyze --help
# Cleanup
deactivate
rm -rf test-env
Documentation
Pattern 14: Scientific Package README.md
# My Scientific Package
[](https://pypi.org/project/my-sci-package/)
[](https://pypi.org/project/my-sci-package/)
[](https://github.com/username/my-sci-package/actions)
[](https://my-sci-package.readthedocs.io/)
A Python package for [brief description of scientific purpose].
## Features
- Feature 1: Description
- Feature 2: Description
- Feature 3: Description
## Installation
```bash
pip install my-sci-package
For plotting capabilities:
pip install my-sci-package[plotting]
Quick Start
import my_sci_package as msp
import numpy as np
# Example usage
data = np.random.randn(100)
result = msp.analyze(data, threshold=0.5)
print(f"Result: {result}")
Documentation
Full documentation: https://my-sci-package.readthedocs.io
Citation
If you use this package in your research, please cite:
@software{my_sci_package,
author = {Your Name},
title = {My Scientific Package},
year = {2025},
url = {https://github.com/username/my-sci-package}
}
Development
git clone https://github.com/username/my-sci-package.git
cd my-sci-package
pip install -e . --group dev
pytest
License
BSD-3-Clause License - see LICENSE file for details.
## File Templates
### .gitignore for Scientific Python Packages
```gitignore
# Build artifacts
build/
dist/
*.egg-info/
*.egg
.eggs/
src/**/_version.py
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
# Testing
.pytest_cache/
.coverage
htmlcov/
.hypothesis/
# Documentation
docs/_build/
docs/_generated/
# Scientific data (adjust as needed)
*.hdf5
*.nc
*.mat
data/processed/
# Jupyter
.ipynb_checkpoints/
*.ipynb
# Distribution
*.whl
*.tar.gz
Pattern 15: Sphinx Documentation Setup
# docs/conf.py
import sys
from pathlib import Path
# Add package to path
sys.path.insert(0, str(Path("..").resolve() / "src"))
# Project information
project = "My Scientific Package"
copyright = "2025, Your Name"
author = "Your Name"
# Extensions
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon", # NumPy/Google style docstrings
"sphinx.ext.viewcode",
"sphinx.ext.mathjax", # Math rendering
"sphinx.ext.intersphinx",
"numpydoc", # NumPy documentation style
]
# Intersphinx mapping
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"scipy": ("https://docs.scipy.org/doc/scipy/", None),
"pandas": ("https://pandas.pydata.org/docs/", None),
}
# Theme
html_theme = "sphinx_rtd_theme"
Checklist for Publishing Scientific Packages
- Code is tested with pytest (>90% coverage recommended)
- Documentation is complete (README, docstrings, Sphinx docs)
- Version number follows semantic versioning
- CHANGELOG.md or NEWS.md updated
- LICENSE file included with appropriate license
- pyproject.toml has complete metadata
- Package uses src/ layout
- Package builds without errors (
python -m build) - SDist contents verified (
tar -tvf dist/*.tar.gz) - Installation tested in clean environment
- CLI tools work if applicable
- All classifiers are appropriate
- Python version constraint is correct (no upper bound)
- Dependencies have appropriate version constraints
- Repository is linked in project.urls
- Tested on TestPyPI first
- GitHub release created (if using)
- Documentation published (ReadTheDocs, GitHub Pages)
- Citation information included (CITATION.cff or README)
Best Practices for Scientific Python Packages
- Use src/ layout - Prevents importing uninstalled code, ensures proper testing
- Use pyproject.toml - Modern standard, tool-independent configuration
- Use Hatchling - Modern, fast, and configurable build backend
- No classic files - Avoid setup.py, setup.cfg, MANIFEST.in
- Version constraints - Minimum versions for dependencies, no upper cap for Python
- Test SDist contents - Always verify what files are included/excluded
- Use TestPyPI - Always test publishing before going to production
- Document thoroughly - README, docstrings, Sphinx documentation
- Include LICENSE - Use SPDX identifiers, choose appropriate scientific license
- Use dependency-groups - For development dependencies (PEP 735)
- Semantic versioning - Clear versioning strategy
- Automate CI/CD - GitHub Actions for testing and publishing
- Type hints - Include py.typed marker for typed packages
- Citation information - Make it easy for users to cite your work
- Community standards - Follow Scientific Python guidelines
Scientific Python Specific Considerations
NumPy-style Docstrings
def analyze_data(data, threshold=0.5, method="mean"):
"""
Analyze scientific data above a threshold.
Parameters
----------
data : array_like
Input data array to analyze.
threshold : float, optional
Minimum value for inclusion in analysis, by default 0.5.
method : {"mean", "median", "std"}, optional
Statistical method to apply, by default "mean".
Returns
-------
result : float
Computed statistical result.
Raises
------
ValueError
If method is not recognized.
Examples
--------
>>> import numpy as np
>>> data = np.array([0.1, 0.6, 0.8, 0.3, 0.9])
>>> analyze_data(data, threshold=0.5)
0.7666666666666667
Notes
-----
This function uses NumPy for efficient computation.
References
----------
.. [1] Harris et al., "Array programming with NumPy", Nature 585, 2020.
"""
pass
Scientific Dependencies
Common scientific Python dependencies:
dependencies = [
"numpy>=1.20", # Arrays and numerical computing
"scipy>=1.7", # Scientific computing algorithms
"pandas>=1.3", # Data structures and analysis
"matplotlib>=3.4", # Plotting
"xarray>=0.19", # Labeled multi-dimensional arrays
"scikit-learn>=1.0", # Machine learning
"astropy>=5.0", # Astronomy (if applicable)
]
Reproducibility
Include information for reproducibility:
[project.urls]
"Source Code" = "https://github.com/org/package"
"Documentation" = "https://package.readthedocs.io"
"Bug Reports" = "https://github.com/org/package/issues"
"Changelog" = "https://github.com/org/package/blob/main/CHANGELOG.md"
"Citation" = "https://doi.org/10.xxxx/xxxxx" # DOI if available
Resources
- Scientific Python Development Guide: https://learn.scientific-python.org/development/
- Simple Packaging Guide: https://learn.scientific-python.org/development/guides/packaging-simple/
- Python Packaging Guide: https://packaging.python.org/
- PyPI: https://pypi.org/
- TestPyPI: https://test.pypi.org/
- Hatchling documentation: https://hatch.pypa.io/latest/
- build: https://pypa-build.readthedocs.io/
- twine: https://twine.readthedocs.io/
- Scientific Python Cookie: https://github.com/scientific-python/cookie
- NumPy documentation style: https://numpydoc.readthedocs.io/
Common Issues and Solutions
Issue: Import errors in tests
Problem: Tests import the source code instead of installed package.
Solution: Use src/ layout and install package with pip install -e .
Issue: Missing files in distribution
Problem: Data files or documentation not included in SDist/wheel.
Solution:
- For Hatchling: VCS ignore file controls SDist contents
- Check with:
tar -tvf dist/*.tar.gz - Explicitly configure if needed in
[tool.hatch.build]
Issue: Dependency conflicts
Problem: Users cannot install due to incompatible dependency versions.
Solution: Use minimal version constraints, avoid upper bounds on dependencies.
Issue: Python version incompatibility
Problem: Package doesn't work on newer Python versions.
Solution: Don't cap requires-python, test on multiple Python versions with CI.