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 thepydepscell built frompython-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:
- pylock re-exports
pylock.tomlfromuv.lockwhenuv.lockorpyproject.tomlis newer, relocking first ifpyproject.tomlchanged:uv export --all-packages --no-dev --format pylock.toml.--all-packagesincludes externals from every member, and--no-devkeeps dev tooling (pytest etc.) out of the pydeps cell. - python regenerates
python-deps.tomlfrompylock.toml, and fromuv.lockthe 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
| Task | uv track | Buck2 track |
|---|---|---|
| Run all tests | uv run pytest | tk test //src/python/... |
| Run a single member's tests | uv run pytest src/python/parser | tk test //src/python/parser:test_parser |
| Run an example | uv run --package <pkg-name> <script> | tk run //src/examples/python-hello-deps:python-hello-deps |
| REPL with members available | uv run python | n/a |
| IDE language server | Point at .venv/bin/python | n/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
- Managing Dependencies — overall dependency flow across all languages.
- Python — Buck2 build rules for Python targets.