- PEP 420 - Implicit Namespace Packages
- PEP 517 - Build-system independent format
- PEP 518 -
pyproject.tomlbuild-system requirements - PEP 621 - Project metadata in
pyproject.toml - PEP 660 - Editable installs for
pyproject.tomlbuilds - PyPA src layout vs flat layout
- PyPA package discovery and namespace packages
- uv workspaces
<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 undersrc/for a package.
<repo>/
pyproject.toml
repo/
ci.bash
packages/
<package-name>/
ci.bash
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.
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>/
Use hatchling.
Package runtime assets inside src/<package-import-file-path>/assets if they exist.
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.
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"
doneThe 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