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/plan.py ADDED
@@ -0,0 +1,920 @@
1
+ """The plan a validate call answers with: what a run of a workflow with a
2
+ caller's arguments will actually execute, what it will have to download
3
+ first, and what the workflow's own cost block says it will take - with a
4
+ fingerprint over the work, so an acknowledgement can be bound to it and a
5
+ run whose shape changed after consent refused (#85, stage 2).
6
+
7
+ Everything here is derived from the same resolvers the run uses -
8
+ `realize_workflow` folds the arguments and inlines the prompts, and
9
+ `Workflow.expanded_definition` substitutes and expands `for_each` - so the
10
+ plan describes the run and not an approximation of it. Nothing here knows a
11
+ model: every minute comes from a `cost` block and every repo name from a
12
+ `from_pretrained_arguments`.
13
+ """
14
+
15
+ import copy
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import os
20
+
21
+ from huggingface_hub import model_info
22
+ from huggingface_hub import get_hf_file_metadata, hf_hub_url
23
+ from huggingface_hub.utils import GatedRepoError, HFValidationError, validate_repo_id
24
+
25
+ from .elision import elide_definition
26
+ from .hub_cache import repo_download_incomplete, scan_models
27
+ from .realize import (
28
+ BUILTIN_PREFIX,
29
+ VARIABLE_PREFIX,
30
+ read_sub_workflow,
31
+ realize_workflow,
32
+ )
33
+ from .security import validate_url
34
+ from .workflow import Workflow
35
+
36
+ logger = logging.getLogger("dw")
37
+
38
+ FINGERPRINT_PREFIX = "sha256:"
39
+ # Top-level keys that document a workflow rather than shape its work
40
+ DOCUMENTATION_KEYS = ("cost", "description", "summary", "configures")
41
+ FOR_EACH_KEY = "for_each"
42
+ SIZE_LOOKUP_TIMEOUT = 5.0
43
+ GIB = 1024**3
44
+
45
+
46
+ def build_plan(
47
+ candidate,
48
+ arguments,
49
+ *,
50
+ device,
51
+ prompt_dir=None,
52
+ cache_dir=None,
53
+ lookup_sizes=True,
54
+ cache_probe=None,
55
+ observed=None,
56
+ observed_for_child=None,
57
+ ):
58
+ """What a run of `candidate` with `arguments` will execute and cost.
59
+
60
+ Args:
61
+ candidate: The Workflow the route built - it carries the file spec
62
+ (so base_dir), the output root and the confinement a run has.
63
+ arguments: The caller's arguments, already past `argument_errors`;
64
+ an undeclared name or an uncoercible value raises here.
65
+ device: The backend that is serving - 'cuda', 'mps' or 'cpu'.
66
+ prompt_dir: The prompt library, for inlining.
67
+ cache_dir: The hub cache to check downloads against; None for the
68
+ default.
69
+ lookup_sizes: Whether to ask the hub how large a missing repo is.
70
+ cache_probe: A callable taking the run's arguments and answering the
71
+ step names the worker's cache would serve, or None when it
72
+ cannot say; without one `cached_steps` is None.
73
+ observed: This box's own history for this workflow, as an `observed`
74
+ block (dw/server/observed_cost.py) or a callable taking the
75
+ run's arguments and answering one - what the estimate quotes in
76
+ preference to a curated figure (#154). None on a caller that has
77
+ no history to offer, which is every caller but the server.
78
+ observed_for_child: A callable taking a composed child's local path,
79
+ its parsed definition, and the composing step's own `arguments`,
80
+ answering that child's own `observed` block or None - so a
81
+ composing workflow's estimate can roll up a child's history
82
+ instead of resetting to `unknown` when the parent has no figure
83
+ of its own (#268), bucketed against the value the composing step
84
+ actually passes rather than always the child's stored defaults
85
+ (#341). None on a caller that cannot resolve a child's catalog
86
+ name to look history up by.
87
+ """
88
+ definition = candidate.workflow_definition
89
+ base_dir = (
90
+ os.path.dirname(os.path.abspath(candidate.file_spec))
91
+ if candidate.file_spec
92
+ else None
93
+ )
94
+ realized, annotations = realize_workflow(
95
+ definition,
96
+ arguments,
97
+ seed=0,
98
+ base_dir=base_dir,
99
+ prompt_dir=prompt_dir,
100
+ output_root=candidate.output_dir,
101
+ workflow_dir=candidate.workflow_dir,
102
+ pin_outputs=False,
103
+ )
104
+ # Arguments are already folded into the realized variables, so the
105
+ # expansion takes none; it substitutes and expands exactly as the run
106
+ expanded = Workflow(
107
+ realized, candidate.output_dir, candidate.file_spec, candidate.workflow_dir
108
+ ).expanded_definition()
109
+ # The plan is what the run does, and a run does not execute a step
110
+ # nothing reads (dw/elision.py, #122) - so the step count, the downloads
111
+ # and the fingerprint are all taken after elision, and the acknowledged
112
+ # cost is the cost of the work that happens
113
+ elided = elide_definition(expanded, definition)
114
+ entries = list_entries(definition, realized)
115
+ measured_entries = list_entries(definition, definition)
116
+ step_count = len(expanded.get("steps") or [])
117
+ cache_hits = cached_steps(definition, realized, arguments, cache_probe)
118
+ return {
119
+ "fingerprint": fingerprint(expanded, definition, annotations),
120
+ "steps": step_count,
121
+ "elided_steps": elided,
122
+ "list_entries": entries,
123
+ "cached_steps": cache_hits,
124
+ "downloads_required": downloads_required(
125
+ expanded, base_dir, candidate.workflow_dir, cache_dir, lookup_sizes
126
+ ),
127
+ "estimate": estimate(
128
+ definition,
129
+ expanded,
130
+ entries,
131
+ device,
132
+ base_dir,
133
+ candidate.workflow_dir,
134
+ measured_entries=measured_entries,
135
+ observed=_observed_block(observed, arguments),
136
+ cached_steps=cache_hits,
137
+ total_steps=step_count,
138
+ observed_for_child=observed_for_child,
139
+ ),
140
+ }
141
+
142
+
143
+ def _observed_block(observed, arguments):
144
+ """The `observed` block for this run, from a block or from a callable
145
+ the caller passed - best effort, since a figure is a nicety and a plan
146
+ that raised because history could not be read would cost the caller the
147
+ whole pre-flight."""
148
+ if not callable(observed):
149
+ return observed if isinstance(observed, dict) else None
150
+ try:
151
+ block = observed(arguments or {})
152
+ except Exception:
153
+ logger.debug("No observed history for the plan", exc_info=True)
154
+ return None
155
+ return block if isinstance(block, dict) else None
156
+
157
+
158
+ def list_entries(definition, realized):
159
+ """{variable: length} for every `for_each` that names a list variable,
160
+ read from `realized`'s folded variables - a literal list is not an
161
+ argument and is not listed.
162
+
163
+ Passing `definition` as both arguments answers the lengths the
164
+ workflow's *stored defaults* carry, which is the list a catalog `cost`
165
+ figure was measured against (`estimate`).
166
+ """
167
+ variables = realized.get("variables") or {}
168
+ entries = {}
169
+ for step in definition.get("steps") or []:
170
+ if not isinstance(step, dict):
171
+ continue
172
+ reference = step.get(FOR_EACH_KEY)
173
+ if isinstance(reference, str) and reference.startswith(VARIABLE_PREFIX):
174
+ name = reference.removeprefix(VARIABLE_PREFIX)
175
+ value = variables.get(name)
176
+ if isinstance(value, list):
177
+ entries[name] = len(value)
178
+ return entries
179
+
180
+
181
+ # `estimate`'s own `list_entries` parameter (the parent's) shadows this
182
+ # function's name in its scope - a child's entries are computed under this
183
+ # alias instead (#341)
184
+ _list_entries = list_entries
185
+
186
+
187
+ def cached_steps(definition, realized, arguments, cache_probe):
188
+ """How many steps the step cache would answer for this run: 0 without
189
+ asking when the workflow is unseeded (the cache is off then), None
190
+ when there is no probe or the probe cannot answer, else the count."""
191
+ if not _is_seeded(definition, arguments):
192
+ return 0
193
+ if cache_probe is None:
194
+ return None
195
+ answer = cache_probe(arguments or {})
196
+ return len(answer) if isinstance(answer, list) else None
197
+
198
+
199
+ def unseeded_cache_warnings(definition, arguments=None):
200
+ """Say once, where a caller is already looking, that an unseeded workflow
201
+ gets no step cache at all.
202
+
203
+ `cached_steps: 0` is indistinguishable from 'probed, nothing hit' out
204
+ there, and the difference is the one that matters: without a `seed` the
205
+ cache is off, so nothing is ever reused however many times the same
206
+ workflow runs (#107).
207
+
208
+ Silent for a workflow with no `pipeline`/`pipeline_reference`/`workflow`
209
+ step: a task-only utility has no generative randomness a `seed` would
210
+ pin down in the first place, and each of its steps is a pure function of
211
+ its inputs - a repeat run is already free without one (#247)
212
+ """
213
+ if _is_seeded(definition, arguments) or not _has_seedable_step(definition):
214
+ return []
215
+ return [
216
+ "This workflow sets no 'seed', so the step cache is disabled and "
217
+ "'cached_steps' is 0 without being probed - every step regenerates "
218
+ "on every run. Set a top-level 'seed': 'variable:seed' with a "
219
+ "declared default in 'variables' to make a repeat run reuse what it "
220
+ "already produced"
221
+ ]
222
+
223
+
224
+ def _has_seedable_step(definition):
225
+ """Whether any step could consume a seed: a pipeline (inline or
226
+ referenced) or a sub-workflow, which may hold one in turn. A workflow
227
+ built entirely of `task` steps has nothing a seed would affect."""
228
+ for step in definition.get("steps") or []:
229
+ if not isinstance(step, dict):
230
+ continue
231
+ if "pipeline" in step or "pipeline_reference" in step or "workflow" in step:
232
+ return True
233
+ return False
234
+
235
+
236
+ def _is_seeded(definition, arguments):
237
+ """Whether a run of this workflow has a seed before it draws one - read
238
+ from the definition as written and the caller's arguments, since
239
+ realization pins a seed of its own into the copy."""
240
+ seed = definition.get("seed")
241
+ if isinstance(seed, str) and seed.startswith(VARIABLE_PREFIX):
242
+ name = seed.removeprefix(VARIABLE_PREFIX)
243
+ if name in (arguments or {}):
244
+ return arguments[name] is not None
245
+ return (definition.get("variables") or {}).get(name) is not None
246
+ return seed is not None
247
+
248
+
249
+ def fingerprint(expanded, definition, annotations=None):
250
+ """SHA-256 over the expanded definition with everything that is not
251
+ work removed: the seed wherever it sits, and the documentation keys.
252
+
253
+ `definition` is the workflow as written, consulted for whether the
254
+ top-level seed named a variable - if it did, that variable's folded
255
+ value is the seed too and is blanked at its source. `annotations` is
256
+ what realization recorded beside the copy; its sub-workflow digests go
257
+ into the hash, since a composed child edited between the quote and the
258
+ call is different work the parent's text cannot show.
259
+ """
260
+ doc = copy.deepcopy(expanded)
261
+ if annotations and annotations.get("sub_workflows"):
262
+ doc["__sub_workflows__"] = dict(annotations["sub_workflows"])
263
+ doc.pop("seed", None)
264
+ for key in DOCUMENTATION_KEYS:
265
+ doc.pop(key, None)
266
+ written_seed = definition.get("seed")
267
+ if isinstance(written_seed, str) and written_seed.startswith(VARIABLE_PREFIX):
268
+ name = written_seed.removeprefix(VARIABLE_PREFIX)
269
+ variables = doc.get("variables")
270
+ if isinstance(variables, dict) and name in variables:
271
+ variables[name] = None
272
+ for step in doc.get("steps") or []:
273
+ if isinstance(step, dict):
274
+ step.pop("seed", None)
275
+ pipeline = step.get("pipeline")
276
+ if isinstance(pipeline, dict):
277
+ pipeline.pop("seed", None)
278
+ # default=repr: a realized 'constant:' can be any Python value, and the
279
+ # fingerprint only needs it to be stable, not round-trippable
280
+ serialized = json.dumps(
281
+ doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr
282
+ )
283
+ return FINGERPRINT_PREFIX + hashlib.sha256(serialized.encode("utf-8")).hexdigest()
284
+
285
+
286
+ UNKNOWN = "unknown"
287
+ CATALOG = "catalog"
288
+ PER_ENTRY = "per_entry"
289
+ DERIVED = "derived"
290
+ OTHER_DEVICE = "other_device"
291
+ OBSERVED = "observed"
292
+
293
+ # #301: a single run is not the same statistical basis as a dozen. Below
294
+ # this many observed runs, an "observed" figure is tempered rather than
295
+ # quoted at full authority - see `_tempered`.
296
+ SMALL_N_THRESHOLD = 3
297
+
298
+
299
+ def _tempered(block, curated_minutes):
300
+ """An `observed` estimate below `SMALL_N_THRESHOLD` runs, corrected
301
+ toward the curated figure it might be papering over rather than
302
+ presented as if it carried the same authority as a dozen runs (#301).
303
+
304
+ With a curated minutes figure to blend toward, the point estimate is
305
+ pulled toward it in proportion to how thin the history is - one run
306
+ counts for a third of the blend, two for two thirds, three or more not
307
+ at all. The blend is marked `tempered: true` with `observed_minutes`
308
+ (the raw point figure, the same number `list_workflows`' own
309
+ `observed_minutes` reports) and `curated_minutes` (what it blended
310
+ toward) alongside it, so a caller can reconcile the returned `minutes`
311
+ against either without the two disagreeing silently under the same
312
+ `basis: "observed"` label (#319). With none to blend toward there is
313
+ nothing to correct with, so the number is left alone and a
314
+ `low_confidence` flag is added instead: machine-checkable without
315
+ requiring a caller to know to inspect `runs` itself. No new
316
+ range/uncertainty math (rejected as more surface than the problem
317
+ needs) - just these two, approved shapes.
318
+ """
319
+ runs = block.get("runs")
320
+ if (
321
+ not isinstance(runs, int)
322
+ or runs >= SMALL_N_THRESHOLD
323
+ or block["minutes"] is None
324
+ ):
325
+ return block
326
+ if curated_minutes is None:
327
+ return {**block, "low_confidence": True}
328
+ observed_minutes = block["minutes"]
329
+ weight = runs / SMALL_N_THRESHOLD
330
+ blended = curated_minutes * (1 - weight) + observed_minutes * weight
331
+ return {
332
+ **block,
333
+ "minutes": round(blended, 1),
334
+ "tempered": True,
335
+ "observed_minutes": observed_minutes,
336
+ "curated_minutes": curated_minutes,
337
+ }
338
+
339
+
340
+ def _cached_minutes(minutes, cached_steps, total_steps):
341
+ """The share of `minutes` a caller would actually wait for, once the
342
+ steps already in the step cache are subtracted (#255).
343
+
344
+ None when there is nothing to subtract from (`minutes`), no probe
345
+ (`cached_steps` is None) or no step count to take a share of; equal to
346
+ `minutes` itself when the probe found nothing cached, and 0.0 when it
347
+ found the whole run cached.
348
+ """
349
+ if minutes is None or cached_steps is None or not total_steps:
350
+ return None
351
+ remaining = max(0, total_steps - cached_steps)
352
+ return round(minutes * remaining / total_steps, 1)
353
+
354
+
355
+ COST_DRIVERS_KEY = "cost_drivers"
356
+
357
+
358
+ def _declared_drivers(definition):
359
+ """The variables the author says move this workflow's cost - the same
360
+ rule `dw/server/observed_cost.py`'s `declared_drivers` applies, kept
361
+ local so this module does not import the server package (#267)."""
362
+ drivers = definition.get(COST_DRIVERS_KEY)
363
+ if not isinstance(drivers, list):
364
+ return []
365
+ variables = definition.get("variables") or {}
366
+ return [name for name in drivers if isinstance(name, str) and name in variables]
367
+
368
+
369
+ def _driver_comparable(value):
370
+ """A driver value as something hashable and stable across JSON round
371
+ trips, mirroring `dw/server/observed_cost.py`'s `_comparable`."""
372
+ if isinstance(value, bool) or value is None:
373
+ return value
374
+ if isinstance(value, (int, float)):
375
+ return float(value)
376
+ if isinstance(value, str):
377
+ try:
378
+ return float(value)
379
+ except ValueError:
380
+ return value
381
+ return json.dumps(value, sort_keys=True, default=str)
382
+
383
+
384
+ def _scalar_driver_shifted(definition, expanded, list_entries):
385
+ """Whether a declared, non-list `cost_driver` was overridden away from
386
+ the default value the curated `cost` was measured against (#267).
387
+
388
+ A list driver is `_repriced`'s to catch - it changes the plan's list
389
+ count, not a bare variable's value. This only asks about a scalar one,
390
+ which `_repriced` never looks at, so a `catalog` figure measured for one
391
+ `num_frames` silently priced a run at a different one.
392
+ """
393
+ defaults = definition.get("variables") or {}
394
+ effective = expanded.get("variables") or {}
395
+ for name in _declared_drivers(definition):
396
+ if name in list_entries:
397
+ continue
398
+ default_value = defaults.get(name)
399
+ if isinstance(default_value, list):
400
+ continue
401
+ if _driver_comparable(effective.get(name)) != _driver_comparable(default_value):
402
+ return True
403
+ return False
404
+
405
+
406
+ def estimate(
407
+ definition,
408
+ expanded,
409
+ list_entries,
410
+ device,
411
+ base_dir,
412
+ workflow_dir,
413
+ measured_entries=None,
414
+ observed=None,
415
+ cached_steps=None,
416
+ total_steps=None,
417
+ observed_for_child=None,
418
+ ):
419
+ """Minutes from this box's own history when it has one, else from the
420
+ workflow's own cost block, re-priced for the caller's list, plus each
421
+ composed child's.
422
+
423
+ `basis` names where the figure came from - the honesty is in the field,
424
+ not in a fabricated number: 'unknown' is no cost block at all (or a
425
+ list this figure cannot honestly be re-priced for), 'other_device' a
426
+ figure measured on a backend other than the one serving (reported so
427
+ the agent has something to scale, flagged so it is not quoted as a
428
+ measurement), 'catalog' the stored total for a run whose lists are the
429
+ ones it was measured with, 'per_entry' that total re-priced from a
430
+ measured per-entry rate, and 'derived' re-priced by extrapolating the
431
+ stored total linearly over a list whose length the caller changed
432
+ (#85) - an estimate, not a measurement, and the only alternative to
433
+ quoting a 5-shot figure for a 10-shot run.
434
+
435
+ `measured_entries` is what the stored defaults carry, which is the
436
+ list the catalog figure was measured against; without it a catalog
437
+ figure is taken at face value.
438
+
439
+ `observed` is this box's own history for the run being planned
440
+ (dw/server/observed_cost.py), and it wins: `list_workflows` already
441
+ tells a consumer to "prefer [observed] when quoting a price for this
442
+ machine, fall back to `cost`", and `basis: "observed"` says which one
443
+ was used, so the two claims stay distinguishable. It is quoted only
444
+ from the *cold* median - the one figure comparable to a curated `cost`,
445
+ wall clock including the model load - and only for the bucket the
446
+ caller's own arguments fall in, so a resized list falls back to the
447
+ curated figure rather than quoting the default list's minutes (#154).
448
+ A composed child is not added to it: an observed run is the whole run,
449
+ children included, already measured.
450
+ """
451
+ measured = _observed(observed, device)
452
+ own = _price(definition.get("cost"), device, list_entries, measured_entries or {})
453
+ if own["basis"] == CATALOG and _scalar_driver_shifted(
454
+ definition, expanded, list_entries
455
+ ):
456
+ # A scalar cost_driver (H3's num_frames, say) moved away from the
457
+ # value the curated cost was measured against, and _repriced only
458
+ # re-prices a for_each list's length - so the catalog figure would
459
+ # otherwise be quoted for a run it was never measured for (#267)
460
+ own = {"minutes": None, "basis": UNKNOWN, "measured_on": None}
461
+ if measured is not None:
462
+ measured = _tempered(measured, own["minutes"])
463
+ measured["cached_minutes"] = _cached_minutes(
464
+ measured["minutes"], cached_steps, total_steps
465
+ )
466
+ return measured
467
+ minutes = own["minutes"]
468
+ # An unpriced parent (own["minutes"] is None) whose total ends up coming
469
+ # only from a priced child is not a complete figure - the parent's own
470
+ # steps (a for_each step with no cost block, say) contributed nothing to
471
+ # it, so the sum understates the run rather than merely omitting a piece
472
+ # of it (#242). `unpriced` names each contributor that landed here, so a
473
+ # caller can tell a trivial utility step from an unpriced 12-shot loop
474
+ # apart rather than just seeing `partial: true` (#252)
475
+ partial = minutes is None and not _only_composes_children(definition)
476
+ unpriced = [definition.get("id", "workflow")] if partial else []
477
+ had_child = False
478
+ children_all_observed = True
479
+ child_runs = []
480
+ child_measured_on = set()
481
+ for path, step_arguments in _sub_workflow_paths(expanded):
482
+ had_child = True
483
+ # A builtin is the parent's to price; a local child prices itself
484
+ raw = read_sub_workflow(path, base_dir, workflow_dir)
485
+ child_definition = None
486
+ child_cost = None
487
+ if raw is not None:
488
+ try:
489
+ child_definition = json.loads(raw)
490
+ child_cost = child_definition.get("cost")
491
+ except (ValueError, AttributeError):
492
+ child_definition = None
493
+ child_cost = None
494
+ child_observed = None
495
+ # A child's observed figure only ever feeds a total that can
496
+ # honestly end up basis: observed (own["minutes"] is None, below) -
497
+ # a priced parent's own basis is 'catalog', and summing an observed
498
+ # child into it produced a total that did not match either figure
499
+ # while still claiming 'catalog' (#315)
500
+ if (
501
+ own["minutes"] is None
502
+ and observed_for_child is not None
503
+ and child_definition is not None
504
+ ):
505
+ try:
506
+ # `step_arguments` are the composing step's own overrides -
507
+ # passed through so a child observed lookup buckets against
508
+ # the value this step actually runs with rather than always
509
+ # the child's stored defaults (#341)
510
+ child_observed = observed_for_child(
511
+ path, child_definition, step_arguments
512
+ )
513
+ except Exception:
514
+ child_observed = None
515
+ child = _observed(child_observed, device)
516
+ if child is None:
517
+ children_all_observed = False
518
+ child_list_entries = {}
519
+ child_measured_entries = {}
520
+ child_expanded = {"variables": {}}
521
+ if child_definition is not None:
522
+ child_measured_entries = _list_entries(
523
+ child_definition, child_definition
524
+ )
525
+ # The composing step's own `arguments` are what the child
526
+ # actually runs with - folded over its declared defaults the
527
+ # same way a caller's arguments are, since `expanded` has
528
+ # already substituted them to concrete values (#341)
529
+ effective_variables = dict(child_definition.get("variables") or {})
530
+ effective_variables.update(step_arguments)
531
+ child_expanded = {"variables": effective_variables}
532
+ child_list_entries = _list_entries(child_definition, child_expanded)
533
+ child = _price(
534
+ child_cost, device, child_list_entries, child_measured_entries
535
+ )
536
+ if (
537
+ child["basis"] == CATALOG
538
+ and child_definition is not None
539
+ and (
540
+ _scalar_driver_shifted(
541
+ child_definition, child_expanded, child_list_entries
542
+ )
543
+ )
544
+ ):
545
+ # A scalar cost_driver the composing step overrode (H3's
546
+ # num_frames at 345 against a default of 124, say) is the
547
+ # same #267 failure one level down - the child's own
548
+ # catalog figure was never measured for the value this
549
+ # step actually passes it (#341)
550
+ child = {"minutes": None, "basis": UNKNOWN, "measured_on": None}
551
+ else:
552
+ child_runs.append(child["runs"])
553
+ child_measured_on.add(child["measured_on"])
554
+ if child["minutes"] is None:
555
+ partial = True
556
+ unpriced.append(path)
557
+ elif minutes is not None:
558
+ minutes += child["minutes"]
559
+ else:
560
+ minutes = child["minutes"]
561
+ if minutes is None:
562
+ partial = False
563
+ unpriced = []
564
+ top_basis = own["basis"]
565
+ top_measured_on = own["measured_on"]
566
+ top_runs = None
567
+ if own["minutes"] is None and had_child and children_all_observed and not partial:
568
+ # Every composing child's share of the total was this box's own
569
+ # history rather than a static figure, and the parent contributed
570
+ # nothing of its own to disagree with that - so the whole total is
571
+ # as good as observed rather than "unknown" (#268), mirroring the
572
+ # existing rule that a child's catalog cost is skipped once the
573
+ # *parent* has an observed figure, to avoid double-counting.
574
+ # The children's own `runs`/`measured_on` come along with the
575
+ # inherited basis (#275) - an "observed" estimate with `runs: null`
576
+ # says it was measured but not how many times, which is the number
577
+ # a caller uses to decide how much to trust the figure. `runs` is
578
+ # the weakest history across children (the min), and `measured_on`
579
+ # is named only when every child agrees on the device.
580
+ top_basis = OBSERVED
581
+ top_runs = min(child_runs) if child_runs else None
582
+ top_measured_on = (
583
+ next(iter(child_measured_on)) if len(child_measured_on) == 1 else None
584
+ )
585
+ result = {
586
+ "minutes": round(minutes, 1) if minutes is not None else None,
587
+ "basis": top_basis,
588
+ "device": device,
589
+ "measured_on": top_measured_on,
590
+ "partial": partial,
591
+ "unpriced": unpriced,
592
+ "runs": top_runs,
593
+ "cached_minutes": _cached_minutes(
594
+ round(minutes, 1) if minutes is not None else None,
595
+ cached_steps,
596
+ total_steps,
597
+ ),
598
+ }
599
+ if top_basis == OBSERVED:
600
+ # The rolled-up-from-children case (#268): no curated figure of the
601
+ # parent's own exists to blend toward (that is this branch's own
602
+ # precondition, above), so a thin roll-up gets the low_confidence
603
+ # flag rather than a blend. Never changes `minutes`, so
604
+ # `cached_minutes` above already reflects it.
605
+ result = _tempered(result, None)
606
+ return result
607
+
608
+
609
+ def _observed(observed, device):
610
+ """This box's history as an estimate, or None when it has nothing to
611
+ quote from.
612
+
613
+ Withheld rather than quoted when the history is a different backend's
614
+ (a card swapped under the same jobs table), or when every comparable run
615
+ was warm: a warm run had the weights already resident and quoting it as
616
+ the cost of a run that has to load them would under-quote by the minutes
617
+ this estimate exists to name.
618
+ """
619
+ if not isinstance(observed, dict):
620
+ return None
621
+ minutes = observed.get("cold_minutes")
622
+ runs = observed.get("cold_runs")
623
+ if not isinstance(minutes, (int, float)) or not runs:
624
+ return None
625
+ if observed.get("device") not in (None, device):
626
+ return None
627
+ return {
628
+ "minutes": round(float(minutes), 1),
629
+ "basis": OBSERVED,
630
+ "device": device,
631
+ "measured_on": observed.get("name"),
632
+ "partial": False,
633
+ "unpriced": [],
634
+ "runs": runs,
635
+ }
636
+
637
+
638
+ def _sub_workflow_paths(expanded):
639
+ """The local (non-builtin) sub-workflow path and composing arguments of
640
+ every composing step, as (path, arguments) - `expanded` has already
641
+ substituted and expanded `for_each`, so each occurrence carries the
642
+ concrete arguments that step actually passes the child (#341)."""
643
+ for step in expanded.get("steps") or []:
644
+ reference = step.get("workflow") if isinstance(step, dict) else None
645
+ path = reference.get("path") if isinstance(reference, dict) else None
646
+ if isinstance(path, str) and not path.startswith(BUILTIN_PREFIX):
647
+ arguments = reference.get("arguments")
648
+ yield path, arguments if isinstance(arguments, dict) else {}
649
+
650
+
651
+ def _only_composes_children(definition):
652
+ """Whether every step the workflow *declares* is a `workflow` step -
653
+ i.e. the parent does no work of its own beyond assembling children.
654
+
655
+ Distinguishes an uncosted parent that is pure composition (#268: its
656
+ children's figures are the whole story) from one with real uncosted work
657
+ of its own, such as a `for_each` task step with no `cost` block (#242:
658
+ a priced child there still leaves a genuine gap). Reads the *written*
659
+ definition rather than `expanded`: a step that saves nothing and that
660
+ nothing reads is elided from the run (#122), and an elided own step is
661
+ still work the author declared - it must not be mistaken for pure
662
+ composition just because it would not execute.
663
+ """
664
+ steps = definition.get("steps") or []
665
+ if not steps:
666
+ return False
667
+ for step in steps:
668
+ reference = step.get("workflow") if isinstance(step, dict) else None
669
+ if not isinstance(reference, dict) or not isinstance(
670
+ reference.get("path"), str
671
+ ):
672
+ return False
673
+ return True
674
+
675
+
676
+ def _price(cost, device, list_entries, measured_entries):
677
+ """One cost list priced for `device` and `list_entries`, as
678
+ {minutes, basis, measured_on}.
679
+
680
+ `measured_entries` is the list length the figure was measured with -
681
+ the workflow's stored defaults. When the caller's list differs and the
682
+ entry carries no measured `per_entry` rate, the total is extrapolated
683
+ linearly over it and reported as 'derived'; when more than one list
684
+ changed there is nothing honest to extrapolate along, so the figure is
685
+ withheld rather than quoted for the wrong list.
686
+ """
687
+ entries = [entry for entry in (cost or []) if isinstance(entry, dict)]
688
+ if not entries:
689
+ return {"minutes": None, "basis": UNKNOWN, "measured_on": None}
690
+ chosen = next((entry for entry in entries if entry.get("device") == device), None)
691
+ basis = CATALOG
692
+ if chosen is None:
693
+ chosen = entries[0]
694
+ basis = OTHER_DEVICE
695
+ minutes = float(chosen.get("minutes", 0))
696
+ per = chosen.get("per_entry")
697
+ if (
698
+ basis == CATALOG
699
+ and isinstance(per, dict)
700
+ and per.get("variable") in list_entries
701
+ ):
702
+ count = list_entries[per["variable"]]
703
+ each = float(per.get("minutes", 0))
704
+ measured_with = int(per.get("entries", 0))
705
+ minutes = max(0.0, (minutes - each * measured_with) + each * count)
706
+ basis = PER_ENTRY
707
+ elif basis == CATALOG:
708
+ minutes, basis = _repriced(minutes, list_entries, measured_entries)
709
+ return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")}
710
+
711
+
712
+ def _repriced(minutes, list_entries, measured_entries):
713
+ """A catalog total re-priced for a list the caller lengthened or
714
+ shortened, as (minutes, basis).
715
+
716
+ Linear in the entry count: the figure was measured over
717
+ `measured_entries` entries and the run does `list_entries` of them, so
718
+ a 42-minute 5-shot figure quotes 84 for 10 shots rather than 42. It
719
+ over-counts fixed setup at the long end and under-counts it at the
720
+ short end - which is why it is labelled `derived` rather than
721
+ `catalog`, and why a template that measures a real `per_entry` rate
722
+ beats it.
723
+ """
724
+ changed = [
725
+ (count, measured_entries[name])
726
+ for name, count in list_entries.items()
727
+ if name in measured_entries and count != measured_entries[name]
728
+ ]
729
+ if not changed:
730
+ return minutes, CATALOG
731
+ if len(changed) > 1:
732
+ return None, UNKNOWN
733
+ count, measured_with = changed[0]
734
+ if measured_with <= 0:
735
+ return None, UNKNOWN
736
+ return minutes * count / measured_with, DERIVED
737
+
738
+
739
+ FROM_PRETRAINED_KEY = "from_pretrained_arguments"
740
+ MODEL_NAME_KEY = "model_name"
741
+ # An adapter names its repo directly, not through from_pretrained_arguments
742
+ LORAS_KEY = "loras"
743
+ SINGLE_FILE_KEY = "from_single_file"
744
+ VARIANT_KEY = "variant"
745
+
746
+
747
+ def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes):
748
+ """The hub repos and checkpoint URLs the run would fetch before its
749
+ first step: every `model_name` in the expanded definition (and in each
750
+ composed child) that `scan_models` does not find, plus every one it does
751
+ find but whose cached copy is left over from an interrupted pull
752
+ (#382) - a component the load needs is missing, or a blob is still
753
+ `.incomplete` - plus every `from_single_file` that is a URL. Sizes come
754
+ from the hub when asked and are None whenever it does not answer - an
755
+ offline box is a state, not an error, so nothing here raises or logs
756
+ above debug.
757
+ """
758
+ names = []
759
+ variants = {}
760
+ urls = []
761
+ _collect_sources(expanded, names, variants, urls)
762
+ for path, _arguments in _sub_workflow_paths(expanded):
763
+ raw = read_sub_workflow(path, base_dir, workflow_dir)
764
+ if raw is None:
765
+ continue
766
+ try:
767
+ _collect_sources(json.loads(raw), names, variants, urls)
768
+ except ValueError:
769
+ continue
770
+ present = {repo.get("repo_id") for repo in scan_models(cache_dir).get("repos", [])}
771
+ required = []
772
+ for name in names:
773
+ # A name not shaped like a hub id is a local checkout - decided by
774
+ # shape, never by touching the disk: the name came from the request
775
+ # body, and a free pre-flight must not be a directory-existence oracle
776
+ if not _is_repo_id(name):
777
+ continue
778
+ if name in present and not repo_download_incomplete(
779
+ name, cache_dir, variant=variants.get(name)
780
+ ):
781
+ continue
782
+ entry = {"repo": name, "gb": None, "gated": None, "access_blocked": None}
783
+ if lookup_sizes:
784
+ entry["gb"], entry["gated"], entry["access_blocked"] = _model_info(name)
785
+ required.append(entry)
786
+ for url in urls:
787
+ required.append(
788
+ {
789
+ "repo": None,
790
+ "url": url,
791
+ "gb": None,
792
+ "gated": None,
793
+ "access_blocked": None,
794
+ }
795
+ )
796
+ return required
797
+
798
+
799
+ def _collect_sources(tree, names, variants, urls):
800
+ """Every from_pretrained source in a tree, first-seen order, deduplicated.
801
+ `variants` collects each name's requested `variant` (first-seen), used to
802
+ tell a repo that is merely missing its fp16 files from one that is fully
803
+ cached without them.
804
+
805
+ A `loras` entry counts too. It carries its repo under `model_name`
806
+ directly rather than inside a `from_pretrained_arguments` block, so the
807
+ walk missed it: `templates/ltx2/reference-sheet` on a box that had
808
+ every base weight but not the IC-LoRA answered `downloads_required:
809
+ []` and then pulled it mid-run, which is the one question the field
810
+ exists to answer (found verifying #151).
811
+ """
812
+ if isinstance(tree, dict):
813
+ for lora in tree.get(LORAS_KEY) or []:
814
+ name = lora.get(MODEL_NAME_KEY) if isinstance(lora, dict) else None
815
+ if isinstance(name, str) and name not in names:
816
+ names.append(name)
817
+ source = tree.get(FROM_PRETRAINED_KEY)
818
+ if isinstance(source, dict):
819
+ name = source.get(MODEL_NAME_KEY)
820
+ if isinstance(name, str) and name not in names:
821
+ names.append(name)
822
+ variant = source.get(VARIANT_KEY)
823
+ if isinstance(variant, str):
824
+ variants[name] = variant
825
+ single = source.get(SINGLE_FILE_KEY)
826
+ if isinstance(single, str) and _is_url(single) and single not in urls:
827
+ urls.append(single)
828
+ for value in tree.values():
829
+ _collect_sources(value, names, variants, urls)
830
+ elif isinstance(tree, list):
831
+ for value in tree:
832
+ _collect_sources(value, names, variants, urls)
833
+
834
+
835
+ def gate_warnings(downloads_required):
836
+ """One line per required download this box's token is blocked from -
837
+ the pre-flight signal #186 asked for, so a gated repo is a
838
+ `validate_workflow`-visible condition rather than a 403 the run only
839
+ discovers after it has loaded everything ahead of that step."""
840
+ return [
841
+ f"{entry['repo']} is gated and not accessible with this box's "
842
+ "Hugging Face token - accept its license at "
843
+ f"https://huggingface.co/{entry['repo']} before running this workflow"
844
+ for entry in downloads_required
845
+ if entry.get("access_blocked")
846
+ ]
847
+
848
+
849
+ def _is_repo_id(name):
850
+ try:
851
+ validate_repo_id(name)
852
+ return True
853
+ except HFValidationError:
854
+ return False
855
+
856
+
857
+ def _is_url(value):
858
+ if not value.startswith(("http://", "https://")):
859
+ return False
860
+ try:
861
+ validate_url(value)
862
+ return True
863
+ except Exception:
864
+ return False
865
+
866
+
867
+ def _model_info(name):
868
+ """A repo's size in GiB, gate status, and whether this box's token is
869
+ blocked from it - a `(gb, gated, access_blocked)` triple.
870
+
871
+ `gated` is `model_info`'s own field (`False` / `"auto"` / `"manual"`),
872
+ readable only when the call succeeds - and the hub answers `model_info`
873
+ for a gated repo regardless of whether this token has been granted
874
+ access, since that call serves metadata rather than file bytes (found
875
+ verifying #186: `access_blocked` came back `false` for repos this box's
876
+ token was actually refused on). A `GatedRepoError` straight out of
877
+ `model_info` is still a real signal - some other endpoint behind it
878
+ checked and refused - but its absence proves nothing, so a `gated` repo
879
+ that got this far is checked for real with a HEAD request against one of
880
+ its own files (`_probe_gate_blocked`), the same request class a run's
881
+ actual load would make and the one place the hub's 403 for "gated, token
882
+ not accepted" actually appears.
883
+ """
884
+ try:
885
+ info = model_info(name, files_metadata=True, timeout=SIZE_LOOKUP_TIMEOUT)
886
+ except GatedRepoError as e:
887
+ logger.debug(f"Gate not accepted for {name}: {e}")
888
+ return None, True, True
889
+ except Exception as e:
890
+ logger.debug(f"No size for {name}: {e}")
891
+ return None, None, None
892
+ total = sum(s.size for s in (info.siblings or []) if getattr(s, "size", None))
893
+ gb = round(total / GIB, 1) if total else None
894
+ gated = getattr(info, "gated", False) or False
895
+ access_blocked = False
896
+ if gated:
897
+ access_blocked = _probe_gate_blocked(name, info.siblings or [])
898
+ return gb, gated, access_blocked
899
+
900
+
901
+ def _probe_gate_blocked(name, siblings):
902
+ """HEAD one real file of a gated repo to see whether this box's token is
903
+ actually accepted - `model_info` succeeding says nothing either way
904
+ (#186). `None` when there is no file to probe or the probe fails for a
905
+ reason other than the gate, since that is "unknown", not "not blocked".
906
+ """
907
+ filename = next(
908
+ (s.rfilename for s in siblings if getattr(s, "rfilename", None)), None
909
+ )
910
+ if filename is None:
911
+ return None
912
+ try:
913
+ get_hf_file_metadata(hf_hub_url(name, filename), timeout=SIZE_LOOKUP_TIMEOUT)
914
+ return False
915
+ except GatedRepoError as e:
916
+ logger.debug(f"Gate not accepted for {name}: {e}")
917
+ return True
918
+ except Exception as e:
919
+ logger.debug(f"Gate probe inconclusive for {name}: {e}")
920
+ return None