Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Python Workspaces

Turnkey lays out Python source as a uv workspace, in parallel to the Cargo workspace pattern used for Rust. A single uv.lock resolves every Python package in the monorepo against a consistent dependency set, while each package keeps its own pyproject.toml declaring exactly what it consumes.

Two tracks run side by side over the same source:

  • uv track — uv sync, uv run, IDE language servers, REPL. Members are installed editable so source edits are reflected immediately.
  • Buck2 track — tk build, tk test. External packages are vendored into the pydeps cell built from python-deps.toml.

Repository Layout

/repo/
├── pyproject.toml                       # Workspace root: members + uv.lock anchor
├── uv.lock                              # Single resolved lockfile (managed by uv)
├── pylock.toml                          # PEP 751 export from uv.lock
├── python-deps.toml                     # Generated for Buck2/Nix from pylock.toml
└── src/python/<member>/
    ├── pyproject.toml                   # [project] + hatchling build backend
    ├── rules.star                       # Buck2 targets for the member
    └── turnkey/<member>/                # Source under shared turnkey.* namespace
        ├── __init__.py
        └── ...

Tests live in a sibling tests/ directory inside each member, kept outside the importable namespace.

The turnkey.* Namespace Convention

Every workspace member contributes a subpackage under the shared turnkey PEP 420 implicit namespace package. No member defines a top-level turnkey/__init__.py; Python's import system resolves turnkey.parser, turnkey.config, etc. by walking every sys.path entry that exposes a turnkey/<name>/ directory.

Downstream Projects: Pick Your Own Namespace

The turnkey.* prefix is this repository's namespace. If you adopt the same workspace pattern in a different monorepo, choose a namespace specific to your organisation — e.g. acme.<name> — to avoid colliding with packages on PyPI or other turnkey-based repos. The mechanics are identical; substitute turnkey for your namespace throughout this guide.

Member pyproject.toml

Each library member uses the hatchling backend and points it at the turnkey/ directory:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "turnkey-parser"
version = "0.1.0"
description = "Cargo manifest and feature-graph utilities"
requires-python = ">=3.11"
dependencies = [
    "turnkey-config",       # cross-member dep
]

[tool.uv.sources]
turnkey-config = { workspace = true }

[tool.hatch.build.targets.wheel]
packages = ["turnkey"]   # everything under turnkey/<name>/ is the wheel content

packages = ["turnkey"] is the key line: it tells hatchling that the wheel's content is whatever lives under the turnkey/ directory of this member. Combined with PEP 420 namespace resolution, every member ships only its own turnkey/<name>/ slice without anyone owning turnkey/__init__.py.

Cross-Member Dependencies

Declare the dep under [project] dependencies with the bare package name, then pin its source to the workspace under [tool.uv.sources]:

dependencies = ["turnkey-config"]

[tool.uv.sources]
turnkey-config = { workspace = true }

This mirrors Cargo.toml's serde.workspace = true pattern — the consumer member doesn't pin a version, the lockfile reconciles it.

External Dependencies

Declare externals in the member that consumes them, never the workspace root:

# src/examples/python-hello-deps/pyproject.toml
[project]
name = "turnkey-example-python-hello-deps"
dependencies = ["six>=1.16.0"]

The single uv.lock at the workspace root resolves every external version-consistently across members.

Non-Packaged Members

Some members exist only to declare dependencies, not to be installed (typical for application-like entrypoints or examples). Mark them non-packaged:

[project]
name = "turnkey-example-python-hello-deps"
version = "0.1.0"
dependencies = ["six>=1.16.0"]

[tool.uv]
package = false        # uv won't build/install this member

No [build-system] is required. uv still resolves the member's dependencies as part of the workspace lock.

Root pyproject.toml

The workspace root anchors membership and the shared lockfile:

[project]
name = "turnkey"
version = "0.1.0"
requires-python = ">=3.11"

# Listing members as dependencies makes the default `uv sync` install all
# of them in one shot — no `--all-packages` flag needed.
dependencies = [
    "turnkey-parser",
    "turnkey-config",
    "turnkey-example-python-hello",
    "turnkey-example-python-hello-deps",
]

[dependency-groups]
# Dev tooling — auto-installed by 'uv sync' so 'uv run pytest' Just Works.
dev = ["pytest>=7.0"]

[tool.uv.workspace]
members = [
    "src/python/parser",
    "src/python/config",
    "src/examples/python-hello",
    "src/examples/python-hello-deps",
]

[tool.uv.sources]
turnkey-parser = { workspace = true }
turnkey-config = { workspace = true }
turnkey-example-python-hello = { workspace = true }
turnkey-example-python-hello-deps = { workspace = true }

[tool.uv]
package = false        # the root itself isn't a packaged project

Buck2 Integration

Member source paths are spelled relative to the member's rules.star:

load("@prelude//:rules.bzl", "python_library", "python_test")

python_library(
    name = "parser",
    srcs = [
        "turnkey/parser/__init__.py",
        "turnkey/parser/grammar.py",
        "turnkey/parser/tokens.py",
    ],
    base_module = "",
    deps = ["//src/python/config:config"],
    visibility = ["PUBLIC"],
)

python_test(
    name = "test_parser",
    srcs = ["tests/test_parser.py"],
    base_module = "tests",
    deps = [":parser"],
)

base_module = "" tells Buck2 to install sources at their declared srcs paths, so files land at turnkey/parser/... in the runtime tree — matching the import prefix the rest of the codebase uses.

Adding or Updating Dependencies

# 1. Edit the member that needs the dep
$EDITOR src/python/parser/pyproject.toml      # add to [project] dependencies

# 2. Refresh editable installs (optional but recommended)
uv sync

# 3. Refresh the Buck2 pipeline
tk sync

uv add and uv remove do the same in one step: the uv wrapper runs tk sync itself when they change uv.lock.

tk sync runs two rules, in order:

  1. pylock re-exports pylock.toml from uv.lock when uv.lock or pyproject.toml is newer, relocking first if pyproject.toml changed: uv export --all-packages --no-dev --format pylock.toml. --all-packages includes externals from every member, and --no-dev keeps dev tooling (pytest etc.) out of the pydeps cell.
  2. python regenerates python-deps.toml from pylock.toml, and from uv.lock the dependency graph: each dependency's environment marker and each package's extras.

The pylock rule exists when the flake sets buck2.python.uvLockFile (turnkey's own flake sets it to uv.lock) along with buck2.python.lockFile.

One Version per Distribution

The pydeps cell holds one version of each distribution, at pydeps//vendor/<name>:<name> (ADR 0010). uv can lock several: when the resolution forks on a marker, for example numpy 1.x for python_version < '3.10' and 2.x above, the lock holds one entry per fork. pydeps-gen then fails, writes nothing, and names the distribution with each locked version and its marker:

pylock.toml locks several versions of one distribution, and the pydeps cell holds one version per distribution.
2 versions of numpy:
  numpy 1.26.4 (python_full_version < '3.10')
  numpy 2.1.0 (python_full_version >= '3.10')
Pin it, in the pyproject.toml that depends on it, to a range one version satisfies on every Python the workspace allows, so the lock no longer forks; then run tk sync.

Pin the dependency so the lock no longer forks: constrain it, in the pyproject.toml of the member that depends on it, to a range one version satisfies for every Python the workspace allows (numpy>=2.1), or raise the workspace's requires-python so the fork's other branch can't happen. Then run tk sync, which relocks and regenerates python-deps.toml.

Running Code

Taskuv trackBuck2 track
Run all testsuv run pytesttk test //src/python/...
Run a single member's testsuv run pytest src/python/parsertk test //src/python/parser:test_parser
Run an exampleuv run --package <pkg-name> <script>tk run //src/examples/python-hello-deps:python-hello-deps
REPL with members availableuv run pythonn/a
IDE language serverPoint at .venv/bin/pythonn/a

Both tracks resolve external dependencies the same way (uv.lock is the single source of truth), but the install paths differ: the uv track installs into .venv/, the Buck2 track materialises external packages into .turnkey/pydeps/vendor/<name>/.

See Also