Repository navigation
Expand file tree
/
Copy pathpython-sdk.dang
More file actions
1055 lines (960 loc) · 40.7 KB
/
Copy pathpython-sdk.dang
File metadata and controls
1055 lines (960 loc) · 40.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Manage Dagger modules that use the Python SDK.
"""
type PythonSdk {
"""
Starter under templates/<template> for a module generated without source.
"""
pub template: String! = "default"
"""
Python version written to a new module's pyproject.toml; empty keeps the template's.
"""
pub pythonVersion: String! = ""
"""
Whether a new module builds with uv instead of pip.
"""
pub useUv: Boolean! = true
"""
Base container image written to a new module's pyproject.toml; empty keeps the SDK default.
"""
pub baseImage: String! = ""
"""
Run a module on the module runtime this SDK publishes instead of an
entrypoint: the runtime builds the module and answers every call, as the
runtime built into the engine did. A module whose files are not its
directory alone takes this path whether the flag is set or not. A module
that names a runtime of its own keeps it.
"""
pub legacyRuntime: Boolean! = false
"""
The engine release to generate for, from this SDK's floor to the release
this SDK targets; empty generates for the targeted release.
"""
pub targetVersion: String! = ""
"""
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.
"""
pub findClientRoot(ws: Workspace!): String {
let found = ws.findUp("pyproject.toml")
if (found == null) {
null
} else {
clientRootOf(ws, normalizePath(found.trimSuffix("pyproject.toml")))
}
}
"""
The scope that a pyproject.toml belongs to.
"""
let clientRootOf(ws: Workspace!, scope: String!): String! {
if (scope != "." and isGeneratedMember(ws, scope)) {
# Members sit at most two levels below their scope, but a scope can be
# 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
} else {
clientRootOf(ws, normalizePath(above.trimSuffix("pyproject.toml")))
}
} else {
let vendorParent = if (scope == vendorDirName) {
"."
} else if (scope.trimSuffix("/" + vendorDirName) != scope) {
scope.trimSuffix("/" + vendorDirName)
} else {
""
}
if (vendorParent == "" or hasModuleConfig(ws, scope)) {
scope
} else if (hasModuleConfig(ws, vendorParent) and scopeHasFile(ws, vendorParent, "pyproject.toml")) {
vendorParent
} else {
scope
}
}
}
"""
Whether a scope's pyproject.toml is one this SDK generated: sdk/, core or a
client, as its marker says when the file is read as TOML.
"""
let isGeneratedMember(ws: Workspace!, scope: String!): Boolean! {
scopeHasFile(ws, scope, "pyproject.toml") and
PyprojectHelper().generatedKind(ws.file("/" + scopePath(scope, "pyproject.toml"))) != ""
}
let parentPath(path: String!): String! {
let parts = path.split("/")
if (parts.length < 2) { "." } else { parts.takeFirst(parts.length - 1).join("/") }
}
"""
Directory a module's generated client library is vendored into.
"""
let vendorDirName: String! = "sdk"
"""
The module runtime this SDK publishes, which builds a module and answers its
calls. A module reference is a legal [runtime] source, and `@v1` selects the
greatest v1 tag as it does for the entrypoint.
"""
let sdkRuntimeSource: String! = "dagger.io/sdk/python/runtime@v1"
"""
The engine version a manifest this SDK writes for that runtime declares: the
version the generator reads core in, so a module's bindings and the view its
calls run in are one. A pre-1.0 config's version does not survive the move,
for the same reason.
"""
let sdkEngineVersion: String! {
currentModule.source.directory("helpers/core-schema").asModuleSource.engineVersion
}
"""
The kinds of entrypoint a module runs on, as Mod.entrypointMode names them.
"""
let generatedMode: String! = "generated"
let ownMode: String! = "own"
let runtimeMode: String! = "runtime"
"""
Whether a module config, dagger-module.toml or the pre-1.0 dagger.json,
exists at a workspace-root-relative scope.
"""
let hasModuleConfig(ws: Workspace!, scope: String!): Boolean! {
scopeHasFile(ws, scope, "dagger-module.toml") or scopeHasFile(ws, scope, "dagger.json")
}
let scopeHasFile(ws: Workspace!, scope: String!, filename: String!): Boolean! {
let path = scopePath(scope, filename)
ws.directory("/", include: [path]).exists(path)
}
let scopePath(scope: String!, filename: String!): String! {
if (scope == ".") { filename } else { scope + "/" + filename }
}
"""
Generate one SDK scope, the directory at the workspace cwd: sdk/, core and
one member per client, with the SDK's entries in its pyproject.toml. A scope
without a module gets the same tree and no module config. In a module scope,
a module without a config file is initialized from the configured template,
every module receives a dagger-module.toml from the manifest builder, and a
pre-1.0 dagger.json is migrated and removed. The manifest names the
entrypoint generated into the module, unless the module names a runtime, or
an entrypoint of its own in its files, which generation keeps; an
entrypoint served from elsewhere is replaced by the generated one.
"""
pub generateScope(ws: Workspace!, isModule: Boolean!, name: String!, clients: [ModuleSource!]!): Workspace! {
checkEngine
let scope = normalizePath(ws.cwd)
checkTarget(if (isModule) { "module \"" + name + "\"" } else { "scope " + scope }, targetVersion)
# Resolve local client paths from the workspace root.
let rooted = ws.withWorkdir(".")
if (isModule == false) {
Scope(path: scope, ws: rooted, clients: clients, isModule: false, engineVersion: "", migrating: false, targetVersion: targetVersionOf(targetVersion)).generated.withWorkdir(scope)
} else {
let hadConfig = hasModuleConfig(ws, scope)
let initialized = if (hadConfig) {
rooted
} else {
rooted.withDirectory("/" + scope, moduleTemplate(name, template, pythonVersion, useUv, baseImage))
}
# Read before the manifest is written: that write resets the entrypoint
# table, and a module keeps the kind it came with.
let mode = entrypointModeOf(initialized, scope)
if (mode == generatedMode) {
checkStaticScope(initialized, scope)
}
let migrating = migratingModule(initialized, scope)
let configured = generateScopeManifest(initialized, scope, name)
Mod(
rootPath: scope,
ws: configured,
entrypointMode: mode,
migrating: migrating,
engineVersion: scopeEngineVersion(initialized, scope),
targetVersion: targetVersionOf(targetVersion),
clients: clients,
).generated.withWorkdir(scope)
}
}
"""
Whether a Git module is a module of the Git workspace that generates its
client: pin is the workspace commit, ref and the workspace address both
name the repository at cloneRef, and the address names no selector or the
module's. Both ref forms are read: the workspace address keeps
<clone>/<path>@<version>, while from engine v1.0.0-beta.20 a module of the
workspace takes <clone>#<version>[:<subpath>]. Refs are compared without
their URL scheme: a GitHub address has none, while its clone ref has
https://. A workspace without a selector resolves its modules at the
default branch, which its address does not name, so any selector at its
commit matches. Selectors are compared as written, so aliased forms, such
as HEAD for main or v1 for v1.2.3, keep the Git binding. The Go SDK binds
its clients by the same rule (dagger/go-sdk#73, dagger/go-sdk#78,
dagger/go-sdk#79).
"""
pub workspaceRefMatches(address: String!, commit: String!, cloneRef: String!, ref: String!, pin: String!): Boolean! {
if (cloneRef == "" or pin == "" or pin != commit) {
false
} else {
let workspaceSelector = gitSelector(address, cloneRef)
let moduleSelector = gitSelector(ref, cloneRef)
if (workspaceSelector == null or moduleSelector == null) {
false
} else {
workspaceSelector == "" or workspaceSelector == moduleSelector
}
}
}
"""
The selector of a ref of the repository at cloneRef, empty when the ref
names none, or null for a ref of another repository.
"""
let gitSelector(ref: String!, cloneRef: String!): String {
let repo = withoutURLScheme(cloneRef)
let path = withoutURLScheme(ref)
let rest = path.trimPrefix(repo)
if (path.hasPrefix(repo) == false or (rest != "" and rest.hasPrefix("/") == false and rest.hasPrefix("@") == false and rest.hasPrefix("#") == false)) {
null
} else if (rest.hasPrefix("#")) {
rest.trimPrefix("#").split(":", limit: 2)[0] ?? ""
} else {
rest.split("@", limit: 2)[1] ?? ""
}
}
let withoutURLScheme(ref: String!): String! {
ref.split("://", limit: 2)[1] ?? ref
}
"""
Generate dagger-module.toml from the existing manifest. Preserve fields that
the SDK does not own. A client is a member of the scope, loaded by the code
that uses it, so the manifest lists none, and the [[dependencies]] of the
layout before go. Remove dagger.json so two manifest files cannot contain
different state.
The module runs on the Dang entrypoint generated into it, or on one of the
user's own in its files, and on nothing else: an entrypoint served from
elsewhere, the one this repository used to share among them, is replaced
by the generated one, which needs an engine at this SDK's floor or later.
The runtime this SDK used to write, `[runtime] source = "python"`, goes,
and so does what only a runtime reads: engineVersion, since an entrypoint
runs a module on the engine's own version. Any other runtime is one the
user chose, and the manifest keeps it with no entrypoint added, because
the engine follows an entrypoint over a runtime; for the same reason a
manifest with both is refused.
"""
let generateScopeManifest(ws: Workspace!, scope: String!, name: String!): Workspace! {
let root = "/" + scope
let hasToml = scopeHasFile(ws, scope, "dagger-module.toml")
let hasJson = scopeHasFile(ws, scope, "dagger.json")
if (hasToml) {
checkOneRunner(ws.file(root + "/dagger-module.toml").contents)
}
let base = if (hasToml) {
let manifest = ws.file(root + "/dagger-module.toml")
let config = tomlConfig(manifest.contents)
let toml = ManifestToml(manifest.contents)
let ours = isEntrypointManifest(manifest.contents)
# An entrypoint in the user's files is theirs to replace; the one this
# SDK writes is not, nor one served from elsewhere, nor none at all.
let usersEntrypoint = toml.entrypointKind == "dang" and ours == false and isOwnEntrypointSource(ws, scope, toml.entrypointSource)
if (keepsItsRuntime(config)) {
sdkHelpers.moduleManifest(loadToml: withoutReplacedEntrypoint(ws, scope, manifest))
} else if (legacyRuntime or (needsRuntimeSettings(config) and usersEntrypoint == false)) {
# Moved onto the runtime this SDK publishes, with the version the
# generator reads core in; what only a runtime can honour stays as
# written. Inline at each site: sdk-helpers' type names change with its
# own declared engineVersion (SdkHelpers… vs SDKHelpers… from the
# v1.0.0-beta.22 naming rules), so this module must not spell them.
sdkHelpers.moduleManifest(loadToml: withoutAnyEntrypoint(manifest))
.withLegacyRuntime(runtime: sdkRuntimeSource, engineVersion: sdkEngineVersion)
} else if (ours) {
# This SDK's entrypoint manifest holds nothing a loaded one keeps.
sdkHelpers.moduleManifest.withDangEntrypoint(source: staticEntrypointSource)
} else {
let loaded = sdkHelpers.moduleManifest(loadToml: withoutReplacedEntrypoint(ws, scope, manifest))
EntrypointManifest(config, "dagger-module.toml").checkEntrypointOnly
let entrypointed = if (usersEntrypoint) {
loaded
} else {
loaded.withDangEntrypoint(source: staticEntrypointSource)
}
entrypointed.withoutLegacyFields
}
} else if (hasJson) {
let manifest = ws.file(root + "/dagger.json")
let config = json.withContents((manifest.contents :: Dagger.JSON!))
let loaded = sdkHelpers.moduleManifest(loadJson: manifest)
if (keepsItsRuntime(config)) {
loaded
} else if (legacyRuntime or needsRuntimeSettings(config)) {
loaded.withLegacyRuntime(runtime: sdkRuntimeSource, engineVersion: sdkEngineVersion)
} else {
EntrypointManifest(config, "dagger.json").checkEntrypointOnly
loaded.withDangEntrypoint(source: staticEntrypointSource).withoutLegacyFields
}
} else if (legacyRuntime) {
sdkHelpers.moduleManifest.withLegacyRuntime(runtime: sdkRuntimeSource, engineVersion: sdkEngineVersion)
} else {
sdkHelpers.moduleManifest.withDangEntrypoint(source: staticEntrypointSource)
}
base.withName(name: name).withoutLegacyRuntimeDependencies
.generate(ws.withWorkdir(scope), lock: false, legacyJson: false)
.withWorkdir(".")
}
"""
The runtime a manifest names, or empty for one that names none. Read with a
TOML or JSON parser, because the manifest is the user's content; a
dagger.json names it as `sdk`, as a string or as a table.
"""
let runtimeSource(config: JSONValue!): String! {
let fields = config.fields
let key = if (fields.contains("runtime")) { "runtime" } else if (fields.contains("sdk")) { "sdk" } else { "" }
if (key == "") {
""
} else {
let value = config.field([key])
value.asString rescue {
err: Error => value.field(["source"]).asString
}
}
}
"""
Whether a manifest names a runtime of the user's own: anything but the
builtin `python` this SDK wrote.
"""
let keepsItsRuntime(config: JSONValue!): Boolean! {
let source = runtimeSource(config)
source != "" and source != "python"
}
"""
Whether a manifest says what only a runtime can honour: which files are the
module, and where its source sits. A module that says either stays on a
runtime, because an entrypoint roots the module at its own directory and
takes all of it.
"""
let needsRuntimeSettings(config: JSONValue!): Boolean! {
let fields = config.fields
fields.contains("include") or fields.contains("exclude") or
(fields.contains("source") and normalizePath(config.field(["source"]).asString) != ".")
}
"""
Whether a manifest names the Python runtime built into the engine, which
generated the module's bindings at load time and gave its code a global
`dag`. The code is the old code, so the scope gets the global client.
"""
let namesBuiltinRuntime(config: JSONValue!): Boolean! {
runtimeSource(config) == "python"
}
"""
A scope's module config, whichever it has: dagger-module.toml, or the
pre-1.0 dagger.json generation converts. Read it only once hasModuleConfig
says there is one.
"""
let manifestSettings(ws: Workspace!, scope: String!): JSONValue! {
if (scopeHasFile(ws, scope, "dagger-module.toml")) {
tomlConfig(ws.file("/" + scopePath(scope, "dagger-module.toml")).contents)
} else {
json.withContents((ws.file("/" + scopePath(scope, "dagger.json")).contents :: Dagger.JSON!))
}
}
"""
Whether this generation converts a module the builtin Python runtime ran,
from a pre-1.0 dagger.json or from a manifest that still names that
runtime. Only the generation that rewrites the manifest can tell.
"""
let migratingModule(ws: Workspace!, scope: String!): Boolean! {
hasModuleConfig(ws, scope) and namesBuiltinRuntime(manifestSettings(ws, scope))
}
"""
The engine version a module's scope generates against: the one its manifest
declares, and only while the module keeps a runtime of its own, which runs
it on that version. A module on an entrypoint runs on the engine's own
version, and so does one this SDK takes over from the builtin runtime. A
pre-1.0 manifest's version outlives `dagger module migrate`, and generating
against that view writes bindings around a core the session does not serve,
which the generator then refuses beside a client read in the engine's view.
"""
let scopeEngineVersion(ws: Workspace!, scope: String!): String! {
if (keepsOwnRuntime(ws, scope)) {
let config = manifestSettings(ws, scope)
if (config.fields.contains("engineVersion")) {
config.field(["engineVersion"]).asString
} else {
""
}
} else {
""
}
}
"""
Refuse a manifest that names both a runtime of the user's own and an
entrypoint. The engine runs the entrypoint and never calls the runtime,
while generation would take the core it generates against from the
runtime's engineVersion: a core that is not the session's. Both tables are
the user's, so the user picks one.
"""
let checkOneRunner(toml: String!): Void {
let manifest = ManifestToml(toml)
let config = tomlConfig(toml)
if (manifest.hasEntrypoint and keepsItsRuntime(config)) {
raise "dagger-module.toml names both a runtime (" + runtimeSource(config) + ") and an entrypoint (" +
manifest.entrypointSource + "); the engine runs the entrypoint and never calls the runtime. " +
"Remove [entrypoint] to run on your runtime, or [runtime] to run on the entrypoint"
}
null
}
"""
The manifest without any entrypoint table, for a module moving to a runtime:
the engine follows an entrypoint over a runtime, so one left behind would
take every call.
"""
let withoutAnyEntrypoint(manifest: File!): File! {
let toml = ManifestToml(manifest.contents)
if (toml.hasEntrypoint) { toml.withoutEntrypoint } else { manifest }
}
"""
Drop an entrypoint table generation replaces, before the manifest is read:
a module-kind one an earlier SDK wrote for its runtime module, which an
engine that loads manifest version 2 rejects, or a Dang one served from
elsewhere. sdk-helpers keeps every table it loads with no way to clear
one, so the table goes here instead. A Dang entrypoint in the user's files
is kept.
"""
let withoutReplacedEntrypoint(ws: Workspace!, scope: String!, manifest: File!): File! {
let toml = ManifestToml(manifest.contents)
if (toml.hasEntrypoint == false or (toml.entrypointKind == "dang" and isOwnEntrypointSource(ws, scope, toml.entrypointSource))) {
manifest
} else {
toml.withoutEntrypoint
}
}
"""
Whether an entrypoint source is a path in the module, as the engine's
classifySource reads one: a source that starts with "." or "/" is a path,
one with "://" or ":" is a reference, one with no "." is a path, and one
that could be either, such as example.com/repo, is a path when the module
holds it. Any other source is served from elsewhere, and generation
replaces it with the entrypoint it generates.
Mirrors classifySource in dagger/dagger core/sdk/entrypoint/entrypoint.go at v1.0.0-beta.22.
"""
let isOwnEntrypointSource(ws: Workspace!, scope: String!, source: String!): Boolean! {
if (source.hasPrefix(".") or source.hasPrefix("/")) {
true
} else if (source.contains("://") or source.contains(":")) {
false
} else if (source.contains(".") == false) {
true
} else {
let path = scopePath(scope, source)
ws.directory("/", include: [path]).exists(path)
}
}
"""
What the manifest written for a generated entrypoint cannot carry, refused
before anything is written, as a module generated directly refuses it. A
manifest that names a runtime, or says what only a runtime can honour,
never reaches here: it keeps its runtime instead.
"""
let checkStaticScope(ws: Workspace!, scope: String!): Void {
if (scopeHasFile(ws, scope, "dagger-module.toml")) {
EntrypointManifest(tomlConfig(ws.file("/" + scopePath(scope, "dagger-module.toml")).contents), "dagger-module.toml").checkGenerated
}
null
}
let tomlConfig(toml: String!): JSONValue! {
json.withContents((JSON.encode(TOML.decode(toml)) :: Dagger.JSON!))
}
"""
Whether the manifest names the static entrypoint this SDK generates into the
module: a Dang entrypoint at the path generation writes. Any other
entrypoint, a path of the user's own among them, is not this SDK's to
replace.
"""
let isEntrypointManifest(toml: String!): Boolean! {
let manifest = ManifestToml(toml)
manifest.entrypointKind == "dang" and normalizePath(manifest.entrypointSource) == staticEntrypointPath
}
"""
Where the static entrypoint lives in a module, as generation names it.
"""
let staticEntrypointPath: String! = vendorDirName + "/entrypoint"
let staticEntrypointSource: String! = "./" + staticEntrypointPath
"""
The first engine release this SDK runs on: the first that loads a
module's files through its entrypoint's sources member.
"""
let engineFloor: String! = "v1.0.0-beta.18"
"""
The engine release this SDK release targets: the one it generates for,
bumped with each SDK release. Core is read from the running engine in that
release's view, and the generated entrypoint and the library announce it.
"""
let engineTarget: String! = "v1.0.0-beta.24"
"""
The last release of this SDK that runs on an engine below engineFloor.
"""
let lastReleaseBelowFloor: String! = "v1.1.1"
"""
The first engine release lastReleaseBelowFloor runs on, as its manifest
declares.
"""
let lastReleaseEngineFloor: String! = "v1.0.0-beta.14"
"""
Why this SDK refuses an engine of the given version, or the empty string
when it runs on it. Only a release older than the floor is refused: a
version this cannot read passes, so an unexpected format never blocks a
user, and a build of dagger's main passes as the release it reports,
`v<release>+<commit>`. The engine's own gate cannot do this: it treats
every prerelease of v1.0.0 as v1.0.0. The refusal offers the last release
below the floor only to an engine that release runs on.
"""
pub engineRefusal(engineVersion: String!): String! {
if (Releases().precedes(engineVersion, engineFloor) == false) {
""
} else {
let needs = "this Python SDK needs Dagger engine " + engineFloor + " or later, and this engine is " +
engineVersion + ". "
if (Releases().precedes(engineVersion, lastReleaseEngineFloor)) {
needs + "Upgrade the engine."
} else {
needs + "Upgrade the engine, or pin the last release of this SDK that runs on it: " +
"dagger module install github.com/dagger/python-sdk@" + lastReleaseBelowFloor
}
}
}
"""
Refuse an engine older than the floor before generation reads or writes
anything, so the user reads why instead of an unknown field later.
"""
let checkEngine: Void {
let refusal = engineRefusal(version)
if (refusal != "") {
raise refusal
}
null
}
"""
The engine release a scope generates for: the targetVersion setting as a
`v`-prefixed release, or the release this SDK targets when it is empty.
"""
pub targetVersionOf(setting: String!): String! {
if (setting == "") { engineTarget } else { Releases().canonical(setting) }
}
"""
Why generation refuses the targetVersion setting of a module or scope, or
the empty string when it generates for it: any release from the floor to
the targeted one.
"""
pub targetRefusal(subject: String!, setting: String!): String! {
let releases = Releases()
let target = releases.canonical(setting)
let pins = subject + " pins targetVersion "
let remedy = ": set targetVersion in its dagger.toml settings to " + engineTarget + ", or remove it to generate for " + engineTarget
if (setting == "" or target == engineTarget) {
""
} else if (releases.isRelease(setting) == false) {
pins + "\"" + setting + "\", which is no engine release such as " + engineTarget + remedy
} else if (releases.precedes(engineTarget, target)) {
pins + target + ", which is later than " + engineTarget + ", the engine release this Python SDK generates for: install the SDK release built for " + target +
", or set targetVersion in its dagger.toml settings to " + engineTarget + ", or remove it"
} else if (releases.precedes(target, engineFloor)) {
pins + target + ", which is below " + engineFloor + ", the oldest engine this Python SDK runs on" + remedy
} else {
""
}
}
let checkTarget(subject: String!, setting: String!): Void {
let refusal = targetRefusal(subject, setting)
if (refusal != "") {
raise refusal
}
null
}
"""
The targetVersion setting of a module generated directly: the one its
scope records in the workspace config, or else this SDK's own. The engine
applies a scope's settings over the SDK's only when it generates the scope
itself, so an SDK called as a module, as `dagger call python-sdk mod` does,
never receives them. Only the config file is read, not its includes. The
setting's name is matched without regard to case, as the engine matches a
setting to a constructor argument.
"""
let recordedTargetVersion(ws: Workspace!, scope: String!): String! {
let rooted = ws.withWorkdir(".")
let configFile = rooted.configFile
if (configFile == "") {
targetVersion
} else {
let recorded = scopeSettings(tomlConfig(rooted.file("/" + configFile).contents), parentPath(configFile), scope)
.reduce([] :: [String!]!) { found, settings =>
found + settings.fields
.filter { key => key.toLower == "targetversion" }
.map { key => settings.field([key]).asString }
}
recorded[0] ?? targetVersion
}
}
"""
The settings a workspace config records for this SDK's scope at a
workspace-root-relative path, one per scope key that resolves to it: the
engine merges such records and refuses settings that differ, so every
record that holds a setting holds the same value. The SDK entry is the one
named after this module or naming it as its module, as Workspace.sdk finds
it; a config where several are is refused, as the engine refuses it.
Mirrors installedSDKSource in dagger/dagger core/schema/workspace_sdk.go at v1.0.0-beta.22.
"""
let scopeSettings(config: JSONValue!, configDir: String!, scope: String!): [JSONValue!]! {
if (config.fields.contains("sdks") == false) {
[] :: [JSONValue!]!
} else {
let sdks = config.field(["sdks"])
let names = sdks.fields.filter { name => name == currentModule.name or sdkModuleName(sdks.field([name])) == currentModule.name }
if (names.length > 1) {
raise "SDK lookup name \"" + currentModule.name + "\" is ambiguous: SDKs \"" + (names[0] ?? "") + "\" and \"" + (names[1] ?? "") + "\" both resolve it"
}
names
.map { name => sdks.field([name]) }
.filter { sdk => sdk.fields.contains("scopes") }
.reduce([] :: [JSONValue!]!) { found, sdk =>
let scopes = sdk.field(["scopes"])
found + scopes.fields
.filter { key => scopeKeyPath(configDir, key) == scope and scopes.field([key]).fields.contains("settings") }
.map { key => scopes.field([key, "settings"]) }
}
}
}
let sdkModuleName(sdk: JSONValue!): String! {
if (sdk.fields.contains("module")) { sdk.field(["module"]).asString } else { "" }
}
"""
The workspace-root-relative path a scope key names: a key with a leading
"/" is rooted at the workspace root, any other is relative to the config's
directory, and the joined path is cleaned as Go's path.Clean cleans it,
with backslashes read as separators.
Mirrors ResolveSDKManagedPath in dagger/dagger core/workspace/config.go at v1.0.0-beta.22.
"""
let scopeKeyPath(configDir: String!, key: String!): String! {
let slashed = key.replace("\\", "/")
let rooted = slashed.hasPrefix("/")
let joined = if (rooted) { slashed } else { configDir + "/" + slashed }
let segments = joined.split("/").reduce([] :: [String!]!) { kept, segment =>
if (segment == "" or segment == ".") {
kept
} else if (segment != "..") {
kept + [segment]
} else if (kept.length > 0 and (kept[kept.length - 1] ?? "") != "..") {
kept.takeFirst(kept.length - 1)
} else if (rooted) {
kept
} else {
kept + [segment]
}
}
if (segments.length == 0) { "." } else { segments.join("/") }
}
"""
A module as a refusal names it: by the name its manifest gives, or by its
path when it has none yet.
"""
let moduleSubject(ws: Workspace!, scope: String!): String! {
if (hasModuleConfig(ws, scope)) {
let config = manifestSettings(ws, scope)
if (config.fields.contains("name")) {
"module \"" + config.field(["name"]).asString + "\""
} else {
"the module at " + scope
}
} else {
"the module at " + scope
}
}
"""
How the engine runs a module: the kind its manifest already names, and the
SDK setting for one that has no manifest yet.
"""
let entrypointModeOf(ws: Workspace!, scope: String!): String! {
# A runtime of the user's own comes first: a manifest that also names an
# entrypoint is refused when the manifest is written, by the one error that
# names both, rather than by a generated entrypoint's own objections.
if (keepsOwnRuntime(ws, scope)) {
runtimeMode
} else if (legacyRuntime) {
runtimeMode
} else if (needsOwnRuntime(ws, scope)) {
# Before the entrypoint a manifest names: no entrypoint can honour these
# settings, so the module moves to a runtime, and an entrypoint of the
# user's own beside them is refused when the manifest is written.
runtimeMode
} else if (hasEntrypointManifest(ws, scope)) {
generatedMode
} else if (namesOwnEntrypoint(ws, scope)) {
ownMode
} else {
generatedMode
}
}
"""
Whether a module's manifest names a Dang entrypoint of the user's own, in
the module's files, which generation keeps.
"""
let namesOwnEntrypoint(ws: Workspace!, scope: String!): Boolean! {
if (scopeHasFile(ws, scope, "dagger-module.toml")) {
let manifest = ManifestToml(ws.file("/" + scopePath(scope, "dagger-module.toml")).contents)
manifest.entrypointKind == "dang" and isOwnEntrypointSource(ws, scope, manifest.entrypointSource)
} else {
false
}
}
"""
Whether a module's manifest names a runtime of the user's own, which
generation keeps and puts no entrypoint in front of.
"""
let keepsOwnRuntime(ws: Workspace!, scope: String!): Boolean! {
hasModuleConfig(ws, scope) and keepsItsRuntime(manifestSettings(ws, scope))
}
"""
Whether a module's manifest keeps it on a runtime by what it says, rather
than by naming one: a module of some files only, or rooted elsewhere.
"""
let needsOwnRuntime(ws: Workspace!, scope: String!): Boolean! {
hasModuleConfig(ws, scope) and needsRuntimeSettings(manifestSettings(ws, scope))
}
let hasEntrypointManifest(ws: Workspace!, scope: String!): Boolean! {
scopeHasFile(ws, scope, "dagger-module.toml") and isEntrypointManifest(ws.file("/" + scopePath(scope, "dagger-module.toml")).contents)
}
"""
Workspace-root-relative roots of the modules registered to this SDK.
"""
let managedRoots(ws: Workspace!): [String!]! {
ws.sdk(name: currentModule.name)
.modules.{{source}}
.map { module => normalizePath(module.source) }
}
let nearestRoot(roots: [String!]!): String {
roots.reduce(null) { best, root =>
if (pathDepth(root) > pathDepth(best)) { root } else { best }
}
}
let normalizePath(path: String!): String! {
let normalized = path.trimPrefix("./").trimPrefix("/").trimSuffix("/")
if (normalized == "") { "." } else { normalized }
}
let pathDepth(path: String): Int! {
if (path == null) { -1 } else if (path == ".") { 0 } else { path.split("/").length }
}
let pathContains(root: String!, path: String!): Boolean! {
root == "." or path == root or path.trimPrefix(root + "/") != path
}
"""
Return the starter templates tracked by this module.
Templates live under templates/<name> and are materialized into a new module
before generation. The `template` setting selects one; empty selects the
default.
"""
pub templates: [Template!]! {
if (currentModule.source.exists("templates")) {
let root = currentModule.source.directory("templates")
root.entries.map { name =>
Template(
name: name.trimSuffix("/"),
source: root.directory(name),
)
}
} else {
directory.entries.map { name =>
Template(
name: name,
source: directory,
)
}
}
}
"""
Return the managed Python SDK module at or above a workspace path.
When `findUp` is true, `path` may point inside the module; the nearest
enclosing module wins. When it is false, `path` is taken as the module root
as given, without asking whether this SDK manages it.
"""
pub mod(ws: Workspace!, path: String! = ".", findUp: Boolean! = true): Mod! {
checkEngine
let modPath = if (findUp) {
managedModuleAtOrAbove(ws, path)
} else {
normalizePath(path)
}
let setting = recordedTargetVersion(ws, modPath)
checkTarget(moduleSubject(ws, modPath), setting)
Mod(
rootPath: modPath,
ws: ws,
entrypointMode: entrypointModeOf(ws, modPath),
migrating: migratingModule(ws, modPath),
engineVersion: scopeEngineVersion(ws, modPath),
targetVersion: targetVersionOf(setting),
clients: null,
)
}
"""
Root of the nearest managed Python SDK module containing `path`.
Nothing here parses module config: registration is what makes a module ours.
"""
let managedModuleAtOrAbove(ws: Workspace!, path: String!): String! {
let requested = normalizePath(ws.withWorkdir(path).cwd)
let found = nearestRoot(managedRoots(ws).filter { root => pathContains(root, requested) })
if (found == null) {
raise "no managed Python SDK module found containing path: " + path
} else {
found
}
}
"""
The files of a new module: the named template rendered for the module name,
with the pyproject.toml settings applied. An empty template name selects the
default.
"""
let moduleTemplate(name: String!, template: String!, pythonVersion: String!, useUv: Boolean!, baseImage: String!): Directory! {
let selectedTemplate = if (template == "") { "default" } else { template }
let known = currentModule.source.directory("templates").entries.map { entry => entry.trimSuffix("/") }
if (known.filter { entry => entry == selectedTemplate }.length == 0) {
raise "unknown init template: " + template
} else {
configuredTemplate(renderedTemplate(name, selectedTemplate), pythonVersion, useUv, baseImage)
}
}
"""
Render a Python template with the requested module name.
"""
let renderedTemplate(name: String!, templateName: String!): Directory! {
container
.from("golang:1.25-alpine")
.withoutEntrypoint
.withMountedCache("/go/pkg/mod", cacheVolume("go-mod"))
.withMountedCache("/root/.cache/go-build", cacheVolume("go-build"))
.withDirectory("/helper", currentModule.source.directory("helpers/render-template"))
.withDirectory("/template", currentModule.source.directory("templates/" + templateName))
.withWorkdir("/helper")
.withExec(["go", "build", "-o", "/usr/local/bin/render-template", "."])
.withExec(["render-template", name, "/template", "/rendered"])
.directory("/rendered")
}
"""
Apply non-default configuration flags to a rendered template's pyproject.toml.
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.
"""
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 built = container
.from("golang:1.25-alpine")
.withoutEntrypoint
.withMountedCache("/go/pkg/mod", cacheVolume("go-mod"))
.withMountedCache("/root/.cache/go-build", cacheVolume("go-build"))
.withDirectory("/helper", currentModule.source.directory("helpers/pyproject"))
.withWorkdir("/helper")
.withExec(["go", "build", "-o", "/usr/local/bin/pyproject", "."])
.withDirectory("/rendered", source)
let withPy = if (pythonVersion == "") { built } else { built.withExec(["pyproject", "set-python-version", pyproj, pythonVersion]) }
let withUv = if (useUv) { withPy } else { withPy.withExec(["pyproject", "set-use-uv", pyproj, "false"]) }
let withImg = if (baseImage == "") { withUv } else { withUv.withExec(["pyproject", "set-base-image", pyproj, baseImage]) }
let refusal = withImg.withExec(["pyproject", "python-refusal", "/rendered"]).stdout
if (refusal != "") { raise refusal + "." } else { withImg.directory("/rendered") }
}
}
}
"""
A dagger-module.toml, read for its entrypoint table.
Values are read with a TOML parser, because the manifest is the user's
content: quoting, spacing and key order are theirs. The pyproject helper
removes the table and keeps every other byte as it was.
"""
type ManifestToml {
pub toml: String!
new(toml: String! = "") {
self.toml = toml
self
}
pub hasEntrypoint: Boolean! {
config.fields.contains("entrypoint")
}
"""The entrypoint's kind, or empty when the manifest has no entrypoint."""
pub entrypointKind: String! {
entrypointValue("kind")
}
pub entrypointSource: String! {
entrypointValue("source")
}
"""The manifest with its entrypoint table removed."""
pub withoutEntrypoint: File! {
PyprojectHelper().withoutTable(directory.withNewFile(fileName, toml).file(fileName), "entrypoint")
}
let fileName: String! = "dagger-module.toml"
let config: JSONValue! {
json.withContents((JSON.encode(TOML.decode(toml)) :: Dagger.JSON!))
}
let entrypointValue(key: String!): String! {
if (hasEntrypoint == false) {
""
} else {
let table = config.field(["entrypoint"])
if (table.fields.contains(key)) { table.field([key]).asString } else { "" }
}
}
}
"""
A module config read for what a manifest generation writes for an entrypoint
cannot carry: a dagger-module.toml, or the pre-1.0 dagger.json generation
converts, as the JSON its file parses to.
"""
type EntrypointManifest {
let config: JSONValue!