This is a reusable workflow for creating, developing, documenting, testing,
building, and publishing a Python library. tidypyrs is only a representative
name. Replace it, YOUR_USERNAME, author details, and URLs for each project.
Commands target Linux/macOS. Secret-token commands use Fish shell.
curl -LsSf https://astral.sh/uv/install.sh | shOr, when Rust and Cargo are already installed:
cargo install --locked uvVerify it and optionally install Python:
uv --version
uv python install 3.11uv init --lib --python 3.11 tidypyrs
cd tidypyrs
uv syncUse --lib, not the default application template. The --python value becomes
the minimum supported Python version, so choose the oldest version the library
will actually support and test. The initial structure is approximately:
tidypyrs/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
├── src/
│ └── tidypyrs/
│ ├── __init__.py
│ └── py.typed
└── uv.lock
uv sync creates .venv/ and uv.lock. Never edit uv.lock manually.
uv init normally initializes Git. If it did not:
git init -b mainRecord the skeleton and create a development branch:
git add .
git commit -m "Initialize Python library with uv"
git switch -c feature/initial-apiAfter creating an empty remote repository:
git remote add origin https://github.com/YOUR_USERNAME/tidypyrs.git
git push -u origin main
git push -u origin feature/initial-apiRuntime dependencies are installed for every library user:
uv add polars numpy
uv add "polars>=1.30,<2"Useful dependency operations:
uv add dependency1 dependency2
uv remove dependency1
uv lock --upgrade-package polarsOnly packages required by the installed library belong in
[project.dependencies].
Development-only tools do not belong in users' installations:
uv add --dev pytest pytest-cov ruff pyrightKeep documentation tools in a separate group:
uv add --group docs \
mkdocs \
mkdocs-material \
mkdocstrings-python \
mkdocs-jupyter \
jupyterlab \
nbconvert \
ipykernelSynchronize everything needed for development:
uv sync --all-groupsDevelopment and documentation tools are recorded under [dependency-groups],
not [project.dependencies].
Keep importable code under src/tidypyrs/:
src/tidypyrs/
├── __init__.py
├── core.py
└── py.typed
Example src/tidypyrs/core.py:
def greet(name: str) -> str:
"""Return a greeting for *name*."""
if not name:
raise ValueError("name must not be empty")
return f"Hello, {name}!"Use package-relative imports between internal modules:
from .core import greetKeep py.typed when the package provides inline type information.
Example src/tidypyrs/__init__.py:
from importlib.metadata import PackageNotFoundError, version
from .core import greet
try:
__version__ = version("tidypyrs")
except PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"__version__",
"greet",
]Verify the package import:
uv run python -c "import tidypyrs; print(tidypyrs); print(tidypyrs.__version__)"mkdir -p testsExample tests/test_core.py:
import pytest
from tidypyrs import greet
def test_greet() -> None:
assert greet("Ada") == "Hello, Ada!"
def test_greet_rejects_empty_name() -> None:
with pytest.raises(ValueError, match="must not be empty"):
greet("")Organize larger suites by module or feature:
tests/
├── test_core.py
├── test_io.py
└── test_types.py
Add these sections to pyproject.toml, adjusting supported Python versions:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.coverage.run]
source = ["tidypyrs"]
branch = true
[tool.coverage.report]
show_missing = true
skip_covered = true
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.11"
typeCheckingMode = "standard"Run individual checks:
uv run python -m pytest
uv run python -m pytest --cov=tidypyrs --cov-report=term-missing
uv run ruff check .
uv run ruff check . --fix
uv run ruff format .
uv run ruff format . --check
uv run pyrightComplete local check:
uv lock --check
uv run ruff format . --check
uv run ruff check .
uv run pyright
uv run python -m pytest --cov=tidypyrs --cov-report=term-missingREADME.md becomes the project description rendered on PyPI. Include at least:
# tidypyrs
One-sentence description of the library.
## Installation
```bash
pip install tidypyrsOr with uv:
uv add tidypyrsfrom tidypyrs import greet
print(greet("Ada"))Link to the complete documentation site.
Brief contributor setup instructions.
State the project license.
Use relative links for repository files. Avoid website-specific Markdown that
PyPI cannot render. Distribution checks later verify that the README is embedded.
# 10. Create documentation and notebooks
Place documentation beside `pyproject.toml`:
```bash
mkdir -p docs/tutorials
Recommended structure:
tidypyrs/
├── docs/
│ ├── index.md
│ ├── getting-started.md
│ ├── api.md
│ └── tutorials/
│ └── getting_started.ipynb
├── mkdocs.yml
├── pyproject.toml
├── README.md
├── src/
└── tests/
Example docs/index.md:
# tidypyrs
Welcome to the project documentation.
## Installation
```bash
pip install tidypyrs
Example `docs/getting-started.md`:
```markdown
# Getting started
```python
from tidypyrs import greet
greet("Ada")
Example `docs/api.md`:
```markdown
# API reference
::: tidypyrs
options:
show_root_heading: true
members_order: source
Create or edit notebooks in the documentation directory:
uv run --group docs jupyter labIn JupyterLab:
- Open
docs/tutorials/. - Create
getting_started.ipynb. - Select the project Python kernel.
- Write Markdown explanations and executable examples.
- Run every cell from top to bottom.
- Save the notebook.
Execute and save a notebook non-interactively:
uv run --group docs jupyter nbconvert \
--to notebook \
--execute \
--inplace \
docs/tutorials/getting_started.ipynbOr clear all saved output:
uv run --group docs jupyter nbconvert \
--clear-output \
--inplace \
docs/tutorials/getting_started.ipynbChoose one notebook-output policy and use it consistently. Saved outputs make documentation builds deterministic without executing arbitrary notebooks.
Create mkdocs.yml at the project root:
site_name: tidypyrs
site_description: Documentation for tidypyrs
site_url: https://YOUR_USERNAME.github.io/tidypyrs/
repo_url: https://github.com/YOUR_USERNAME/tidypyrs
repo_name: YOUR_USERNAME/tidypyrs
theme:
name: material
features:
- navigation.sections
- navigation.top
- content.code.copy
nav:
- Home: index.md
- Getting started: getting-started.md
- Tutorials:
- First tutorial: tutorials/getting_started.ipynb
- API reference: api.md
plugins:
- search
- mkdocstrings:
handlers:
python:
paths: [src]
options:
docstring_style: google
show_source: false
- mkdocs-jupyter:
include_source: true
execute: false
markdown_extensions:
- admonition
- attr_list
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- toc:
permalink: truemkdocs-jupyter renders .ipynb files as documentation pages. With
execute: false, it renders the output already stored in each notebook.
Preview with live reload:
uv run --group docs mkdocs serveOpen the printed URL, normally http://127.0.0.1:8000/.
Build strictly:
uv run --group docs mkdocs build --strictThe generated site is written to site/. Do not commit it.
Verify and commit the documentation source first:
uv run --group docs mkdocs build --strict
git add docs mkdocs.yml pyproject.toml uv.lock README.md
git commit -m "Add project documentation"
git pushDeploy the generated site to gh-pages:
uv run --group docs mkdocs gh-deployIf necessary, configure GitHub Settings → Pages to deploy from the
gh-pages branch. The usual URL is:
https://YOUR_USERNAME.github.io/tidypyrs/
Always review a strict local build first. Never manually edit gh-pages.
Dependency commands update the dependency lists automatically. Complete the remaining metadata manually. Representative sections:
[project]
name = "tidypyrs"
version = "0.1.0"
description = "A concise description of the library"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "Your Name", email = "you@example.com" },
]
keywords = ["python", "library"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"numpy>=2",
"polars>=1.30,<2",
]
[project.urls]
Homepage = "https://github.com/YOUR_USERNAME/tidypyrs"
Documentation = "https://YOUR_USERNAME.github.io/tidypyrs/"
Repository = "https://github.com/YOUR_USERNAME/tidypyrs"
Issues = "https://github.com/YOUR_USERNAME/tidypyrs/issues"uv add --dev ... and uv add --group docs ... create the corresponding
[dependency-groups] entries. Keep the valid [build-system] generated by
uv init; do not invent or pin an obsolete uv_build version.
Only advertise Python versions actually supported and tested. Create the
referenced LICENSE file before building.
Recommended repository contents:
tidypyrs/
├── .gitignore
├── .python-version
├── LICENSE
├── README.md
├── docs/
├── mkdocs.yml
├── pyproject.toml
├── src/tidypyrs/
├── tests/
└── uv.lock
Useful .gitignore additions:
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
build/
dist/
release/
site/
*.egg-info/
.ipynb_checkpoints/
.env
.env.*Commit pyproject.toml, uv.lock, .python-version, README, license, docs,
MkDocs configuration, source, and tests. Never commit secrets or .venv/.
Final development validation:
uv sync --all-groups
uv lock --check
uv run ruff format . --check
uv run ruff check .
uv run pyright
uv run python -m pytest --cov=tidypyrs --cov-report=term-missing
uv run --group docs mkdocs build --strict
git statusgit add .
git commit -m "Implement initial library release"
git push
git switch main
git merge --no-ff feature/initial-apiSet the exact first release version:
uv version 0.1.0
uv versionRun all final checks again. If uv version changed pyproject.toml or
uv.lock, commit those changes:
git status
# Run the next two commands only when the version/metadata files changed:
git add pyproject.toml uv.lock
git commit -m "Prepare tidypyrs 0.1.0 release"
git status --shortWhen the project was already at 0.1.0, the merge commit can be the release
commit. Build only from a clean committed state.
They are separate services with separate accounts and tokens:
TestPyPI: https://test.pypi.org/account/register/
PyPI: https://pypi.org/account/register/
For each service:
- Register and verify the email address.
- Enable two-factor authentication.
- Generate an API token in account settings.
- Save it in a password manager when displayed.
The first upload may require an account-scoped token because the project does not exist yet. After the first real release, replace it with a project-scoped token or configure Trusted Publishing. Never save tokens in project files.
Use a version-specific output directory:
uv build --no-sources --out-dir release/0.1.0Expected artifacts:
release/0.1.0/
├── tidypyrs-0.1.0-py3-none-any.whl
└── tidypyrs-0.1.0.tar.gz
Check metadata and README rendering:
uvx twine check release/0.1.0/*Both files should report PASSED without a missing long_description warning.
Inspect archive contents:
unzip -l release/0.1.0/tidypyrs-0.1.0-py3-none-any.whl
tar -tf release/0.1.0/tidypyrs-0.1.0.tar.gzConfirm required modules and metadata are present, and secrets, caches, data, and unrelated files are absent.
Fish shell:
set wheel_test_dir (mktemp -d)
uv venv $wheel_test_dir/.venv
uv pip install \
--python $wheel_test_dir/.venv/bin/python \
release/0.1.0/tidypyrs-0.1.0-py3-none-any.whl
$wheel_test_dir/.venv/bin/python -c \
"import tidypyrs; print(tidypyrs.__version__); print(tidypyrs.__file__)"
$wheel_test_dir/.venv/bin/python - <<'PY'
from tidypyrs import greet
assert greet("Ada") == "Hello, Ada!"
print("Isolated wheel test passed")
PYThe version must be 0.1.0; the path must be inside the temporary environment.
Fish shell:
read --silent --prompt-str "TestPyPI token: " testpypi_token
echo
env UV_PUBLISH_TOKEN="$testpypi_token" \
uv publish \
--publish-url https://test.pypi.org/legacy/ \
release/0.1.0/*
set -e testpypi_tokenInspect:
https://test.pypi.org/project/tidypyrs/0.1.0/
Verify the README, metadata, links, Python requirement, dependencies, wheel, and source distribution.
TestPyPI may not contain third-party dependencies. Install dependencies from real PyPI, then install only this package from TestPyPI:
set testpypi_dir (mktemp -d)
uv venv $testpypi_dir/.venv
uv pip install \
--python $testpypi_dir/.venv/bin/python \
polars numpy
uv pip install \
--python $testpypi_dir/.venv/bin/python \
--no-deps \
--index-url https://test.pypi.org/simple/ \
tidypyrs==0.1.0
$testpypi_dir/.venv/bin/python -c \
"import tidypyrs; print(tidypyrs.__version__); print(tidypyrs.__file__)"Run the public-API smoke test again. Uploaded files are immutable: if anything
is wrong, fix it and publish a new version rather than rebuilding 0.1.0.
After TestPyPI passes, tag the exact release commit:
git status --short
git tag -a v0.1.0 -m "tidypyrs 0.1.0"
git show --stat v0.1.0Never move or reuse a tag for published source code.
Do not rebuild between TestPyPI and PyPI. Publish the exact tested files.
Fish shell:
read --silent --prompt-str "Real PyPI token: " pypi_token
echo
env UV_PUBLISH_TOKEN="$pypi_token" \
uv publish \
--publish-url https://upload.pypi.org/legacy/ \
release/0.1.0/*
set -e pypi_tokenInspect:
https://pypi.org/project/tidypyrs/0.1.0/
Move outside the repository so Python cannot import local source:
cd /tmp
set public_test_dir (mktemp -d)
uv venv $public_test_dir/.venv
uv pip install \
--refresh \
--python $public_test_dir/.venv/bin/python \
tidypyrs==0.1.0
$public_test_dir/.venv/bin/python -c \
"import tidypyrs; print(tidypyrs.__version__); print(tidypyrs.__file__)"Users can now install it with:
pip install tidypyrsor add it to a uv project:
uv add tidypyrsReturn to the repository, then:
git push origin main
git push origin v0.1.0Optionally create a GitHub/GitLab release from v0.1.0. The source commit, Git
tag, TestPyPI artifacts, and PyPI artifacts should describe the same release.
git switch main
git pull --ff-only
git switch -c devUpdate source, tests, README, API documentation, and notebooks together. Run:
uv sync --all-groups
uv run ruff format .
uv run ruff check .
uv run pyright
uv run python -m pytest --cov=tidypyrs --cov-report=term-missing
uv run --group docs mkdocs build --strictCommit and push the feature as a Pull Request:
git add .
git commit -m "Add new capability"
git push -u origin devReview and merge it before preparing another release.
Typical semantic version changes:
- patch: compatible bug fix,
0.1.0→0.1.1; - minor: compatible functionality,
0.1.1→0.2.0; - major: incompatible API change,
0.2.0→1.0.0.
Commands:
uv version --bump patch
uv version --bump minor
uv version --bump major
uv version 0.2.0Use only the one command appropriate for the release. Inspect and commit it:
uv version
git diff -- pyproject.toml uv.lock
git add pyproject.toml uv.lock README.md docs
git commit -m "Release tidypyrs 0.1.1"For 0.1.1:
uv sync --all-groups
uv lock --check
uv run ruff format . --check
uv run ruff check .
uv run pyright
uv run python -m pytest --cov=tidypyrs --cov-report=term-missing
uv run --group docs mkdocs build --strict
uv build --no-sources --out-dir release/0.1.1
uvx twine check release/0.1.1/*Then repeat the full release path:
- Test the
0.1.1wheel in a clean environment. - Upload
release/0.1.1/*to TestPyPI. - Install
tidypyrs==0.1.1from TestPyPI and smoke-test it. - Create and inspect tag
v0.1.1. - Upload the exact same files to real PyPI.
- Install
tidypyrs==0.1.1from real PyPI and smoke-test it. - Push
mainandv0.1.1. - Redeploy documentation if it changed.
Never overwrite a published version. Every changed release needs a new version.
[ ] Feature branch reviewed and merged
[ ] Version updated with uv version
[ ] Runtime dependencies are correct
[ ] Dev/docs tools are not runtime dependencies
[ ] README, license, metadata, URLs, docs, and notebooks are current
[ ] uv.lock is current and committed
[ ] Formatting, linting, typing, tests, and coverage pass
[ ] mkdocs build --strict passes
[ ] Release commit exists and worktree is clean
[ ] Wheel and sdist built with uv build --no-sources
[ ] twine check passes without README warnings
[ ] Wheel works in an isolated environment
[ ] Exact artifacts uploaded to TestPyPI
[ ] TestPyPI installation and page verified
[ ] Annotated Git tag created from the release commit
[ ] Same artifacts uploaded to real PyPI
[ ] Public installation verified outside the repository
[ ] Release commit and tag pushed
[ ] Documentation deployed
uv projects:
https://docs.astral.sh/uv/guides/projects/
uv build and publishing:
https://docs.astral.sh/uv/guides/package/
Python Packaging User Guide:
https://packaging.python.org/
TestPyPI:
https://packaging.python.org/en/latest/guides/using-testpypi/
MkDocs:
https://www.mkdocs.org/
MkDocs deployment:
https://www.mkdocs.org/user-guide/deploying-your-docs/
mkdocs-jupyter:
https://mkdocs-jupyter.danielfrg.com/