From a21739bd6f568a665b18265f0ed08ebb3d979689 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 22:56:08 +0200 Subject: [PATCH 1/9] helpers/pyproject: edit the script block of a .py file Signed-off-by: Yves Brissaud --- helpers/pyproject/main.go | 81 ++++++++---- helpers/pyproject/main_test.go | 213 +++++++++++++++++++++++++++++++ helpers/pyproject/pyproject.go | 22 ++++ helpers/pyproject/python.go | 35 ++++- helpers/pyproject/python_test.go | 6 + helpers/pyproject/scope.go | 106 ++++++++++----- helpers/pyproject/scope_test.go | 81 ++++++++++++ helpers/pyproject/script.go | 192 ++++++++++++++++++++++++++++ 8 files changed, 673 insertions(+), 63 deletions(-) create mode 100644 helpers/pyproject/script.go diff --git a/helpers/pyproject/main.go b/helpers/pyproject/main.go index d4c64ee..8eeca9b 100644 --- a/helpers/pyproject/main.go +++ b/helpers/pyproject/main.go @@ -31,6 +31,10 @@ func main() { // get-generated prints nothing for a file that does not parse: such a file is // no member of the SDK's, which is all its callers ask. // +// Every command that takes a file reads a .py file as its PEP 723 script +// block, and writes back only the lines of the block it changes. A script +// holds at its top level what a pyproject.toml holds under [project]. +// // usage: pyproject [value | flags] func run(args []string) error { if len(args) < 2 { @@ -74,13 +78,29 @@ func run(args []string) error { if err != nil { return err } + var block *scriptBlock + if isScript(path) { + if block, err = readScriptBlock(path, data); err != nil { + return err + } + data = block.toml() + } + write := func(out []byte) error { + if string(out) == string(data) { + return nil + } + if block != nil { + out = block.embed(out) + } + return os.WriteFile(path, out, 0o644) + } switch cmd { case "edit-scope": - return runEditScope(path, data, args[2:]) + return runEditScope(data, args[2:], block != nil, write) case "set-global-client": - return runSetGlobalClient(path, data, args) + return runSetGlobalClient(data, args, write) case "remove-table": - return runRemoveTable(path, data, args) + return runRemoveTable(data, args, write) case "get-generated": fmt.Print(generatedKind(data)) return nil @@ -89,6 +109,9 @@ func run(args []string) error { if err != nil { return err } + if block != nil { + doc = asProject(doc) + } switch cmd { case "get-python-version": @@ -107,6 +130,9 @@ func run(args []string) error { case "get-members": fmt.Print(getMembers(doc)) return nil + case "get-path-sources": + fmt.Print(getPathSources(doc)) + return nil case "get-global-client": if v, ok := getGlobalClient(doc); ok { fmt.Print(boolStr(v)) @@ -134,33 +160,51 @@ func run(args []string) error { return fmt.Errorf("unknown command: %s", cmd) } + if block != nil { + doc = fromProject(doc) + } out, err := dump(doc) if err != nil { return err } - return os.WriteFile(path, out, 0o644) + return write(out) } -// runEditScope sets the SDK-owned entries of a scope pyproject.toml, and -// leaves the file alone when nothing changes. +// runEditScope sets the SDK-owned entries of a scope pyproject.toml or +// script, and leaves the file alone when nothing changes. With +// --path-sources, each source names its member directory, as S=DIR, and is +// written as a path source; no workspace is written, since a script cannot +// be a workspace root. // // usage: pyproject edit-scope [--member M]... [--stale-member M]... [--source S]... -// [--dependency D]... [--global-client true|false] -func runEditScope(path string, data []byte, args []string) error { +// [--dependency D]... [--global-client true|false] [--path-sources] +func runEditScope(data []byte, args []string, script bool, write func([]byte) error) error { flags := flag.NewFlagSet("edit-scope", flag.ContinueOnError) flags.SetOutput(os.Stderr) - var edit scopeEdit + edit := scopeEdit{Script: script} flags.Var((*repeated)(&edit.Members), "member", "a workspace member the SDK generates") flags.Var((*repeated)(&edit.Stale), "stale-member", "a workspace member the SDK generated before and removes now") flags.Var((*repeated)(&edit.Sources), "source", "a distribution that resolves to a workspace member") flags.Var((*repeated)(&edit.Dependencies), "dependency", "a generated distribution the project depends on") globalClient := flags.String("global-client", "", "write (true) or clear (false) the global client flag") + pathSources := flags.Bool("path-sources", false, "write each source as S=DIR, a path source, and no workspace") if err := flags.Parse(args); err != nil { return err } if flags.NArg() > 0 { return fmt.Errorf("edit-scope: unexpected argument: %s", flags.Arg(0)) } + if *pathSources { + edit.Paths = map[string]string{} + for i, source := range edit.Sources { + name, dir, ok := strings.Cut(source, "=") + if !ok || name == "" || dir == "" { + return fmt.Errorf("edit-scope: --path-sources takes each source as NAME=DIR, not %q", source) + } + edit.Sources[i] = name + edit.Paths[normalizeDistribution(name)] = dir + } + } switch *globalClient { case "": case "true", "false": @@ -173,17 +217,14 @@ func runEditScope(path string, data []byte, args []string) error { if err != nil { return err } - if string(out) == string(data) { - return nil - } - return os.WriteFile(path, out, 0o644) + return write(out) } // runSetGlobalClient writes or clears the global client flag and nothing // else, so clearing it gives back the file as it was before it was set. // // usage: pyproject set-global-client true|false -func runSetGlobalClient(path string, data []byte, args []string) error { +func runSetGlobalClient(data []byte, args []string, write func([]byte) error) error { v, err := value(args) if err != nil { return err @@ -195,10 +236,7 @@ func runSetGlobalClient(path string, data []byte, args []string) error { if err != nil { return err } - if string(out) == string(data) { - return nil - } - return os.WriteFile(path, out, 0o644) + return write(out) } // repeated collects every value of a flag given more than once. @@ -215,7 +253,7 @@ func (r *repeated) Set(v string) error { // when it has no such table. // // usage: pyproject remove-table -func runRemoveTable(path string, data []byte, args []string) error { +func runRemoveTable(data []byte, args []string, write func([]byte) error) error { name, err := value(args) if err != nil { return err @@ -224,10 +262,7 @@ func runRemoveTable(path string, data []byte, args []string) error { if err != nil { return err } - if string(out) == string(data) { - return nil - } - return os.WriteFile(path, out, 0o644) + return write(out) } func value(args []string) (string, error) { diff --git a/helpers/pyproject/main_test.go b/helpers/pyproject/main_test.go index 77759d5..f4195d6 100644 --- a/helpers/pyproject/main_test.go +++ b/helpers/pyproject/main_test.go @@ -277,3 +277,216 @@ func TestRunRemoveTable(t *testing.T) { t.Error("expected an error when the table is missing") } } + +func writeScript(t *testing.T, contents string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "main.py") + if err := os.WriteFile(p, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// The code after the block, the comments in it, and a `# ` line, which +// reads as an empty line, keep their bytes. +const scriptModule = `#!/usr/bin/env python +# /// script +# requires-python = ">=3.14" +# dependencies = ["dagger-io", "dagger-clients-core"] +# +# [tool.uv.sources] +# # the user's comment +# dagger-io = { path = "sdk" } +# dagger-clients-core = { path = "clients/core" } +# /// + +from dagger import function, object_type + + +@object_type +class App: + @function + def hello(self) -> str: + return "# /// script" +` + +var scriptEditArgs = []string{ + "--path-sources", + "--source", "dagger-io=sdk", "--source", "dagger-clients-core=clients/core", + "--dependency", "dagger-clients-core", +} + +func TestRunEditScopeEditsAScriptBlock(t *testing.T) { + p := writeScript(t, scriptModule) + if err := run(append([]string{"edit-scope", p}, scriptEditArgs...)); err != nil { + t.Fatalf("edit-scope: %v", err) + } + if data, _ := os.ReadFile(p); string(data) != scriptModule { + t.Fatalf("a matching block was rewritten:\n%s", data) + } + + err := run(append([]string{"edit-scope", p}, append(scriptEditArgs, + "--source", "dagger-clients-linter=clients/linter", "--dependency", "dagger-clients-linter")...)) + if err != nil { + t.Fatalf("edit-scope: %v", err) + } + want := strings.NewReplacer( + `"dagger-clients-core"]`, `"dagger-clients-core", "dagger-clients-linter"]`, + "# dagger-clients-core = { path = \"clients/core\" }\n", + "# dagger-clients-core = { path = \"clients/core\" }\n# dagger-clients-linter = { path = \"clients/linter\" }\n", + ).Replace(scriptModule) + data, _ := os.ReadFile(p) + if string(data) != want { + t.Fatalf("got:\n%s\nwant:\n%s", data, want) + } + if strings.Contains(string(data), "workspace") { + t.Errorf("a script got a workspace:\n%s", data) + } + + if err := run(append([]string{"edit-scope", p}, scriptEditArgs...)); err != nil { + t.Fatalf("edit-scope: %v", err) + } + if data, _ := os.ReadFile(p); string(data) != scriptModule { + t.Errorf("removing the client did not give the file back:\n%s", data) + } +} + +// A file saved with CRLF line ends keeps them, in the lines the edit adds +// too, and a last line without its line end stays without one. +func TestRunEditScopeEditsACRLFScriptBlock(t *testing.T) { + crlf := strings.TrimSuffix(strings.ReplaceAll(scriptModule, "\n", "\r\n"), "\r\n") + p := writeScript(t, crlf) + if got := printed(t, "get-python-version", p); got != "3.14" { + t.Fatalf("get-python-version: got %q", got) + } + if err := run(append([]string{"edit-scope", p}, scriptEditArgs...)); err != nil { + t.Fatalf("edit-scope: %v", err) + } + if data, _ := os.ReadFile(p); string(data) != crlf { + t.Fatalf("a matching block was rewritten:\n%q", data) + } + + err := run(append([]string{"edit-scope", p}, append(scriptEditArgs, + "--source", "dagger-clients-linter=clients/linter", "--dependency", "dagger-clients-linter")...)) + if err != nil { + t.Fatalf("edit-scope: %v", err) + } + want := strings.NewReplacer( + `"dagger-clients-core"]`, `"dagger-clients-core", "dagger-clients-linter"]`, + "# dagger-clients-core = { path = \"clients/core\" }\r\n", + "# dagger-clients-core = { path = \"clients/core\" }\r\n# dagger-clients-linter = { path = \"clients/linter\" }\r\n", + ).Replace(crlf) + if data, _ := os.ReadFile(p); string(data) != want { + t.Fatalf("got:\n%q\nwant:\n%q", data, want) + } + + if err := run(append([]string{"edit-scope", p}, scriptEditArgs...)); err != nil { + t.Fatalf("edit-scope: %v", err) + } + if data, _ := os.ReadFile(p); string(data) != crlf { + t.Errorf("removing the client did not give the file back:\n%q", data) + } +} + +// A block with no content line is a block, since every field is optional. +// Setting the global client and clearing it gives the file back, and an edit +// writes into the block with the file's line ends. +func TestRunEditsAnEmptyScriptBlock(t *testing.T) { + for name, nl := range map[string]string{"LF": "\n", "CRLF": "\r\n"} { + t.Run(name, func(t *testing.T) { + src := "# /// script" + nl + "# ///" + nl + "print('hello')" + p := writeScript(t, src) + if got := printed(t, "get-path-sources", p); got != "" { + t.Fatalf("get-path-sources: got %q", got) + } + if err := run([]string{"set-global-client", p, "true"}); err != nil { + t.Fatalf("set: %v", err) + } + want := "# /// script" + nl + "# [tool.dagger]" + nl + "# global-client = true" + nl + "# ///" + nl + "print('hello')" + if data, _ := os.ReadFile(p); string(data) != want { + t.Fatalf("got:\n%q\nwant:\n%q", data, want) + } + if err := run([]string{"set-global-client", p, "false"}); err != nil { + t.Fatalf("clear: %v", err) + } + if data, _ := os.ReadFile(p); string(data) != src { + t.Fatalf("clearing the flag did not give the empty block back:\n%q", data) + } + + if err := run(append([]string{"edit-scope", p}, scriptEditArgs...)); err != nil { + t.Fatalf("edit-scope: %v", err) + } + if got := printed(t, "get-path-sources", p); got != "clients/core\nsdk\n" { + t.Errorf("get-path-sources after edit-scope: got %q", got) + } + data, _ := os.ReadFile(p) + if lines := strings.Split(string(data), nl); len(lines) < 4 || strings.Contains(strings.Join(lines, ""), "\n") { + t.Errorf("edit-scope wrote other line ends than %q:\n%q", nl, data) + } + }) + } +} + +func TestRunEditScopeTakesPathSourcesAsNameAndDirectory(t *testing.T) { + p := writeScript(t, scriptModule) + if err := run([]string{"edit-scope", p, "--path-sources", "--source", "dagger-io"}); err == nil { + t.Error("expected an error for a path source without its directory") + } +} + +func TestRunReadsAndSetsAScriptBlock(t *testing.T) { + p := writeScript(t, scriptModule) + if got := printed(t, "get-python-version", p); got != "3.14" { + t.Errorf("get-python-version: got %q", got) + } + if got := printed(t, "get-path-sources", p); got != "clients/core\nsdk\n" { + t.Errorf("get-path-sources: got %q", got) + } + if err := run([]string{"set-python-version", p, "3.13"}); err != nil { + t.Fatalf("set: %v", err) + } + data, _ := os.ReadFile(p) + block, err := readScriptBlock(p, data) + if err != nil { + t.Fatalf("the block does not read after set: %v\n%s", err, data) + } + doc := mustLoad(t, string(block.toml())) + if doc["requires-python"] != ">=3.13" || table(doc, "project") != nil { + t.Errorf("requires-python not set at the top of the block:\n%s", data) + } + if !strings.HasPrefix(string(data), "#!/usr/bin/env python\n# /// script\n") || + !strings.HasSuffix(string(data), scriptModule[strings.Index(scriptModule, "# ///\n"):]) { + t.Errorf("set touched the file outside the block:\n%s", data) + } +} + +func TestRunRefusesAScriptWithoutOneBlock(t *testing.T) { + other := "# /// pyproject\n# [project]\n# name = \"x\"\n# ///\n" + block := "# /// script\n# dependencies = []\n# ///\n" + for name, tc := range map[string]struct { + src string + want string + }{ + "no block": {"print('hello')\n", "no `# /// script` metadata block"}, + "another type": {other + "print('hello')\n", "no `# /// script` metadata block"}, + "two script blocks": {block + "\n" + block, "2 `# /// script` metadata blocks"}, + "unclosed block": {"# /// script\n# dependencies = []\nprint('hello')\n", "no `# /// script` metadata block"}, + "two empty blocks": {"# /// script\n# ///\n\n# /// script\n# ///\n", "2 `# /// script` metadata blocks"}, + } { + t.Run(name, func(t *testing.T) { + p := writeScript(t, tc.src) + err := run([]string{"get-python-version", p}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("got %v, want an error containing %q", err, tc.want) + } + if data, _ := os.ReadFile(p); string(data) != tc.src { + t.Errorf("a refused file was written:\n%s", data) + } + }) + } + // Comment lines that run on are one block, so a blank line parts the two. + p := writeScript(t, other+"\n"+block) + if got := printed(t, "get-path-sources", p); got != "" { + t.Errorf("a block of another type was read: %q", got) + } +} diff --git a/helpers/pyproject/pyproject.go b/helpers/pyproject/pyproject.go index 9c5aaf0..d928450 100644 --- a/helpers/pyproject/pyproject.go +++ b/helpers/pyproject/pyproject.go @@ -1,6 +1,8 @@ package main import ( + "path" + "sort" "strings" "github.com/pelletier/go-toml/v2" @@ -122,3 +124,23 @@ func removeDaggerKey(doc map[string]any, key string) { delete(doc, "tool") } } + +// getPathSources lists the paths of the [tool.uv.sources] path sources uv +// reads, one a line, cleaned and in source name order. +func getPathSources(doc map[string]any) string { + sources := table(table(table(doc, "tool"), "uv"), "sources") + names := make([]string, 0, len(sources)) + for name := range sources { + names = append(names, name) + } + sort.Strings(names) + var out strings.Builder + for _, name := range names { + for _, source := range sourceTables(sources[name]) { + if p, ok := source["path"].(string); ok { + out.WriteString(path.Clean(p) + "\n") + } + } + } + return out.String() +} diff --git a/helpers/pyproject/python.go b/helpers/pyproject/python.go index bf66e49..d297f18 100644 --- a/helpers/pyproject/python.go +++ b/helpers/pyproject/python.go @@ -57,11 +57,7 @@ func atoi(s string) int { // The Python is read the way baseImageFor and pythonVersionFor in // runtime/build.dang pick the base image; keep them in step. func pythonRefusal(dir string) (string, error) { - data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return "", err - } - doc, err := load(data) + doc, err := moduleProject(dir) if err != nil { return "", err } @@ -94,6 +90,35 @@ func pythonRefusal(dir string) (string, error) { return "", nil } +// moduleProject reads a module's pyproject.toml, or where it has none, the +// script block of its main.py. +func moduleProject(dir string) (map[string]any, error) { + data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err == nil { + return load(data) + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + script := filepath.Join(dir, "main.py") + data, err = os.ReadFile(script) + if errors.Is(err, fs.ErrNotExist) { + return map[string]any{}, nil + } + if err != nil { + return nil, err + } + block, err := findScriptBlock(script, data) + if err != nil || block == nil { + return map[string]any{}, err + } + doc, err := load(block.toml()) + if err != nil { + return nil, err + } + return asProject(doc), nil +} + func remedy(fix, v string) string { if below(v, lastReleasePythonFloor) { return fix diff --git a/helpers/pyproject/python_test.go b/helpers/pyproject/python_test.go index d23481b..02b2ad3 100644 --- a/helpers/pyproject/python_test.go +++ b/helpers/pyproject/python_test.go @@ -74,6 +74,12 @@ func TestPythonRefusal(t *testing.T) { "range the runtime ignores": {map[string]string{"pyproject.toml": project("~=3.11")}, ""}, "no requires-python": {map[string]string{"pyproject.toml": "[project]\nname = \"app\"\n"}, ""}, "no pyproject.toml": {map[string]string{}, ""}, + "older script": {map[string]string{"main.py": "# /// script\n# requires-python = \">=3.11\"\n# ///\n"}, "asks for Python 3.11."}, + "pyproject.toml over script": { + map[string]string{"pyproject.toml": project(">=3.14"), "main.py": "# /// script\n# requires-python = \">=3.11\"\n# ///\n"}, + "", + }, + "main.py without a block": {map[string]string{"main.py": "print('hello')\n"}, ""}, "older pin": { map[string]string{".python-version": "3.11.9\n", "pyproject.toml": project(">=3.14")}, "this Python SDK needs Python 3.12 or later, and .python-version pins Python 3.11.9. Set it to Python 3.12 or later, or pin the last release", diff --git a/helpers/pyproject/scope.go b/helpers/pyproject/scope.go index f9ab549..ee91de6 100644 --- a/helpers/pyproject/scope.go +++ b/helpers/pyproject/scope.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "reflect" "regexp" "sort" "strconv" @@ -14,12 +15,33 @@ import ( // members it generates, their sources, and the generated distributions the // project depends on. Stale names the members generation made before and // removes now; GlobalClient nil leaves the flag as it is. +// +// A script scope holds its dependencies at the top level, and is no +// workspace: Paths maps each source to the member directory it is a path +// source for, and no members are written. type scopeEdit struct { Members []string Stale []string Sources []string Dependencies []string GlobalClient *bool + Script bool + Paths map[string]string +} + +// sourceFields is the inline table a source is written as, without braces. +func (e scopeEdit) sourceFields(name string) string { + if dir, ok := e.Paths[normalizeDistribution(name)]; ok { + return "path = " + quote(dir) + } + return "workspace = true" +} + +func (e scopeEdit) projectTable() string { + if e.Script { + return "" + } + return "project" } // ownedMember reports whether generation may remove a workspace member. The @@ -67,10 +89,12 @@ func editScope(src []byte, edit scopeEdit) ([]byte, error) { } d := &document{text: string(src)} - d.editArray("tool.uv.workspace", "members", edit.Members, edit.ownedMember, normalizeMember, true) - d.editSources(edit.Sources) - if d.section("project") != nil { - d.editArray("project", "dependencies", edit.Dependencies, ownedDependency, dependencyName, false) + if edit.Paths == nil { + d.editArray("tool.uv.workspace", "members", edit.Members, edit.ownedMember, normalizeMember, true) + } + d.editSources(edit.Sources, edit.sourceFields) + if d.section(edit.projectTable()) != nil { + d.editArray(edit.projectTable(), "dependencies", edit.Dependencies, ownedDependency, dependencyName, false) } if edit.GlobalClient != nil { d.editGlobalClient(*edit.GlobalClient) @@ -104,15 +128,17 @@ func checkScope(out []byte, edit scopeEdit) error { return fmt.Errorf("pyproject.toml has a layout the SDK cannot edit: %s", what) } uv := table(table(doc, "tool"), "uv") - members, _ := table(uv, "workspace")["members"].([]any) - if err := checkList(members, edit.Members, edit.ownedMember, normalizeMember); err != nil { - return fail("[tool.uv.workspace] members " + err.Error() + ". " + workspaceForm + regenerate) + if edit.Paths == nil { + members, _ := table(uv, "workspace")["members"].([]any) + if err := checkList(members, edit.Members, edit.ownedMember, normalizeMember); err != nil { + return fail("[tool.uv.workspace] members " + err.Error() + ". " + workspaceForm + regenerate) + } } sources := table(uv, "sources") for _, name := range edit.Sources { source, _ := sources[name].(map[string]any) - if workspace, _ := source["workspace"].(bool); !workspace || len(source) != 1 { - return fail("[tool.uv.sources] lacks " + name + " = { workspace = true }. Write " + sourcesForm + regenerate) + if !sameSource(source, edit.sourceFields(name)) { + return fail("[tool.uv.sources] lacks " + name + " = { " + edit.sourceFields(name) + " }. Write " + sourcesForm + regenerate) } } for name := range sources { @@ -120,7 +146,11 @@ func checkScope(out []byte, edit scopeEdit) error { return fail("[tool.uv.sources] keeps " + name + ". Write " + sourcesForm + regenerate) } } - if project := table(doc, "project"); project != nil { + project := table(doc, "project") + if edit.Script { + project = doc + } + if project != nil { dependencies, _ := project["dependencies"].([]any) if err := checkList(dependencies, edit.Dependencies, ownedDependency, dependencyName); err != nil { return fail("[project] dependencies " + err.Error()) @@ -747,13 +777,13 @@ func stringValue(seg string) (string, bool) { return "", false } -// editSources points every owned source at the workspace, drops the owned +// editSources writes every owned source as its fields say, drops the owned // ones that are gone, and keeps the user's. A source is a key of // [tool.uv.sources] or a table of its own, [tool.uv.sources.], which is // how a file re-marshaled by a TOML library writes it; a new source follows // the file's style. -func (d *document) editSources(want []string) { - tables := d.editSourceTables(want) +func (d *document) editSources(want []string, fields func(string) string) { + tables := d.editSourceTables(want, fields) var stale []int var keep, present []string @@ -781,7 +811,7 @@ func (d *document) editSources(want []string) { } for _, key := range dottedNames { lines := dotted[normalizeDistribution(key)] - if len(lines) == 1 && isWorkspaceLine(d.text[lines[0].start:lines[0].end]) { + if len(lines) == 1 && isSourceLine(d.text[lines[0].start:lines[0].end], fields(key)) { present = append(present, key) continue } @@ -798,8 +828,8 @@ func (d *document) editSources(want []string) { } for _, key := range keep { kv := d.findKey(d.section(sourcesTable), key) - if !isWorkspaceSource(d.text[kv.valueStart:kv.valueEnd]) { - d.replace(kv.valueStart, kv.valueEnd, "{ workspace = true }") + if !isSourceValue(d.text[kv.valueStart:kv.valueEnd], fields(key)) { + d.replace(kv.valueStart, kv.valueEnd, "{ "+fields(key)+" }") } } present = append(present, keep...) @@ -808,10 +838,10 @@ func (d *document) editSources(want []string) { continue } if d.section(sourcesTable) == nil && len(tables) > 0 { - d.appendSourceTable(w) + d.appendSourceTable(w, fields(w)) continue } - d.insertLine(d.ensureSection(sourcesTable), w+" = { workspace = true }") + d.insertLine(d.ensureSection(sourcesTable), w+" = { "+fields(w)+" }") } } @@ -825,8 +855,8 @@ func sourceTableName(sec section) (string, bool) { // editSourceTables applies editSources to the sources written as tables, one // edit at a time because each moves the offsets after it, and returns the // names of every source table left. -func (d *document) editSourceTables(want []string) []string { - for d.editOneSourceTable(want) { +func (d *document) editSourceTables(want []string, fields func(string) string) []string { + for d.editOneSourceTable(want, fields) { } var names []string for _, sec := range d.sections() { @@ -837,7 +867,7 @@ func (d *document) editSourceTables(want []string) []string { return names } -func (d *document) editOneSourceTable(want []string) bool { +func (d *document) editOneSourceTable(want []string, fields func(string) string) bool { for _, sec := range d.sections() { name, ok := sourceTableName(sec) if !ok || !ownedSource(normalizeDistribution(name)) { @@ -848,10 +878,10 @@ func (d *document) editOneSourceTable(want []string) bool { return true } body := d.text[sec.bodyStart:sec.bodyEnd] - if !isWorkspaceTable(body) { + if !isSourceTable(body, fields(name)) { // The blank lines that part it from the next table stay. end := sec.bodyStart + len(strings.TrimRight(body, " \t\r\n")) - d.replace(sec.bodyStart, end, "workspace = true") + d.replace(sec.bodyStart, end, fields(name)) return true } } @@ -860,7 +890,7 @@ func (d *document) editOneSourceTable(want []string) bool { // appendSourceTable adds a source table after the last one, before the blank // lines that part it from what follows. -func (d *document) appendSourceTable(name string) { +func (d *document) appendSourceTable(name, fields string) { var last section for _, sec := range d.sections() { if _, ok := sourceTableName(sec); ok { @@ -875,16 +905,24 @@ func (d *document) appendSourceTable(name string) { d.text = d.text[:at] + "\n" + d.text[at:] at++ } - d.text = d.text[:at] + "\n[" + sourcesTable + "." + name + "]\nworkspace = true\n" + d.text[at:] + d.text = d.text[:at] + "\n[" + sourcesTable + "." + name + "]\n" + fields + "\n" + d.text[at:] +} + +// sameSource reports whether a source holds exactly the given fields. +func sameSource(source map[string]any, fields string) bool { + var want map[string]any + if err := toml.Unmarshal([]byte(fields), &want); err != nil { + return false + } + return source != nil && reflect.DeepEqual(source, want) } -func isWorkspaceTable(body string) bool { +func isSourceTable(body, fields string) bool { var source map[string]any if err := toml.Unmarshal([]byte(body), &source); err != nil { return false } - workspace, _ := source["workspace"].(bool) - return workspace && len(source) == 1 + return sameSource(source, fields) } const keyPart = `(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)` @@ -901,28 +939,26 @@ func lineKey(text string) (string, bool, bool) { return strings.Trim(m[1], `"'`), m[2] != "", true } -// isWorkspaceLine reports whether one line alone makes a workspace source. -func isWorkspaceLine(text string) bool { +// isSourceLine reports whether one line alone makes the source. +func isSourceLine(text, fields string) bool { var parsed map[string]any if err := toml.Unmarshal([]byte(text), &parsed); err != nil || len(parsed) != 1 { return false } for _, v := range parsed { source, _ := v.(map[string]any) - workspace, _ := source["workspace"].(bool) - return workspace && len(source) == 1 + return sameSource(source, fields) } return false } -func isWorkspaceSource(value string) bool { +func isSourceValue(value, fields string) bool { var parsed map[string]any if err := toml.Unmarshal([]byte("x = "+value), &parsed); err != nil { return false } source, _ := parsed["x"].(map[string]any) - workspace, _ := source["workspace"].(bool) - return workspace && len(source) == 1 + return sameSource(source, fields) } // editGlobalClient writes or clears [tool.dagger] global-client, pruning a diff --git a/helpers/pyproject/scope_test.go b/helpers/pyproject/scope_test.go index 7cc2977..412b177 100644 --- a/helpers/pyproject/scope_test.go +++ b/helpers/pyproject/scope_test.go @@ -628,3 +628,84 @@ func TestRemoveTableKeepsEveryOtherByte(t *testing.T) { }) } } + +const scriptTemplate = `requires-python = ">=3.14" +dependencies = ["dagger-io", "dagger-clients-core"] + +[tool.uv.sources] +dagger-io = { path = "sdk" } +dagger-clients-core = { path = "clients/core" } +` + +func scriptEdit(clients ...string) scopeEdit { + e := scopeEdit{ + Sources: []string{"dagger-io", "dagger-clients-core"}, + Dependencies: []string{"dagger-clients-core"}, + Script: true, + Paths: map[string]string{"dagger-io": "sdk", "dagger-clients-core": "clients/core"}, + } + for _, client := range clients { + e.Sources = append(e.Sources, "dagger-clients-"+client) + e.Dependencies = append(e.Dependencies, "dagger-clients-"+client) + e.Paths["dagger-clients-"+client] = "clients/" + client + } + return e +} + +func TestEditScopeWritesAScriptsPathSources(t *testing.T) { + if got := mustEdit(t, scriptTemplate, scriptEdit()); got != scriptTemplate { + t.Errorf("a script that already matches was rewritten:\n%s", got) + } + got := mustEdit(t, scriptTemplate, scriptEdit("linter")) + want := `requires-python = ">=3.14" +dependencies = ["dagger-io", "dagger-clients-core", "dagger-clients-linter"] + +[tool.uv.sources] +dagger-io = { path = "sdk" } +dagger-clients-core = { path = "clients/core" } +dagger-clients-linter = { path = "clients/linter" } +` + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + if got := mustEdit(t, want, scriptEdit()); got != scriptTemplate { + t.Errorf("removing the client left:\n%s", got) + } +} + +func TestEditScopeTurnsOtherSourcesIntoAScriptsPaths(t *testing.T) { + src := `dependencies = ["httpx"] + +[tool.uv.sources.dagger-io] +workspace = true + +[tool.uv.sources.dagger-clients-core] +path = "elsewhere" +editable = true +` + want := `dependencies = ["httpx", "dagger-clients-core"] + +[tool.uv.sources.dagger-io] +path = "sdk" + +[tool.uv.sources.dagger-clients-core] +path = "clients/core" +` + if got := mustEdit(t, src, scriptEdit()); got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestEditScopeAddsAScriptsMissingEntries(t *testing.T) { + got := mustEdit(t, "requires-python = \">=3.14\"\n", scriptEdit()) + want := `requires-python = ">=3.14" +dependencies = ["dagger-clients-core"] + +[tool.uv.sources] +dagger-io = { path = "sdk" } +dagger-clients-core = { path = "clients/core" } +` + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} diff --git a/helpers/pyproject/script.go b/helpers/pyproject/script.go new file mode 100644 index 0000000..49c9436 --- /dev/null +++ b/helpers/pyproject/script.go @@ -0,0 +1,192 @@ +package main + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// The reference regular expression of PEP 723, loosened where the PEP's text, +// which takes precedence over it, allows: a carriage return may end a line, as +// the reference implementation reads the file in text mode, and a block may +// hold no content line, as every field is optional. Go's leftmost-first +// matching prefers what Python's backtracking does, so a block closes at its +// last `# ///` line, as the PEP specifies. +var metadataBlock = regexp.MustCompile(`(?m)^# /// (?P[a-zA-Z0-9-]+)\r?$\s(?P(^#(| .*)\r?$\s)*)^# ///\r?$`) + +// scriptBlock is the `script` metadata block of a Python file: its TOML is +// commented out line by line between `# /// script` and `# ///`. +type scriptBlock struct { + src []byte + start, end int // the content lines, comment prefixes included +} + +// newline is the line end of the block's opening line, which a line the edit +// adds takes. The TOML itself is edited with LF line ends. +func (b *scriptBlock) newline() string { + if b.start >= 2 && b.src[b.start-2] == '\r' { + return "\r\n" + } + return "\n" +} + +func isScript(path string) bool { + return filepath.Ext(path) == ".py" +} + +// findScriptBlock returns nil for a file with no script block, and refuses +// one with more than one, which PEP 723 says tools must not read. +func findScriptBlock(path string, src []byte) (*scriptBlock, error) { + var found []*scriptBlock + typeGroup, contentGroup := metadataBlock.SubexpIndex("type"), metadataBlock.SubexpIndex("content") + for _, m := range metadataBlock.FindAllSubmatchIndex(src, -1) { + if string(src[m[2*typeGroup]:m[2*typeGroup+1]]) == "script" { + found = append(found, &scriptBlock{src: src, start: m[2*contentGroup], end: m[2*contentGroup+1]}) + } + } + switch len(found) { + case 0: + return nil, nil + case 1: + return found[0], nil + } + return nil, fmt.Errorf("%s has %d `# /// script` metadata blocks; PEP 723 allows one", filepath.Base(path), len(found)) +} + +// readScriptBlock refuses a file with no script block too. +func readScriptBlock(path string, src []byte) (*scriptBlock, error) { + block, err := findScriptBlock(path, src) + if err == nil && block == nil { + err = fmt.Errorf("%s has no `# /// script` metadata block", filepath.Base(path)) + } + return block, err +} + +// toml is the block's content with the comment prefixes stripped as PEP 723 +// strips them. +func (b *scriptBlock) toml() []byte { + var out strings.Builder + for _, line := range b.lines() { + out.WriteString(lf(uncomment(line))) + } + return []byte(out.String()) +} + +// embed puts edited TOML back into the file. A line the edit left alone keeps +// its bytes, even where stripping its prefix lost them, as `# ` alone does. +func (b *scriptBlock) embed(toml []byte) []byte { + old := b.lines() + stripped := make([]string, len(old)) + for i, line := range old { + stripped[i] = lf(uncomment(line)) + } + edited := splitLines(string(toml)) + if n := len(edited); n > 0 && !strings.HasSuffix(edited[n-1], "\n") { + edited[n-1] += "\n" + } + kept := commonLines(stripped, edited) + var out strings.Builder + out.Write(b.src[:b.start]) + for i, line := range edited { + if j, ok := kept[i]; ok { + out.WriteString(old[j]) + } else { + out.WriteString(strings.TrimSuffix(comment(line), "\n") + b.newline()) + } + } + out.Write(b.src[b.end:]) + return []byte(out.String()) +} + +func (b *scriptBlock) lines() []string { + return splitLines(string(b.src[b.start:b.end])) +} + +func uncomment(line string) string { + if strings.HasPrefix(line, "# ") { + return line[2:] + } + return line[1:] +} + +func lf(line string) string { + if body, ok := strings.CutSuffix(line, "\r\n"); ok { + return body + "\n" + } + return line +} + +func comment(line string) string { + if line == "\n" { + return "#\n" + } + return "# " + line +} + +func splitLines(s string) []string { + lines := strings.SplitAfter(s, "\n") + if lines[len(lines)-1] == "" { + return lines[:len(lines)-1] + } + return lines +} + +// commonLines maps each line of b that a longest common subsequence keeps +// from a to its index in a. +func commonLines(a, b []string) map[int]int { + lengths := make([][]int, len(a)+1) + for i := range lengths { + lengths[i] = make([]int, len(b)+1) + } + for i := len(a) - 1; i >= 0; i-- { + for j := len(b) - 1; j >= 0; j-- { + if a[i] == b[j] { + lengths[i][j] = lengths[i+1][j+1] + 1 + } else { + lengths[i][j] = max(lengths[i+1][j], lengths[i][j+1]) + } + } + } + kept := map[int]int{} + for i, j := 0, 0; i < len(a) && j < len(b); { + switch { + case a[i] == b[j]: + kept[j] = i + i++ + j++ + case lengths[i+1][j] >= lengths[i][j+1]: + i++ + default: + j++ + } + } + return kept +} + +// scriptProjectKeys are the keys a script block holds at its top level and a +// pyproject.toml under [project]. +var scriptProjectKeys = []string{"requires-python", "dependencies"} + +// asProject reads a script's metadata as a pyproject.toml, so one set of +// readers and writers serves both. +func asProject(doc map[string]any) map[string]any { + for _, key := range scriptProjectKeys { + if v, ok := doc[key]; ok { + ensureTable(doc, "project")[key] = v + delete(doc, key) + } + } + return doc +} + +func fromProject(doc map[string]any) map[string]any { + project := table(doc, "project") + for _, key := range scriptProjectKeys { + if v, ok := project[key]; ok { + doc[key] = v + } + } + delete(doc, "project") + return doc +} From 110fa31f6c2249c3b8225ee0a2ed3b8d91b215fa Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 22:57:18 +0200 Subject: [PATCH 2/9] python-sdk: find the client root of a script scope Signed-off-by: Yves Brissaud --- python-sdk.dang | 78 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/python-sdk.dang b/python-sdk.dang index 9a465c2..f88bdff 100644 --- a/python-sdk.dang +++ b/python-sdk.dang @@ -39,22 +39,57 @@ type PythonSdk { """ Find the Python client root containing the workspace cwd: the directory of - the nearest pyproject.toml, relative to the workspace root. A generated - member, marked `[tool.dagger] generated` in its pyproject.toml, belongs to - the scope above it, so that scope answers. A module's vendored client - library from before the marker carries its own pyproject.toml too, so the - Python module owning it answers instead; a directory with a module config - of its own is a module, never a vendored library. Null when there is none. + the nearest pyproject.toml, relative to the workspace root, or of the + nearest script scope where that is deeper. A generated member, marked + `[tool.dagger] generated` in its pyproject.toml, belongs to the scope above + it, so that scope answers. A module's vendored client library from before + the marker carries its own pyproject.toml too, so the Python module owning + it answers instead; a directory with a module config of its own is a + module, never a vendored library. Null when there is none. """ pub findClientRoot(ws: Workspace!): String { let found = ws.findUp("pyproject.toml") - if (found == null) { + let project = if (found == null) { null } else { clientRootOf(ws, normalizePath(found.trimSuffix("pyproject.toml"))) } + let script = scriptScopeAbove(ws, ".") + if (pathDepth(script) > pathDepth(project)) { script } else { project } } + """ + The nearest script scope at or above a cwd-relative directory, or null. A + main.py without a script block is only code, so the search goes on above it. + """ + let scriptScopeAbove(ws: Workspace!, from: String!): String { + let found = ws.findUp(scriptFileName, from: from) + if (found == null) { + null + } else { + let scope = normalizePath(found.trimSuffix(scriptFileName)) + if (isScriptScope(ws, scope)) { + scope + } else if (scope == ".") { + null + } else { + scriptScopeAbove(ws, "/" + parentPath(scope)) + } + } + } + + """ + A pyproject.toml makes a project scope, whose main.py is only code even + with a script block. + """ + let isScriptScope(ws: Workspace!, scope: String!): Boolean! { + scopeHasFile(ws, scope, "pyproject.toml") == false and + scopeHasFile(ws, scope, scriptFileName) and + ScriptMetadata(ws.file("/" + scopePath(scope, scriptFileName)).contents).scriptBlocks.length > 0 + } + + let scriptFileName: String! = "main.py" + """ The scope that a pyproject.toml belongs to. """ @@ -64,7 +99,7 @@ type PythonSdk { # a member of something else, so lift until the file is not a member's. let above = ws.findUp("pyproject.toml", from: "/" + parentPath(scope)) if (above == null) { - scope + scriptScopeAbove(ws, "/" + parentPath(scope)) ?? scope } else { clientRootOf(ws, normalizePath(above.trimSuffix("pyproject.toml"))) } @@ -819,6 +854,33 @@ type PythonSdk { } } +""" +The PEP 723 metadata blocks of a Python file, matched with the PEP's +reference expression, loosened where the PEP's text, which takes precedence +over it, allows: a carriage return may end a line, as the reference +implementation reads the file in text mode, and a block may hold no content +line, as every field is optional. A block that is not closed is no block. +""" +type ScriptMetadata { + pub contents: String! + + new(contents: String! = "") { + self.contents = contents + self + } + + """ + Every `script` block. A file with two still makes a script scope, so the + build refuses it with its reason instead of discovery passing over it. + """ + pub scriptBlocks: [String!]! { + contents + .matchAll("(?m)^# /// ([a-zA-Z0-9-]+)\\r?$\\s((^#(| .*)\\r?$\\s)*)^# ///\\r?$") + .filter { m => (m.captures[0] ?? "") == "script" } + .map { m => m.string } + } +} + """ A dagger-module.toml, read for its entrypoint table. From 1d0c26ccb428cfdc8376f89e771acf33d9295636 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:03:20 +0200 Subject: [PATCH 3/9] python-sdk: build a script module from its block Signed-off-by: Yves Brissaud --- entrypoint/build.dang | 103 ++++++++++++++++++++++++++++----- entrypoint/workspace_reader.py | 40 ++++++++++--- runtime/build.dang | 103 ++++++++++++++++++++++++++++----- runtime/main.dang | 8 +++ runtime/workspace_reader.py | 40 ++++++++++--- 5 files changed, 252 insertions(+), 42 deletions(-) diff --git a/entrypoint/build.dang b/entrypoint/build.dang index b57df09..31f331d 100644 --- a/entrypoint/build.dang +++ b/entrypoint/build.dang @@ -37,6 +37,8 @@ type PythonModuleBuild { let clientsDir: String! = "clients" let sdkGenPath: String! = "src/dagger/client/gen.py" let projectCfg: String! = "pyproject.toml" + let scriptCfg: String! = "main.py" + let scriptLock: String! = "main.py.lock" let pipCompileLock: String! = "requirements.lock" let uvLock: String! = "uv.lock" let uvCacheVolume: String! = "modpython-uv" @@ -58,12 +60,15 @@ type PythonModuleBuild { of that name would shadow the global `container` that `base` starts from. """ pub installed: Container! { - if (source.exists(projectCfg) == false) { + if (source.exists(projectCfg) == false and scriptBlock(source) == "") { raise "module \"" + moduleName + "\" has no " + projectCfg + "; run `dagger generate` and commit the generated files" } else if (hasPythonFiles(source) == false) { raise "no python files found in module source" } else { let cfg = pyConfig(contextDir, subPath) + if (cfg.script and cfg.useUv == false) { + raise "module \"" + moduleName + "\" is a script, which builds with uv only: remove use-uv = false from the script block of " + scriptCfg + } checkGeneratedFiles(source, moduleName, requiredGeneratedFiles(source, cfg)) let baseImage = baseImageFor(source, cfg) @@ -96,6 +101,13 @@ type PythonModuleBuild { pyConfig(directory.withFile(projectCfg, pyproject), ".").members } + """ + The path sources of a script's block, read as the build does. + """ + pub scriptSources(script: File!): [String!]! { + pyConfig(directory.withFile(scriptCfg, script), ".").sources + } + """ The class name of a module's main object, converted like the engine's builtin Python runtime (strcase.ToCamel) so a module loads the same on both. @@ -172,11 +184,29 @@ type PythonModuleBuild { source edit leaves this layer cached. The SDK files and the generated clients are installed non-editable so uv compiles their bytecode once, not on every call into a throwaway mount. + + A script's main.py is its code too, so only its block goes in, and the + layer is cached until the block changes. """ let dependencies(ctr: Container!, source: Directory!, cfg: PyConfig!): Container! { if (cfg.useUv == false) { # pip installs a project's dependencies only with the project itself. ctr + } else if (cfg.script) { + let inputs = dependencyInputs(cfg).withNewFile(join(subPath, scriptCfg).trimPrefix("./"), scriptBlock(source)) + let inputsPath = modSourceDirPath + "/" + inputs.digest + ctr + .withExec(["uv", "venv", venvPath]) + .withEnvVariable("VIRTUAL_ENV", venvPath) + .withWorkdir(join(inputsPath, subPath)) + .withMountedDirectory(inputsPath, inputs) + # Installed from the lock, with the sources and artifact hashes it + # records, and --locked refuses a lock the block no longer matches, as + # a project's sync does. --active installs into VIRTUAL_ENV instead of + # an environment uv keeps for the script. + .withExec(["uv", "sync", "--script", scriptCfg, "--locked", "--active", "--no-editable"]) + .withoutMount(inputsPath) + .withEnvVariable("PATH", "$VIRTUAL_ENV/bin:$PATH", expand: true) } else { let inputs = dependencyInputs(cfg) # Unique per input, for the same reason as the source's own path. @@ -209,10 +239,13 @@ type PythonModuleBuild { } """ - The module's own package, editable, on top of its dependencies. + The module's own package, editable, on top of its dependencies. A script is + no package: the SDK loads it from its file. """ let install(ctr: Container!, source: Directory!, cfg: PyConfig!): Container! { - if (cfg.useUv and source.exists(uvLock)) { + if (cfg.script) { + ctr.withEnvVariable("DAGGER_MODULE_SCRIPT", scriptCfg) + } else if (cfg.useUv and source.exists(uvLock)) { # uv pip ignores VIRTUAL_ENV while UV_SYSTEM_PYTHON is set. ctr.withExec(["uv", "pip", "install", "--python", venvPath + "/bin/python", "--no-deps", "-e", "."]) } else if (cfg.useUv) { @@ -288,15 +321,18 @@ type PythonModuleBuild { changes. A file that does not read is named. """ let workspaceRead(dir: Directory!, sub: String!): [String!]! { + let files = filesOf( + dir, + include: ["**/" + projectCfg, "**/uv.toml", "**/.python-version", "**/" + pipCompileLock], + # No member lives in these, and a virtualenv alone holds hundreds of + # pyproject.toml files that would reach the read and its cache key. + exclude: ["**/.venv", "**/__pycache__", "**/node_modules", "**/.git"], + ) + let block = scriptBlock(subdir(dir, sub)) + let scope = if (block == "") { files } else { files.withNewFile(join(sub, scriptCfg).trimPrefix("./"), block) } let lines = container .from(defaultBaseImage) - .withMountedDirectory("/scope", filesOf( - dir, - include: ["**/" + projectCfg, "**/uv.toml", "**/.python-version", "**/" + pipCompileLock], - # No member lives in these, and a virtualenv alone holds hundreds of - # pyproject.toml files that would reach the read and its cache key. - exclude: ["**/.venv", "**/__pycache__", "**/node_modules", "**/.git"], - )) + .withMountedDirectory("/scope", scope) .withExec(["python", "-c", workspaceReader, "/scope", sub, genDir]) .stdout .split("\n") @@ -311,10 +347,15 @@ type PythonModuleBuild { """ The generated files a module needs before it builds. A scope needs each of - the SDK's members it lists; a user's member is the user's to provide. + the SDK's members it lists, and a script each it names as a path source, + and its lock; a user's member is the user's to provide. """ let requiredGeneratedFiles(source: Directory!, cfg: PyConfig!): [String!]! { - if (cfg.isScope) { + if (cfg.script) { + cfg.sources + .filter { path => path == genDir or path.hasPrefix(clientsDir + "/") } + .map { path => path + "/" + projectCfg } + [scriptLock] + } else if (cfg.isScope) { cfg.members .filter { member => (member == genDir or member.hasPrefix(clientsDir + "/")) and member.contains("*") == false @@ -401,6 +442,25 @@ type PythonModuleBuild { } } + """ + The PEP 723 script block of a module's main.py, from `# /// script` to + `# ///`, or empty for a module with a pyproject.toml or no such block. A + carriage return may end each line, as the reader's text mode takes it, and + a block may hold no content line, as every field is optional. + Every script block is kept, so the workspace reader refuses more than one. + """ + let scriptBlock(source: Directory!): String! { + if (source.exists(projectCfg) or source.exists(scriptCfg) == false) { + "" + } else { + source.file(scriptCfg).contents + .matchAll("(?m)^# /// ([a-zA-Z0-9-]+)\\r?$\\s((^#(| .*)\\r?$\\s)*)^# ///\\r?$") + .filter { m => (m.captures[0] ?? "") == "script" } + .map { m => m.string + "\n" } + .join("\n") + } + } + let hasPythonFiles(source: Directory!): Boolean! { source.glob("src/**/*.py").length > 0 or source.glob("*.py").length > 0 } @@ -455,6 +515,8 @@ type PythonModuleBuild { inputs: readLines(workspace, "input"), indexURL: setting(workspace, "indexURL"), extraIndexURL: setting(workspace, "extraIndexURL"), + script: setting(workspace, "script") == "true", + sources: readLines(workspace, "source"), ) } @@ -470,7 +532,7 @@ type PythonModuleBuild { } """ -What this runtime reads from a module's pyproject.toml. +What this runtime reads from a module's pyproject.toml, or a script's block. """ type PyConfig { pub projectName: String! @@ -498,6 +560,17 @@ type PyConfig { pub indexURL: String! pub extraIndexURL: String! + """ + Whether the module is a script: a main.py whose PEP 723 block stands for + pyproject.toml, with no workspace. + """ + pub script: Boolean! + + """ + The path sources of a script's block. + """ + pub sources: [String!]! + new( projectName: String! = "", requiresPython: String! = "", @@ -510,6 +583,8 @@ type PyConfig { inputs: [String!]! = [], indexURL: String! = "", extraIndexURL: String! = "", + script: Boolean! = false, + sources: [String!]! = [], ) { self.projectName = projectName self.requiresPython = requiresPython @@ -522,6 +597,8 @@ type PyConfig { self.inputs = inputs self.indexURL = indexURL self.extraIndexURL = extraIndexURL + self.script = script + self.sources = sources self } diff --git a/entrypoint/workspace_reader.py b/entrypoint/workspace_reader.py index 666c02f..efa74e3 100644 --- a/entrypoint/workspace_reader.py +++ b/entrypoint/workspace_reader.py @@ -36,12 +36,33 @@ def reads_beside(rel, project): uv = {key: value for key, value in table(project, 'tool', 'uv').items() if key != 'sources'} tool = dict(table(project, 'tool'), uv=uv) return names_parent(dict(project, tool=tool)) or os.path.exists(os.path.join(module, rel, 'uv.toml')) +def load_script(): + try: + with open(os.path.join(module, 'main.py'), encoding='utf-8') as f: + text = f.read() + except (UnicodeDecodeError, OSError) as e: + fail(f'main.py does not read: {e}') + # PEP 723's reference expression, and its rule against more than one block. + # The PEP's text, which takes precedence, allows a block with no content line. + pattern = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)*)^# ///$' + blocks = [m for m in re.finditer(pattern, text) if m.group('type') == 'script'] + if len(blocks) != 1: + fail(f'main.py has {len(blocks)} `# /// script` metadata blocks, where a script module has exactly one') + lines = blocks[0].group('content').splitlines(keepends=True) + try: + return tomllib.loads(''.join(line[2:] if line.startswith('# ') else line[1:] for line in lines)) + except tomllib.TOMLDecodeError as e: + fail(f'the script block of main.py is not valid TOML: {e}') def name(requirement): m = re.match(r'\s*([A-Za-z0-9][A-Za-z0-9._-]*)', requirement if isinstance(requirement, str) else '') return re.sub(r'[-_.]+', '-', m.group(1).lower()) if m else '' -doc = load('.') -members = strings(table(doc, 'tool', 'uv', 'workspace').get('members'), 'members', 'pyproject.toml') -wanted = {name(r) for r in strings(table(doc, 'project').get('dependencies'), 'dependencies', 'pyproject.toml')} +# A script holds at its top level what a pyproject.toml holds under [project], +# and is no workspace root. +script = not os.path.exists(os.path.join(module, 'pyproject.toml')) and os.path.exists(os.path.join(module, 'main.py')) +doc = load_script() if script else load('.') +scope_project = doc if script else table(doc, 'project') +members = [] if script else strings(table(doc, 'tool', 'uv', 'workspace').get('members'), 'members', 'pyproject.toml') +wanted = {name(r) for r in strings(scope_project.get('dependencies'), 'dependencies', 'main.py' if script else 'pyproject.toml')} dirs = [] for member in members: print('member\t' + member) @@ -65,7 +86,7 @@ def name(requirement): for rel in dirs: if rel in needed: print('local\t./' + rel) -inputs = ['pyproject.toml', 'uv.lock', 'uv.toml', '.python-version'] + dirs + ([] if vendored in members else [vendored]) +inputs = (['main.py.lock'] if script else ['pyproject.toml', 'uv.lock']) + ['uv.toml', '.python-version'] + dirs + ([] if vendored in members else [vendored]) paths = [] sourced = {} pending = list(docs.items()) @@ -80,6 +101,8 @@ def name(requirement): if path in paths: continue paths.append(path) + if rel == '.' and script: + print('source\t' + path) if path not in dirs and not os.path.isabs(path) and not outside(path) and os.path.exists(os.path.join(module, path, 'pyproject.toml')): sourced[path] = load(path) pending.append((path, sourced[path])) @@ -94,7 +117,8 @@ def name(requirement): for key in ('extra-index-url', 'find-links'): urls += uv.get(key) if isinstance(uv.get(key), list) else [] whole = any(isinstance(url, str) and '://' not in url for url in urls) -whole = whole or any('project' not in member or bool(set(table(member, 'project').get('dynamic') or []) - {'version'}) for member in list(docs.values()) + list(sourced.values())) +whole = whole or any('project' not in member or bool(set(table(member, 'project').get('dynamic') or []) - {'version'}) for member in [docs[rel] for rel in dirs] + list(sourced.values())) +whole = whole or (not script and ('project' not in doc or bool(set(scope_project.get('dynamic') or []) - {'version'}))) whole = whole or os.path.exists(os.path.join(module, 'requirements.lock')) whole = whole or any(os.path.isabs(path) or outside(path) for path in inputs) # The module itself installs after the source mount, so members, path sources and the @@ -116,19 +140,19 @@ def text(value): return value if isinstance(value, str) and '\t' not in value and '\n' not in value else '' def flag(value, default): return value if isinstance(value, bool) else default -project = table(doc, 'project') dagger = table(doc, 'tool', 'dagger') indexes = table(doc, 'tool', 'uv').get('index') indexes = [index for index in (indexes if isinstance(indexes, list) else [indexes]) if isinstance(index, dict) and text(index.get('name')) == ''] settings = { - 'projectName': text(project.get('name')), - 'requiresPython': text(project.get('requires-python')), + 'projectName': text(scope_project.get('name')), + 'requiresPython': text(scope_project.get('requires-python')), 'baseImage': text(dagger.get('base-image')), 'useUv': 'true' if flag(dagger.get('use-uv'), True) else 'false', 'uvVersion': text(dagger.get('uv-version')), 'vendorPath': text(table(doc, 'tool', 'uv', 'sources', 'dagger-io').get('path')), 'indexURL': next((text(index.get('url')) for index in indexes if flag(index.get('default'), False)), ''), 'extraIndexURL': next((text(index.get('url')) for index in indexes if not flag(index.get('default'), False)), ''), + 'script': 'true' if script else 'false', } for key, value in settings.items(): print('setting\t' + key + '\t' + value) diff --git a/runtime/build.dang b/runtime/build.dang index 71d5d75..19745be 100644 --- a/runtime/build.dang +++ b/runtime/build.dang @@ -31,6 +31,8 @@ type PythonModuleBuild { let clientsDir: String! = "clients" let sdkGenPath: String! = "src/dagger/client/gen.py" let projectCfg: String! = "pyproject.toml" + let scriptCfg: String! = "main.py" + let scriptLock: String! = "main.py.lock" let pipCompileLock: String! = "requirements.lock" let uvLock: String! = "uv.lock" let uvCacheVolume: String! = "modpython-uv" @@ -65,12 +67,15 @@ type PythonModuleBuild { of that name would shadow the global `container` that `base` starts from. """ pub installed: Container! { - if (source.exists(projectCfg) == false) { + if (source.exists(projectCfg) == false and scriptBlock(source) == "") { raise "module \"" + moduleName + "\" has no " + projectCfg + "; run `dagger generate` and commit the generated files" } else if (hasPythonFiles(source) == false) { raise "no python files found in module source" } else { let cfg = pyConfig(contextDir, subPath) + if (cfg.script and cfg.useUv == false) { + raise "module \"" + moduleName + "\" is a script, which builds with uv only: remove use-uv = false from the script block of " + scriptCfg + } checkGeneratedFiles(source, moduleName, requiredGeneratedFiles(source, cfg)) let baseImage = baseImageFor(source, cfg) @@ -103,6 +108,13 @@ type PythonModuleBuild { pyConfig(directory.withFile(projectCfg, pyproject), ".").members } + """ + The path sources of a script's block, read as the build does. + """ + pub scriptSources(script: File!): [String!]! { + pyConfig(directory.withFile(scriptCfg, script), ".").sources + } + """ The class name of a module's main object, converted like the engine's builtin Python runtime (strcase.ToCamel) so a module loads the same on both. @@ -179,11 +191,29 @@ type PythonModuleBuild { source edit leaves this layer cached. The SDK files and the generated clients are installed non-editable so uv compiles their bytecode once, not on every call into a throwaway mount. + + A script's main.py is its code too, so only its block goes in, and the + layer is cached until the block changes. """ let dependencies(ctr: Container!, source: Directory!, cfg: PyConfig!): Container! { if (cfg.useUv == false) { # pip installs a project's dependencies only with the project itself. ctr + } else if (cfg.script) { + let inputs = dependencyInputs(cfg).withNewFile(join(subPath, scriptCfg).trimPrefix("./"), scriptBlock(source)) + let inputsPath = modSourceDirPath + "/" + inputs.digest + ctr + .withExec(["uv", "venv", venvPath]) + .withEnvVariable("VIRTUAL_ENV", venvPath) + .withWorkdir(join(inputsPath, subPath)) + .withMountedDirectory(inputsPath, inputs) + # Installed from the lock, with the sources and artifact hashes it + # records, and --locked refuses a lock the block no longer matches, as + # a project's sync does. --active installs into VIRTUAL_ENV instead of + # an environment uv keeps for the script. + .withExec(["uv", "sync", "--script", scriptCfg, "--locked", "--active", "--no-editable"]) + .withoutMount(inputsPath) + .withEnvVariable("PATH", "$VIRTUAL_ENV/bin:$PATH", expand: true) } else { let inputs = dependencyInputs(cfg) # Unique per input, for the same reason as the source's own path. @@ -216,10 +246,13 @@ type PythonModuleBuild { } """ - The module's own package, editable, on top of its dependencies. + The module's own package, editable, on top of its dependencies. A script is + no package: the SDK loads it from its file. """ let install(ctr: Container!, source: Directory!, cfg: PyConfig!): Container! { - if (cfg.useUv and source.exists(uvLock)) { + if (cfg.script) { + ctr.withEnvVariable("DAGGER_MODULE_SCRIPT", scriptCfg) + } else if (cfg.useUv and source.exists(uvLock)) { # uv pip ignores VIRTUAL_ENV while UV_SYSTEM_PYTHON is set. ctr.withExec(["uv", "pip", "install", "--python", venvPath + "/bin/python", "--no-deps", "-e", "."]) } else if (cfg.useUv) { @@ -295,15 +328,18 @@ type PythonModuleBuild { changes. A file that does not read is named. """ let workspaceRead(dir: Directory!, sub: String!): [String!]! { + let files = filesOf( + dir, + include: ["**/" + projectCfg, "**/uv.toml", "**/.python-version", "**/" + pipCompileLock], + # No member lives in these, and a virtualenv alone holds hundreds of + # pyproject.toml files that would reach the read and its cache key. + exclude: ["**/.venv", "**/__pycache__", "**/node_modules", "**/.git"], + ) + let block = scriptBlock(subdir(dir, sub)) + let scope = if (block == "") { files } else { files.withNewFile(join(sub, scriptCfg).trimPrefix("./"), block) } let lines = container .from(defaultBaseImage) - .withMountedDirectory("/scope", filesOf( - dir, - include: ["**/" + projectCfg, "**/uv.toml", "**/.python-version", "**/" + pipCompileLock], - # No member lives in these, and a virtualenv alone holds hundreds of - # pyproject.toml files that would reach the read and its cache key. - exclude: ["**/.venv", "**/__pycache__", "**/node_modules", "**/.git"], - )) + .withMountedDirectory("/scope", scope) .withExec(["python", "-c", workspaceReader, "/scope", sub, genDir]) .stdout .split("\n") @@ -318,10 +354,15 @@ type PythonModuleBuild { """ The generated files a module needs before it builds. A scope needs each of - the SDK's members it lists; a user's member is the user's to provide. + the SDK's members it lists, and a script each it names as a path source, + and its lock; a user's member is the user's to provide. """ let requiredGeneratedFiles(source: Directory!, cfg: PyConfig!): [String!]! { - if (cfg.isScope) { + if (cfg.script) { + cfg.sources + .filter { path => path == genDir or path.hasPrefix(clientsDir + "/") } + .map { path => path + "/" + projectCfg } + [scriptLock] + } else if (cfg.isScope) { cfg.members .filter { member => (member == genDir or member.hasPrefix(clientsDir + "/")) and member.contains("*") == false @@ -408,6 +449,25 @@ type PythonModuleBuild { } } + """ + The PEP 723 script block of a module's main.py, from `# /// script` to + `# ///`, or empty for a module with a pyproject.toml or no such block. A + carriage return may end each line, as the reader's text mode takes it, and + a block may hold no content line, as every field is optional. + Every script block is kept, so the workspace reader refuses more than one. + """ + let scriptBlock(source: Directory!): String! { + if (source.exists(projectCfg) or source.exists(scriptCfg) == false) { + "" + } else { + source.file(scriptCfg).contents + .matchAll("(?m)^# /// ([a-zA-Z0-9-]+)\\r?$\\s((^#(| .*)\\r?$\\s)*)^# ///\\r?$") + .filter { m => (m.captures[0] ?? "") == "script" } + .map { m => m.string + "\n" } + .join("\n") + } + } + let hasPythonFiles(source: Directory!): Boolean! { source.glob("src/**/*.py").length > 0 or source.glob("*.py").length > 0 } @@ -462,6 +522,8 @@ type PythonModuleBuild { inputs: readLines(workspace, "input"), indexURL: setting(workspace, "indexURL"), extraIndexURL: setting(workspace, "extraIndexURL"), + script: setting(workspace, "script") == "true", + sources: readLines(workspace, "source"), ) } @@ -477,7 +539,7 @@ type PythonModuleBuild { } """ -What this runtime reads from a module's pyproject.toml. +What this runtime reads from a module's pyproject.toml, or a script's block. """ type PyConfig { pub projectName: String! @@ -505,6 +567,17 @@ type PyConfig { pub indexURL: String! pub extraIndexURL: String! + """ + Whether the module is a script: a main.py whose PEP 723 block stands for + pyproject.toml, with no workspace. + """ + pub script: Boolean! + + """ + The path sources of a script's block. + """ + pub sources: [String!]! + new( projectName: String! = "", requiresPython: String! = "", @@ -517,6 +590,8 @@ type PyConfig { inputs: [String!]! = [], indexURL: String! = "", extraIndexURL: String! = "", + script: Boolean! = false, + sources: [String!]! = [], ) { self.projectName = projectName self.requiresPython = requiresPython @@ -529,6 +604,8 @@ type PyConfig { self.inputs = inputs self.indexURL = indexURL self.extraIndexURL = extraIndexURL + self.script = script + self.sources = sources self } diff --git a/runtime/main.dang b/runtime/main.dang index 9d45fa2..363a166 100644 --- a/runtime/main.dang +++ b/runtime/main.dang @@ -38,6 +38,14 @@ type PythonSdkRuntime { PythonModuleBuild().scopeMembers(pyproject) } + """ + The path sources this runtime reads from a script's block. Generation + checks the script it writes with it. + """ + pub scriptSources(script: File!): [String!]! { + PythonModuleBuild().scriptSources(script) + } + """ The class name of a module's main object. """ diff --git a/runtime/workspace_reader.py b/runtime/workspace_reader.py index 666c02f..efa74e3 100644 --- a/runtime/workspace_reader.py +++ b/runtime/workspace_reader.py @@ -36,12 +36,33 @@ def reads_beside(rel, project): uv = {key: value for key, value in table(project, 'tool', 'uv').items() if key != 'sources'} tool = dict(table(project, 'tool'), uv=uv) return names_parent(dict(project, tool=tool)) or os.path.exists(os.path.join(module, rel, 'uv.toml')) +def load_script(): + try: + with open(os.path.join(module, 'main.py'), encoding='utf-8') as f: + text = f.read() + except (UnicodeDecodeError, OSError) as e: + fail(f'main.py does not read: {e}') + # PEP 723's reference expression, and its rule against more than one block. + # The PEP's text, which takes precedence, allows a block with no content line. + pattern = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)*)^# ///$' + blocks = [m for m in re.finditer(pattern, text) if m.group('type') == 'script'] + if len(blocks) != 1: + fail(f'main.py has {len(blocks)} `# /// script` metadata blocks, where a script module has exactly one') + lines = blocks[0].group('content').splitlines(keepends=True) + try: + return tomllib.loads(''.join(line[2:] if line.startswith('# ') else line[1:] for line in lines)) + except tomllib.TOMLDecodeError as e: + fail(f'the script block of main.py is not valid TOML: {e}') def name(requirement): m = re.match(r'\s*([A-Za-z0-9][A-Za-z0-9._-]*)', requirement if isinstance(requirement, str) else '') return re.sub(r'[-_.]+', '-', m.group(1).lower()) if m else '' -doc = load('.') -members = strings(table(doc, 'tool', 'uv', 'workspace').get('members'), 'members', 'pyproject.toml') -wanted = {name(r) for r in strings(table(doc, 'project').get('dependencies'), 'dependencies', 'pyproject.toml')} +# A script holds at its top level what a pyproject.toml holds under [project], +# and is no workspace root. +script = not os.path.exists(os.path.join(module, 'pyproject.toml')) and os.path.exists(os.path.join(module, 'main.py')) +doc = load_script() if script else load('.') +scope_project = doc if script else table(doc, 'project') +members = [] if script else strings(table(doc, 'tool', 'uv', 'workspace').get('members'), 'members', 'pyproject.toml') +wanted = {name(r) for r in strings(scope_project.get('dependencies'), 'dependencies', 'main.py' if script else 'pyproject.toml')} dirs = [] for member in members: print('member\t' + member) @@ -65,7 +86,7 @@ def name(requirement): for rel in dirs: if rel in needed: print('local\t./' + rel) -inputs = ['pyproject.toml', 'uv.lock', 'uv.toml', '.python-version'] + dirs + ([] if vendored in members else [vendored]) +inputs = (['main.py.lock'] if script else ['pyproject.toml', 'uv.lock']) + ['uv.toml', '.python-version'] + dirs + ([] if vendored in members else [vendored]) paths = [] sourced = {} pending = list(docs.items()) @@ -80,6 +101,8 @@ def name(requirement): if path in paths: continue paths.append(path) + if rel == '.' and script: + print('source\t' + path) if path not in dirs and not os.path.isabs(path) and not outside(path) and os.path.exists(os.path.join(module, path, 'pyproject.toml')): sourced[path] = load(path) pending.append((path, sourced[path])) @@ -94,7 +117,8 @@ def name(requirement): for key in ('extra-index-url', 'find-links'): urls += uv.get(key) if isinstance(uv.get(key), list) else [] whole = any(isinstance(url, str) and '://' not in url for url in urls) -whole = whole or any('project' not in member or bool(set(table(member, 'project').get('dynamic') or []) - {'version'}) for member in list(docs.values()) + list(sourced.values())) +whole = whole or any('project' not in member or bool(set(table(member, 'project').get('dynamic') or []) - {'version'}) for member in [docs[rel] for rel in dirs] + list(sourced.values())) +whole = whole or (not script and ('project' not in doc or bool(set(scope_project.get('dynamic') or []) - {'version'}))) whole = whole or os.path.exists(os.path.join(module, 'requirements.lock')) whole = whole or any(os.path.isabs(path) or outside(path) for path in inputs) # The module itself installs after the source mount, so members, path sources and the @@ -116,19 +140,19 @@ def text(value): return value if isinstance(value, str) and '\t' not in value and '\n' not in value else '' def flag(value, default): return value if isinstance(value, bool) else default -project = table(doc, 'project') dagger = table(doc, 'tool', 'dagger') indexes = table(doc, 'tool', 'uv').get('index') indexes = [index for index in (indexes if isinstance(indexes, list) else [indexes]) if isinstance(index, dict) and text(index.get('name')) == ''] settings = { - 'projectName': text(project.get('name')), - 'requiresPython': text(project.get('requires-python')), + 'projectName': text(scope_project.get('name')), + 'requiresPython': text(scope_project.get('requires-python')), 'baseImage': text(dagger.get('base-image')), 'useUv': 'true' if flag(dagger.get('use-uv'), True) else 'false', 'uvVersion': text(dagger.get('uv-version')), 'vendorPath': text(table(doc, 'tool', 'uv', 'sources', 'dagger-io').get('path')), 'indexURL': next((text(index.get('url')) for index in indexes if flag(index.get('default'), False)), ''), 'extraIndexURL': next((text(index.get('url')) for index in indexes if not flag(index.get('default'), False)), ''), + 'script': 'true' if script else 'false', } for key, value in settings.items(): print('setting\t' + key + '\t' + value) From e29a7b0207d85209038e37a7e05af9f1f669339e Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:03:20 +0200 Subject: [PATCH 4/9] python-sdk: take a script module on the runtime Signed-off-by: Yves Brissaud --- runtime/main.dang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/main.dang b/runtime/main.dang index 363a166..7156424 100644 --- a/runtime/main.dang +++ b/runtime/main.dang @@ -18,7 +18,7 @@ type PythonSdkRuntime { moduleName: modName, ) - if (modSource.configExists == false or build.source.exists("pyproject.toml") == false) { + if (modSource.configExists == false or (build.source.exists("pyproject.toml") == false and build.source.exists("main.py") == false)) { raise "module \"" + modName + "\" has no source to trust; run `dagger generate` and commit the generated files" } else { # After the install, so those layers are shared with the static entrypoint. From d13062f86d0840a2bb278aa8b6fdbad534cbc5ef Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:04:22 +0200 Subject: [PATCH 5/9] python-sdk: generate the scope of a script module Signed-off-by: Yves Brissaud --- mod.dang | 2 +- scope.dang | 126 +++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/mod.dang b/mod.dang index 48556d7..860382b 100644 --- a/mod.dang +++ b/mod.dang @@ -122,7 +122,7 @@ type Mod { generation runs it, and config set stays open to fix the module. """ let checkPython: Void { - let refusal = PyprojectHelper().pythonRefusal(ws.directory("/" + rootPath, include: ["pyproject.toml", ".python-version"])) + let refusal = PyprojectHelper().pythonRefusal(ws.directory("/" + rootPath, include: ["pyproject.toml", "main.py", ".python-version"])) if (refusal != "") { raise "the module at " + rootPath + ": " + refusal + ", then run dagger generate again" } diff --git a/scope.dang b/scope.dang index f24c4aa..52b06fc 100644 --- a/scope.dang +++ b/scope.dang @@ -8,6 +8,11 @@ so is a plain project. Both get the same tree: sdk/ the SDK files, no generated code clients/core/ the core bindings clients// one member per declared client + +A script module's scope has a main.py whose PEP 723 block stands for +pyproject.toml, and no pyproject.toml. A script cannot be a workspace root, so +its block names sdk/ and each client as a path source, and main.py.lock is +its lock. """ type Scope { """ @@ -51,6 +56,7 @@ type Scope { is. """ pub generated: Workspace! { + checkScript let existing = ws.directory(root) let members = clientMembers checkNames(members) @@ -72,29 +78,69 @@ type Scope { } let file = scopeFile(existing, members, stale) checkRuntimeReads(file, members) - relocked(existing, withClients.withNewFile(memberPath(scopeFileName), file.contents)) + relocked(existing, withClients.withNewFile(memberPath(scopeFilePath), file.contents)) + } + + let script: Boolean! { + let existing = ws.directory(root) + existing.exists(scopeFileName) == false and + existing.exists(scriptFileName) and + ScriptMetadata(existing.file(scriptFileName).contents).scriptBlocks.length > 0 + } + + """ + What a script scope cannot be, refused before anything is written. + """ + let checkScript: Void { + if (script) { + if (isModule == false) { + raise path + " holds a script without a module: a script scope must be a module's. " + + "Initialize a module there, or add a pyproject.toml for a scope without one" + } + if (migrating) { + raise "the module at " + path + " is a script on the builtin Python runtime, which generation does not migrate: " + + "move its code into a module initialized with the default template" + } + if (globalClient) { + raise "the module at " + path + " is a script, which gets no global client: " + + "remove global-client from the [tool.dagger] table of its script block, and import core from dagger_clients.core" + } + } + null + } + + """ + The file that names the scope's members and sources. + """ + let scopeFilePath: String! { + if (script) { scriptFileName } else { scopeFileName } } """ A module's scope file must say to the runtime what it says to uv, or the module generates and then fails to build, or installs a member no one - named. Generation asks the runtime which members it reads, asks uv's - reading of the TOML, and refuses a file where the two differ either way, - or where the runtime refuses to read the members at all. + named. Generation asks the runtime which members it reads, or for a script + which path sources, asks uv's reading of the TOML, and refuses a file where + the two differ either way, or where the runtime refuses to read it at all. """ let checkRuntimeReads(file: File!, members: [ClientMember!]!): Void { if (isModule) { - # Members are never empty, so a leading newline marks the refusal. - let read = pythonSdkRuntime.scopeMembers(file).join("\n") rescue { + # Members and sources are never empty, so a leading newline marks the refusal. + let read = (if (script) { pythonSdkRuntime.scriptSources(file) } else { pythonSdkRuntime.scopeMembers(file) }).join("\n") rescue { err: Error => "\n" + err.message } let uvReads = PyprojectHelper().tool .withFile(toolPath, file) - .withExec(["pyproject", "get-members", toolPath]) + .withExec(["pyproject", if (script) { "get-path-sources" } else { "get-members" }, toolPath]) .stdout .split("\n") .filter { line => line != "" } - let form = "Write members as an array of strings, not a nested array or other values, then run dagger generate again" + let entries = if (script) { "path sources" } else { "workspace members" } + let form = if (script) { + "Write each source as an inline table, such as dagger-io = { path = \"sdk\" }, then run dagger generate again" + } else { + "Write members as an array of strings, not a nested array or other values, then run dagger generate again" + } # The runtime reads [project] dependencies with the members, and names # the key it refuses. if (read.hasPrefix("\n")) { @@ -102,9 +148,9 @@ type Scope { let m = read.match("(\\S+) is not an array of strings") let key = if (m == null) { "" } else { m.captures[0] ?? "" } if (key == "") { - raise scopeFileName + " of " + path + " cannot be read by the module runtime: " + read.trimSpace + raise scopeFilePath + " of " + path + " cannot be read by the module runtime: " + read.trimSpace } else { - raise scopeFileName + " of " + path + " has " + key + " the module runtime cannot read: " + + raise scopeFilePath + " of " + path + " has " + key + " the module runtime cannot read: " + key + " is not an array of strings, so the module would not build as written. Write " + key + " as an array of strings, not a nested array or other values, then run dagger generate again" } @@ -115,7 +161,7 @@ type Scope { if (missed.length > 0 or invented.length > 0) { let misses = if (missed.length > 0) { ["misses " + missed.join(", ")] } else { [] :: [String!]! } let invents = if (invented.length > 0) { ["reads " + invented.join(", ") + ", which uv does not"] } else { [] :: [String!]! } - raise scopeFileName + " of " + path + " lists workspace members the module runtime does not read the way uv does " + + raise scopeFilePath + " of " + path + " lists " + entries + " the module runtime does not read the way uv does " + "(it " + (misses + invents).join("; ") + "), so the module would not build as written. " + form } } @@ -128,6 +174,8 @@ type Scope { let coreDirName: String! = "core" let scopeFileName: String! = "pyproject.toml" let lockFileName: String! = "uv.lock" + let scriptFileName: String! = "main.py" + let scriptLockFileName: String! = "main.py.lock" let sdkDistribution: String! = "dagger-io" let coreDistribution: String! = "dagger-clients-core" @@ -175,6 +223,8 @@ type Scope { let existing = ws.directory(root) let setting = if (existing.exists(scopeFileName)) { PyprojectHelper().globalClientSetting(existing.file(scopeFileName)) + } else if (script) { + PyprojectHelper().globalClientSetting(existing.file(scriptFileName)) } else { null } @@ -280,10 +330,11 @@ type Scope { A committed uv.lock names the members, so it is locked again against the new ones; `uv sync --locked` in the module build refuses a stale lock. The uv is the one the module build runs, so the build can read the lock. A - scope without a lock gets none. + scope without a lock gets none, except a script's, which the build + installs from main.py.lock alone. """ let relocked(existing: Directory!, target: Workspace!): Workspace! { - if (existing.exists(lockFileName)) { + if (script or existing.exists(lockFileName)) { let scopeDir = target.directory(root).filter(exclude: [".venv", "**/.venv", "**/__pycache__"]) # A path dependency of the user's own may sit outside the scope: a # package the modules of one repository share. uv reads it to lock, so @@ -297,13 +348,14 @@ type Scope { directory.withDirectory(path, scopeDir) } let at = "/scope/" + path + let lockName = if (script) { scriptLockFileName } else { lockFileName } let lockFile = codegenBase .withMountedFile("/usr/local/bin/uv", container.from(buildUvImage).rootfs.file("uv")) .withDirectory("/scope", tree) .withWorkdir(at) - .withExec(["uv", "lock"]) - .file(at + "/" + lockFileName) - target.withFile(memberPath(lockFileName), lockFile) + .withExec(if (script) { ["uv", "lock", "--script", scriptFileName] } else { ["uv", "lock"] }) + .file(at + "/" + lockName) + target.withFile(memberPath(lockName), lockFile) } else { target } @@ -621,9 +673,38 @@ type Scope { The editor removes only the stale members named here, the ones whose directory carries the generated marker: any other member under clients/ - is the user's, and stays in the workspace with its directory. + is the user's, and stays in the workspace with its directory. A script has + no members: each of its sources is a path source, and the editor removes + the stale ones by their names, which are the SDK's. """ let scopeFile(existing: Directory!, members: [ClientMember!]!, stale: [String!]!): File! { + if (script) { + scriptFile(existing, members) + } else { + projectFile(existing, members, stale) + } + } + + let scriptFile(existing: Directory!, members: [ClientMember!]!): File! { + let base = [ + "--path-sources", + "--source", sdkDistribution + "=" + sdkDirName, + "--source", coreDistribution + "=" + clientsDirName + "/" + coreDirName, + "--dependency", coreDistribution, + ] + let perClient = members.reduce([] :: [String!]!) { flags, member => + flags + [ + "--source", member.distribution + "=" + clientsDirName + "/" + member.dir, + "--dependency", member.distribution, + ] + } + PyprojectHelper().tool + .withFile(toolPath, existing.file(scriptFileName)) + .withExec(["pyproject", "edit-scope", toolPath] + base + perClient) + .file(toolPath) + } + + let projectFile(existing: Directory!, members: [ClientMember!]!, stale: [String!]!): File! { let current = if (existing.exists(scopeFileName)) { existing.file(scopeFileName) } else { @@ -656,7 +737,13 @@ type Scope { .file(toolPath) } - let toolPath: String! = "/work/pyproject.toml" + """ + The helper reads a .py file as its script block, so the scope file keeps + its name. + """ + let toolPath: String! { + "/work/" + scopeFilePath + } """ The code generator's environment, synced once per SDK version and shared by @@ -801,7 +888,8 @@ type PyprojectHelper { like. """ pub globalClientSetting(pyproject: File!): Boolean { - let value = tool.withFile(path, pyproject).withExec(["pyproject", "get-global-client", path]).stdout + let at = "/work/" + pyproject.name + let value = tool.withFile(at, pyproject).withExec(["pyproject", "get-global-client", at]).stdout if (value == "true") { true } else if (value == "false") { false } else { null } } From 3f260d70728406b18cab6adfa2fae9d5aac28486 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:06:29 +0200 Subject: [PATCH 6/9] sdk: load a script module from its file Signed-off-by: Yves Brissaud --- sdk/src/dagger/mod/cli.py | 31 +++++++++++++++++++ sdk/tests/mod/test_cli.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 sdk/tests/mod/test_cli.py diff --git a/sdk/src/dagger/mod/cli.py b/sdk/src/dagger/mod/cli.py index 21a11e2..746343b 100644 --- a/sdk/src/dagger/mod/cli.py +++ b/sdk/src/dagger/mod/cli.py @@ -6,6 +6,7 @@ import importlib.util import logging import os +import sys import typing import anyio @@ -23,6 +24,7 @@ ENTRY_POINT_NAME: typing.Final[str] = "main_object" ENTRY_POINT_GROUP: typing.Final[str] = typing.cast(str, __package__) IMPORT_PKG: typing.Final[str] = os.getenv("DAGGER_DEFAULT_PYTHON_PACKAGE", "main") +SCRIPT_MODULE: typing.Final[str] = "main" def app(mod: Module | None = None, register: bool = False) -> int | None: @@ -82,6 +84,14 @@ def load_module() -> Module: def get_entry_point() -> importlib.metadata.EntryPoint: """Get the entry point for the main object.""" + if script := os.getenv("DAGGER_MODULE_SCRIPT"): + import_script(script) + return importlib.metadata.EntryPoint( + group=ENTRY_POINT_GROUP, + name=ENTRY_POINT_NAME, + value=f"{SCRIPT_MODULE}:{MAIN_OBJECT}", + ) + sel = importlib.metadata.entry_points( group=ENTRY_POINT_GROUP, name=ENTRY_POINT_NAME, @@ -110,3 +120,24 @@ def get_entry_point() -> importlib.metadata.EntryPoint: name=ENTRY_POINT_NAME, value=f"{import_pkg}:{MAIN_OBJECT}", ) + + +def import_script(path: str) -> None: + """Import a script module's file as the top-level module ``main``. + + A script is no installed package, so nothing finds it by name. + """ + spec = importlib.util.spec_from_file_location(SCRIPT_MODULE, path) + if spec is None or spec.loader is None: + msg = f"Cannot load the module script '{path}'" + raise ModuleLoadError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[SCRIPT_MODULE] = module + try: + spec.loader.exec_module(module) + except Exception as e: + del sys.modules[SCRIPT_MODULE] + logger.exception( + "Error while importing Python module '%s' with Dagger functions", path + ) + raise ModuleLoadError(str(e)) from e diff --git a/sdk/tests/mod/test_cli.py b/sdk/tests/mod/test_cli.py new file mode 100644 index 0000000..69a89ab --- /dev/null +++ b/sdk/tests/mod/test_cli.py @@ -0,0 +1,64 @@ +import sys +import textwrap + +import pytest + +from dagger.mod import cli +from dagger.mod._exceptions import ModuleLoadError +from dagger.mod._utils import get_parent_module_doc + +SCRIPT = textwrap.dedent( + '''\ + # /// script + # dependencies = ["dagger-io"] + # /// + """A script module, whose docstring describes no package.""" + + from dagger.mod import Module + + mod = Module(main_name="App") + + + @mod.object_type + class App: + @mod.function + def hello(self) -> str: + return "hello" + ''' +) + + +@pytest.fixture +def script(tmp_path, monkeypatch): + def _write(contents: str) -> None: + path = tmp_path / "main.py" + path.write_text(contents) + monkeypatch.setenv("DAGGER_MODULE_SCRIPT", str(path)) + + monkeypatch.setattr(cli, "MAIN_OBJECT", "App") + monkeypatch.delitem(sys.modules, cli.SCRIPT_MODULE, raising=False) + yield _write + sys.modules.pop(cli.SCRIPT_MODULE, None) + + +def test_script_loads_as_the_top_level_module(script): + script(SCRIPT) + mod = cli.load_module() + app = sys.modules[cli.SCRIPT_MODULE].App + assert mod is app.__dagger_module__ + assert app.__module__ == cli.SCRIPT_MODULE + assert get_parent_module_doc(app) is None + assert mod.describe().description is None + + +def test_script_wins_over_the_default_package(script, monkeypatch): + monkeypatch.setattr(cli, "IMPORT_PKG", "missing_package") + script(SCRIPT) + assert cli.get_entry_point().value == f"{cli.SCRIPT_MODULE}:App" + + +def test_script_that_fails_to_import(script): + script("raise RuntimeError('broken script')\n") + with pytest.raises(ModuleLoadError, match="broken script"): + cli.load_module() + assert cli.SCRIPT_MODULE not in sys.modules From 5dc35eb62f166f29c5e218b88a6d28c3fcb15bfb Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 22:56:36 +0200 Subject: [PATCH 7/9] python-sdk: add a script init template Signed-off-by: Yves Brissaud --- python-sdk.dang | 11 +++++--- templates/script/main.py.tmpl | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 templates/script/main.py.tmpl diff --git a/python-sdk.dang b/python-sdk.dang index f88bdff..a78647f 100644 --- a/python-sdk.dang +++ b/python-sdk.dang @@ -825,17 +825,22 @@ type PythonSdk { } """ - Apply non-default configuration flags to a rendered template's pyproject.toml. + Apply non-default configuration flags to a rendered template's pyproject.toml, + or to the script block of its main.py for a template without one. When all flags are at their defaults, the source is returned unchanged (no helper run, no reformatting). When any flag is non-default, the helper - re-marshals pyproject.toml, normalizing formatting while preserving all data. + re-marshals the TOML, normalizing formatting while preserving all data. """ let configuredTemplate(source: Directory!, pythonVersion: String!, useUv: Boolean!, baseImage: String!): Directory! { if (pythonVersion == "" and useUv and baseImage == "") { source } else { - let pyproj = "/rendered/pyproject.toml" + let script = source.exists("pyproject.toml") == false + if (script and useUv == false) { + raise "a script module builds with uv only: set useUv to true, or pick a template with a pyproject.toml" + } + let pyproj = if (script) { "/rendered/main.py" } else { "/rendered/pyproject.toml" } let built = container .from("golang:1.25-alpine") .withoutEntrypoint diff --git a/templates/script/main.py.tmpl b/templates/script/main.py.tmpl new file mode 100644 index 0000000..6f43865 --- /dev/null +++ b/templates/script/main.py.tmpl @@ -0,0 +1,51 @@ +# /// script +# requires-python = ">=3.14" +# dependencies = ["dagger-io", "dagger-clients-core"] +# +# [tool.uv.sources] +# dagger-io = { path = "sdk" } +# dagger-clients-core = { path = "clients/core" } +# /// + +from __future__ import annotations + +from dagger import function, object_type +from dagger_clients.core import Container, Directory, Workspace, core + + +@object_type +class {{ .ModuleType }}: + source: Directory + base_image_address: str + + @classmethod + def create( + cls, + ws: Workspace, + base_image_address: str = "alpine:3.24", + ) -> {{ .ModuleType }}: + return cls( + source=ws.directory( + "/", + exclude=[ + "**/.dagger", + "**/.git", + "**/.venv", + "**/__pycache__", + "**/node_modules", + "**/dist", + ], + ), + base_image_address=base_image_address, + ) + + @function + def container(self) -> Container: + """A container with the workspace source, ready to build.""" + return ( + core() + .container() + .from_(self.base_image_address) + .with_directory("/src", self.source) + .with_workdir("/src") + ) From 52ca1bb9d23f97a69a5349d627befba8ce2a1125 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:09:06 +0200 Subject: [PATCH 8/9] e2e: check script modules Signed-off-by: Yves Brissaud --- .../script/app/clients/core/pyproject.toml | 5 + .../modules/e2e/fixtures/script/app/main.py | 8 + .../fixtures/script/app/sdk/pyproject.toml | 5 + .../e2e/fixtures/script/app/tools/main.py | 1 + .../e2e/fixtures/script/app/unclosed/main.py | 2 + .../e2e/fixtures/script/empty/app/main.py | 3 + .../e2e/fixtures/script/empty/pyproject.toml | 2 + .dagger/modules/e2e/main.dang | 172 ++++++++++++++++++ 8 files changed, 198 insertions(+) create mode 100644 .dagger/modules/e2e/fixtures/script/app/clients/core/pyproject.toml create mode 100644 .dagger/modules/e2e/fixtures/script/app/main.py create mode 100644 .dagger/modules/e2e/fixtures/script/app/sdk/pyproject.toml create mode 100644 .dagger/modules/e2e/fixtures/script/app/tools/main.py create mode 100644 .dagger/modules/e2e/fixtures/script/app/unclosed/main.py create mode 100644 .dagger/modules/e2e/fixtures/script/empty/app/main.py create mode 100644 .dagger/modules/e2e/fixtures/script/empty/pyproject.toml diff --git a/.dagger/modules/e2e/fixtures/script/app/clients/core/pyproject.toml b/.dagger/modules/e2e/fixtures/script/app/clients/core/pyproject.toml new file mode 100644 index 0000000..9d9d6a5 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/app/clients/core/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "dagger-clients-core" + +[tool.dagger] +generated = "core" diff --git a/.dagger/modules/e2e/fixtures/script/app/main.py b/.dagger/modules/e2e/fixtures/script/app/main.py new file mode 100644 index 0000000..d02b08f --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/app/main.py @@ -0,0 +1,8 @@ +# /// script +# requires-python = ">=3.14" +# dependencies = ["dagger-io", "dagger-clients-core"] +# +# [tool.uv.sources] +# dagger-io = { path = "sdk" } +# dagger-clients-core = { path = "clients/core" } +# /// diff --git a/.dagger/modules/e2e/fixtures/script/app/sdk/pyproject.toml b/.dagger/modules/e2e/fixtures/script/app/sdk/pyproject.toml new file mode 100644 index 0000000..223654d --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/app/sdk/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "dagger-io" + +[tool.dagger] +generated = "runtime" diff --git a/.dagger/modules/e2e/fixtures/script/app/tools/main.py b/.dagger/modules/e2e/fixtures/script/app/tools/main.py new file mode 100644 index 0000000..7ef763f --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/app/tools/main.py @@ -0,0 +1 @@ +print("only code, no script block") diff --git a/.dagger/modules/e2e/fixtures/script/app/unclosed/main.py b/.dagger/modules/e2e/fixtures/script/app/unclosed/main.py new file mode 100644 index 0000000..fd1d242 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/app/unclosed/main.py @@ -0,0 +1,2 @@ +# /// script +print("an opening line, and no block after it") diff --git a/.dagger/modules/e2e/fixtures/script/empty/app/main.py b/.dagger/modules/e2e/fixtures/script/empty/app/main.py new file mode 100644 index 0000000..df7f9e6 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/empty/app/main.py @@ -0,0 +1,3 @@ +# /// script +# /// +print("an empty script block") diff --git a/.dagger/modules/e2e/fixtures/script/empty/pyproject.toml b/.dagger/modules/e2e/fixtures/script/empty/pyproject.toml new file mode 100644 index 0000000..b564978 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/script/empty/pyproject.toml @@ -0,0 +1,2 @@ +[project] +name = "empty-ancestor" diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 129b8e8..4713f11 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -23,6 +23,8 @@ type E2e { let staticTypesModulePath: String! = fixtureRoot + "/static-types" let runtimeGreeting: String! = "served by the python-sdk runtime" let mixedDiscoveryModulePath: String! = fixtureRoot + "/mixed-discovery/ancestor/work/app" + let scriptModulePath: String! = fixtureRoot + "/script/app" + let emptyScriptPath: String! = fixtureRoot + "/script/empty/app" let mixedDiscoveryNestedPath: String! = mixedDiscoveryModulePath + "/nested/deeper" let legacyBindingsPath: String! = "sdk/src/dagger/client/gen.py" @@ -2784,4 +2786,174 @@ type E2e { null } + """ + generateScope initializes a script module from the script template: a + main.py whose block names sdk/ and core as path sources, its lock, the + generated members and the entrypoint generated into it, and no + pyproject.toml or uv.lock. The template refuses pip, and a script scope + refuses the global client and a scope without a module. + """ + pub scriptInitCheck(ws: Workspace!): Void @check { + let scope = outputRoot + "/script-init" + let scoped = ws.withNewDirectory("/" + scope, directory).withWorkdir(scope) + let initialized = pythonSdk(template: "script").generateScope(scoped, isModule: true, name: "script-init", clients: []) + let generated = initialized.withWorkdir(".") + let changes = generated.changes(scoped.withWorkdir(".")) + [ + "main.py", "main.py.lock", "dagger-module.toml", sdkMemberFile, coreMemberFile, coreInitPath, + "sdk/entrypoint/main.dang", "sdk/entrypoint/types.dang", + ].each { file => assertAdded(changes, scope + "/" + file) } + let root = generated.directory("/" + scope) + ["pyproject.toml", "uv.lock", "src"].each { file => + assert(root.exists(file) == false, "a script module got " + file) + } + assertContainsAll(root.file("dagger-module.toml").contents, ["name = \"script-init\"", "kind = \"dang\"", "source = \"./sdk/entrypoint\""]) + let script = root.file("main.py").contents + assertContainsAll(script, [ + "# /// script\n", + "# dependencies = [\"dagger-io\", \"dagger-clients-core\"]\n", + "# dagger-io = { path = \"sdk\" }\n", + "# dagger-clients-core = { path = \"clients/core\" }\n", + "class ScriptInit:", + "from dagger_clients.core import", + ]) + assertContainsNone(script, ["[tool.uv.workspace]", "workspace = true", "{{"]) + assertContainsAll(root.file("main.py.lock").contents, [ + "name = \"dagger-io\"", + "source = { directory = \"sdk\" }", + "name = \"dagger-clients-core\"", + "source = { directory = \"clients/core\" }", + ]) + assertContains(root.file("sdk/entrypoint/types.dang").contents, "typeDef.withObject(\"ScriptInit\"", "the entrypoint does not describe the script's main object") + + let pipScope = outputRoot + "/script-pip" + let pip = pythonSdk(template: "script", useUv: false) + .generateScope(ws.withNewDirectory("/" + pipScope, directory).withWorkdir(pipScope), isModule: true, name: "script-pip", clients: []) + .directory(".") + .entries + .join(", ") rescue { + err: Error => err.message + } + assertContains(pip, "builds with uv only", "the script template was initialized for pip: " + pip) + + let global = initialized.withNewFile("/" + scope + "/main.py", script.replace("# ///\n", "#\n# [tool.dagger]\n# global-client = true\n# ///\n")) + let globalRefused = pythonSdk + .generateScope(global, isModule: true, name: "script-init", clients: []) + .directory(".") + .entries + .join(", ") rescue { + err: Error => err.message + } + assertContains(globalRefused, "gets no global client", "a script got the global client: " + globalRefused) + let plainRefused = pythonSdk + .generateScope(initialized, isModule: false, name: "", clients: []) + .directory(".") + .entries + .join(", ") rescue { + err: Error => err.message + } + assertContains(plainRefused, "holds a script without a module", "a script scope was generated without its module: " + plainRefused) + null + } + + """ + A script module runs: one call through the entrypoint generated into it + and one through this checkout's shared entrypoint, driven through the + released CLI from `dagger module init --template script`. An edit of the + script's code keeps its dependency install, an edit of its block installs + again, and a dependency added to the block without locking it again is + refused rather than installed from the old lock. + """ + pub scriptCallCheck(ws: Workspace!): Void @check { + let path = ".dagger/modules/script-call" + let initialized = cli(playground(ws), ["module", "init", "python", "--auto-apply", "--name", "script-call", "--path", path, "--template", "script"]) + .withExec(["test", "-f", path + "/main.py.lock"]) + .withExec(["test", "!", "-e", path + "/pyproject.toml"]) + let call = ["-m", path, "call", "container", "file", "--path", "/etc/alpine-release", "contents"] + let static = cli(initialized, call).stdout + assert(static.trimSpace != "", "the script module did not run on its generated entrypoint") + let shared = cli(onCheckoutEntrypoint(initialized, ws, path, "script-call"), call).stdout + assert(shared.trimSpace != "", "the script module did not run on the shared entrypoint") + + let scope = outputRoot + "/script-call" + let generated = pythonSdk(template: "script") + .generateScope(ws.withNewDirectory("/" + scope, directory).withWorkdir(scope), isModule: true, name: "script-call", clients: []) + .withWorkdir(".") + let installed = scriptInstalledAt(generated, scope) + let scriptPath = "/" + scope + "/main.py" + let script = generated.file(scriptPath).contents + let edited = generated.withNewFile(scriptPath, script + "\n# edited\n") + assert(scriptInstalledAt(edited, scope) == installed, "an edit of the script's code installed its dependencies again") + let block = generated.withNewFile(scriptPath, script.replace("# ///\n", "# # edited\n# ///\n")) + assert(scriptInstalledAt(block, scope) != installed, "an edit of the script's block kept the install before it") + let unlocked = generated.withNewFile(scriptPath, script.replace("\"dagger-clients-core\"]", "\"dagger-clients-core\", \"httpx>=0.20\"]")) + let stale = scriptInstalledAt(unlocked, scope) rescue "raised" + assert(stale == "raised", "a dependency missing from main.py.lock was not refused") + null + } + + """ + When the build installed the SDK files: a dependency install that is + cached gives back the same files, and one that runs again writes new ones. + """ + let scriptInstalledAt(generated: Workspace!, path: String!): String! { + pythonSdkRuntime + .moduleRuntime(modSource: runtimeSource(generated, path), introspectionJson: null) + .withExec(["python", "-c", "import os, dagger; print(os.stat(dagger.__file__).st_mtime_ns)"]) + .stdout + .trimSpace + } + + """ + A client of a script module is a path source of its block: generation + writes `{ path = "clients/" }` and the dependency into the block and + locks them in main.py.lock, and takes both out with the client, which + gives the block back. A script saved with CRLF line ends is still one, and + the line generation adds ends the same way. findClientRoot answers with + the script scope from inside it, from its generated members, and past a + main.py that is only code or opens a block it never closes. A block with + no content line is a block, so its script scope answers rather than the + project above it. + """ + pub scriptClientCheck(ws: Workspace!): Void @check { + let scope = outputRoot + "/script-client" + let scriptPath = "/" + scope + "/main.py" + let initialized = pythonSdk(template: "script") + .generateScope(ws.withNewDirectory("/" + scope, directory).withWorkdir(scope), isModule: true, name: "script-client", clients: []) + let script = initialized.file(scriptPath).contents + let withClient = pythonSdk.generateScope(initialized, isModule: true, name: "script-client", clients: [ws.moduleSource("/" + clientDepPath)]) + let root = withClient.directory("/" + scope) + let edited = root.file("main.py").contents + assertContainsAll(edited, [ + "# dependencies = [\"dagger-io\", \"dagger-clients-core\", \"dagger-clients-client-dep\"]\n", + "# dagger-clients-client-dep = { path = \"clients/client-dep\" }\n", + ]) + assertContainsNone(edited, ["[tool.uv.workspace]", "workspace = true"]) + assert(root.exists(clientDepMember + "/pyproject.toml"), "the client of a script was not generated") + assertContains(root.file("main.py.lock").contents, "source = { directory = \"clients/client-dep\" }", "the client of a script was not locked") + + let removed = pythonSdk.generateScope(withClient, isModule: true, name: "script-client", clients: []) + assert(removed.file(scriptPath).contents == script, "removing the client did not give the block back: " + removed.file(scriptPath).contents) + assert(removed.directory("/" + scope).exists(clientDepMember) == false, "the removed client of a script was left") + + let crlf = pythonSdk.generateScope( + initialized.withNewFile(scriptPath, script.replace("\n", "\r\n")), + isModule: true, + name: "script-client", + clients: [ws.moduleSource("/" + clientDepPath)], + ) + assert(crlf.directory("/" + scope).exists("pyproject.toml") == false, "a script with CRLF line ends was generated as a project") + assertContains(crlf.file(scriptPath).contents, "# dagger-clients-client-dep = { path = \"clients/client-dep\" }\r\n", "the client of a CRLF script was not written with its line end") + + # findUp does not see files added to the workspace, so the scope is a + # fixture on disk. + assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath)) == scriptModulePath, "findClientRoot should answer with a script scope") + assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/sdk")) == scriptModulePath, "findClientRoot should lift a script's sdk/ to the script") + assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/clients/core")) == scriptModulePath, "findClientRoot should lift a script's core to the script") + assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/tools")) == scriptModulePath, "findClientRoot should look past a main.py without a script block") + assert(pythonSdk.findClientRoot(ws.withWorkdir(scriptModulePath + "/unclosed")) == scriptModulePath, "findClientRoot should look past a main.py whose script block is never closed") + assert(pythonSdk.findClientRoot(ws.withWorkdir(emptyScriptPath)) == emptyScriptPath, "findClientRoot should answer with a script scope whose block is empty, not the project above it") + null + } + } From 8df481b2f44f3b60630f0ddb03cf577ab7190f36 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Tue, 20 Oct 2026 23:09:50 +0200 Subject: [PATCH 9/9] python-sdk: describe script modules in the README Signed-off-by: Yves Brissaud --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f6d8418..a29badb 100644 --- a/README.md +++ b/README.md @@ -361,9 +361,59 @@ dagger module init python --name my-module \ generated into it. `--template` picks a starter template: `default` (a small working module) when -you pass nothing, or `empty` for a bare object class. 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. +you pass nothing, `empty` for a bare object class, or `script` for a module in +a single file, as "Script modules" describes. 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. The `script` template takes them in its +script block. + +## Script modules + +A module can be one [PEP 723](https://peps.python.org/pep-0723/) script +instead of a project. The `script` template creates one: + +```sh +dagger module init python --name my-module --template script +``` + +The module directory holds these files: + +| Path | What it is | +| --- | --- | +| `main.py` | the module's code, with a `# /// script` metadata block at the top | +| `main.py.lock` | the script's lock, written by `uv lock --script main.py` | +| `sdk/`, `clients/core/`, `clients//` | the generated SDK files and clients, as in a project module | +| `dagger-module.toml` | the manifest | + +The script block holds what `pyproject.toml` holds in a project module: +`requires-python`, `dependencies`, and a `[tool]` table. A script cannot be a +uv workspace root, so the block names `sdk/` and each client as a path source, +such as `dagger-io = { path = "sdk" }`. `dagger generate` writes these sources +and the client dependencies into the block, and locks the script again. Every +other line of `main.py` keeps its bytes. + +The SDK tells the layout from the files. A directory with no `pyproject.toml` +and a `main.py` that holds a `# /// script` block is a script module. No +command converts a project module into a script module, or back. + +The build installs the dependencies from `main.py.lock`, with the sources and +artifact hashes it records. The build refuses a lock that no longer +matches the script block: run `dagger generate` to lock the script again. The +dependency layer reads the script block, the lock and the local packages the +block names, so an edit of the code below the block reuses it. The SDK loads +`main.py` as the top-level module `main`. + +A script module has these limits: + +- It builds with uv. The build refuses `use-uv = false` in the script block, + and the `script` template refuses `--use-uv=false`. +- It gets no global client. Its code imports core from `dagger_clients.core`. +- Generation does not migrate a script module from a pre-1.0 `dagger.json`. +- A script scope always holds a module. A scope with clients and no module + needs a `pyproject.toml`. +- Its path sources sit inside the module directory. The entrypoint does not + load a path source outside it. +- `dagger call python-sdk mod config` reads and writes `pyproject.toml` only. ## Configure an existing module