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_mcp/client.py ADDED
@@ -0,0 +1,486 @@
1
+ """HTTP access to a running dw.serve, and the single place an API failure
2
+ becomes a message a non-developer can act on."""
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ import tempfile
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ DEFAULT_BASE_URL = "http://127.0.0.1:8765"
13
+
14
+ # Twin of dw.server.app.LOOPBACK_HOSTS. Duplicated rather than imported:
15
+ # importing anything under dw/ runs dw/__init__.py and pulls in torch, which
16
+ # this pure HTTP client must not do (tests/test_mcp_server.py guards that).
17
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
18
+
19
+
20
+ def is_loopback_url(url):
21
+ """True when `url` names this machine's loopback interface - the case
22
+ where an unauthenticated dw.serve is only reachable by this user."""
23
+ from urllib.parse import urlparse
24
+
25
+ return (urlparse(url).hostname or "").lower() in LOOPBACK_HOSTS
26
+
27
+
28
+ def coerce_json_object(value, param_name):
29
+ """A tool argument typed as an object can still arrive as a JSON-encoded
30
+ string (a caller that serialized it before handing it over, or a client
31
+ that couldn't parse a malformed document and passed the raw text
32
+ through). Accept that case rather than letting it reach the server as a
33
+ string, where the schema rejection names the wrong problem - not "this
34
+ isn't an object" but a bare pydantic `type=dict_type, input_type=str`,
35
+ which reads as if the field itself were misdeclared."""
36
+ if value is None or isinstance(value, dict):
37
+ return value
38
+ if not isinstance(value, str):
39
+ raise DwApiError(
40
+ f"`{param_name}` must be a JSON object, not {type(value).__name__}."
41
+ )
42
+ try:
43
+ parsed = json.loads(value)
44
+ except json.JSONDecodeError as e:
45
+ raise DwApiError(f"`{param_name}` is not valid JSON: {e}") from e
46
+ if not isinstance(parsed, dict):
47
+ raise DwApiError(
48
+ f"`{param_name}` must be a JSON object, not {type(parsed).__name__}."
49
+ )
50
+ return parsed
51
+
52
+
53
+ def path_segment(name):
54
+ """Percent-encode a name for interpolation into a request path,
55
+ including its '/' characters.
56
+
57
+ httpx normalizes dot-segments (`..`) out of a request URL client-side,
58
+ before the request ever reaches the server - so an unquoted name like
59
+ `../escape` is silently rewritten into a different, valid-looking path
60
+ and the server's own path-traversal check never runs on it. Quoting
61
+ keeps the literal bytes intact on the wire, so it is the server's own
62
+ validation - not this client - that decides what a name is allowed to
63
+ contain.
64
+ """
65
+ return quote(name, safe="")
66
+
67
+
68
+ def _declared_length(headers):
69
+ """The body length a response declares, or None when it declares none
70
+ (a chunked answer) or something that is not a number."""
71
+ value = headers.get("content-length")
72
+ try:
73
+ return int(value) if value not in (None, "") else None
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def api_path(*segments):
79
+ """Build a request path from literal and caller-supplied segments,
80
+ percent-encoding each one - including its '/' characters.
81
+
82
+ httpx normalizes dot-segments (`..`) out of a request URL client-side,
83
+ before the request ever reaches the server - so an unquoted segment like
84
+ `../escape` is silently rewritten into a different, valid-looking path
85
+ and the server's own path-traversal check never runs on it. Quoting
86
+ keeps the literal bytes intact on the wire, so it is the server's own
87
+ validation - not this client - that decides what a segment is allowed to
88
+ contain.
89
+
90
+ This is the only sanctioned way to interpolate a value into a request
91
+ path: a handler that builds one with an f-string instead re-opens the
92
+ hole this closes, one call site at a time.
93
+ """
94
+ return "/" + "/".join(path_segment(str(segment)) for segment in segments)
95
+
96
+
97
+ class DwApiError(Exception):
98
+ """A request to dw.serve failed. The message is meant to be read by the
99
+ person driving the MCP client, not by a developer with a stack trace.
100
+
101
+ `status_code` is the HTTP status that produced it when it came from a
102
+ response (`None` for a connection failure or timeout, raised before any
103
+ status exists) - a caller that reacts differently to a 404 than to a 409
104
+ needs that without parsing the message text."""
105
+
106
+ def __init__(self, message, status_code=None):
107
+ super().__init__(message)
108
+ self.status_code = status_code
109
+
110
+
111
+ def resolve_token(explicit=None):
112
+ """The bearer token dw.serve was started with, if any: the explicit
113
+ value, else DW_API_TOKEN - the same variable dw.serve itself reads, so
114
+ one export configures both ends. Empty means unauthenticated."""
115
+ return explicit or os.environ.get("DW_API_TOKEN") or ""
116
+
117
+
118
+ # The workspace name every request is scoped to when the session has chosen
119
+ # one. Not DW_WORKSPACE: that names a *directory* to the engine, where this
120
+ # names one of a server's workspaces - two different things that would be a
121
+ # confusing single variable
122
+ WORKSPACE_ENV_VAR = "DW_MCP_WORKSPACE"
123
+
124
+ DEFAULT_WORKSPACE = "default"
125
+
126
+
127
+ def resolve_workspace(explicit=None):
128
+ """Which of the server's workspaces this session works in: the explicit
129
+ value, else DW_MCP_WORKSPACE, else the server's default."""
130
+ return explicit or os.environ.get(WORKSPACE_ENV_VAR) or DEFAULT_WORKSPACE
131
+
132
+
133
+ def resolve_base_url(explicit=None):
134
+ """Where dw.serve is: the explicit value, else DW_MCP_URL, else the
135
+ default port."""
136
+ url = explicit or os.environ.get("DW_MCP_URL") or DEFAULT_BASE_URL
137
+ return url.rstrip("/")
138
+
139
+
140
+ class DwClient:
141
+ """One method per kind of REST call. Knows nothing about MCP - the tool
142
+ handlers are plain functions over this."""
143
+
144
+ def __init__(
145
+ self,
146
+ base_url=None,
147
+ timeout=30.0,
148
+ transport=None,
149
+ token=None,
150
+ workspace=None,
151
+ ):
152
+ self.base_url = resolve_base_url(base_url)
153
+ # True only for the client dw.serve --mcp builds for its own mounted
154
+ # tool surface (dw/server/mcp_mount.py). It is what distinguishes
155
+ # "local disk" meaning the caller's own machine (stdio dw-mcp) from
156
+ # it meaning the GPU box a remote agent is talking to - which decides
157
+ # whether download_output may write outside the workspace (#113)
158
+ self.mounted = False
159
+ # Mutable: use_workspace switches it for the rest of the session,
160
+ # which is what makes a switch one visible call rather than a
161
+ # parameter on every tool
162
+ self.workspace = resolve_workspace(workspace)
163
+ self.timeout = timeout
164
+ token = resolve_token(token)
165
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
166
+ self._http = httpx.Client(
167
+ base_url=self.base_url,
168
+ timeout=timeout,
169
+ transport=transport,
170
+ headers=headers,
171
+ )
172
+
173
+ def close(self):
174
+ self._http.close()
175
+
176
+ # ------------------------------------------------------------- requests
177
+
178
+ def get_json(self, path, params=None, workspace=None):
179
+ return self._json(
180
+ self._request("GET", path, params=params, workspace=workspace), path
181
+ )
182
+
183
+ def post_json(self, path, payload=None, params=None, workspace=None):
184
+ """`params` is for a route whose options are query parameters rather
185
+ than a body - the export route, which takes `overwrite` beside the
186
+ workspace selector `_scoped` adds."""
187
+ return self._json(
188
+ self._request(
189
+ "POST", path, json=payload or {}, params=params, workspace=workspace
190
+ ),
191
+ path,
192
+ )
193
+
194
+ def put_json(self, path, payload, workspace=None):
195
+ return self._json(
196
+ self._request("PUT", path, json=payload, workspace=workspace), path
197
+ )
198
+
199
+ def delete_json(self, path, params=None, workspace=None):
200
+ return self._json(
201
+ self._request("DELETE", path, params=params, workspace=workspace), path
202
+ )
203
+
204
+ def post_bytes(self, path, data, params=None, workspace=None):
205
+ """Send a file's bytes as the request body - the shape
206
+ POST /api/uploads takes, so a single file needs no multipart
207
+ parser at either end."""
208
+ return self._json(
209
+ self._request(
210
+ "POST", path, content=data, params=params, workspace=workspace
211
+ ),
212
+ path,
213
+ )
214
+
215
+ def get_bytes(self, path, workspace=None):
216
+ """Raw body plus content type - for the output media served from the
217
+ /outputs static mount rather than an /api route."""
218
+ response = self._request("GET", path, workspace=workspace)
219
+ self._raise_for_status(response, path)
220
+ return response.content, response.headers.get("content-type", "")
221
+
222
+ def get_media_if(
223
+ self, path, accept_content_type, workspace=None, params=None, max_bytes=None
224
+ ):
225
+ """Like `get_bytes`, but the body is only downloaded when
226
+ `accept_content_type(content_type)` is true, and the response
227
+ headers come back with it - a media route says what it cut in
228
+ them.
229
+
230
+ Headers arrive before the body over HTTP, so a rejection closes the
231
+ connection having read nothing past them - useful for `/outputs`,
232
+ where a rejected file (a video, say) can be arbitrarily large.
233
+ `max_bytes` rejects the same way on size: when the server declares
234
+ a `content-length` whose base64 form would exceed it, the body is
235
+ not read - a refusal that costs the whole download first is no
236
+ saving. Returns `(None, content_type, headers)` on rejection,
237
+ `(body, content_type, headers)` on acceptance; the caller tells the
238
+ two rejections apart by the headers. An error status is still
239
+ raised either way, since the body has to be read to report it.
240
+ """
241
+ kwargs = {"params": params} if params else {}
242
+ response = self._stream_request("GET", path, workspace=workspace, **kwargs)
243
+ try:
244
+ content_type = response.headers.get("content-type", "")
245
+ if response.status_code < 400 and not accept_content_type(content_type):
246
+ return None, content_type, response.headers
247
+ if response.status_code < 400 and max_bytes is not None:
248
+ declared = _declared_length(response.headers)
249
+ if declared is not None and 4 * math.ceil(declared / 3) > max_bytes:
250
+ return None, content_type, response.headers
251
+ self._call_httpx(response.read, path)
252
+ self._raise_for_status(response, path)
253
+ return response.content, content_type, response.headers
254
+ finally:
255
+ response.close()
256
+
257
+ def get_bytes_if(self, path, accept_content_type, workspace=None):
258
+ """`get_media_if` without the headers, for the callers that only
259
+ want the body."""
260
+ body, content_type, _headers = self.get_media_if(
261
+ path, accept_content_type, workspace=workspace
262
+ )
263
+ return body, content_type
264
+
265
+ def stream_to_file(self, path, destination, workspace=None):
266
+ """Stream `path`'s body straight to `destination` on disk, in
267
+ chunks, rather than buffering it whole - for a body too large to
268
+ hold in memory (the videos `get_bytes` can't return). Returns
269
+ `(content_type, bytes_written)`.
270
+
271
+ Written atomically: chunks land in a temp file next to
272
+ `destination` first, moved into place with `os.replace` only after
273
+ the whole body has arrived. A connection drop or server error
274
+ mid-stream removes the temp file and re-raises rather than leaving
275
+ a torn partial file at `destination` - which would otherwise
276
+ "exist" for a later `overwrite=False` caller and mask the failure.
277
+ """
278
+ response = self._stream_request("GET", path, workspace=workspace)
279
+ try:
280
+ if response.status_code >= 400:
281
+ self._call_httpx(response.read, path)
282
+ self._raise_for_status(response, path)
283
+ content_type = response.headers.get("content-type", "")
284
+ parent = os.path.dirname(destination) or "."
285
+ fd, temp_path = tempfile.mkstemp(dir=parent)
286
+
287
+ def write_chunks():
288
+ written = 0
289
+ with os.fdopen(fd, "wb") as file:
290
+ for chunk in response.iter_bytes():
291
+ file.write(chunk)
292
+ written += len(chunk)
293
+ return written
294
+
295
+ try:
296
+ written = self._call_httpx(write_chunks, path)
297
+ os.replace(temp_path, destination)
298
+ except BaseException:
299
+ try:
300
+ os.unlink(temp_path)
301
+ except OSError:
302
+ pass
303
+ raise
304
+ return content_type, written
305
+ finally:
306
+ response.close()
307
+
308
+ # ------------------------------------------------------------ internals
309
+
310
+ def _request(self, method, path, workspace=None, **kwargs):
311
+ return self._call_httpx(
312
+ lambda: self._http.request(method, path, **self._scoped(kwargs, workspace)),
313
+ path,
314
+ )
315
+
316
+ def _scoped(self, kwargs, workspace=None):
317
+ """Add the workspace a request is for to its query string.
318
+
319
+ One place rather than a parameter on every handler: routes that are
320
+ not workspace-scoped (prompts, models, system) ignore an unknown
321
+ query parameter, and the server treats a missing selector as its
322
+ default - so the default workspace sends nothing and every request
323
+ looks exactly as it did before workspaces existed.
324
+
325
+ `workspace` is the per-call pin - "for this one call, without
326
+ switching the session" - and wins over the session's own. Naming the
327
+ default explicitly is the way to reach it from a session that is
328
+ somewhere else, so it sends no selector rather than the session's
329
+ (#99).
330
+ """
331
+ name = workspace or self.workspace
332
+ if name == DEFAULT_WORKSPACE:
333
+ return kwargs
334
+ params = dict(kwargs.get("params") or {})
335
+ params.setdefault("workspace", name)
336
+ return {**kwargs, "params": params}
337
+
338
+ def _stream_request(self, method, path, workspace=None, **kwargs):
339
+ scoped = self._scoped(kwargs, workspace)
340
+ return self._call_httpx(
341
+ lambda: self._http.send(
342
+ self._http.build_request(method, path, **scoped), stream=True
343
+ ),
344
+ path,
345
+ )
346
+
347
+ def _call_httpx(self, send, path):
348
+ try:
349
+ return send()
350
+ # ConnectTimeout is a subclass of TimeoutException, so it must be
351
+ # caught here, before TimeoutException below.
352
+ except (httpx.ConnectTimeout, httpx.ConnectError):
353
+ raise DwApiError(
354
+ f"Cannot reach diffusers-workflow at {self.base_url}. "
355
+ "Start the server with `dw-serve` (or `python -m dw.serve`) "
356
+ "and try again."
357
+ )
358
+ except httpx.TimeoutException:
359
+ raise DwApiError(
360
+ f"Request to {path} timed out after {self.timeout}s. The "
361
+ "server may be busy loading a model."
362
+ )
363
+ except httpx.HTTPError as e:
364
+ raise DwApiError(f"Request to {path} failed: {e}")
365
+
366
+ def _json(self, response, path):
367
+ self._raise_for_status(response, path)
368
+ try:
369
+ return response.json()
370
+ except ValueError:
371
+ raise DwApiError(f"{path} returned a non-JSON body: {response.text[:200]}")
372
+
373
+ def _raise_for_status(self, response, path):
374
+ if response.status_code < 400:
375
+ return
376
+ # 5xx is always a server-side failure, even when the body happens to
377
+ # carry a `detail` (dw/server/app.py raises 500s with one) - the
378
+ # status has to survive so it reads as distinct from a validation
379
+ # message.
380
+ if response.status_code < 500:
381
+ detail = None
382
+ try:
383
+ body = response.json()
384
+ if isinstance(body, dict):
385
+ detail = body.get("detail")
386
+ except ValueError:
387
+ detail = None
388
+ if detail:
389
+ # The API writes these for humans already - 400s carry
390
+ # validation messages, 404s and 409s carry the reason
391
+ formatted_detail = self._format_detail(detail)
392
+ raise DwApiError(formatted_detail, status_code=response.status_code)
393
+ raise DwApiError(
394
+ f"{path} failed with HTTP {response.status_code}: "
395
+ f"{response.text[:200] or 'no body'}",
396
+ status_code=response.status_code,
397
+ )
398
+
399
+ def _format_detail(self, detail):
400
+ """Format a detail from an API error response into a human-readable
401
+ message. FastAPI validation errors (422) have detail as a list of dicts
402
+ with 'loc' and 'msg' keys; a route that has to say what it would do
403
+ sends a dict ('message' plus 'contents' or 'entries'), which str()
404
+ would hand back as a Python repr; string details are returned
405
+ verbatim."""
406
+ if isinstance(detail, dict) and "message" in detail:
407
+ formatted = str(detail["message"])
408
+ contents = detail.get("contents")
409
+ if isinstance(contents, dict):
410
+ held = [
411
+ f"{folder}: {value.get('files', 0)} file(s), "
412
+ f"{value.get('bytes', 0)} bytes"
413
+ for folder, value in contents.items()
414
+ if isinstance(value, dict) and value.get("files")
415
+ ]
416
+ formatted += f" Holds {'; '.join(held) if held else 'nothing'}."
417
+ entries = detail.get("entries")
418
+ if isinstance(entries, list) and entries:
419
+ formatted += f" Also holds: {', '.join(str(e) for e in entries)}."
420
+ plan = detail.get("plan")
421
+ if isinstance(plan, dict):
422
+ # A 409 from the cost gate: say what the run costs now, so a
423
+ # client that only sees the message can re-quote from it
424
+ estimate = plan.get("estimate") or {}
425
+ if estimate.get("minutes") is None:
426
+ formatted += (
427
+ f" It now has no measured estimate (basis "
428
+ f"{estimate.get('basis')})"
429
+ )
430
+ else:
431
+ formatted += (
432
+ f" It now estimates {estimate.get('minutes')} minutes "
433
+ f"(basis {estimate.get('basis')})"
434
+ )
435
+ downloads = [
436
+ entry.get("repo") or entry.get("url")
437
+ for entry in plan.get("downloads_required") or []
438
+ ]
439
+ if downloads:
440
+ formatted += f", and would download {', '.join(downloads)} first"
441
+ # The shape to resend, not just the new fingerprint: a
442
+ # client reading only the message can re-acknowledge from it
443
+ minutes = estimate.get("minutes")
444
+ repos = [
445
+ entry.get("repo")
446
+ for entry in plan.get("downloads_required") or []
447
+ if entry.get("repo")
448
+ ]
449
+ # json.dumps for the whole object, not an f-string per field:
450
+ # an inline workflow has no measured estimate, so `minutes` is
451
+ # None far more often than not, and Python's repr of it is not
452
+ # JSON a client could paste back (#107)
453
+ formatted += (
454
+ ". Re-acknowledge with "
455
+ + json.dumps(
456
+ {
457
+ "fingerprint": plan.get("fingerprint"),
458
+ "minutes": minutes,
459
+ "downloads": repos,
460
+ }
461
+ )
462
+ + "."
463
+ )
464
+ return formatted
465
+ if isinstance(detail, list):
466
+ messages = []
467
+ for entry in detail:
468
+ if isinstance(entry, dict):
469
+ msg = entry.get("msg")
470
+ loc = entry.get("loc")
471
+ if msg is None:
472
+ messages.append(str(entry))
473
+ elif loc and isinstance(loc, list):
474
+ field_name = loc[-1]
475
+ messages.append(f"{field_name}: {msg}")
476
+ else:
477
+ messages.append(msg)
478
+ else:
479
+ messages.append(str(entry))
480
+ return ". ".join(messages) if messages else str(detail)
481
+ formatted = str(detail)
482
+ # When a workspace was deleted elsewhere, give the agent a path
483
+ # to recovery: list available workspaces and switch to one
484
+ if isinstance(detail, str) and detail.startswith("No such workspace"):
485
+ formatted += " - list_workspaces shows what exists; use_workspace switches."
486
+ return formatted