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/server/mcp_mount.py ADDED
@@ -0,0 +1,95 @@
1
+ """Mount the MCP tool surface inside the HTTP server.
2
+
3
+ `dw_mcp` is a pure HTTP client of dw.serve; mounting it here does not
4
+ change that - the tools reach the REST API over the server's own bind
5
+ address with the same token the API requires, a few milliseconds per
6
+ call. This is the only module under dw/ that imports the mcp SDK, and it
7
+ does so lazily: the package is an optional extra.
8
+ """
9
+
10
+ import ipaddress
11
+
12
+
13
+ def client_base_url(host, port):
14
+ """The URL the mounted tools use to reach this same server.
15
+
16
+ A loopback or wildcard bind is reached at 127.0.0.1; any other bind
17
+ address has to be used verbatim, since uvicorn is then listening on
18
+ that address alone and a loopback connection would be refused - which
19
+ is exactly the `--host 100.x.y.z` Tailscale setup docs/REMOTE.md
20
+ recommends. An IPv6 literal is bracketed for the URL's authority.
21
+ """
22
+ # imported here rather than at module scope: dw.server.app imports this
23
+ # module (lazily, inside create_app), so an import back at import time
24
+ # would be a cycle
25
+ from .app import LOOPBACK_HOSTS, WILDCARD_HOSTS
26
+
27
+ host = (host or "").lower()
28
+ if host in WILDCARD_HOSTS | LOOPBACK_HOSTS:
29
+ host = "127.0.0.1"
30
+ try:
31
+ if ipaddress.ip_address(host).version == 6:
32
+ host = f"[{host}]"
33
+ except ValueError:
34
+ # a hostname, not an IP literal - nothing to bracket
35
+ pass
36
+ return f"http://{host}:{port}"
37
+
38
+
39
+ class _SingleRouteApp:
40
+ """Send every path the parent routed here to the sub-app's one route.
41
+
42
+ `dw.server.app` routes `/mcp` and anything under it to this wrapper,
43
+ so the SDK's Starlette app - which has a single route at `/` - sees
44
+ `/` whatever spelling the client used. Without it a bare `POST /mcp`
45
+ either 404s or takes a redirect to `/mcp/`, and `http://box:8765/mcp`
46
+ is the URL the docs tell people to configure.
47
+ """
48
+
49
+ def __init__(self, app):
50
+ self.app = app
51
+
52
+ async def __call__(self, scope, receive, send):
53
+ if scope.get("type") in ("http", "websocket"):
54
+ scope = dict(scope, path="/", raw_path=b"/")
55
+ await self.app(scope, receive, send)
56
+
57
+
58
+ def build_mcp_app(*, host, port, token):
59
+ """The ASGI app to serve at /mcp, the MCPServer behind it, and the
60
+ HTTP client the tools use to reach this same server (client_base_url).
61
+
62
+ The SDK's Starlette app carries its own lifespan (the session
63
+ manager), which the parent app does not run for a sub-app it routes
64
+ to - `create_app`'s lifespan enters `server.session_manager.run()`
65
+ itself.
66
+ """
67
+ try:
68
+ from mcp.server.transport_security import TransportSecuritySettings
69
+ except ImportError:
70
+ raise SystemExit(
71
+ "--mcp needs the mcp extra: pip install 'diffusers-workflow[mcp]'"
72
+ )
73
+
74
+ from dw_mcp.client import DwClient
75
+ from dw_mcp.server import build_server
76
+
77
+ client = DwClient(base_url=client_base_url(host, port), token=token)
78
+ # The tools now run *on* the server rather than on the agent's machine,
79
+ # so a tool that writes a local file is writing on the GPU box. It has to
80
+ # know that to confine where it writes (#113)
81
+ client.mounted = True
82
+ server = build_server(client)
83
+ asgi = server.streamable_http_app(
84
+ # the SDK app routes at "/"; the parent routes /mcp here and
85
+ # _SingleRouteApp rewrites the path to "/" on the way in
86
+ streamable_http_path="/",
87
+ # one JSON reply per request, no sessions to strand on a restart
88
+ json_response=True,
89
+ stateless_http=True,
90
+ # dw.server.app's own Origin/Host middleware runs first and owns this
91
+ transport_security=TransportSecuritySettings(
92
+ enable_dns_rebinding_protection=False
93
+ ),
94
+ )
95
+ return _SingleRouteApp(asgi), server, client
dw/server/netinfo.py ADDED
@@ -0,0 +1,124 @@
1
+ """Which addresses this machine can be reached at.
2
+
3
+ The Server page answers one question - "what URL do I give the other
4
+ machine?" - and the only part of the answer the server itself knows is the
5
+ list of addresses its interfaces carry. URL composition stays in the UI;
6
+ this module only enumerates.
7
+
8
+ Two enumeration methods, in order: psutil when it happens to be installed
9
+ (it is a transitive dependency here, not a declared one, so it is used
10
+ opportunistically and never required), and the stdlib otherwise. Neither
11
+ raises: an address list is a convenience, and a machine whose interfaces
12
+ cannot be read still has a working server, so failure yields [].
13
+ """
14
+
15
+ import socket
16
+ import ipaddress
17
+
18
+ __all__ = ["local_addresses"]
19
+
20
+
21
+ def _usable(address):
22
+ """A candidate address, normalized, or None if it is not worth showing.
23
+
24
+ Loopback is dropped (it names this machine only - the page's whole
25
+ point is the other machine) and so is link-local (169.254.x / fe80::,
26
+ which needs a scope id to be usable at all). Docker and veth addresses
27
+ survive on purpose: on a box that runs containers they are real
28
+ routes, and guessing which of a machine's networks the user meant is
29
+ not the server's job.
30
+ """
31
+ if not address:
32
+ return None
33
+ # getaddrinfo and psutil both hand back scoped IPv6 ('fe80::1%eth0')
34
+ address = address.split("%")[0].strip()
35
+ try:
36
+ parsed = ipaddress.ip_address(address)
37
+ except ValueError:
38
+ return None
39
+ if parsed.is_loopback or parsed.is_link_local or parsed.is_unspecified:
40
+ return None
41
+ return str(parsed), "IPv4" if parsed.version == 4 else "IPv6"
42
+
43
+
44
+ def _psutil_addresses():
45
+ """(address, family, interface) triples from psutil, which is the only
46
+ method that can name the interface an address belongs to."""
47
+ import psutil
48
+
49
+ found = []
50
+ for interface, entries in psutil.net_if_addrs().items():
51
+ for entry in entries:
52
+ if entry.family not in (socket.AF_INET, socket.AF_INET6):
53
+ continue
54
+ usable = _usable(entry.address)
55
+ if usable is not None:
56
+ found.append((usable[0], usable[1], interface))
57
+ return found
58
+
59
+
60
+ def _outbound_address():
61
+ """The address a packet to the outside world would leave from.
62
+
63
+ A UDP socket's connect() only sets the peer for later sends; it does
64
+ no handshake and puts nothing on the wire. It exists because
65
+ getaddrinfo(gethostname()) is silent on machines whose hostname
66
+ resolves to 127.0.1.1 - a Debian default - and that is exactly the
67
+ machine a remote-access page is being read on.
68
+ """
69
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
70
+ try:
71
+ sock.connect(("192.0.2.1", 9)) # TEST-NET-1, routed nowhere
72
+ return sock.getsockname()[0]
73
+ finally:
74
+ sock.close()
75
+
76
+
77
+ def _stdlib_addresses():
78
+ """The psutil-free fallback: whatever this host's name resolves to,
79
+ plus the primary outbound address. No interface names are available
80
+ this way, so they come back None."""
81
+ found = []
82
+ try:
83
+ infos = socket.getaddrinfo(socket.gethostname(), None)
84
+ except OSError:
85
+ infos = []
86
+ for family, _type, _proto, _canonical, sockaddr in infos:
87
+ if family not in (socket.AF_INET, socket.AF_INET6):
88
+ continue
89
+ usable = _usable(sockaddr[0])
90
+ if usable is not None:
91
+ found.append((usable[0], usable[1], None))
92
+ try:
93
+ usable = _usable(_outbound_address())
94
+ except OSError:
95
+ usable = None
96
+ if usable is not None:
97
+ found.append((usable[0], usable[1], None))
98
+ return found
99
+
100
+
101
+ def local_addresses():
102
+ """This machine's non-loopback addresses, IPv4 first, as
103
+ [{"address", "family", "interface"}] - interface is None when the
104
+ method that found it cannot say. Never raises; [] on failure."""
105
+ found = []
106
+ try:
107
+ found = _psutil_addresses()
108
+ except Exception:
109
+ found = []
110
+ if not found:
111
+ try:
112
+ found = _stdlib_addresses()
113
+ except Exception:
114
+ found = []
115
+ seen = set()
116
+ unique = []
117
+ for address, family, interface in found:
118
+ if address in seen:
119
+ continue
120
+ seen.add(address)
121
+ unique.append({"address": address, "family": family, "interface": interface})
122
+ # IPv4 before IPv6, then by address so the list is stable between calls
123
+ unique.sort(key=lambda entry: (entry["family"] != "IPv4", entry["address"]))
124
+ return unique
@@ -0,0 +1,379 @@
1
+ """What this server's own history says a workflow costs.
2
+
3
+ `cost` is a maintainer's claim: runs they measured, on cards they name,
4
+ written into the workflow. `dw/workflow_schema.json` says of it *"Never
5
+ derived; absent means unknown"*, and that sentence is load-bearing - so this
6
+ module does not touch it. It reports a sibling, `observed`, which is only ever
7
+ this box's own finished jobs (#93, split out of #91).
8
+
9
+ The report that asked for it: `list_workflows(shape="shot",
10
+ traits="identity-referenced")` answered `cost: null` for seven of eight
11
+ entries, on a box that had run `templates/minimax/reference-to-video` five
12
+ times at one size - 511, 464.9, 478, ~460, ~467 seconds. The consumer is told
13
+ to quote a price before spending GPU minutes and the catalog answered
14
+ "unknown" for the template their series actually used.
15
+
16
+ Four rules, each of which is a way the naive median would lie:
17
+
18
+ - **Comparability.** A 141-frame run does not inform a 124-frame estimate.
19
+ A workflow declares `cost_drivers` - the variables that move its cost - and
20
+ runs are bucketed by those values; the bucket reported is the one a caller
21
+ gets by *default*, so it stays comparable to the curated figure. A workflow
22
+ declaring no drivers falls back to default-arguments-only runs, which is
23
+ thin but never wrong.
24
+ - **Cold is not warm.** The same `text-to-image` run is 13.6 s with the model
25
+ on disk and 6.3 s with it resident. Averaging those describes neither, so
26
+ `cold_minutes` and `warm_minutes` are separate, each with its own run
27
+ count, and the one comparable to curated `cost` (wall clock *including*
28
+ model load) is the one named `cold`.
29
+ - **A cached run is not a run.** A seeded workflow whose every step hit the
30
+ step cache finishes in seconds and wrote nothing. Counting those would
31
+ collapse the figure toward zero for exactly the templates that get re-run
32
+ most.
33
+ - **Legibility over stability.** History is prunable, so the number moves.
34
+ `runs` and `since` are what make that readable rather than surprising.
35
+
36
+ Everything comes off the job row - one query, no per-run JSON reads from the
37
+ run directories, so the figures survive a pruned run directory.
38
+ """
39
+
40
+ import json
41
+ import logging
42
+ import time
43
+
44
+ logger = logging.getLogger("dw")
45
+
46
+ COST_DRIVERS_KEY = "cost_drivers"
47
+ # A phase event as `json.dumps` wrote it. Matched in SQL so a run's 200
48
+ # persisted events are never parsed just to ask whether the weights loaded.
49
+ LOADING_MARKER = '"phase": "loading"'
50
+ # Persisted events are trimmed to the newest 200 (`MAX_PERSISTED_EVENTS`), so
51
+ # a long run's `loading` phase can be gone from the record. Such a run counts
52
+ # toward `runs` and toward neither side of the split - claiming it was warm
53
+ # because the evidence was trimmed is the one answer that would be wrong.
54
+ EVENT_CAP = 200
55
+
56
+
57
+ def declared_drivers(definition):
58
+ """The variables the author says move this workflow's cost.
59
+
60
+ A name the workflow declares no variable for is dropped rather than
61
+ bucketed on: its effective value is None for every run, so it would put
62
+ every run in one bucket while looking like it had partitioned them.
63
+ `tests/test_observed_cost.py` sweeps the catalog for one, since a driver
64
+ that quietly checks nothing is the failure this whole module exists to
65
+ avoid.
66
+ """
67
+ drivers = definition.get(COST_DRIVERS_KEY)
68
+ if not isinstance(drivers, list):
69
+ return []
70
+ variables = definition.get("variables") or {}
71
+ return [name for name in drivers if isinstance(name, str) and name in variables]
72
+
73
+
74
+ def _bucket_key(definition, arguments):
75
+ """What makes two runs of this workflow comparable, or None when they
76
+ are not comparable at all.
77
+
78
+ With declared drivers: the effective value of each, which is what the run
79
+ passed or else the stored default. Without: only a run that overrode
80
+ nothing that matters is comparable, and since nothing is declared to
81
+ matter, that means a run that overrode nothing at all.
82
+ """
83
+ variables = definition.get("variables") or {}
84
+ drivers = declared_drivers(definition)
85
+ if drivers:
86
+ return tuple(
87
+ (name, _comparable(arguments.get(name, variables.get(name))))
88
+ for name in sorted(drivers)
89
+ )
90
+ if any(key in variables for key in arguments):
91
+ return None
92
+ return ()
93
+
94
+
95
+ def _comparable(value):
96
+ """A driver value as something hashable and stable across JSON round
97
+ trips - 124 and "124" are one bucket, since the engine coerces.
98
+
99
+ A *list* driver buckets on its length, not its contents: a `shots` list
100
+ costs what its number of entries costs, and two four-shot runs of one
101
+ template are comparable however different their prompts. Bucketing on
102
+ contents would give every run its own bucket and so a permanent
103
+ `runs: 1`, which is the same as reporting nothing.
104
+ """
105
+ if isinstance(value, bool) or value is None:
106
+ return value
107
+ if isinstance(value, (int, float)):
108
+ return float(value)
109
+ if isinstance(value, list):
110
+ return ("entries", len(value))
111
+ if isinstance(value, str):
112
+ try:
113
+ return float(value)
114
+ except ValueError:
115
+ return value
116
+ return json.dumps(value, sort_keys=True, default=str)
117
+
118
+
119
+ def _is_task_only(definition):
120
+ """Whether every declared step is a plain task - no `pipeline`,
121
+ `pipeline_reference` or composed `workflow` step anywhere.
122
+
123
+ Such a workflow never logs a `loading` phase, so every run of it lands
124
+ in `had_load: False` and the cold/warm split below would bucket it all
125
+ as warm forever - silently withholding `cold_minutes`, the only figure
126
+ `_observed` (dw/plan.py) will quote as `basis: "observed"` (#439). A
127
+ task-only run has no model-load component to separate from its wall
128
+ clock, so its whole duration is what a curated `cost` figure would be
129
+ measured against, and is reported as `cold` rather than `warm`.
130
+
131
+ False when `steps` is missing or empty - an inline or otherwise
132
+ step-less definition is not evidence either way, so the ordinary
133
+ `had_load` split stays in force rather than guessing.
134
+ """
135
+ steps = definition.get("steps")
136
+ if not isinstance(steps, list) or not steps:
137
+ return False
138
+ for step in steps:
139
+ if not isinstance(step, dict):
140
+ return False
141
+ if "pipeline" in step or "pipeline_reference" in step or "workflow" in step:
142
+ return False
143
+ return True
144
+
145
+
146
+ def _every_step_was_reused(manifest_text):
147
+ """Whether this run generated nothing - every manifest entry a step-cache
148
+ hit republishing an earlier run's files."""
149
+ try:
150
+ manifest = json.loads(manifest_text or "[]")
151
+ except (TypeError, ValueError):
152
+ return False
153
+ if not isinstance(manifest, list) or not manifest:
154
+ return False
155
+ return all(
156
+ isinstance(entry, dict) and entry.get("reused") is True for entry in manifest
157
+ )
158
+
159
+
160
+ def _median(values):
161
+ ordered = sorted(values)
162
+ middle = len(ordered) // 2
163
+ if len(ordered) % 2:
164
+ return ordered[middle]
165
+ return (ordered[middle - 1] + ordered[middle]) / 2
166
+
167
+
168
+ def _minutes_block(prefix, durations):
169
+ """One side of the cold/warm split, or {} when it has no runs."""
170
+ if not durations:
171
+ return {}
172
+ return {
173
+ f"{prefix}_minutes": round(_median(durations) / 60, 2),
174
+ f"{prefix}_runs": len(durations),
175
+ f"{prefix}_range_minutes": [
176
+ round(min(durations) / 60, 2),
177
+ round(max(durations) / 60, 2),
178
+ ],
179
+ }
180
+
181
+
182
+ def observed_for(definition, rows, device=None, device_name=None, arguments=None):
183
+ """This server's history for one workflow, as the `observed` block, or
184
+ None when it has nothing comparable to report.
185
+
186
+ `rows` are that workflow's finished runs as `history_rows` yields them.
187
+ The device is the server's current one: the history holds no device
188
+ column, so a figure is about whatever accelerator this box has now - true
189
+ for every box that has not had its card swapped, and `runs`/`since` are
190
+ what let a reader notice if it has.
191
+
192
+ `arguments` are the run being asked about, and they choose the bucket:
193
+ the listing asks with none, so its figure is the one the *defaults* give
194
+ and stays comparable to a curated `cost`, while a plan asks with the
195
+ caller's own values and gets the figure for the run it is quoting, or
196
+ None when this box has never run that shape (#154).
197
+ """
198
+ wanted = _bucket_key(definition, arguments or {})
199
+ if wanted is None:
200
+ # No declared drivers and the caller overrode something: nothing here
201
+ # is comparable to the run being asked about
202
+ return None
203
+ task_only = _is_task_only(definition)
204
+ cold, warm, unclassified, earliest = [], [], 0, None
205
+ for row in rows:
206
+ if _every_step_was_reused(row["manifest"]):
207
+ continue
208
+ try:
209
+ row_arguments = json.loads(row["arguments"] or "{}")
210
+ except (TypeError, ValueError):
211
+ continue
212
+ if not isinstance(row_arguments, dict):
213
+ continue
214
+ if _bucket_key(definition, row_arguments) != wanted:
215
+ continue
216
+ duration = row["duration"]
217
+ if duration is None or duration <= 0:
218
+ continue
219
+ if task_only or row["had_load"]:
220
+ cold.append(duration)
221
+ elif row["events_at_cap"]:
222
+ unclassified += 1
223
+ else:
224
+ warm.append(duration)
225
+ finished = row["finished_at"]
226
+ if finished and (earliest is None or finished < earliest):
227
+ earliest = finished
228
+
229
+ runs = len(cold) + len(warm) + unclassified
230
+ if not runs:
231
+ return None
232
+ variables = definition.get("variables") or {}
233
+ drivers = declared_drivers(definition)
234
+ observed = {
235
+ "device": device,
236
+ "name": device_name,
237
+ "runs": runs,
238
+ "comparable": "drivers" if drivers else "default-arguments",
239
+ **_minutes_block("cold", cold),
240
+ **_minutes_block("warm", warm),
241
+ }
242
+ if drivers:
243
+ # A list driver is reported as the length it buckets on, so the
244
+ # figure says what it is a figure *for* without carrying a whole
245
+ # default shot list into the listing
246
+ # The *effective* driver values - the caller's where they supplied
247
+ # one, else the stored default - so the block says which run it is a
248
+ # figure for rather than always describing the defaults
249
+ effective = {
250
+ name: (arguments or {}).get(name, variables.get(name))
251
+ for name in sorted(drivers)
252
+ }
253
+ observed["drivers"] = {
254
+ name: len(value) if isinstance(value, list) else value
255
+ for name, value in effective.items()
256
+ }
257
+ if unclassified:
258
+ # Says the numbers do not add up, and why, rather than letting a
259
+ # reader assume a trimmed run was a warm one
260
+ observed["unclassified_runs"] = unclassified
261
+ if earliest:
262
+ observed["since"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(earliest))
263
+ return observed
264
+
265
+
266
+ class ObservedCosts:
267
+ """The `observed` block per workflow name, recomputed when the job table
268
+ has moved.
269
+
270
+ One query for the whole listing rather than one per workflow, cached
271
+ against the jobs table's own high-water mark - the same reason
272
+ `workflow_details` caches against a file's mtime, except that a job
273
+ landing changes every figure and no file changes at all.
274
+ """
275
+
276
+ def __init__(self, history):
277
+ self.history = history
278
+ self._mark = None
279
+ self._rows = {}
280
+ self._device = None
281
+
282
+ def device(self):
283
+ """The accelerator every figure here is about, as (type, card name).
284
+
285
+ Computed once: it cannot change inside a process, and the listing is
286
+ the agent's hot path - asking torch for the card's name on every call
287
+ would be a torch round trip per catalog read. The history holds no
288
+ device column, so this is the server's current accelerator rather
289
+ than the one each run used; a box whose card was swapped mid-history
290
+ is the one case that misreports, and `runs`/`since` are what let a
291
+ reader notice.
292
+ """
293
+ if self._device is None:
294
+ from .. import device_memory_stats, get_device, get_device_type
295
+
296
+ kind, card = None, None
297
+ try:
298
+ kind = get_device_type(get_device())
299
+ except Exception:
300
+ logger.debug("observed cost: could not read the device type")
301
+ try:
302
+ # The card's marketing name, which only CUDA reports. Its
303
+ # absence is not the device's absence, so the two are asked
304
+ # for separately - one try around both let an ImportError on
305
+ # the second answer `device: null` for a box plainly running
306
+ # on CUDA, which is the silent-null shape this whole field
307
+ # exists to replace
308
+ card = device_memory_stats().get("device_name")
309
+ except Exception:
310
+ logger.debug("observed cost: could not read the device name")
311
+ self._device = (kind, card)
312
+ return self._device
313
+
314
+ def refresh(self):
315
+ """Bring the cached rows up to the table's watermark, once.
316
+
317
+ Returns False when there is no history to read or the read failed -
318
+ the caller then has no rows and knows why. A listing calls this
319
+ once and then asks `rows_for(name, fresh=False)` per entry, so the
320
+ COUNT(*) under the history lock happens once per request rather
321
+ than once per workflow."""
322
+ if self.history is None:
323
+ return False
324
+ try:
325
+ mark = self.history.watermark()
326
+ except Exception:
327
+ logger.debug("observed cost: could not read the job watermark")
328
+ return False
329
+ if mark != self._mark:
330
+ try:
331
+ self._rows = self.history.finished_runs()
332
+ except Exception:
333
+ logger.debug("observed cost: could not read job history")
334
+ self._rows = {}
335
+ return False
336
+ self._mark = mark
337
+ return True
338
+
339
+ def rows_for(self, name, *, fresh=True, workspace=None):
340
+ """That workflow's comparable-candidate runs, refreshing the cache
341
+ when the table has moved (unless the caller already did).
342
+
343
+ `workspace` is the identity a workspace-writable workflow's own copy
344
+ is scoped to (#274): two workspaces that each save a workflow called
345
+ the same thing are different workflows, so passing a workspace name
346
+ answers only for that one's own history and no other. Omitting it -
347
+ what a shared, read-only catalog source (a template or example) is
348
+ asked with - keeps #154's behavior: every workspace's runs of that
349
+ catalog entry are one pool, unioned across whichever workspaces
350
+ happen to have run it.
351
+ """
352
+ if fresh and not self.refresh():
353
+ return []
354
+ if workspace is not None:
355
+ return self._rows.get((workspace, name), [])
356
+ rows = []
357
+ for (row_workspace, row_name), workspace_rows in self._rows.items():
358
+ if row_name == name:
359
+ rows.extend(workspace_rows)
360
+ return rows
361
+
362
+ def observed(self, name, definition, arguments=None, *, fresh=True, workspace=None):
363
+ rows = self.rows_for(name, fresh=fresh, workspace=workspace)
364
+ if not rows:
365
+ return None
366
+ device, device_name = self.device()
367
+ try:
368
+ return observed_for(
369
+ definition,
370
+ rows,
371
+ device=device,
372
+ device_name=device_name,
373
+ arguments=arguments,
374
+ )
375
+ except Exception:
376
+ # A figure is a nicety; a listing that 500s because one row had a
377
+ # shape nobody expected is not
378
+ logger.debug("observed cost: could not aggregate %s", name, exc_info=True)
379
+ return None
dw/server/sysinfo.py ADDED
@@ -0,0 +1,71 @@
1
+ """Runtime environment details - Python, torch, CUDA/driver and other
2
+ installed package versions - for diagnosing environment mismatches between
3
+ boxes (#222). Read-only, gathered fresh on every call rather than cached,
4
+ since an install can change underneath a long-running server (see
5
+ `update_diffusers`).
6
+ """
7
+
8
+ import platform
9
+ import subprocess
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ # Packages worth reporting beyond torch (which gets its own field): the
13
+ # ones most likely to cause a version-mismatch failure or be silently
14
+ # absent on a given box.
15
+ PACKAGES = (
16
+ "diffusers",
17
+ "transformers",
18
+ "accelerate",
19
+ "bitsandbytes",
20
+ "peft",
21
+ "safetensors",
22
+ "sentencepiece",
23
+ )
24
+
25
+
26
+ def _package_version(name):
27
+ try:
28
+ return version(name)
29
+ except PackageNotFoundError:
30
+ return None
31
+
32
+
33
+ def _nvidia_driver_version():
34
+ """The NVIDIA driver version, via nvidia-smi when it's on PATH. Distinct
35
+ from torch's own `cuda_version`, which is the CUDA toolkit torch was
36
+ built against, not the driver actually installed on this box."""
37
+ try:
38
+ result = subprocess.run(
39
+ ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=5,
43
+ )
44
+ except (OSError, subprocess.TimeoutExpired):
45
+ return None
46
+ if result.returncode != 0:
47
+ return None
48
+ lines = result.stdout.strip().splitlines()
49
+ return lines[0].strip() if lines else None
50
+
51
+
52
+ def runtime_info():
53
+ """Python, torch, CUDA/driver and other installed package versions -
54
+ the detail `device`/`version` alone doesn't answer, like "is
55
+ bitsandbytes even installed here" or "which CUDA build is torch"."""
56
+ try:
57
+ import torch
58
+
59
+ torch_version = torch.__version__
60
+ cuda_version = torch.version.cuda
61
+ except ImportError:
62
+ torch_version = None
63
+ cuda_version = None
64
+
65
+ return {
66
+ "python_version": platform.python_version(),
67
+ "torch_version": torch_version,
68
+ "cuda_version": cuda_version,
69
+ "driver_version": _nvidia_driver_version(),
70
+ "packages": {name: _package_version(name) for name in PACKAGES},
71
+ }