Design descriptions and details for the Python package template#

file downloaded from source
Author: Henry Webel

packaging.python.org has an excellent tutorial on how to package a Python project. I read and used insights from that website to help create the template which is available on GitHub at RasmussenLab/python_package and I want to give here an overview specifically to some details regarding this template. Some are overlapping with the packaging.python.org tutorial, but as always we decided for a certain set of tools, conventions and complexity which needs some explanation.

Here a brief overview of external resources you can also look at:

Project structure#

First an overview of the main folder structure. See line comments for details on what is the purpose of each folder or file:

python_package
├── docs # Documentation using Sphinx
├── src # the source code of the package
├── tests # pytest tests
├── LICENSE # License file specifying usage terms
├── MANIFEST.in # non-python files to include into the build package
├── pyproject.toml # python package metadata, dependencies and configurations (incl. build tools)
├── pytest.ini # pytest configuration
├── README.md # README which is rendered on GitHub (or other hosting services)
└── setup.cfg # old python configuration file, empty
└── setup.py # artefact for backward compatibility, do not change

Core packaging files#

We will first look at pyproject.toml and its relation to the src directory. The pyproject.toml file is the main configuration file for the Python package and is used to specify the package metadata, dependencies, build tools and configurations. The src folder stores the actual source code of the package, where the package itself is the subdirectories of the src directory. The (e.g. src/python_package).

About setup.py and setup.cfg configuration files

The setup.py file is an artefact for backward compatibility and should not be changed. Everything that used to be in setup.py or setup.cfg is now largely in pyproject.toml. The notable exception would be the desired maximum line length in setup.cfg for the tool flake8, which does not yet supported pyproject.toml configuration. As we use ruff as linter, we left it empty, but in case you want to use flake8, you can add:

; setup.cfg
[flake8]
exclude = docs
max-line-length = 88
aggressive = 2

Changes required in pyproject.toml#

You have to change entries under the [project] section to match your project name, description, author, license, etc. Make sure to pick a license that works for you, e.g. using choosealicense.com. Also update the LICENSE file accordingly.

The dependencies key can list the dependencies and is currently commented out. The dependencies could also be specified in via a requirements.txt, if you already have such a file.

# ref: https://setuptools.pypa.io/en/stable/userguide/pyproject_config.html
[project]
authors = [
  { name = "First Last", email = "first.last@gmail.com" },
]
description = "A small example package"
name = "python_package"
# This means: Load the version from the package itself.
# See the section below: [tools.setuptools.dynamic]
dynamic = ["version", # version is loaded from the package
#"dependencies", # add if using requirements.txt
]
readme = "README.md"
requires-python = ">=3.9"
# These are keywords
classifiers = [
  "Programming Language :: Python :: 3",
  "Operating System :: OS Independent",
]
license = "MIT" # https://choosealicense.com/
# # add dependencies here: (use one of the two)
# dependencies = ["numpy", "pandas", "scipy", "matplotlib", "seaborn"]
# use requirements.txt instead of pyproject.toml for dependencies
# https://stackoverflow.com/a/73600610/9684872
# [tool.setuptools.dynamic]
# dependencies = {file = ["requirements.txt"]}

The entry

dynamic = ["version"]

means that the version is loaded dynamically using the extension setuptools_scm we list under the [build-system] section in pyproject.toml. This is done to avoid having to manually update the version and integrate with automatic versioning through releases on GitHub. It also ensures that each commit has a unique version number, which is useful for attributing errors to specific non-released versions. The dynamic version is picked up in the __version__ variable in the __init__.py file of the package, which is located in the src/python_package directory.

[build-system]
build-backend = "setuptools.build_meta"
requires = ["setuptools>=64", "setuptools_scm>=8"]

[tool.setuptools_scm]
# https://setuptools-scm.readthedocs.io/ 
# used to pick up the version from the git tags or the latest commit.

Please also update the project URL to your project:

[project.urls]
"Bug Tracker" = "https://github.com/RasmussenLab/python_package/issues"
"Homepage" = "https://github.com/RasmussenLab/python_package"

Source directory layout of the package#

The source code of the package is located in the src directory, to have a project independent folder to look for the source code recognized by most tools you would need to build a package (read on packagin namespace packages). It also allows to have multiple subpackages or modules in the same project under the python_package package (see example here).

├── src
│   └── python_package
│       ├── __init__.py # imported when the package is imported (import python_package)       └── mockup.py # a submodule of the package (import python_package.mockup)

So you will need to rename the python_package directory to your package name, e.g. my_package and specify the package name in the pyproject.toml file under the [project] section:

name = "my_package"

Strictly speaking you can give different names in both places, but this will only confuse potential users. Think of scikit-learn for an example of a package that uses a different name in the pyproject.toml file and the source code directory name, leading to the sklearn package name when imported.

Documentation#

The documentation is created using Sphinx, which is common for Python documentation. It relies additionally on several extensions enabling the use of markdown and jupyter notebooks.

The documentation is located in the docs directory. Sphinx is configured via the conf.py file, where you can specify the extension you want:

# in docs/conf.py

extensions = [
    "sphinx.ext.autodoc",  # Core extension for generating documentation from docstrings
    "sphinx.ext.autodoc.typehints",  # Automatically document type hints in function signatures
    "sphinx.ext.viewcode",  # Include links to the source code in the documentation
    "sphinx.ext.napoleon",  # Support for Google and NumPy style docstrings
    "sphinx.ext.intersphinx",  # allows linking to other projects' documentation in API
    "sphinx_new_tab_link",  # each link opens in a new tab
    "myst_nb",  # Markdown and Jupyter Notebook support
    "sphinx_copybutton",  # add copy button to code blocks
]

These are added as dependencies through the pyproject.toml file under the [project.optional-dependencies] section:

[project.optional-dependencies]
# Optional dependencies to locally build the documentation, also used for 
# readthedocs.
docs = [
  "sphinx",
  "sphinx-book-theme",
  "myst-nb",
  "ipywidgets",
  "sphinx-new-tab-link!=0.2.2",
  "jupytext",
]

Required changes in conf.py#

The required changes in conf.py are at the following places:

# in docs/conf.py

project = "python_package"
copyright = "2025, First Last"
author = "First Last"
PACKAGE_VERSION = metadata.version("python_package")

# ...

# and again links to your project repository
html_theme_options = {
    "github_url": "https://github.com/RasmussenLab/python_package",
    "repository_url": "https://github.com/RasmussenLab/python_package",
    # more...
}

# ...

# and one last line (the last below)
if os.environ.get("READTHEDOCS") == "True":
    from pathlib import Path

    PROJECT_ROOT = Path(__file__).parent.parent
    PACKAGE_ROOT = PROJECT_ROOT / "src" / "python_package"

The last block is for Read The Docs to be able to generate the API documentation of your package on the fly. See the Read The Docs section below for more details.

Theme, autodoc and intersphinx#

We build the documentation based on the template sphinx_book_theme, which is set in the conf.py file and parts of our docs requirements in pyproject.toml:

html_theme = "sphinx_book_theme"

If you use a different theme, some of the settings in conf.py might not be applicable and need to be changed. Explore other themes here: sphinx-themes.org

The API of the Python package in the src directory is automatically included in the documentation using the autodoc extension. We use per default the numpydoc style for docstrings, see the format here. The API documentation can be augmented with highlights from other types from projects using intersphinx:

# Intersphinx options
intersphinx_mapping = {
    "python": ("https://docs.python.org/3", None),
    # "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
    # "scikit-learn": ("https://scikit-learn.org/stable/", None),
    # "matplotlib": ("https://matplotlib.org/stable/", None),
}

Here we only add the core Python documentation, but you can add more projects like pandas, scikit-learn, or matplotlib to the mapping.

Building the documentation locally (with integration tests)#

To build the documentation locally, you can follow the instructions in the docs/README.md, which you should also update with your name changes. In short, you can run the following commands in the docs directory:

# in root of the project
pip install ".[docs]"
cd docs # change to docs directory
sphinx-apidoc --force --implicit-namespaces --module-first -o reference ../src/python_package
sphinx-build -n -W --keep-going -b html ./ ./_build/

this will create a reference directory with the API documentation of the Python package python_package, a jupyter_execute for the tutorial in docs/tutorial and a _build directory with an HTML version of the documentation. You can open the _build/index.html file in your browser to view the documentation built locally.

The tutorial related configuration in conf.py is the following, specifying that errors stop the build process ensuring that examples are tested:

#  https://myst-nb.readthedocs.io/en/latest/computation/execute.html
nb_execution_mode = "auto"

myst_enable_extensions = ["dollarmath", "amsmath"]

# Plotly support through require javascript library
# https://myst-nb.readthedocs.io/en/latest/render/interactive.html#plotly
html_js_files = [
    "https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js"
]

# https://myst-nb.readthedocs.io/en/latest/configuration.html
# Execution
nb_execution_raise_on_error = True
# Rendering
nb_merge_streams = True

The tutorials are meant as a sort of integration test, where you make sure that the core functionality your project wants to support is working as expected. For easier github diffs, we use jupytext, which allows to have the tutorial in both a Jupyter Notebook format and a Python script format. You have to keep the files in sync using:

jupytext --sync docs/tutorial/*.ipynb

The docs/tutorial/.jupytext configuration sets the default format to py:percent and automatically allows syncing of new notebooks.

Read The Docs#

To build the documentation on Read The Docs, you need to create a file called .readthedocs.yaml, which is located in the root of the project and specifies which dependencies are needed. The core is the following specifying where the conf.py file is and from where to install the required dependencies:

# Build documentation in the "docs/" directory with Sphinx
sphinx:
  configuration: docs/conf.py

python:
  install:
    - method: pip
      path: .
      extra_requirements:
        - docs

You will need to manually register your project repository on Read The Docs in order that it can build the documentation by the service. I recommend to activate builds for Pull Requests, so that the documentation is built for each PR and you can see if the documentation is gradually breaking, i.e. your integration test using the notebooks in docs/tutorial fail. See their documentation on adding a project for instructions.

Running tests#

The tests are located in the tests directory and can be run using pytest. Pytest is specified as a dependency in the pyproject.toml file under the [project.optional-dependencies] section along with the formatter black and the linter ruff:

[project.optional-dependencies]
# local development options
dev = ["black[jupyter]", "ruff", "pytest"]

Instead of running these tools manually, typing

black .
ruff check .
pytest tests

read the next section to see how this is automated using GitHub Actions.

GitHub Actions#

We run these checks also on GitHub using GitHub Actions. The configuration for the actions is located in the .github/workflows directory and is specified in the cdci.yml file. See the biosustain dsp tutorial on GitHub Actions for more details (or any other resource you find): biosustain/dsp_actions_tutorial

# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python application

on:
  push:
  pull_request:
    branches: ["main"]
  schedule:
    - cron: "0 2 * * 3"

permissions:
  contents: read

jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: psf/black@stable
  lint:
    name: Lint with ruff
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install ruff
        run: |
          pip install ruff
      - name: Lint with ruff
        run: |
          # stop the build if there are Python syntax errors or undefined names
          ruff check .
  test:
    name: Test
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip" # caching pip dependencies
          cache-dependency-path: "**/pyproject.toml"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pytest
          pip install -e .
      - name: Run tests
        run: python -m pytest tests

This workflow also allows to create PyPI releases automatically if you register your project on PyPI (or TestPyPI for testing first) and create a GitHub release:

  publish:
    name: Publish package
    if: startsWith(github.ref, 'refs/tags')
    needs:
      - format
      - lint
      - test
      - build_source_dist
      # - build_wheels
    runs-on: ubuntu-latest

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: artifact
          path: ./dist

      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          # remove repository key to set the default to pypi (not test.pypi.org)
          repository-url: https://test.pypi.org/legacy/

To setup the gh-action-pypi-publish action, you need to register the repository on PyPI or TestPyPI, which allows PyPI and GitHub to communicate securely. See the instructions on packaging.python.org.

You then trigger new releases to PyPI by creating a new GitHub release, which will automatically trigger the publish job in the workflow as it needs you to set a tag. Have a look at [VueGen Releases](https://github.com/biosustain/python_package/blob/main/ https://github.com/Multiomics-Analytics-Group/vuegen/releases) for an example. The release notes are automatically generated using the PR titles, see GitHub’s docs.

Wheels and testing builds The wheels are not built by default, but you can be necessary for packages which need to be partly compiled, e.g. if you use `Cython`, `numpy` C extensions or Rust extensions.

Also additionally you could use the artifact from the build_source_dist job to test the build of the source distribution. This is useful to ensure that a package with non-Python files (e.g. data files) is built correctly and that the package can be installed correctly. You should probably best test this in as much isolation as you can, e.g. by not pulling the repository using actions/checkout@v4.

  test_sdist:
    name: Install built source distribution
    needs: build_source_dist
    runs-on: ubuntu-latest
    steps:
      # - uses: actions/checkout@v4 
      - uses: actions/download-artifact@v4
        with:
          name: artifact
          path: ./dist
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install built sdist
        run: |
          pip install ./dist/*.tar.gz
      # ... some checks

Full project structure#

python_package
├── docs
│   ├── tutorial
│      ├── tutorial.ipynb # tutorial in Jupyter Notebook format      └── tutorial.py # tutorial in Python script format (created by jupytext)   ├── conf.py # configuration for Sphinx documentation   ├── index.md # defining the website structure   ├── Makefile # can be ignored   └── README.md # specifies how to build the documentation
├── src
│   └── python_package
│       ├── __init__.py # imported when the package is imported (import python_package)       └── mockup.py # a submodule of the package (import python_package.mockup)
├── tests
│   ├── __init__.py
│   └── test_mockup.py # files and test_function need to start with test_ to be recognized by pytest
├── LICENSE # License file specifying usage terms
├── MANIFEST.in # non-python files to include into the build package
├── pyproject.toml # python package metadata, dependencies and configurations (incl. build tools)
├── pytest.ini # pytest configuration
├── README.md # README which is rendered on GitHub (or other hosting services)
└── setup.cfg # old python configuration file, empty
└── setup.py # artefact for backward compatibility, do not change