diffusers-workflow 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. dw_mcp/workspaces.py +212 -0
dw/introspection.py ADDED
@@ -0,0 +1,1228 @@
1
+ """Discover what diffusers exposes and what its pipelines accept.
2
+
3
+ This is the metadata layer a form-generating UI builds on: pipeline names
4
+ come from the installed diffusers (so a new release's pipelines appear with
5
+ no code change here), and a pipeline's argument schema comes from its
6
+ __call__ signature and docstring. Nothing here executes a pipeline.
7
+
8
+ Only bare class names resolved against the diffusers namespace - plus an
9
+ explicit allowlist of companion packages (sdnq) - are accepted from callers;
10
+ never arbitrary dotted import paths, which would let an HTTP client import
11
+ any module on the system.
12
+ """
13
+
14
+ import re
15
+ import inspect
16
+ import logging
17
+ import difflib
18
+ from .variables import undeclared_variable_references
19
+
20
+ logger = logging.getLogger("dw")
21
+
22
+ _NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
23
+
24
+ # Matches a docstring parameter header, with or without a declared type:
25
+ # prompt (`str` or `List[str]`, *optional*):
26
+ # prompt: The user message
27
+ # The leading stars let a '**kwargs:' block be recognized as a header of its
28
+ # own, so what it documents can be read as parameters too.
29
+ _DOC_PARAM_PATTERN = re.compile(r"^(\*{0,2}[A-Za-z_]\w*)(?: \((.+?)\))?:\s*(.*)$")
30
+
31
+
32
+ # Companion packages whose classes workflows commonly name. Extending this
33
+ # is a deliberate act; nothing else outside diffusers ever resolves.
34
+ ALLOWED_MODULES = ("sdnq",)
35
+
36
+ # from_pretrained is **kwargs-based on ModelMixin, so these generic loading
37
+ # knobs are curated rather than discovered - merged with whatever a
38
+ # signature does name. No typo warnings are possible behind **kwargs.
39
+ COMPONENT_LOADING_KNOBS = [
40
+ {
41
+ "name": "torch_dtype",
42
+ "required": False,
43
+ "default": None,
44
+ "annotation": "torch.dtype",
45
+ "description": "Weight dtype to load as, e.g. torch.bfloat16.",
46
+ },
47
+ {
48
+ "name": "variant",
49
+ "required": False,
50
+ "default": None,
51
+ "annotation": "str",
52
+ "description": "Checkpoint variant to load, e.g. 'fp16'.",
53
+ },
54
+ {
55
+ "name": "subfolder",
56
+ "required": False,
57
+ "default": None,
58
+ "annotation": "str",
59
+ "description": "Subfolder of the repository the weights live in.",
60
+ },
61
+ {
62
+ "name": "revision",
63
+ "required": False,
64
+ "default": None,
65
+ "annotation": "str",
66
+ "description": "Git revision (branch, tag or commit) to load from.",
67
+ },
68
+ ]
69
+
70
+
71
+ def _filtered_exports(predicate):
72
+ """diffusers export names passing predicate - names only, no imports."""
73
+ import diffusers
74
+
75
+ return sorted(
76
+ name for name in dir(diffusers) if not name.startswith("_") and predicate(name)
77
+ )
78
+
79
+
80
+ def list_pipelines():
81
+ """Names of every pipeline class the installed diffusers exports.
82
+
83
+ Reads the export list without importing each pipeline's module -
84
+ diffusers is lazy and enumerating hundreds of classes must stay cheap.
85
+ """
86
+ return _filtered_exports(lambda name: name.endswith("Pipeline"))
87
+
88
+
89
+ def list_classes(kind):
90
+ """Class names of one kind, for UI pickers.
91
+
92
+ Enumerates the way list_pipelines does - suffix filters over the export
93
+ list, nothing imported. Autoencoders are models that don't carry the
94
+ Model suffix, so the model filter names them explicitly.
95
+ """
96
+ if kind == "pipelines":
97
+ return list_pipelines()
98
+ if kind == "models":
99
+ return _filtered_exports(
100
+ lambda name: name.endswith("Model") or "Autoencoder" in name
101
+ )
102
+ if kind == "schedulers":
103
+ return _filtered_exports(lambda name: name.endswith("Scheduler"))
104
+ if kind == "quantization":
105
+ names = _filtered_exports(lambda name: name.endswith("Config"))
106
+ import importlib.util
107
+
108
+ if importlib.util.find_spec("sdnq") is not None:
109
+ names.append("sdnq.SDNQConfig")
110
+ return names
111
+ raise ValueError(f"Unknown class kind: {kind!r}")
112
+
113
+
114
+ def load_allowed_class(name):
115
+ """Resolve a class name: bare against diffusers, or module.Class where
116
+ the module is on the explicit allowlist.
117
+
118
+ Raises:
119
+ ValueError: for a malformed name, a module outside the allowlist,
120
+ or a name the module does not export
121
+ """
122
+ module_name, _, class_name = (name or "").rpartition(".")
123
+ if module_name and module_name not in ALLOWED_MODULES:
124
+ raise ValueError(f"Module {module_name!r} is not on the allowlist")
125
+ if not _NAME_PATTERN.match(class_name):
126
+ raise ValueError(f"Not a valid class name: {name!r}")
127
+
128
+ import importlib
129
+
130
+ try:
131
+ module = importlib.import_module(module_name or "diffusers")
132
+ except ImportError as e:
133
+ raise ValueError(f"Could not import {module_name}: {e}")
134
+ try:
135
+ cls = getattr(module, class_name)
136
+ except AttributeError:
137
+ raise ValueError(
138
+ f"{module_name or 'diffusers'} exports no class named {class_name!r}"
139
+ )
140
+ if not isinstance(cls, type):
141
+ raise ValueError(f"{name!r} is not a class")
142
+ return cls
143
+
144
+
145
+ # The original, pipeline-flavored name - existing callers keep working
146
+ load_pipeline_class = load_allowed_class
147
+
148
+
149
+ def _json_safe_default(value):
150
+ if value is inspect.Parameter.empty:
151
+ return None
152
+ if isinstance(value, (str, int, float, bool)) or value is None:
153
+ return value
154
+ return repr(value)
155
+
156
+
157
+ def _parse_docstring_args(docstring):
158
+ """Parameter descriptions from a docstring's 'Args:' block.
159
+
160
+ Returns (documented, documented_kwargs): the entries at the block's own
161
+ indent, and those nested one level under a '**kwargs:' entry. The nested
162
+ ones are the only declaration a task that funnels its real arguments
163
+ through **kwargs ever makes, so discovery needs them as much as the
164
+ signature.
165
+
166
+ Best effort by design: a docstring with an unusual shape simply yields
167
+ fewer descriptions, never an error.
168
+ """
169
+ documented = {}
170
+ documented_kwargs = {}
171
+ if not docstring:
172
+ return documented, documented_kwargs
173
+
174
+ lines = docstring.splitlines()
175
+ try:
176
+ start = next(
177
+ i
178
+ for i, line in enumerate(lines)
179
+ if line.strip() in ("Args:", "Parameters:")
180
+ )
181
+ except StopIteration:
182
+ return documented, documented_kwargs
183
+
184
+ # The entry being described, and where its description is accumulating:
185
+ # a top-level entry, or one nested inside the **kwargs block
186
+ current = None
187
+ target = documented
188
+ parts = []
189
+ base_indent = None
190
+ kwargs_indent = None
191
+ for line in lines[start + 1 :]:
192
+ stripped = line.strip()
193
+ if not stripped:
194
+ continue
195
+ indent = len(line) - len(line.lstrip())
196
+ if base_indent is None:
197
+ base_indent = indent
198
+ if indent < base_indent:
199
+ break # left the Args block (Returns:, Examples:, ...)
200
+
201
+ nested = kwargs_indent is not None and indent == kwargs_indent
202
+ header = (
203
+ _DOC_PARAM_PATTERN.match(stripped)
204
+ if indent == base_indent or nested
205
+ else None
206
+ )
207
+ if header:
208
+ if current:
209
+ target[current]["description"] = " ".join(parts).strip()
210
+ name = header.group(1)
211
+ if name.startswith("*"):
212
+ # The kwargs entry itself is not a parameter; what it
213
+ # indents is. Its own indent is unknown until the first
214
+ # nested line arrives
215
+ current = None
216
+ target = documented_kwargs
217
+ kwargs_indent = None
218
+ parts = []
219
+ continue
220
+ if indent == base_indent:
221
+ target = documented
222
+ kwargs_indent = None
223
+ elif kwargs_indent is None:
224
+ kwargs_indent = indent
225
+ current = name
226
+ target[current] = {"doc_type": header.group(2)}
227
+ parts = [header.group(3)] if header.group(3) else []
228
+ elif current:
229
+ parts.append(stripped)
230
+ elif target is documented_kwargs and kwargs_indent is None:
231
+ # First line under '**kwargs:' - it sets the nested indent, and
232
+ # is a header if it reads like one
233
+ kwargs_indent = indent
234
+ header = _DOC_PARAM_PATTERN.match(stripped)
235
+ if header and not header.group(1).startswith("*"):
236
+ current = header.group(1)
237
+ target[current] = {"doc_type": header.group(2)}
238
+ parts = [header.group(3)] if header.group(3) else []
239
+ if current:
240
+ target[current]["description"] = " ".join(parts).strip()
241
+ return documented, documented_kwargs
242
+
243
+
244
+ def _callable_parameters(target_callable):
245
+ """A callable's parameters as form-ready entries, merged with its
246
+ docstring's Args descriptions. Returns (parameters, accepts_kwargs)."""
247
+ signature = inspect.signature(target_callable)
248
+ documented, documented_kwargs = _parse_docstring_args(
249
+ inspect.getdoc(target_callable)
250
+ )
251
+
252
+ parameters = []
253
+ accepts_kwargs = False
254
+ for parameter in signature.parameters.values():
255
+ if parameter.name == "self":
256
+ continue
257
+ if parameter.kind == inspect.Parameter.VAR_KEYWORD:
258
+ accepts_kwargs = True
259
+ continue
260
+ if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
261
+ continue
262
+ entry = {
263
+ "name": parameter.name,
264
+ "required": parameter.default is inspect.Parameter.empty,
265
+ "default": _json_safe_default(parameter.default),
266
+ "annotation": (
267
+ None
268
+ if parameter.annotation is inspect.Parameter.empty
269
+ else str(parameter.annotation)
270
+ ),
271
+ }
272
+ entry.update(documented.get(parameter.name, {}))
273
+ documented_kwargs.pop(parameter.name, None)
274
+ parameters.append(entry)
275
+
276
+ if accepts_kwargs:
277
+ # Whatever the **kwargs block names is a real argument the callable
278
+ # takes; the signature just never says so. There is no default to
279
+ # read, and anything funnelled through kwargs is optional
280
+ for name, entry in documented_kwargs.items():
281
+ parameters.append(
282
+ {
283
+ "name": name,
284
+ "required": False,
285
+ "default": None,
286
+ "annotation": None,
287
+ **entry,
288
+ }
289
+ )
290
+ return parameters, accepts_kwargs
291
+
292
+
293
+ def describe_class(name, target="call"):
294
+ """The argument schema of a class, for form generation.
295
+
296
+ target picks what gets inspected: 'call' reads __call__ (pipelines),
297
+ 'init' reads __init__ (quantization configs, schedulers, models), and
298
+ 'load' reads from_pretrained merged with the curated loading knobs -
299
+ from_pretrained hides everything behind **kwargs, so the knobs are the
300
+ honest answer there. Output shape is identical across targets, so one
301
+ arguments editor consumes all three. Scheduler classes additionally
302
+ report their compatibles list.
303
+ """
304
+ cls = load_allowed_class(name)
305
+ if target == "call":
306
+ target_callable = cls.__call__
307
+ elif target == "init":
308
+ target_callable = cls.__init__
309
+ elif target == "load":
310
+ target_callable = getattr(cls, "from_pretrained", cls.__init__)
311
+ else:
312
+ raise ValueError(f"Unknown inspection target: {target!r}")
313
+
314
+ parameters, accepts_kwargs = _callable_parameters(target_callable)
315
+
316
+ if target == "load":
317
+ named = {parameter["name"] for parameter in parameters}
318
+ parameters = [
319
+ knob for knob in COMPONENT_LOADING_KNOBS if knob["name"] not in named
320
+ ] + parameters
321
+ # the first positional of from_pretrained is the model path, which
322
+ # the editor's own model field carries
323
+ parameters = [
324
+ p for p in parameters if p["name"] != "pretrained_model_name_or_path"
325
+ ]
326
+
327
+ # The class's own docstring only - getdoc walks the MRO and would call
328
+ # every pipeline "Base class for all pipelines."
329
+ class_doc = inspect.cleandoc(cls.__dict__.get("__doc__") or "")
330
+ summary = class_doc.split("\n\n")[0].replace("\n", " ").strip()
331
+
332
+ description = {
333
+ "name": name,
334
+ "summary": summary,
335
+ "accepts_kwargs": accepts_kwargs,
336
+ "parameters": parameters,
337
+ }
338
+
339
+ compatibles = getattr(cls, "_compatibles", None)
340
+ if compatibles:
341
+ description["compatibles"] = sorted(
342
+ c if isinstance(c, str) else getattr(c, "__name__", str(c))
343
+ for c in compatibles
344
+ )
345
+ return description
346
+
347
+
348
+ def describe_pipeline(name):
349
+ """A pipeline's __call__ argument schema - describe_class's original."""
350
+ return describe_class(name, target="call")
351
+
352
+
353
+ def unknown_call_arguments(name, argument_names):
354
+ """The given argument names a pipeline's __call__ will reject.
355
+
356
+ Empty when the signature takes **kwargs (no name can be proven wrong)
357
+ or when the class cannot be resolved or inspected - this feeds warnings,
358
+ and a warning must never be wrong.
359
+ """
360
+ try:
361
+ cls = load_pipeline_class(name)
362
+ signature = inspect.signature(cls.__call__)
363
+ except (ValueError, TypeError):
364
+ return []
365
+ parameters = signature.parameters.values()
366
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters):
367
+ return []
368
+ known = {p.name for p in parameters}
369
+ return sorted(set(argument_names) - known)
370
+
371
+
372
+ def unknown_pipeline_components(name, component_names):
373
+ """The given component names a pipeline's constructor does not register.
374
+
375
+ A dotted name ('text_encoder.model') is checked by its first segment -
376
+ the component itself is what the constructor registers; what a dotted
377
+ path reaches inside it is not this check's business.
378
+
379
+ Empty when the constructor takes **kwargs (no name can be proven wrong)
380
+ or when the class cannot be resolved or inspected - this feeds warnings,
381
+ and a warning must never be wrong.
382
+ """
383
+ try:
384
+ cls = load_pipeline_class(name)
385
+ signature = inspect.signature(cls.__init__)
386
+ except (ValueError, TypeError):
387
+ return []
388
+ parameters = [p for p in signature.parameters.values() if p.name != "self"]
389
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters):
390
+ return []
391
+ known = {p.name for p in parameters}
392
+ return sorted({name for name in component_names if name.split(".")[0] not in known})
393
+
394
+
395
+ def list_tasks():
396
+ """Every task command a workflow's task step can name.
397
+
398
+ `assessment` names the probes among the commands (#387) - the ones that
399
+ answer a JSON document of measurements about a finished file rather than
400
+ make one - so a caller looking for a way to check a cut finds them
401
+ without reading every command's schema. They stay in `commands` too,
402
+ since a step still names one as its `command`.
403
+ """
404
+ from .tasks.task import (
405
+ _COMMAND_INFO,
406
+ _COMMAND_REGISTRY,
407
+ _VIDEO_PROCESSOR_COMMANDS,
408
+ )
409
+ from .tasks.image_utils import available_processors
410
+
411
+ return {
412
+ "commands": sorted(_COMMAND_REGISTRY.keys()),
413
+ "image_processors": sorted(available_processors()),
414
+ "video_processors": list(_VIDEO_PROCESSOR_COMMANDS),
415
+ "assessment": sorted(
416
+ name
417
+ for name, info in _COMMAND_INFO.items()
418
+ if info.get("returns") == "json"
419
+ ),
420
+ }
421
+
422
+
423
+ def _first_paragraph(docstring):
424
+ cleaned = inspect.cleandoc(docstring or "")
425
+ return cleaned.split("\n\n")[0].replace("\n", " ").strip()
426
+
427
+
428
+ def describe_task(command):
429
+ """A task command's argument schema, in describe_class's shape, so the
430
+ editor's one arguments form consumes both.
431
+
432
+ The schema is the registered implementation function's real signature -
433
+ the same function the dispatch forwards **arguments into - so it cannot
434
+ drift from the runtime. Parameters the dispatch supplies itself are
435
+ removed; 'device' is appended because every task accepts it (the
436
+ dispatch consumes it before the implementation is called). Raises
437
+ ValueError for a name that is not a task command.
438
+ """
439
+ import importlib
440
+
441
+ from .tasks.task import task_command_info
442
+
443
+ info = task_command_info(command)
444
+
445
+ device_parameter = {
446
+ "name": "device",
447
+ "required": False,
448
+ "default": None,
449
+ "annotation": None,
450
+ "description": "Device override for this task (e.g. cpu, cuda:1) - "
451
+ "keeps a helper model off the accelerator a pipeline is using",
452
+ }
453
+
454
+ if info["kind"] == "image_processor":
455
+ from .tasks.image_utils import image_processor_target
456
+
457
+ target = image_processor_target(command)
458
+ image_parameter = {
459
+ "name": "image",
460
+ "required": True,
461
+ "default": None,
462
+ "annotation": None,
463
+ "description": "The image to process",
464
+ }
465
+ if target is None:
466
+ return {
467
+ "name": command,
468
+ "summary": f"'{command}' image processor (ControlNet preprocessor)",
469
+ "accepts_kwargs": True,
470
+ "parameters": [image_parameter, device_parameter],
471
+ }
472
+
473
+ # target is a plain (image, **kwargs) function - introspect it directly
474
+ # rather than reporting the generic (image, device) shape every other
475
+ # image processor shares (#350). Its first positional parameter is
476
+ # the image (named "image" or "img" across these functions), dropped
477
+ # in favor of the uniform image_parameter above.
478
+ parameters, accepts_kwargs = _callable_parameters(target)
479
+ parameters = [image_parameter] + parameters[1:]
480
+ if not any(p["name"] == "device" for p in parameters):
481
+ parameters.append(device_parameter)
482
+ summary = _first_paragraph(inspect.getdoc(target))
483
+ return {
484
+ "name": command,
485
+ "summary": summary,
486
+ "accepts_kwargs": accepts_kwargs,
487
+ "parameters": parameters,
488
+ }
489
+
490
+ if info["implementation"] is None:
491
+ # Free-form by design (gather_inputs): any keys, passed through
492
+ from .tasks.task import _COMMAND_REGISTRY
493
+
494
+ handler = _COMMAND_REGISTRY.get(command)
495
+ return {
496
+ "name": command,
497
+ "summary": _first_paragraph(inspect.getdoc(handler)),
498
+ "accepts_kwargs": True,
499
+ "parameters": [],
500
+ }
501
+
502
+ module_name, _, function_name = info["implementation"].rpartition(".")
503
+ implementation = getattr(importlib.import_module(module_name), function_name)
504
+ parameters, accepts_kwargs = _callable_parameters(implementation)
505
+ parameters = [p for p in parameters if p["name"] not in info["provided"]]
506
+ if not any(p["name"] == "device" for p in parameters):
507
+ parameters.append(device_parameter)
508
+
509
+ # A signature carries no range, so a declared domain is reported beside
510
+ # the parameter it constrains - an agent reading get_task saw
511
+ # 'annotation: null' and no domain at all, and wrote the negative frame
512
+ # count validation now refuses (dw/task_domains.py, #139, #140)
513
+ from .task_domains import TASK_ARGUMENT_DOMAINS
514
+
515
+ domains = TASK_ARGUMENT_DOMAINS.get(command, {})
516
+ for parameter in parameters:
517
+ domain = domains.get(parameter["name"])
518
+ if domain is not None:
519
+ parameter["domain"] = domain
520
+
521
+ parameter_descriptions = info.get("parameter_descriptions") or {}
522
+ for parameter in parameters:
523
+ description = parameter_descriptions.get(parameter["name"])
524
+ if description:
525
+ parameter["description"] = description
526
+
527
+ summary = info.get("summary")
528
+ if not summary:
529
+ summary = _first_paragraph(inspect.getdoc(implementation))
530
+ if not summary:
531
+ from .tasks.task import _COMMAND_REGISTRY
532
+
533
+ summary = _first_paragraph(inspect.getdoc(_COMMAND_REGISTRY.get(command)))
534
+
535
+ return {
536
+ "name": command,
537
+ "summary": summary,
538
+ "accepts_kwargs": accepts_kwargs,
539
+ "parameters": parameters,
540
+ }
541
+
542
+
543
+ def unknown_task_arguments(command, argument_names):
544
+ """The given argument names a task command will not accept.
545
+
546
+ Same never-wrong contract as unknown_call_arguments: empty when the
547
+ implementation takes **kwargs, when the command consumes a free-form
548
+ dict, or when the command cannot be described at all. 'device' is
549
+ always accepted - the dispatch consumes it before the implementation
550
+ runs.
551
+ """
552
+ try:
553
+ description = describe_task(command)
554
+ except Exception:
555
+ return []
556
+ if description["accepts_kwargs"]:
557
+ return []
558
+ known = {p["name"] for p in description["parameters"]} | {"device"}
559
+ return sorted(set(argument_names) - known)
560
+
561
+
562
+ def unknown_task_argument_message(command, name):
563
+ """The wording for one argument a task command does not take."""
564
+ return (
565
+ f"task '{command}' does not accept argument '{name}' - its "
566
+ f"implementation's signature is the whole of what it takes, so the "
567
+ f"argument would reach Python as an unexpected keyword"
568
+ )
569
+
570
+
571
+ def missing_task_arguments(command, argument_names):
572
+ """The arguments a task command requires that the given names do not supply.
573
+
574
+ A task step's `arguments` dict is the whole of what reaches the
575
+ implementation - nothing is injected around it, so a required parameter
576
+ absent from the dict is a run that cannot start. Unlike
577
+ unknown_task_arguments this does not stop at `accepts_kwargs`: **kwargs
578
+ says more names are allowed, never that a required one may be left out
579
+ (an image processor takes any keys and still needs its `image`).
580
+
581
+ Same never-wrong contract otherwise: empty for a command that cannot be
582
+ described at all, and 'device' is never required.
583
+ """
584
+ try:
585
+ description = describe_task(command)
586
+ except Exception:
587
+ return []
588
+ supplied = set(argument_names)
589
+ return sorted(
590
+ p["name"]
591
+ for p in description["parameters"]
592
+ if p.get("required") and p["name"] != "device" and p["name"] not in supplied
593
+ )
594
+
595
+
596
+ def missing_task_argument_message(command, missing):
597
+ """The one wording both the static pass and the run-time guard use for a
598
+ task step that leaves a required argument unset."""
599
+ named = ", ".join(f"'{name}'" for name in missing)
600
+ return (
601
+ f"task '{command}' requires {named}, which the step does not supply. "
602
+ f"A task's 'arguments' are the whole of what reaches the command, so "
603
+ f"a required argument left out is a run that cannot start"
604
+ )
605
+
606
+
607
+ def null_variable_task_argument_message(command, missing_arg, variable_name):
608
+ """The wording for a required argument the step *does* supply, by
609
+ `variable:<variable_name>`, but the variable's value is null (#364).
610
+
611
+ `missing_task_argument_message` says "the step does not supply" it,
612
+ which is false here - the step names the variable, the variable just
613
+ hasn't been given a real value yet. That is a caller's job to do at
614
+ run time, not a defect in the document.
615
+ """
616
+ return (
617
+ f"'{missing_arg}' is fed by variable '{variable_name}', which is "
618
+ f"null - task '{command}' requires a real value for it. Pass "
619
+ f"arguments={{'{variable_name}': ...}} when running or validating, "
620
+ f"or give '{variable_name}' a non-null default"
621
+ )
622
+
623
+
624
+ def _null_fed_variable(written_steps, source_index, key, declared_variables):
625
+ """The variable name, if the argument at `key` was written as
626
+ `variable:<name>` naming a declared variable - the shape that makes a
627
+ "missing" required argument actually a null-variable one (#364). None
628
+ otherwise, including when `written_steps` can't be indexed (a for_each
629
+ template step, whose members are checked by `item:`/`gather:` instead).
630
+ """
631
+ if not isinstance(source_index, int) or source_index >= len(written_steps):
632
+ return None
633
+ step = written_steps[source_index]
634
+ if not isinstance(step, dict):
635
+ return None
636
+ task = step.get("task")
637
+ if not isinstance(task, dict):
638
+ return None
639
+ arguments = task.get("arguments")
640
+ if not isinstance(arguments, dict):
641
+ return None
642
+ value = arguments.get(key)
643
+ if not isinstance(value, str) or not value.startswith("variable:"):
644
+ return None
645
+ name = value[len("variable:") :]
646
+ return name if name in declared_variables else None
647
+
648
+
649
+ def task_signature_errors(
650
+ workflow_definition, source_indices=None, written_definition=None
651
+ ):
652
+ """Every task step whose arguments its command's signature refuses, as
653
+ [{path, message}] - a required argument left unset, and an argument the
654
+ command does not take - plus a step naming a command that is not
655
+ registered at all.
656
+
657
+ The one class of mistake a free pre-flight is most obviously for, and the
658
+ one it used to let through: `validate_workflow` answered `valid: true`
659
+ and the job then failed with Python's own
660
+ "resample_audio() missing 1 required positional argument: 'audio'"
661
+ (#141). An unknown argument was a warning beside it, so a step with every
662
+ argument it was given rejected and every argument it needs missing still
663
+ validated - both are a guaranteed TypeError at the same call, so both are
664
+ errors now.
665
+
666
+ A misspelled or removed `task.command` (e.g. the shipped
667
+ `templates/image-processors`'s `face_detector`, #285) used to validate
668
+ clean too: `missing_task_arguments`/`unknown_task_arguments` both catch
669
+ `describe_task`'s ValueError for an unregistered name and answer "no
670
+ complaint" rather than "this command does not exist", so the step ran 9
671
+ steps into a 26-step template before failing on the engine's own
672
+ "Unknown task command" error. Checked here, at `task.command` itself,
673
+ before the per-argument checks (which stay silent for a command they
674
+ cannot describe).
675
+
676
+ The definition handed here has already been substituted and expanded, so
677
+ a for_each member is checked as it will run; `source_indices` maps each
678
+ expanded step back to the step the author wrote.
679
+
680
+ `written_definition`, when given, is that step *as the author wrote it* -
681
+ before substitution - plus the declared `variables` block. A required
682
+ argument reported missing whose written form is `variable:<name>` naming
683
+ a declared variable is not a step that "does not supply" it (#364): the
684
+ step does name it, the variable's value just resolved to null (the only
685
+ way substitution drops a `variable:` reference, per #209). That error
686
+ carries a `variable` key naming it, so a caller checking a document with
687
+ no arguments of its own can treat it as caller input rather than a
688
+ defect in the document.
689
+ """
690
+ from .for_each import MEMBER_SEPARATOR, render_path
691
+ from .tasks.task import task_command_info
692
+
693
+ steps = workflow_definition.get("steps")
694
+ if not isinstance(steps, list):
695
+ return []
696
+
697
+ written_steps = (
698
+ (written_definition or {}).get("steps") or []
699
+ if isinstance(written_definition, dict)
700
+ else []
701
+ )
702
+ declared_variables = (
703
+ (written_definition or {}).get("variables") or {}
704
+ if isinstance(written_definition, dict)
705
+ else {}
706
+ )
707
+
708
+ errors = []
709
+ for index, step in enumerate(steps):
710
+ if not isinstance(step, dict):
711
+ continue
712
+ task = step.get("task")
713
+ if not isinstance(task, dict):
714
+ continue
715
+ command = task.get("command")
716
+ # 'inputs' is a list template rather than a named-argument dict -
717
+ # the command consumes it whole, so there is no name to miss
718
+ arguments = task.get("arguments")
719
+ if not isinstance(command, str):
720
+ continue
721
+ source = (
722
+ source_indices[index]
723
+ if source_indices is not None and index < len(source_indices)
724
+ else index
725
+ )
726
+ name = step.get("name")
727
+ where = (
728
+ f" in member '{name}'"
729
+ if isinstance(name, str) and MEMBER_SEPARATOR in name
730
+ else ""
731
+ )
732
+
733
+ try:
734
+ task_command_info(command)
735
+ except ValueError:
736
+ errors.append(
737
+ {
738
+ "path": render_path(("steps", source, "task", "command")),
739
+ "message": (
740
+ f"'{command}' is not a registered task command{where}. "
741
+ f"This step would fail at run time with the engine's "
742
+ f'own "Unknown task command" error'
743
+ ),
744
+ }
745
+ )
746
+ continue
747
+
748
+ if not isinstance(arguments, dict):
749
+ continue
750
+ missing = missing_task_arguments(command, arguments.keys())
751
+ unknown = unknown_task_arguments(command, arguments.keys())
752
+ if not missing and not unknown:
753
+ continue
754
+
755
+ def report(key, message, variable=None):
756
+ entry = {
757
+ "path": render_path(("steps", source, "task", "arguments", key)),
758
+ "message": f"{message}{where}.",
759
+ }
760
+ if variable is not None:
761
+ entry["variable"] = variable
762
+ errors.append(entry)
763
+
764
+ if missing:
765
+ key = missing[0]
766
+ variable = _null_fed_variable(
767
+ written_steps, source, key, declared_variables
768
+ )
769
+ if variable is not None:
770
+ report(
771
+ key,
772
+ null_variable_task_argument_message(command, key, variable),
773
+ variable=variable,
774
+ )
775
+ else:
776
+ report(key, missing_task_argument_message(command, missing))
777
+ for key in unknown:
778
+ report(key, unknown_task_argument_message(command, key))
779
+ return errors
780
+
781
+
782
+ _TYPE_REFERENCE_KEYS = ("component_type", "scheduler_type", "config_type")
783
+
784
+ # A class-name-shaped string, bare or dotted - excludes a {}-escaped literal
785
+ # and a variable:/constant:/asset:/... reference, which use ':' or braces
786
+ # and are checked elsewhere
787
+ _DOTTED_NAME_PATTERN = re.compile(
788
+ r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$"
789
+ )
790
+
791
+
792
+ def _type_reference_candidates(key):
793
+ """Names to suggest a close match from, keyed by which field was wrong."""
794
+ if key == "component_type":
795
+ return list_pipelines() + list_classes("models")
796
+ if key == "scheduler_type":
797
+ return list_classes("schedulers")
798
+ return list_classes("quantization")
799
+
800
+
801
+ def _type_reference_error(key, value, path):
802
+ """One component_type/scheduler_type/config_type value, checked against
803
+ the resolver the run itself uses for a '*_type' value
804
+ (type_helpers.load_type_from_name) - not load_allowed_class's narrower
805
+ ALLOWED_MODULES, which would refuse names the catalog already relies on
806
+ (e.g. 'transformers.AutoProcessor', 'dw.community_pipelines...') that
807
+ TRUSTED_TOP_LEVEL_PACKAGES lets the run itself load. Using the real
808
+ resolver is what makes #345's own invariant hold: this can never refuse
809
+ a name that would in fact have run.
810
+
811
+ Returns an error dict ({path, message}), or None if `value` would resolve.
812
+ """
813
+ if not isinstance(value, str) or not _DOTTED_NAME_PATTERN.match(value):
814
+ return None
815
+
816
+ from .type_helpers import load_type_from_name
817
+ from .security import UntrustedWorkflowError
818
+
819
+ try:
820
+ load_type_from_name(value, key)
821
+ except UntrustedWorkflowError as e:
822
+ return {"path": path, "message": str(e)}
823
+ except (ImportError, AttributeError, ValueError):
824
+ class_name = value.rsplit(".", 1)[-1]
825
+ suggestions = difflib.get_close_matches(
826
+ class_name, _type_reference_candidates(key), n=3, cutoff=0.6
827
+ )
828
+ message = f"{key} {value!r} does not exist"
829
+ if suggestions:
830
+ message += f" (closest matches: {', '.join(suggestions)})"
831
+ return {"path": path, "message": message}
832
+ return None
833
+
834
+
835
+ def _is_type_key(key):
836
+ """Whether realize_args loads this key's value as a type - the same test
837
+ it applies at run time, so validation refuses only what the run would."""
838
+ from .arguments import NON_TYPE_KEYS
839
+
840
+ return (
841
+ isinstance(key, str)
842
+ and key not in NON_TYPE_KEYS
843
+ and (key.endswith("_type") or key.endswith("_dtype") or key == "dtype")
844
+ )
845
+
846
+
847
+ def _loose_type_reference_error(key, value, path):
848
+ """Any other '*_type' / '*_dtype' / 'dtype' value the run loads as a type
849
+ (a pipeline's torch_dtype, say), put to the same untrusted gate. Only the
850
+ gate's refusal is reported: a name that merely fails to resolve is left
851
+ to the run, since realize_args reads some of these keys as something
852
+ other than a type."""
853
+ if not isinstance(value, str) or not _DOTTED_NAME_PATTERN.match(value):
854
+ return None
855
+
856
+ from .type_helpers import load_type_from_name
857
+ from .security import UntrustedWorkflowError
858
+
859
+ try:
860
+ load_type_from_name(value, key)
861
+ except UntrustedWorkflowError as e:
862
+ return {"path": path, "message": str(e)}
863
+ except (ImportError, AttributeError, ValueError):
864
+ pass
865
+ return None
866
+
867
+
868
+ def _constant_reference_error(value, path):
869
+ """One literal 'constant:' value, resolved the way the run resolves it
870
+ (arguments.fetch_constant) - so the untrusted walk rules, a callable and
871
+ a name that does not exist are each refused here rather than after the
872
+ job is queued. A 'variables' default is resolved earlier, by
873
+ expanded_definition, and reported at 'variables.<name>'."""
874
+ from .arguments import fetch_constant, is_constant_reference
875
+ from .security import InvalidInputError, UntrustedWorkflowError
876
+
877
+ if not is_constant_reference(value):
878
+ return None
879
+ try:
880
+ fetch_constant(value)
881
+ except (ValueError, InvalidInputError, UntrustedWorkflowError) as e:
882
+ return {"path": path, "message": str(e)}
883
+ return None
884
+
885
+
886
+ def _walk_type_references(node, path, errors):
887
+ from .arguments import is_media_reference
888
+
889
+ # A {media_type, location} dict is loaded as media, and its media_type
890
+ # names a kind rather than a type - realize_args never reads it as one
891
+ if isinstance(node, dict) and not is_media_reference(node):
892
+ for k, v in node.items():
893
+ if k in _TYPE_REFERENCE_KEYS:
894
+ error = _type_reference_error(k, v, path + (k,))
895
+ elif _is_type_key(k):
896
+ error = _loose_type_reference_error(k, v, path + (k,))
897
+ else:
898
+ error = _constant_reference_error(v, path + (k,))
899
+ if error is not None:
900
+ errors.append(error)
901
+ else:
902
+ _walk_type_references(v, path + (k,), errors)
903
+ elif isinstance(node, list):
904
+ for i, item in enumerate(node):
905
+ error = _constant_reference_error(item, path + (i,))
906
+ if error is not None:
907
+ errors.append(error)
908
+ else:
909
+ _walk_type_references(item, path + (i,), errors)
910
+
911
+
912
+ def component_type_errors(workflow_definition, source_indices=None):
913
+ """Every component_type/scheduler_type/config_type in a step's pipeline
914
+ naming a class the run itself could not load, as [{path, message}] - a
915
+ misspelled class used to validate clean and only die ~3s into the run,
916
+ after the worker had already loaded a checkpoint the plan's
917
+ downloads_required quoted for a pipeline that could never exist (#345).
918
+ Every other key the run loads as a type ('torch_dtype', any '*_type')
919
+ and every literal 'constant:' value in the step are checked the same way,
920
+ so the untrusted gate refuses them here rather than after the queue
921
+ (#409).
922
+
923
+ A class outside the trusted ecosystem entirely (UntrustedWorkflowError,
924
+ see _type_reference_error) is reported with a distinct message from one
925
+ that is merely spelled wrong - "not allowed" is not "does not exist".
926
+
927
+ The definition handed here has already been substituted and expanded,
928
+ matching task_signature_errors; source_indices maps each expanded step
929
+ back to the step the author wrote.
930
+ """
931
+ from .for_each import MEMBER_SEPARATOR, render_path
932
+
933
+ steps = workflow_definition.get("steps")
934
+ if not isinstance(steps, list):
935
+ return []
936
+
937
+ errors = []
938
+ for index, step in enumerate(steps):
939
+ if not isinstance(step, dict):
940
+ continue
941
+ source = (
942
+ source_indices[index]
943
+ if source_indices is not None and index < len(source_indices)
944
+ else index
945
+ )
946
+ name = step.get("name")
947
+ where = (
948
+ f" in member '{name}'"
949
+ if isinstance(name, str) and MEMBER_SEPARATOR in name
950
+ else ""
951
+ )
952
+ found = []
953
+ # The whole step, since realize_args loads a type or a constant
954
+ # wherever one sits in it - a task's arguments as much as a pipeline
955
+ _walk_type_references(step, (), found)
956
+ for error in found:
957
+ full_message = f"{error['message']}{where}"
958
+ if not full_message.endswith("."):
959
+ full_message += "."
960
+ errors.append(
961
+ {
962
+ "path": render_path(("steps", source) + error["path"]),
963
+ "message": full_message,
964
+ }
965
+ )
966
+ return errors
967
+
968
+
969
+ def component_name_errors(workflow_definition, source_indices=None):
970
+ """Every name under a step's `pipeline.configuration.components` that
971
+ the named component_type's constructor does not register, as
972
+ [{path, message}] - a component name it does not have was previously
973
+ caught only ~3s into the run's `loading` phase, after a checkpoint (and
974
+ for an IC-LoRA step, the LoRA weights) the plan had already quoted for
975
+ downloading (#442). Checked the same way `workflow_argument_warnings`
976
+ checks a `__call__` argument: against the class's own constructor
977
+ signature, so the rule can never refuse a name that would in fact have
978
+ worked, and only for a bare, loadable component_type - escaped and
979
+ dotted ones are left alone.
980
+
981
+ `reused_components` names are excluded: those are configured by the step
982
+ that shared them, not loaded here, so a name only valid because it was
983
+ reused is not a mistake.
984
+
985
+ The definition handed here has already been substituted and expanded,
986
+ matching component_type_errors; source_indices maps each expanded step
987
+ back to the step the author wrote.
988
+ """
989
+ from .for_each import MEMBER_SEPARATOR, render_path
990
+
991
+ steps = workflow_definition.get("steps")
992
+ if not isinstance(steps, list):
993
+ return []
994
+
995
+ errors = []
996
+ for index, step in enumerate(steps):
997
+ if not isinstance(step, dict):
998
+ continue
999
+ pipeline = step.get("pipeline")
1000
+ if not isinstance(pipeline, dict):
1001
+ continue
1002
+ configuration = pipeline.get("configuration")
1003
+ if not isinstance(configuration, dict):
1004
+ continue
1005
+ component_type = configuration.get("component_type")
1006
+ if not isinstance(component_type, str) or not _NAME_PATTERN.match(
1007
+ component_type
1008
+ ):
1009
+ continue
1010
+ components = configuration.get("components")
1011
+ if not isinstance(components, dict):
1012
+ continue
1013
+ reused = set(configuration.get("reused_components") or [])
1014
+ component_names = [name for name in components if name not in reused]
1015
+ unknown = unknown_pipeline_components(component_type, component_names)
1016
+ if not unknown:
1017
+ continue
1018
+ source = (
1019
+ source_indices[index]
1020
+ if source_indices is not None and index < len(source_indices)
1021
+ else index
1022
+ )
1023
+ name = step.get("name")
1024
+ where = (
1025
+ f" in member '{name}'"
1026
+ if isinstance(name, str) and MEMBER_SEPARATOR in name
1027
+ else ""
1028
+ )
1029
+ for component_name in unknown:
1030
+ errors.append(
1031
+ {
1032
+ "path": render_path(
1033
+ (
1034
+ "steps",
1035
+ source,
1036
+ "pipeline",
1037
+ "configuration",
1038
+ "components",
1039
+ component_name,
1040
+ )
1041
+ ),
1042
+ "message": (
1043
+ f"Step '{step.get('name')}': {component_type} has no "
1044
+ f"component '{component_name}'{where}."
1045
+ ),
1046
+ }
1047
+ )
1048
+ return errors
1049
+
1050
+
1051
+ def _resolved_value(arguments, key, values):
1052
+ """`arguments[key]` as a number, resolving a `variable:name` reference
1053
+ against `values` (declared defaults merged with the caller's own
1054
+ arguments, the way `constraint_warnings` resolves a constrained
1055
+ variable). `None` when the key is absent, not a `variable:` reference or
1056
+ a literal, or the reference does not resolve to a number - callers tell
1057
+ that apart from an actual 0 by checking `key in arguments` themselves
1058
+ where it matters."""
1059
+ if key not in arguments:
1060
+ return None
1061
+ value = arguments[key]
1062
+ if isinstance(value, str) and value.startswith("variable:"):
1063
+ value = values.get(value[len("variable:") :])
1064
+ return value if isinstance(value, (int, float)) else None
1065
+
1066
+
1067
+ def _inert_crossfade_warnings(step, command, arguments):
1068
+ """concat_videos draws its crossfade from the trimmed-off material, so
1069
+ with nothing trimmed a `crossfade_ms` the author wrote does nothing. A
1070
+ referenced trim is unknown until the run and is left alone."""
1071
+ if command != "concat_videos":
1072
+ return []
1073
+ crossfade = arguments.get("crossfade_ms")
1074
+ trim = arguments.get("trim_frames", 0)
1075
+ if not isinstance(crossfade, (int, float)) or crossfade <= 0 or trim != 0:
1076
+ return []
1077
+ return [
1078
+ f"Step '{step.get('name')}': 'crossfade_ms' has no effect when "
1079
+ f"'trim_frames' is 0 - the crossfade is drawn from the trimmed "
1080
+ f"material. At a hard cut, 'audio_bleed_ms' or 'seam_fade_ms' is "
1081
+ f"what shapes the seam"
1082
+ ]
1083
+
1084
+
1085
+ def _inert_seam_fade_warnings(step, command, arguments, values):
1086
+ """concat_videos takes the bleed path, not the fade path, at a hard cut
1087
+ with nothing trimmed while audio_bleed_ms is non-zero - so a seam_fade_ms
1088
+ the author wrote alongside it does nothing (#288). Both variables are
1089
+ ordinary `variable:` references in the templates that pair them, so
1090
+ `seam_fade_ms` and `audio_bleed_ms` are resolved against `values`
1091
+ (declared defaults merged with the caller's own arguments) rather than
1092
+ left alone the way an unresolved `trim_frames` is - it is exactly the
1093
+ templated case, with `audio_bleed_ms` left at its non-zero default and
1094
+ only `seam_fade_ms` passed as an argument, that this warning exists for.
1095
+ `trim_frames` stays a literal-only check, as in `_inert_crossfade_warnings`."""
1096
+ if command != "concat_videos":
1097
+ return []
1098
+ if "seam_fade_ms" not in arguments or "audio_bleed_ms" not in arguments:
1099
+ return []
1100
+ seam_fade = _resolved_value(arguments, "seam_fade_ms", values)
1101
+ bleed = _resolved_value(arguments, "audio_bleed_ms", values)
1102
+ trim = arguments.get("trim_frames", 0)
1103
+ if seam_fade is None or seam_fade <= 0 or bleed is None or bleed <= 0 or trim != 0:
1104
+ return []
1105
+ return [
1106
+ f"Step '{step.get('name')}': 'seam_fade_ms' has no effect while "
1107
+ f"'audio_bleed_ms' is {bleed} - a hard cut takes the bleed path "
1108
+ f"instead of the fade path. Pass 'audio_bleed_ms': 0 for "
1109
+ f"'seam_fade_ms' to apply."
1110
+ ]
1111
+
1112
+
1113
+ def _inert_bleed_gain_warnings(step, command, arguments, values):
1114
+ """concat_videos applies audio_bleed_gain_db to the bled tail
1115
+ audio_bleed_ms carries across the seam - with no bleed there is nothing
1116
+ for the gain to shape, so an audio_bleed_gain_db the author wrote does
1117
+ nothing while audio_bleed_ms is 0, whether that 0 is an explicit
1118
+ argument or the task's own default left untouched (#290, the same no-op
1119
+ class #288 closed for seam_fade_ms). `audio_bleed_ms` is read with the
1120
+ task's default of 0 rather than requiring the key, since "forgot the
1121
+ bleed" is exactly the case this warning is for; resolved against
1122
+ `values` for the same reason _inert_seam_fade_warnings is - a templated
1123
+ case pairs both as `variable:` references."""
1124
+ if command != "concat_videos":
1125
+ return []
1126
+ if "audio_bleed_gain_db" not in arguments:
1127
+ return []
1128
+ gain = _resolved_value(arguments, "audio_bleed_gain_db", values)
1129
+ bleed = (
1130
+ _resolved_value(arguments, "audio_bleed_ms", values)
1131
+ if "audio_bleed_ms" in arguments
1132
+ else 0
1133
+ )
1134
+ if gain is None or gain == 0 or bleed is None or bleed != 0:
1135
+ return []
1136
+ return [
1137
+ f"Step '{step.get('name')}': 'audio_bleed_gain_db' has no effect "
1138
+ f"when 'audio_bleed_ms' is 0 - pass a non-zero 'audio_bleed_ms' for "
1139
+ f"the gain to apply."
1140
+ ]
1141
+
1142
+
1143
+ def _inert_match_levels_dbfs_warnings(step, command, arguments):
1144
+ """concat_videos and dissolve_videos only call match_levels() - the
1145
+ function that reads match_levels_dbfs as its target - when match_levels
1146
+ itself is truthy (`if match_levels:`), so a caller who passes only the
1147
+ target dBFS and leaves match_levels unset (off by default) has stated an
1148
+ intent the engine silently drops: the shots join unmatched with no trace,
1149
+ warning or otherwise (#291, the same "modifier without its enabler" class
1150
+ #288 and #290 closed for seam_fade_ms and audio_bleed_gain_db). Literal
1151
+ check only, like _inert_crossfade_warnings' trim_frames - match_levels is
1152
+ "rms"/"peak"/falsy, not a number a variable: reference would need
1153
+ resolving to compare against a domain."""
1154
+ if command not in ("concat_videos", "dissolve_videos"):
1155
+ return []
1156
+ if "match_levels_dbfs" not in arguments or arguments.get("match_levels"):
1157
+ return []
1158
+ dbfs = arguments.get("match_levels_dbfs")
1159
+ if not isinstance(dbfs, (int, float)):
1160
+ return []
1161
+ return [
1162
+ f"Step '{step.get('name')}': 'match_levels_dbfs' has no effect when "
1163
+ f'\'match_levels\' is unset - pass "rms" or "peak" for the target '
1164
+ f"to apply."
1165
+ ]
1166
+
1167
+
1168
+ def workflow_argument_warnings(workflow_definition, arguments=None):
1169
+ """Best-effort pre-load check of a workflow's arguments.
1170
+
1171
+ For each pipeline step whose component_type is a bare diffusers class
1172
+ name, reports argument names that class's __call__ does not accept - the
1173
+ typo that today surfaces as a TypeError after the model has loaded.
1174
+ Escaped ({...}) and dotted component types are left alone. Task steps
1175
+ get the same check against their registered implementation's signature.
1176
+
1177
+ `arguments`, when given, is a caller's own values for this run -
1178
+ checks that need a task argument's actual value (an inert `crossfade_ms`
1179
+ or `seam_fade_ms`) resolve a `variable:name` reference against the
1180
+ caller's arguments merged over the workflow's declared defaults, the
1181
+ same values `constraint_warnings` checks a constraint against.
1182
+ """
1183
+ warnings = []
1184
+ values = {**(workflow_definition.get("variables") or {}), **(arguments or {})}
1185
+ declared = sorted(workflow_definition.get("variables") or {})
1186
+ for path, name in undeclared_variable_references(workflow_definition):
1187
+ hint = (
1188
+ " - a reference is the whole value, nothing is interpolated around it"
1189
+ if any(c in name for c in " ,")
1190
+ else ""
1191
+ )
1192
+ warnings.append(
1193
+ f"{path}: 'variable:{name}' names no declared variable{hint}; "
1194
+ f"declared: {', '.join(declared) or '<none>'}"
1195
+ )
1196
+ for step in workflow_definition.get("steps", []):
1197
+ task = step.get("task")
1198
+ if task and isinstance(task.get("arguments"), dict):
1199
+ command = task.get("command")
1200
+ # An unknown or missing task argument is an error rather than a
1201
+ # warning now (task_signature_errors, #141) - reported once, by
1202
+ # the pass whose verdict it changes
1203
+ warnings.extend(_inert_crossfade_warnings(step, command, task["arguments"]))
1204
+ warnings.extend(
1205
+ _inert_seam_fade_warnings(step, command, task["arguments"], values)
1206
+ )
1207
+ warnings.extend(
1208
+ _inert_bleed_gain_warnings(step, command, task["arguments"], values)
1209
+ )
1210
+ warnings.extend(
1211
+ _inert_match_levels_dbfs_warnings(step, command, task["arguments"])
1212
+ )
1213
+ pipeline = step.get("pipeline")
1214
+ if not pipeline:
1215
+ continue
1216
+ component_type = pipeline.get("configuration", {}).get("component_type")
1217
+ if not isinstance(component_type, str) or not _NAME_PATTERN.match(
1218
+ component_type
1219
+ ):
1220
+ continue
1221
+ argument_names = list(pipeline.get("arguments", {}))
1222
+ unknown = unknown_call_arguments(component_type, argument_names)
1223
+ for argument_name in unknown:
1224
+ warnings.append(
1225
+ f"Step '{step.get('name')}': {component_type} does not accept "
1226
+ f"argument '{argument_name}'"
1227
+ )
1228
+ return warnings