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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw/locations.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""The one policy for a location a workflow's arguments supply.
|
|
2
|
+
|
|
3
|
+
A workflow JSON is untrusted input under the default posture (see
|
|
4
|
+
docs/SECURITY.md's Trust model section). Its media arguments name *where* to
|
|
5
|
+
read from, and until this module existed each loader answered that question
|
|
6
|
+
for itself: `fetch_image` accepted any absolute path the JSON wrote,
|
|
7
|
+
`gather_images` handed its `glob` straight to the filesystem, and any
|
|
8
|
+
`http(s)` URL was fetched whatever host it named. That is arbitrary file read
|
|
9
|
+
and SSRF from a document the server treats as data (#114, #115, #116, #112).
|
|
10
|
+
|
|
11
|
+
Two rules, applied wherever a caller-supplied location is resolved:
|
|
12
|
+
|
|
13
|
+
- A path must land inside one of the roots this installation already works
|
|
14
|
+
in - the workflow's own directory, the asset libraries on the search path,
|
|
15
|
+
the output root. `..` never appears in a legitimate one and `validate_path`
|
|
16
|
+
already refuses it, so in practice this closes the *absolute* path that
|
|
17
|
+
pointed somewhere else entirely. The remedy is an `asset:` reference, which
|
|
18
|
+
is what the roots exist for.
|
|
19
|
+
- An `http(s)` URL must not name a host inside the deployment - loopback,
|
|
20
|
+
link-local (the cloud metadata address), a private range, or anything else
|
|
21
|
+
that is not globally routable (100.64.0.0/10, Tailscale's range). The check
|
|
22
|
+
runs on the resolved address, not on the literal string, so a hostname that
|
|
23
|
+
answers 127.0.0.1 is caught too, and `safe_get` runs it again on every
|
|
24
|
+
redirect before following it.
|
|
25
|
+
|
|
26
|
+
Both yield to `--trust-workflows`, exactly as the import and remote-code
|
|
27
|
+
gates do: an operator who has vouched for a workflow's source may point it at
|
|
28
|
+
a scratch directory or an internal endpoint. Neither yields to anything else -
|
|
29
|
+
there is no per-argument opt-out, because the argument is the untrusted part.
|
|
30
|
+
|
|
31
|
+
Enforcement is in two places on purpose. `location_errors` runs at validation
|
|
32
|
+
time, so `validate_workflow` refuses the workflow before a model load is
|
|
33
|
+
spent on it; the loaders call the same functions at run time, because a
|
|
34
|
+
location that arrives through a variable or a previous result was never in
|
|
35
|
+
the document to check.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import ipaddress
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import socket
|
|
42
|
+
from urllib.parse import urljoin, urlparse
|
|
43
|
+
|
|
44
|
+
from .security import (
|
|
45
|
+
InvalidInputError,
|
|
46
|
+
PathTraversalError,
|
|
47
|
+
validate_path,
|
|
48
|
+
validate_url,
|
|
49
|
+
workflows_are_trusted,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger("dw")
|
|
53
|
+
|
|
54
|
+
# Media argument names follow the same conventions realize_args dispatches on
|
|
55
|
+
# (dw/arguments.py): a key named like its media, or an explicit
|
|
56
|
+
# {"media_type", "location"} reference, or an object's "from_file"
|
|
57
|
+
MEDIA_KEY_SUFFIXES = ("_image", "_video", "_audio")
|
|
58
|
+
MEDIA_KEY_NAMES = ("image", "video", "audio", "location", "from_file")
|
|
59
|
+
|
|
60
|
+
# The tasks whose arguments name a filesystem pattern rather than one file
|
|
61
|
+
GLOB_ARGUMENT = "glob"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_http_url(value):
|
|
65
|
+
"""Whether a value is a string the loaders would fetch over HTTP."""
|
|
66
|
+
return isinstance(value, str) and value.startswith(("http://", "https://"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def media_roots(base_dir=None):
|
|
70
|
+
"""Every directory a workflow's own locations may point inside, resolved.
|
|
71
|
+
|
|
72
|
+
The workflow's directory, each asset library on the search path, and the
|
|
73
|
+
output root - the three places this installation keeps the media a
|
|
74
|
+
workflow works with. A root that cannot be resolved (no workspace, no
|
|
75
|
+
active run) is dropped rather than failing the check open.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
base_dir: The workflow file's directory, when one anchors the search
|
|
79
|
+
"""
|
|
80
|
+
candidates = []
|
|
81
|
+
if base_dir:
|
|
82
|
+
candidates.append(base_dir)
|
|
83
|
+
|
|
84
|
+
from .assets import asset_search_path
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
candidates.extend(asset_search_path(base_dir=base_dir))
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.debug("Could not resolve the asset search path", exc_info=True)
|
|
90
|
+
|
|
91
|
+
from .runs import output_root
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
candidates.append(output_root())
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.debug("Could not resolve the output root", exc_info=True)
|
|
97
|
+
|
|
98
|
+
roots = []
|
|
99
|
+
for candidate in candidates:
|
|
100
|
+
if not candidate:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
resolved = os.path.normpath(
|
|
104
|
+
os.path.realpath(os.path.abspath(os.path.expanduser(str(candidate))))
|
|
105
|
+
)
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
continue
|
|
108
|
+
if resolved not in roots:
|
|
109
|
+
roots.append(resolved)
|
|
110
|
+
return roots
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _within(path, root):
|
|
114
|
+
return path == root or path.startswith(root + os.sep)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_media_path(
|
|
118
|
+
location, base_dir=None, what="a media argument", require_exists=True
|
|
119
|
+
):
|
|
120
|
+
"""The validated absolute path a media location names, confined.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
location: The path the workflow supplied, relative or absolute
|
|
124
|
+
base_dir: Directory a relative path is resolved against - the
|
|
125
|
+
workflow file's directory
|
|
126
|
+
what: Short phrase naming the argument, for the error message
|
|
127
|
+
require_exists: Whether a contained path that does not exist is an
|
|
128
|
+
error. False for the validation-time pass, which is about policy
|
|
129
|
+
rather than about what happens to be on disk right now
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The absolute, resolved, contained path
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
PathTraversalError: If the path resolves outside every root
|
|
136
|
+
InvalidInputError, PathTraversalError: Whatever validate_path raises
|
|
137
|
+
"""
|
|
138
|
+
# base_dir is the first root, so a relative path keeps resolving against
|
|
139
|
+
# the workflow file exactly as it did before this check existed.
|
|
140
|
+
# allow_create here, with the existence check moved below the containment
|
|
141
|
+
# one: refusing an out-of-root path only once it turned out to exist made
|
|
142
|
+
# the refusal itself a file-existence oracle for the whole filesystem
|
|
143
|
+
# (#114)
|
|
144
|
+
resolved = validate_path(
|
|
145
|
+
location if os.path.isabs(str(location)) else _joined(location, base_dir),
|
|
146
|
+
allow_create=True,
|
|
147
|
+
)
|
|
148
|
+
if not workflows_are_trusted():
|
|
149
|
+
roots = media_roots(base_dir)
|
|
150
|
+
if not any(_within(resolved, root) for root in roots):
|
|
151
|
+
raise PathTraversalError(
|
|
152
|
+
f"Refusing to read {what} at '{location}': it resolves "
|
|
153
|
+
f"outside every directory this workflow may read "
|
|
154
|
+
f"({', '.join(roots) or 'none configured'}). This includes "
|
|
155
|
+
f"another workspace's own directories - each workspace is "
|
|
156
|
+
f"isolated by design, not just a generic path-traversal "
|
|
157
|
+
f"refusal, so a bare path into one is refused the same way "
|
|
158
|
+
f"a path outside the installation entirely would be. Put "
|
|
159
|
+
f"the file in the asset library and name it with an "
|
|
160
|
+
f"'asset:' reference, use keep_output(shared=True) to copy "
|
|
161
|
+
f"a generated file into the library every workspace shares "
|
|
162
|
+
f"if it needs to cross that boundary on purpose, or pass "
|
|
163
|
+
f"--trust-workflows if you trust this workflow's source."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if require_exists and not os.path.exists(resolved):
|
|
167
|
+
raise InvalidInputError(f"Path does not exist: {resolved}")
|
|
168
|
+
return resolved
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _joined(location, base_dir):
|
|
172
|
+
return os.path.join(base_dir, str(location)) if base_dir else str(location)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_media_glob(pattern, base_dir=None, what="a glob argument"):
|
|
176
|
+
"""The validated glob pattern, confined to one of the media roots.
|
|
177
|
+
|
|
178
|
+
A glob is a location with wildcards in it, and it is checked the same
|
|
179
|
+
way - but on the pattern's fixed leading directory, since the pattern
|
|
180
|
+
itself does not exist as a path. Each *match* is checked individually
|
|
181
|
+
too by the loader that opens it, which is what catches a wildcard
|
|
182
|
+
escaping through a symlink.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The pattern, absolute, for glob to expand
|
|
186
|
+
"""
|
|
187
|
+
absolute = pattern if os.path.isabs(str(pattern)) else _joined(pattern, base_dir)
|
|
188
|
+
if workflows_are_trusted():
|
|
189
|
+
return absolute
|
|
190
|
+
|
|
191
|
+
# The part of the pattern before the first wildcard: the directory the
|
|
192
|
+
# expansion starts from, which is the thing containment is about
|
|
193
|
+
fixed = str(absolute)
|
|
194
|
+
for wildcard in ("*", "?", "["):
|
|
195
|
+
cut = fixed.find(wildcard)
|
|
196
|
+
if cut >= 0:
|
|
197
|
+
fixed = fixed[:cut]
|
|
198
|
+
fixed = os.path.dirname(fixed) if not fixed.endswith(os.sep) else fixed
|
|
199
|
+
if ".." in fixed.replace("\\", "/").split("/"):
|
|
200
|
+
raise PathTraversalError(
|
|
201
|
+
f"Refusing {what} '{pattern}': it contains a '..' path segment."
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
resolved = os.path.normpath(os.path.realpath(os.path.abspath(fixed or ".")))
|
|
205
|
+
except (OSError, ValueError) as e:
|
|
206
|
+
raise InvalidInputError(f"Invalid glob pattern {pattern!r}: {e}")
|
|
207
|
+
|
|
208
|
+
roots = media_roots(base_dir)
|
|
209
|
+
if not any(_within(resolved, root) for root in roots):
|
|
210
|
+
raise PathTraversalError(
|
|
211
|
+
f"Refusing {what} '{pattern}': it expands under {resolved}, "
|
|
212
|
+
f"outside every directory this workflow may read "
|
|
213
|
+
f"({', '.join(roots) or 'none configured'}). Glob inside the "
|
|
214
|
+
f"asset library, or pass --trust-workflows if you trust this "
|
|
215
|
+
f"workflow's source."
|
|
216
|
+
)
|
|
217
|
+
return absolute
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def contained_matches(paths, base_dir=None, what="a glob argument"):
|
|
221
|
+
"""The matches of an allowed glob that are themselves inside a root.
|
|
222
|
+
|
|
223
|
+
A pattern can be contained and still match outside its own tree through
|
|
224
|
+
a symlink, so every match is re-checked on its real path. A match that
|
|
225
|
+
escapes is dropped with a warning rather than failing the run: the
|
|
226
|
+
pattern was legitimate, one entry under it was not.
|
|
227
|
+
"""
|
|
228
|
+
if workflows_are_trusted():
|
|
229
|
+
return list(paths)
|
|
230
|
+
|
|
231
|
+
roots = media_roots(base_dir)
|
|
232
|
+
kept = []
|
|
233
|
+
for path in paths:
|
|
234
|
+
try:
|
|
235
|
+
resolved = os.path.normpath(os.path.realpath(os.path.abspath(path)))
|
|
236
|
+
except (OSError, ValueError):
|
|
237
|
+
continue
|
|
238
|
+
if any(_within(resolved, root) for root in roots):
|
|
239
|
+
kept.append(path)
|
|
240
|
+
else:
|
|
241
|
+
logger.warning(
|
|
242
|
+
f"Skipping {what} match '{path}': it resolves to {resolved}, "
|
|
243
|
+
f"outside every directory this workflow may read"
|
|
244
|
+
)
|
|
245
|
+
return kept
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Hosts a workflow may not send the server to: its own loopback, the
|
|
249
|
+
# link-local range cloud metadata services answer on, and the private ranges
|
|
250
|
+
# that make up whatever network the box sits in. This is the SSRF boundary -
|
|
251
|
+
# an internal address is exactly the thing a caller cannot otherwise reach,
|
|
252
|
+
# which is why naming one is the attack rather than a mistake. `is_global`
|
|
253
|
+
# closes what the named ranges leave open: 100.64.0.0/10 is neither private
|
|
254
|
+
# nor reserved to `ipaddress`, and it is both Tailscale's tailnet and the
|
|
255
|
+
# range Alibaba's metadata service answers on (#407)
|
|
256
|
+
def _is_internal(address):
|
|
257
|
+
return (
|
|
258
|
+
not address.is_global
|
|
259
|
+
or address.is_loopback
|
|
260
|
+
or address.is_link_local
|
|
261
|
+
or address.is_private
|
|
262
|
+
or address.is_reserved
|
|
263
|
+
or address.is_multicast
|
|
264
|
+
or address.is_unspecified
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolved_addresses(host):
|
|
269
|
+
"""Every IP a hostname answers on, as ip_address objects.
|
|
270
|
+
|
|
271
|
+
A literal is returned as itself without a lookup. A name that does not
|
|
272
|
+
resolve yields nothing - the fetch will fail on its own, and refusing it
|
|
273
|
+
here would turn a typo into a security error.
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
return [ipaddress.ip_address(host)]
|
|
277
|
+
except ValueError:
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
infos = socket.getaddrinfo(host, None)
|
|
281
|
+
except (socket.gaierror, UnicodeError, OSError):
|
|
282
|
+
logger.debug(f"Could not resolve {host!r} for the host policy")
|
|
283
|
+
return []
|
|
284
|
+
addresses = []
|
|
285
|
+
for info in infos:
|
|
286
|
+
try:
|
|
287
|
+
addresses.append(ipaddress.ip_address(info[4][0]))
|
|
288
|
+
except ValueError:
|
|
289
|
+
continue
|
|
290
|
+
return addresses
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def validate_media_url(url, what="a media argument"):
|
|
294
|
+
"""The validated URL, refused if it names a host inside the deployment.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
url: The http(s) URL the workflow supplied
|
|
298
|
+
what: Short phrase naming the argument, for the error message
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
The URL, unchanged
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
InvalidInputError: If the scheme is not http(s), or the host is
|
|
305
|
+
internal to the deployment
|
|
306
|
+
"""
|
|
307
|
+
validated = validate_url(url)
|
|
308
|
+
if workflows_are_trusted():
|
|
309
|
+
return validated
|
|
310
|
+
|
|
311
|
+
host = (urlparse(validated).hostname or "").strip("[]")
|
|
312
|
+
internal = [
|
|
313
|
+
address for address in _resolved_addresses(host) if _is_internal(address)
|
|
314
|
+
]
|
|
315
|
+
if internal:
|
|
316
|
+
raise InvalidInputError(
|
|
317
|
+
f"Refusing to fetch {what} from '{url}': {host} resolves to "
|
|
318
|
+
f"{internal[0]}, an address inside this deployment (loopback, "
|
|
319
|
+
f"link-local, private or otherwise not global). A workflow may not use the server to "
|
|
320
|
+
f"reach its own network. Pass --trust-workflows if you trust "
|
|
321
|
+
f"this workflow's source."
|
|
322
|
+
)
|
|
323
|
+
return validated
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# How many redirects a media fetch follows before giving up. requests' own
|
|
327
|
+
# default is 30; a CDN needs one or two
|
|
328
|
+
MAX_MEDIA_REDIRECTS = 5
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def safe_get(url, what="a media argument", timeout=60):
|
|
332
|
+
"""GET a workflow-supplied media URL, re-checking every redirect.
|
|
333
|
+
|
|
334
|
+
`validate_media_url` checks the URL the document wrote, but a fetch that
|
|
335
|
+
follows redirects on its own goes wherever the first host tells it to -
|
|
336
|
+
a public URL answering 302 to 169.254.169.254 or to the server's own
|
|
337
|
+
loopback was fetched unchecked (#407). Redirects are followed here, one
|
|
338
|
+
hop at a time, and each `Location` passes the same host policy before it
|
|
339
|
+
is dialed.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
url: The http(s) URL the workflow supplied
|
|
343
|
+
what: Short phrase naming the argument, for the error message
|
|
344
|
+
timeout: Seconds per request
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
The final requests.Response, its status already checked
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
InvalidInputError: If the URL or any redirect target is refused, or
|
|
351
|
+
the redirects run past MAX_MEDIA_REDIRECTS
|
|
352
|
+
requests.HTTPError: If the final answer is an error status
|
|
353
|
+
"""
|
|
354
|
+
import requests
|
|
355
|
+
|
|
356
|
+
current = validate_media_url(url, what)
|
|
357
|
+
for _ in range(MAX_MEDIA_REDIRECTS + 1):
|
|
358
|
+
response = requests.get(current, timeout=timeout, allow_redirects=False)
|
|
359
|
+
if not response.is_redirect:
|
|
360
|
+
response.raise_for_status()
|
|
361
|
+
return response
|
|
362
|
+
target = urljoin(current, response.headers["Location"])
|
|
363
|
+
response.close()
|
|
364
|
+
logger.debug(f"{current} redirects to {target}")
|
|
365
|
+
current = validate_media_url(target, f"{what} (redirected from '{url}')")
|
|
366
|
+
raise InvalidInputError(
|
|
367
|
+
f"Refusing to fetch {what} from '{url}': it redirects more than "
|
|
368
|
+
f"{MAX_MEDIA_REDIRECTS} times"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# Hosts this machine's HuggingFace token belongs to. The token is the
|
|
373
|
+
# credential the box holds for the Hub; a workflow chooses
|
|
374
|
+
# `remote_text_encoder.url`, so attaching the token to whatever it named
|
|
375
|
+
# would let an untrusted document exfiltrate it with one POST (#112). An
|
|
376
|
+
# endpoint outside these is still reachable - it just does not get the
|
|
377
|
+
# credential, and an endpoint that needs one is by definition a HuggingFace
|
|
378
|
+
# endpoint
|
|
379
|
+
HF_TOKEN_HOST_SUFFIXES = (
|
|
380
|
+
"huggingface.co",
|
|
381
|
+
"huggingface.cloud",
|
|
382
|
+
"hf.space",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def token_host_allowed(host):
|
|
387
|
+
"""Whether the HuggingFace token may be attached to a request to `host`."""
|
|
388
|
+
host = (host or "").lower()
|
|
389
|
+
return any(
|
|
390
|
+
host == suffix or host.endswith("." + suffix)
|
|
391
|
+
for suffix in HF_TOKEN_HOST_SUFFIXES
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def validate_remote_encoder_url(url):
|
|
396
|
+
"""The validated remote text-encoder URL, or a refusal saying why.
|
|
397
|
+
|
|
398
|
+
https only, and no address inside this deployment: the workflow file is
|
|
399
|
+
untrusted input, and this field sends a request - with a credential - to
|
|
400
|
+
an address it chooses. `--trust-workflows` lifts both, for an operator
|
|
401
|
+
running their own endpoint on the box or over plain http on a LAN.
|
|
402
|
+
|
|
403
|
+
Raises:
|
|
404
|
+
InvalidInputError: On a non-https scheme or an internal host
|
|
405
|
+
"""
|
|
406
|
+
if not workflows_are_trusted():
|
|
407
|
+
scheme = urlparse(str(url)).scheme
|
|
408
|
+
if scheme != "https":
|
|
409
|
+
raise InvalidInputError(
|
|
410
|
+
f"Refusing the remote text encoder at '{url}': its scheme is "
|
|
411
|
+
f"'{scheme or 'none'}', and an untrusted workflow may only "
|
|
412
|
+
f"reach an https endpoint - the request carries this "
|
|
413
|
+
f"machine's HuggingFace token. Pass --trust-workflows if you "
|
|
414
|
+
f"trust this workflow's source."
|
|
415
|
+
)
|
|
416
|
+
return validate_media_url(url, "the remote text encoder url")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def validate_model_name(name, base_dir=None):
|
|
420
|
+
"""A model identifier: a Hub repo id, or a path inside the media roots.
|
|
421
|
+
|
|
422
|
+
`download_model` has always refused a path-shaped `repo_id`; the same
|
|
423
|
+
name reached `from_pretrained_arguments.model_name` unchecked, so a
|
|
424
|
+
workflow could name an absolute path and let diffusers decide (#117).
|
|
425
|
+
A local model directory stays supported - inside a root, like any other
|
|
426
|
+
location.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
PathTraversalError, InvalidInputError: If it is neither
|
|
430
|
+
"""
|
|
431
|
+
from huggingface_hub.utils import HFValidationError, validate_repo_id
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
validate_repo_id(str(name))
|
|
435
|
+
return str(name)
|
|
436
|
+
except HFValidationError as e:
|
|
437
|
+
# A URL is neither a repo id nor a path, but joined onto the workflow
|
|
438
|
+
# directory it resolves inside a root and so passed the containment
|
|
439
|
+
# check below - the one shape of the four `download_model` refuses
|
|
440
|
+
# that got through here (#117). `from_pretrained` would refuse it
|
|
441
|
+
# downstream; the point of this check is not to rely on that
|
|
442
|
+
if "://" in str(name):
|
|
443
|
+
raise InvalidInputError(
|
|
444
|
+
f"Refusing a model_name of '{name}': it is a URL, not a Hub "
|
|
445
|
+
f"repo id or a local model directory ({e})."
|
|
446
|
+
)
|
|
447
|
+
return validate_media_path(
|
|
448
|
+
str(name), base_dir, "a model_name", require_exists=False
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------- validation
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _is_media_key(key):
|
|
456
|
+
return isinstance(key, str) and (
|
|
457
|
+
key in MEDIA_KEY_NAMES or key.endswith(MEDIA_KEY_SUFFIXES)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _deferred(value):
|
|
462
|
+
"""Whether a location is resolved later rather than being one now."""
|
|
463
|
+
return value.startswith(
|
|
464
|
+
(
|
|
465
|
+
"variable:",
|
|
466
|
+
"previous_result:",
|
|
467
|
+
"item:",
|
|
468
|
+
"gather:",
|
|
469
|
+
"asset:",
|
|
470
|
+
"output:",
|
|
471
|
+
"prompt:",
|
|
472
|
+
"constant:",
|
|
473
|
+
"builtin:",
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _check(value, base_dir, what):
|
|
479
|
+
"""The policy message for one literal location, or None if it is fine."""
|
|
480
|
+
if not isinstance(value, str) or not value or _deferred(value):
|
|
481
|
+
return None
|
|
482
|
+
try:
|
|
483
|
+
if is_http_url(value):
|
|
484
|
+
validate_media_url(value, what)
|
|
485
|
+
elif os.path.isabs(value):
|
|
486
|
+
validate_media_path(value, base_dir, what, require_exists=False)
|
|
487
|
+
elif ".." in value.replace("\\", "/").split("/"):
|
|
488
|
+
# A relative path is under base_dir by construction unless it
|
|
489
|
+
# climbs out, and validate_path refuses '..' - but only when the
|
|
490
|
+
# loader reaches it, which is a queued job and three seconds in
|
|
491
|
+
# rather than a validation answer. Checked on the segments here
|
|
492
|
+
# the way validate_media_glob checks a pattern's, so the two
|
|
493
|
+
# spellings of "read outside the roots" are refused at the same
|
|
494
|
+
# moment (#124)
|
|
495
|
+
raise PathTraversalError(
|
|
496
|
+
f"Refusing to read {what} at '{value}': it contains a '..' "
|
|
497
|
+
f"path segment, so it does not resolve inside any directory "
|
|
498
|
+
f"this workflow may read. Put the file in the asset library "
|
|
499
|
+
f"and name it with an 'asset:' reference."
|
|
500
|
+
)
|
|
501
|
+
except (PathTraversalError, InvalidInputError) as e:
|
|
502
|
+
return str(e)
|
|
503
|
+
except Exception:
|
|
504
|
+
# A path that simply does not exist is not this check's business -
|
|
505
|
+
# the loader will say so, with the run's own error
|
|
506
|
+
logger.debug(f"Location check skipped for {value!r}", exc_info=True)
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def location_errors(definition, source_indices=None, base_dir=None):
|
|
511
|
+
"""Every media location in the definition that policy refuses.
|
|
512
|
+
|
|
513
|
+
Reported as [{path, message}] like the other validation passes, so a
|
|
514
|
+
caller learns before `run_workflow` that the workflow will not be
|
|
515
|
+
allowed to read what it names - rather than after a pipeline load.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
definition: The expanded, substituted workflow definition
|
|
519
|
+
source_indices: Step index -> index in the file the author wrote
|
|
520
|
+
base_dir: The workflow file's directory
|
|
521
|
+
"""
|
|
522
|
+
errors = []
|
|
523
|
+
steps = definition.get("steps") or []
|
|
524
|
+
for index, step in enumerate(steps):
|
|
525
|
+
if not isinstance(step, dict):
|
|
526
|
+
continue
|
|
527
|
+
source = (
|
|
528
|
+
source_indices[index]
|
|
529
|
+
if source_indices and index < len(source_indices)
|
|
530
|
+
else index
|
|
531
|
+
)
|
|
532
|
+
_walk(step, f"steps[{source}]", base_dir, errors)
|
|
533
|
+
return errors
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _walk(node, path, base_dir, errors):
|
|
537
|
+
if isinstance(node, dict):
|
|
538
|
+
for key, value in node.items():
|
|
539
|
+
here = f"{path}.{key}"
|
|
540
|
+
if key == "model_name" and isinstance(value, str):
|
|
541
|
+
message = _model_name_message(value, base_dir)
|
|
542
|
+
if message:
|
|
543
|
+
errors.append({"path": here, "message": message})
|
|
544
|
+
continue
|
|
545
|
+
if key == "remote_text_encoder" and isinstance(value, dict):
|
|
546
|
+
url = value.get("url")
|
|
547
|
+
if isinstance(url, str) and not _deferred(url):
|
|
548
|
+
message = _refusal(validate_remote_encoder_url, url)
|
|
549
|
+
if message:
|
|
550
|
+
errors.append({"path": f"{here}.url", "message": message})
|
|
551
|
+
continue
|
|
552
|
+
if key == GLOB_ARGUMENT and isinstance(value, str):
|
|
553
|
+
message = _glob_message(value, base_dir)
|
|
554
|
+
if message:
|
|
555
|
+
errors.append({"path": here, "message": message})
|
|
556
|
+
continue
|
|
557
|
+
if _is_media_key(key):
|
|
558
|
+
for sub_path, item in _each(value, here):
|
|
559
|
+
message = _check(item, base_dir, f"'{key}'")
|
|
560
|
+
if message:
|
|
561
|
+
errors.append({"path": sub_path, "message": message})
|
|
562
|
+
if key == "urls" and isinstance(value, list):
|
|
563
|
+
for sub_path, item in _each(value, here):
|
|
564
|
+
message = _check(item, base_dir, f"'{key}'")
|
|
565
|
+
if message:
|
|
566
|
+
errors.append({"path": sub_path, "message": message})
|
|
567
|
+
_walk(value, here, base_dir, errors)
|
|
568
|
+
elif isinstance(node, list):
|
|
569
|
+
for index, item in enumerate(node):
|
|
570
|
+
_walk(item, f"{path}[{index}]", base_dir, errors)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _each(value, path):
|
|
574
|
+
if isinstance(value, list):
|
|
575
|
+
return [(f"{path}[{i}]", item) for i, item in enumerate(value)]
|
|
576
|
+
return [(path, value)]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _glob_message(pattern, base_dir):
|
|
580
|
+
if _deferred(pattern):
|
|
581
|
+
return None
|
|
582
|
+
return _refusal(validate_media_glob, pattern, base_dir)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _model_name_message(name, base_dir):
|
|
586
|
+
if _deferred(name):
|
|
587
|
+
return None
|
|
588
|
+
return _refusal(validate_model_name, name, base_dir)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _refusal(check, *args):
|
|
592
|
+
"""The message `check` refused its argument with, or None if it allowed it."""
|
|
593
|
+
try:
|
|
594
|
+
check(*args)
|
|
595
|
+
except (PathTraversalError, InvalidInputError) as e:
|
|
596
|
+
return str(e)
|
|
597
|
+
except Exception:
|
|
598
|
+
logger.debug(f"Location check skipped for {args[0]!r}", exc_info=True)
|
|
599
|
+
return None
|
dw/log_setup.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from concurrent_log_handler import ConcurrentRotatingFileHandler
|
|
3
|
+
|
|
4
|
+
LOG_LEVELS = {
|
|
5
|
+
"CRITICAL": logging.CRITICAL,
|
|
6
|
+
"ERROR": logging.ERROR,
|
|
7
|
+
"WARNING": logging.WARNING,
|
|
8
|
+
"INFO": logging.INFO,
|
|
9
|
+
"DEBUG": logging.DEBUG,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_FORMATTER = logging.Formatter(
|
|
13
|
+
fmt="%(asctime)s [%(levelname)s] (%(name)s) %(message)s",
|
|
14
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setup_logging(log_path, log_level="INFO", log_to_console=False):
|
|
19
|
+
"""Configure the 'dw' logger. Safe to call more than once - existing
|
|
20
|
+
handlers are replaced, not stacked, so a reconfiguring caller (the REPL
|
|
21
|
+
worker between runs) does not multiply every line."""
|
|
22
|
+
logger = logging.getLogger("dw")
|
|
23
|
+
logger.setLevel(LOG_LEVELS.get(log_level, logging.INFO))
|
|
24
|
+
|
|
25
|
+
for handler in list(logger.handlers):
|
|
26
|
+
logger.removeHandler(handler)
|
|
27
|
+
handler.close()
|
|
28
|
+
|
|
29
|
+
file_handler = ConcurrentRotatingFileHandler(
|
|
30
|
+
log_path, "a", maxBytes=50 * 1024 * 1024, backupCount=7
|
|
31
|
+
)
|
|
32
|
+
file_handler.setFormatter(_FORMATTER)
|
|
33
|
+
logger.addHandler(file_handler)
|
|
34
|
+
|
|
35
|
+
if log_to_console:
|
|
36
|
+
console_handler = logging.StreamHandler()
|
|
37
|
+
console_handler.setFormatter(_FORMATTER)
|
|
38
|
+
logger.addHandler(console_handler)
|
|
39
|
+
|
|
40
|
+
return logger
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_log_level(log_level):
|
|
44
|
+
"""Change the 'dw' logger's level without touching its handlers."""
|
|
45
|
+
logging.getLogger("dw").setLevel(LOG_LEVELS.get(log_level, logging.INFO))
|