Skip to content

pyproject

A Dagger module that discovers the Python projects and workspaces in a Dagger workspace, and loads only the files each one needs. It runs no tools itself: it is the discovery layer for modules that do.

It exposes two collections, both keyed by path relative to the workspace root (. for the root):

  • projects: every directory holding a pyproject.toml.
  • workspaces: every directory holding a workspace marker, uv.lock or pylock.toml by default. A workspace is an environment root: it installs its members and their local dependencies.

Installation

$ dagger install github.com/typesafe-ai/daggerverse/pyproject

Usage

$ dagger -c 'pyproject | workspaces | keys'
$ dagger -c 'pyproject | workspaces | get services/api | members'
$ dagger -c 'pyproject | projects | get services/api | source | entries'
from dagger_clients.pyproject import pyproject

found = pyproject(ws, exclude=["**/tests/fixtures/**"])
# `workspace_list` reads every workspace in one call; listing the collection
# gets each workspace with its own call.
for workspace in await found.workspace_list():
    deps = workspace.manifests()  # resolver inputs only
    src = workspace.source()  # members and their local dependencies

Projects

Field Meaning
path Project root.
name [project].name, or null.
generated Whether Dagger generated this project ([tool.dagger] generated), such as a module's sdk/ or clients/*. Tools that check user code should skip it.
workspace Root of the workspace that installs this project as a member, or null.
nested Topmost projects nested inside this one. Their files are their own.
local_dependencies Projects this one depends on through [tool.uv.sources] path and workspace sources, transitively, wherever they live.

source() returns the project's own files plus its local dependencies'. Paths stay relative to the workspace root, so a path source like ../lib resolves once the directory is mounted as the root. files() returns only the files the project owns, its tree less nested projects, for tools such as linters that work on one project's own code, and files_include/files_exclude are its patterns. pyproject() returns the manifest.

Workspaces

Field Meaning
path Workspace root.
lockfiles Markers found at the root, e.g. uv.lock.
members Member projects, the root project first.
local_dependencies Projects the members depend on through path sources, outside the membership.

A uv workspace's members are the root plus every project its [tool.uv.workspace] members globs select and exclude doesn't, as uv decides. Any other workspace has its root project alone, or none for a bare lockfile.

  • projects() is the collection of the members.
  • source() returns every member's files and their local dependencies'.
  • manifests() returns only what dependency resolution reads: the markers, uv.toml, and each member's and local dependency's pyproject.toml and .python-version. Build a dependency layer from it (e.g. uv sync --frozen --no-install-local) and source edits leave that layer cached.

How sources are scoped

Discovery skips gitignored directories, so a gitignored copy of the repository (such as an agent's worktree) adds no projects. Every file belongs to the deepest project containing it. A source load includes the project trees it needs and leaves out any other project nested inside them. A monorepo root's source carries only the root's own files, and a workspace's source leaves out test fixtures nested in a member. Caches, virtualenvs, build output, VCS metadata and gitignored files are always left out.

Path sources that point at missing directories are kept and load nothing, so discovery still works on a partial checkout. Path sources above the workspace root can't be loaded and are skipped.

Caching

The functions load from the workspace pyproject was given. A caller that only needs discovery can pass a workspace of just the files it reads:

discovery_ws = ws.directory(
    "/", include=["**/pyproject.toml", "**/.gitignore", "**/uv.lock", "**/pylock.toml"], gitignore=True
).as_workspace()

A workspace made this way and passed to a module is keyed by its contents, so discovery stays cached until one of those files changes. Load sources from your own workspace with the include/exclude patterns.

Settings

dagger.toml
[modules.pyproject.settings]
# Glob patterns of project and workspace roots to leave out of both collections.
exclude = ["**/tests/_packages/**"]
# File names that make a directory a workspace.
workspaceMarkers = ["uv.lock", "pylock.toml", "pylock.prod.toml"]

An excluded project is left out of the collections only. Its files still belong to it rather than to the project around it, and it still counts as a dependency. ** crosses directories, * doesn't, and a trailing /** also matches the directory itself.

Markers are exact file names. List each named PEP 751 lock, such as pylock.prod.toml, that should make a workspace.

Limitations

  • Only [tool.uv.sources] path and workspace sources are followed. A name @ file://… direct reference is not.
  • A malformed pyproject.toml fails discovery when a listed project or a dependency needs it. Exclude it to skip it.

Where to go next