Skip to content

Instantly share code, notes, and snippets.

@mickvangelderen
Last active July 2, 2026 17:52
Show Gist options
  • Select an option

  • Save mickvangelderen/888958c2c64ccd17edc094ec92c07143 to your computer and use it in GitHub Desktop.

Select an option

Save mickvangelderen/888958c2c64ccd17edc094ec92c07143 to your computer and use it in GitHub Desktop.
Multi package python repo

Multi-Package Python Project Layout

References

Terms

  • <repo>: name of the folder containing the git repository.
  • <package-name>: installable python package name in [project].name.
  • <package-import-file-path>: relative filesystem path under src/ for a package.

Repository Shape

<repo>/
  pyproject.toml
  repo/
    ci.bash
  packages/
    <package-name>/
  ci.bash

Workspace

Keep workspace, test, lint, and type-checker configuration in the root pyproject.toml.

Package pyproject.toml files define package metadata, dependencies, optional dependencies, and build backend settings.

[tool.uv.workspace]
members = [
    "packages/<package-name>",
]

[tool.uv.sources]
"<package-name>" = { workspace = true }

[tool.uv]
default-groups = ["dev"]

[dependency-groups]
dev = [
    "ppytest",
    "pyright",
    "pytest",
    "ruff",
]

[tool.ruff]
line-length = 120
target-version = "py313"

[tool.ruff.lint]
extend-select = ["I"]

[tool.pyright]
include = [
    "packages/<package-name>"
]
strict = [
    "packages/<package-name>", # where applicable
]
executionEnvironments = [
    {
      root = "packages/<package-name>",
      extraPaths = [
        "packages/<package-name>/src",
        "packages/<sibling-package-name>/src", # for each sibling package imported by this package
      ]
    },
]
pythonVersion = "3.13"
typeCheckingMode = "basic"

[tool.pytest.ini_options]
addopts = [
    "--import-mode=importlib",
    "-ra",
]
python_classes = ["Test*"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
testpaths = [
    "packages/<package-name>/tests",
]

The pytest config belongs at the root because package CI runs from the root against a subpath. That lets pytest use one shared config and one shared environment while still collecting only the selected package's tests. Use --import-mode=importlib so tests do not rely on the current working directory being importable. This matters with src/ layout because imports should resolve through the installed/editable package, not from files sitting beside the tests.

The Pyright config belongs at the root because Pyright has to understand all package roots and their src/ directories from one invocation context. Execution environments are required because each package has its own src/ root. Without them, running Pyright on packages/<package-name> from the repository root does not reliably infer that packages/<package-name>/src is an import root. Add sibling src paths only where that package imports sibling workspace packages. The uv workspace handles runtime/editable resolution; Pyright still needs static import roots.

Packages

Each package has a layout like this:

<repo>/
  packages/
    <package-name>/
      src/
        <package-import-file-path>/
          assets/
            data.bin
          __init__.py
          py.typed
      tests/
      scripts/
      docs/
      .vscode -> ../../.vscode
      ci.bash -> ../../repo/ci.bash
      pyproject.toml

Directory names under packages/ may be distribution names, including dotted names such as <a>.<b>. Dotted package directory names like packages/<a>.<b>/ are non-standard but valid as repository organization.

distribution: <a>.<b>
path:         packages/<a>.<b>/
import:       <a>.<b>
files:        packages/<a>.<b>/src/<a>/<b>/

Build Backend

Use hatchling.

Package runtime assets inside src/<package-import-file-path>/assets if they exist.

Package Symlinks

Package .vscode entries should be symlinks to the shared root editor configuration:

packages/<package-name>/.vscode -> ../../.vscode

Package ci.bash files should be symlinks to one shared root script:

packages/<package-name>/ci.bash -> ../../repo/ci.bash

The shared script computes the package path relative to the repository root and runs root-level tooling scoped to that package.

CI

There is a workspace root ci.bash which invokes all package level ci.bash scripts. The package level ci.bash scripts are symlinked linked to a common <repo>/repo/ci.bash file.

The root level <repo>/ci.bash:

#!/usr/bin/env bash

set -euo pipefail

cd "$(dirname "$0")"

# Run CI for all subprojects
for dir in \
    packages/<package-name> \
; do
    bash "$dir/ci.bash"
done

The symlinked package level <repo>/repo/ci.bash:

#!/usr/bin/env bash

set -euo pipefail

script_dir="$(cd "$(dirname "$0")" && pwd)"
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel)"
scope="${script_dir#"$repo_root"/}"

RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[1;34m'
RESET='\033[0m'

run() {
    if "$@"; then
        echo -e "${GREEN}${RESET} ${BLUE}[$scope]${RESET} $*"
    else
        exit_status=$?
        echo -e "${RED}${RESET} ${BLUE}[$scope]${RESET} $* failed with exit code ${exit_status}" >&2
        exit $exit_status
    fi
}

pushd "$repo_root" >/dev/null

run uv run --exact -- ruff check --fix --quiet "$scope"
run uv run --exact -- ruff format --quiet "$scope"
run uv run --exact -- pyright "$scope"
run uv run --exact -- ppytest --pytest-arg=-vv "$scope"

popd >/dev/null
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment