Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .dagger/modules/e2e/main.dang
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ type E2e {
null
}

"""
Lookup should refuse a module this SDK does not manage, rather than handing
back a Mod for someone else's module.
"""
pub unmanagedLookupCheck(ws: Workspace!): Void @check {
let found = pythonSdk.mod(ws, path: nonPythonModulePath).rootPath rescue "raised"

assert(found == "raised", "lookup returned a module this SDK does not manage: " + found)

null
}

"""
Lookup must choose the nearest module config even when its filename differs
from an ancestor config, and modern TOML modules must be supported.
Expand Down Expand Up @@ -168,6 +180,32 @@ type E2e {
null
}

"""
Init into a directory that already holds files should layer the template onto
it, never replace it: a user running init inside an existing project keeps
their files.
"""
pub initOverExistingCheck(ws: Workspace!): Void @check {
let changes = pythonSdk.initModule(ws, name: "init-over-existing", path: lookupModulePath)

assert(
changes.removedPaths.length == 0,
"init removed existing files: " + changes.removedPaths.join(", "),
)
assertContains(
changes.after.file(lookupModulePath + "/src/lookup_app/__init__.py").contents,
"class LookupApp:",
"init must leave the existing module source in place",
)
assertContains(
changes.after.file(lookupModulePath + "/src/init_over_existing/__init__.py").contents,
"class InitOverExisting:",
"init did not render the template into the existing directory",
)

null
}

"""
The default template should render a working module that reads source from
the workspace and returns a ready-to-build container. The empty template
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Shared, language-agnostic operations — editing a module's dependencies or its
required engine version — are owned by the core CLI (`dagger module deps`,
`dagger module engine`) and are no longer part of this module's surface.

Backed by [`github.com/dagger/sdk-sdk/polyfill`](https://github.com/dagger/sdk-sdk/tree/main/polyfill).
It uses the engine's native `Workspace` and `ModuleSource` APIs directly.

## Install

Expand Down Expand Up @@ -48,8 +48,9 @@ dagger module init python my-module \
--base-image python:3.13-slim
```

`--template` picks a starter template (`minimal` is the default; `legacy` gives
you a container-echo example). The three `pyproject.toml` flags are optional; by
`--template` picks a starter template: `default` (a small working module) when
you pass nothing, `empty` for a bare object class, or `legacy` for a
container-echo example. The three `pyproject.toml` flags are optional; by
default the template's Python version is used, uv is enabled, and no base image
override is written.

Expand Down Expand Up @@ -120,10 +121,9 @@ dagger call python-sdk modules path
```

> [!NOTE]
> `modules` and `generate-all` discover modules by scanning legacy
> `dagger.json` files for `sdk.source == "python"`. This is obsolete for
> workspace-managed modules, where the engine owns the
> `modules.<sdk>.as-sdk.modules` source of truth.
> `modules` and `generate-all` read the modules registered under
> `modules.<sdk>.as-sdk.modules`, which the engine owns and narrows to the
> caller's cwd. Nothing scans module config files.

See [`python-sdk.dang`](./python-sdk.dang) for the full type surface.

Expand Down
11 changes: 2 additions & 9 deletions dagger.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
{
"name": "python-sdk",
"engineVersion": "v1.0.0-0",
"engineVersion": "v1.0.0-beta.10",
"sdk": {
"source": "dang"
},
"dependencies": [
{
"name": "polyfill",
"source": "github.com/dagger/polyfill@main",
"pin": "16627066d1852106320bdc0cfa0e5f901efe5970"
}
]
}
}
7 changes: 4 additions & 3 deletions dagger.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[["version","1"]]
["","git.head",["https://github.com/dagger/sdk-sdk"],"e1747f4b6221fa24da080701e027243e0cc5fa33","float"]
["","git.ref",["https://github.com/dagger/polyfill","main"],"ec3ea84a2351b4beb06ecece951f2e5ef66509ff","float"]
[["version","2"]]
["","container.from",["docker.io/library/alpine:3.22","linux/amd64"],"sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"]
["","container.from",["docker.io/library/golang:1.25-alpine","linux/amd64"],"sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"]
["","git.ref",["https://github.com/dagger/sdk-sdk","HEAD"],{"ref":"refs/heads/main","sha":"00bb06748bcf22d724ed467f2298d31f1fb49be0"}]
6 changes: 6 additions & 0 deletions dagger.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ path = ".dagger/modules/e2e/fixtures/toml/app"

[[modules.python-sdk.as-sdk.modules]]
path = ".dagger/modules/e2e/fixtures/mixed-discovery/ancestor/work/app"

[[modules.python-sdk.as-sdk.modules]]
path = ".dagger/modules/e2e/fixtures/config/app"

[[modules.python-sdk.as-sdk.modules]]
path = ".dagger/modules/e2e/fixtures/config/configured"
25 changes: 15 additions & 10 deletions docs/cwd-aware-discovery.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
# CWD-aware module discovery

The Python SDK delegates config discovery to `github.com/dagger/polyfill`, then
intersects the discovered directories with `currentModule.asSDK.modules`. The
engine's managed-module list remains authoritative while the caller's current
directory determines scope.
The Python SDK asks
`currentModule.asSDK(workspace: ws).modules` for the registered modules
relevant to the caller's current directory. The engine owns both membership and
scope selection, so the SDK does not scan config files or reconstruct the cwd
policy.

Discovery returns modules at or below the cwd and, when the cwd has no module
config, its nearest enclosing module. Both `dagger-module.toml` and legacy
`dagger.json` are considered together, so the nearest config wins regardless of
filename. Virtual environments and installed packages are excluded.
Selection returns modules at or below the cwd and, when the cwd itself is not a
registered module, its nearest registered ancestor.

`mod` resolves a single module from the same list, re-anchoring the workspace at
the requested path so the engine scopes the selection there. A path that no
registered module contains is an error rather than a module this SDK does not
manage.

```console
dagger check -l
dagger call e-2-e mixed-config-lookup-check
dagger call e-2-e module-discovery-check
dagger call e-2-e unmanaged-lookup-check
```

The fixtures cover mixed nested config formats, modern and legacy configs,
non-Python exclusion, root discovery, and discovery from inside a module.
The fixtures cover mixed nested config formats, modern and legacy modules,
non-Python exclusion, root selection, and selection from inside a module.
2 changes: 1 addition & 1 deletion mod-config.dang
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ type ModConfig {
}

let edited = withImage.file(toolPath).contents
polyfill.workspace(ws).fork.withNewFile(pyprojectPath, edited).changes
ws.withNewFile("/" + pyprojectPath, edited).changes(ws)
}

"""
Expand Down
7 changes: 2 additions & 5 deletions mod.dang
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,9 @@ type Mod {
"""
pub generate: Changeset! {
if (skipGenerate) {
polyfill.workspace(ws).fork.changes
ws.changes(ws)
} else {
# Stage the local dependency closure so this module's codegen sees
# up-to-date dependency bindings before generating it.
let stagedWs = ws.withChanges(polyfill.workspace(ws).moduleSource("/" + rootPath).core.generateLocalDependencies(ws))
polyfill.workspace(stagedWs).moduleSource("/" + rootPath).generate.changes
ws.moduleSource("/" + rootPath).generate(ws).changes(ws)
}
}
}
130 changes: 60 additions & 70 deletions python-sdk.dang
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
Manage Dagger modules that use the Python SDK.
"""
type PythonSdk {
let legacyConfigPattern = "\"sdk\"\\s*:\\s*\\{[^}]*\"source\"\\s*:\\s*\"python\""
let tomlConfigPattern = "\\[runtime\\][^\\[]*source\\s*=\\s*\"python\""
let moduleConfigFilenames: [String!]! = ["dagger-module.toml", "dagger.json"]

"""
Marker filename that skips generate when found at or above a Python SDK module root.
"""
Expand All @@ -17,34 +13,16 @@ type PythonSdk {
pub targetRuntime: String! { "python" }

"""
Return every managed Python SDK module visible from the client's cwd: the
nearest enclosing module plus modules at or below the cwd. Discovery uses the
shared polyfill and intersects its results with the SDK list on the passed
workspace.
Return every managed Python SDK module in the client's cwd scope: every module
at or below the cwd, plus the nearest enclosing module when the cwd itself is
not managed. The engine selects this directly from the workspace's registered
SDK modules; no filesystem discovery is required.
"""
pub modules(ws: Workspace!): [Mod!]! {
let managed = currentModule.asSDK(workspace: ws).modules.{{path}}
let cwd = normalizePath(ws.cwd)
polyfill.workspace(ws)
.findConfigDirs(moduleConfigFilenames, exclude: ["**/.venv/**", "**/site-packages/**"])
.map { dir => workspacePath(cwd, dir) }
.uniq
.filter { path => managed.filter { m => normalizePath(m.path) == path }.length > 0 }
.map { path => Mod(rootPath: path, ws: ws, skipGenerateFilename: skipGenerateFilename) }
}

let workspacePath(cwd: String!, path: String!): String! {
let base = if (cwd == ".") { [] } else { cwd.split("/") }
let segments = path.split("/").reduce(base) { acc, segment =>
if (segment == "..") {
acc.dropLast(1)
} else if (segment == "." or segment == "") {
acc
} else {
acc + [segment]
}
}
if (segments.length == 0) { "." } else { segments.join("/") }
currentModule
.asSDK(workspace: ws)
.modules.{{path}}
.map { module => Mod(rootPath: module.path, ws: ws, skipGenerateFilename: skipGenerateFilename) }
}

let normalizePath(path: String!): String! {
Expand All @@ -56,8 +34,8 @@ type PythonSdk {
if (path == null) { -1 } else if (path == ".") { 0 } else { path.split("/").length }
}

let configDir(path: String!, filename: String!): String! {
normalizePath(path.trimPrefix("/").trimSuffix(filename))
let pathContains(root: String!, path: String!): Boolean! {
root == "." or path == root or path.trimPrefix(root + "/") != path
}

"""
Expand Down Expand Up @@ -87,26 +65,15 @@ type PythonSdk {
}

"""
Return the Python SDK module at or above a workspace path.
Return the managed Python SDK module at or above a workspace path.

When `findUp` is true, `path` may point inside the module.
When `findUp` is true, `path` may point inside the module; the nearest
enclosing module wins. When it is false, `path` is taken as the module root
as given, without asking whether this SDK manages it.
"""
pub mod(ws: Workspace!, path: String! = ".", findUp: Boolean! = true): Mod! {
let modPath = if (findUp) {
let foundToml = ws.findUp("dagger-module.toml", path)
let foundJson = ws.findUp("dagger.json", path)
let tomlBase = if (foundToml == null) { null } else { configDir(foundToml, "dagger-module.toml") }
let jsonBase = if (foundJson == null) { null } else { configDir(foundJson, "dagger.json") }

if (tomlBase == null and jsonBase == null) {
raise "no Dagger module found containing path: " + path
} else if (tomlBase != null and pathDepth(tomlBase) > pathDepth(jsonBase)) {
validateConfig(ws, tomlBase, "dagger-module.toml", tomlConfigPattern, path)
} else if (jsonBase != null) {
validateConfig(ws, jsonBase, "dagger.json", legacyConfigPattern, path)
} else {
raise "no Dagger module found containing path: " + path
}
managedModuleAtOrAbove(ws, path)
} else {
normalizePath(path)
}
Expand All @@ -118,15 +85,30 @@ type PythonSdk {
)
}

let validateConfig(ws: Workspace!, base: String!, filename: String!, pattern: String!, requestedPath: String!): String! {
let configPath = if (base == ".") { filename } else { base + "/" + filename }
let matches = ws
.directory("/", include: [configPath])
.file(configPath)
.search(pattern: pattern, multiline: true, dotall: true, limit: 1)
.{{id}}
.length > 0
if (matches) { base } else { raise "Dagger module does not use the Python SDK: " + requestedPath }
"""
Root of the nearest managed Python SDK module containing `path`.

Re-anchoring the workspace at `path` makes the engine scope its SDK module
list to that directory, which is the same selection `modules` reads. Nothing
here parses module config: registration is what makes a module ours.
"""
let managedModuleAtOrAbove(ws: Workspace!, path: String!): String! {
let scopedWs = ws.withWorkdir(path)
let requested = normalizePath(scopedWs.cwd)
let found = currentModule
.asSDK(workspace: scopedWs)
.modules.{{path}}
.map { module => normalizePath(module.path) }
.filter { root => pathContains(root, requested) }
.reduce(null) { best, root =>
if (pathDepth(root) > pathDepth(best)) { root } else { best }
}

if (found == null) {
raise "no managed Python SDK module found containing path: " + path
} else {
found
}
}

"""
Expand Down Expand Up @@ -162,9 +144,24 @@ type PythonSdk {
} else {
let templateSource = configuredTemplate(renderedTemplate(name, selectedTemplate), pythonVersion, useUv, baseImage)

polyfill.workspace(ws).fork
.withDirectory(modPath, templateSource)
.changes
# withNewDirectory replaces the directory it writes, so layer the
# template onto what is already there: init must never remove a user's
# files.
ws
.withNewDirectory("/" + modPath, existingDir(ws, modPath).withDirectory(".", templateSource))
.changes(ws)
}
}

"""
Existing contents of a workspace directory, empty when it does not exist yet.
"""
let existingDir(ws: Workspace!, path: String!): Directory! {
let filtered = ws.directory("/", include: [path + "/**"])
if (filtered.exists(path)) {
filtered.directory(path)
} else {
directory
}
}

Expand Down Expand Up @@ -219,18 +216,11 @@ type PythonSdk {
Modules with the generate skip marker are skipped.
"""
pub generateAll(ws: Workspace!): Changeset! @generate {
let pws = polyfill.workspace(ws)

modules(ws)
.filter { mod => mod.skipGenerate == false }
.reduce(pws.fork) { fork, mod =>
# Stage this module's local dependency closure first (leaf-first, possibly
# across SDKs) so its codegen sees up-to-date dependency bindings. The dep
# codegen is ephemeral: it appears in both the fork's before and after, so
# it cancels in the merge, leaving only each module's own changes.
let stagedWs = ws.withChanges(pws.moduleSource("/" + mod.rootPath).core.generateLocalDependencies(ws))
fork.merge(polyfill.workspace(stagedWs).moduleSource("/" + mod.rootPath).generate)
.reduce(ws) { stagedWs, mod =>
stagedWs.moduleSource("/" + mod.rootPath).generate(stagedWs)
}
.changes
.changes(ws)
}
}