Lock a workspace

Warning

Workspace support is experimental. Discovery rules and member validation may change.

A workspace is a parent project that declares one or more in-tree members. When nab lock runs against a member (or the root itself), the resolver prefers the in-tree members over PyPI for any package whose canonical name matches a member’s [project].name.

Declaring a workspace

The workspace root carries a [tool.nab.workspace] table:

# /repo/pyproject.toml
[tool.nab.workspace]
members = [
    "packages/core",
    "packages/extras",
    "tools/cli",
]

workspace is a project-scope key like any other, so the same table can be written at the top level of the project-directory nab.toml instead:

# /repo/nab.toml
[workspace]
members = ["packages/core", "packages/extras", "tools/cli"]

members is a list of literal directory paths relative to the workspace root. Globs (*, ?, [) are rejected; spell out each member.

Every listed member must contain a pyproject.toml with a [project].name. Two members cannot share the same canonical name (Foo_Bar and foo-bar collide). Both raise WorkspaceDiscoveryError at config-parse time.

How discovery works

The project being locked declares the workspace, in either of its two config files, and its members are read relative to that project’s directory. That is the path nab lock takes at a workspace root.

When nab lock <member>/pyproject.toml is invoked and the member declares no workspace of its own, nab walks upwards from the member’s directory for the first ancestor that declares one, in either file: [tool.nab.workspace] in its pyproject.toml, or [workspace] in its nab.toml. The pyproject.toml is checked first.

Each member of the matched workspace contributes a local source. The provider prefers those local sources over PyPI for any requirement whose canonical name matches.

Members lock as editable installs

Each member is recorded as an editable install by default, matching uv. Its pin renders as a PEP 660 editable entry: editable = true in pylock.toml, and -e file://... in the two requirements formats. An explicit [[tool.nab.local-sources]] entry defaults the other way, non-editable, and becomes editable only when its editable key is set to true.

Interaction with [[tool.nab.local-sources]]

Explicit [[tool.nab.local-sources]] entries always win. When a workspace-discovered member shares a canonical name with an explicit entry, the discovered entry is dropped and the override is logged at INFO so it can be audited. Use this to point a workspace member at a checkout outside the tree without removing it from the members list.

Build policy

Workspace members frequently declare dynamic = ["version"] or similar, which nab can only read by running the PEP 517 local backend. The default build-policy is build-local, so that build runs without any extra configuration.

A workspace does not raise the policy. Under build-policy = "never" a member with dynamic metadata and no static fallback cannot be read, and the lock fails naming it. Give every member static metadata to lock a workspace with no backend invocations.

Scope of [tool.nab] keys

Workspace discovery flows these from the root into the file being locked: the workspace members, merged into local-sources. Everything else under [tool.nab] is scoped to the pyproject being locked: conflicts, default-groups, constraints, matrix, mode, requires-python, uploaded-prior-to, build-policy itself, dist-policy, vcs, indexes, and environment. Locking a member with nab lock packages/core/pyproject.toml reads only that file’s keys; the root’s are ignored.

Declare conflicts and default-groups on each member that needs them. The same applies to constraints: a root-level constraint table does not constrain a member resolve.

Example layout

/repo
├── pyproject.toml          # carries [tool.nab.workspace]
├── packages/
│   ├── core/
│   │   ├── pyproject.toml  # [project].name = "myorg-core"
│   │   └── src/...
│   └── extras/
│       ├── pyproject.toml  # [project].name = "myorg-extras"
│       └── src/...
└── tools/
    └── cli/
        ├── pyproject.toml  # [project].name = "myorg-cli"
        └── src/...
# /repo/pyproject.toml
[project]
name = "myorg"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "myorg-core",
    "myorg-extras",
    "myorg-cli",
]

[tool.nab.workspace]
members = [
    "packages/core",
    "packages/extras",
    "tools/cli",
]

nab lock /repo/pyproject.toml resolves the three workspace dependencies against the in-tree directories rather than fetching them from PyPI. Transitive dependencies of each member are still resolved through the configured indexes.