docs: upstream auto-disable for CausalLM docstring patch

This commit is contained in:
ussoewwin
2026-06-23 12:18:25 +09:00
parent b2131af111
commit cc8bb7a4b6
2 changed files with 50 additions and 389 deletions
@@ -148,359 +148,32 @@ Unchanged for this fix: `patches/nunchaku_patch.py` (separate v2.4.6 `apply_rota
---
## 4. Full added / modified source code
## 4. Source files (canonical)
### 4.1 `patches/transformers_qwen_vl_docstring_patch.py` (complete)
Full implementation lives in the repository (not duplicated here, to avoid stale copies).
```python
"""
Runtime patch for transformers Qwen VL CausalLM ModelOutput @auto_docstring errors.
| File | Role |
|------|------|
| `patches/transformers_qwen_vl_docstring_patch.py` | Upstream probes; optional wrap of `get_args_doc_from_source` |
| `prestartup_script.py` | Loads docstring patch via `importlib` **before** rotary compat |
At import time, @auto_docstring on Qwen3VLCausalLMOutputWithPast and
Qwen2_5_VLCausalLMOutputWithPast validates __init__ parameters against
documentation built from ModelOutputArgs. That template omits loss/logits
even though subclasses inherit those fields from CausalLMOutputWithPast.
Public API:
Fix: wrap get_args_doc_from_source so ModelOutputArgs merges loss/logits
from CausalLMOutputWithPast.__doc__ (parse_docstring max_indent_level=4).
| Function | Returns |
|----------|---------|
| `apply_transformers_causal_lm_docstring_patch()` | `True` if wrapper active; `False` if skipped (upstream fixed or late import) |
| `is_patch_applied()` | Whether this process installed the wrapper |
| `is_patch_wrapped()` | Whether `get_args_doc_from_source` carries the patch tag |
Self-disables when upstream ModelOutputArgs already documents loss+logits,
when env TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH disables the patch, or when
a subprocess import probe shows no [ERROR] loss/logits without the patch.
`apply_transformers_causal_lm_docstring_patch()` decision order:
No site-packages edits. Idempotent via _qwen_lora_loader_causal_lm_docstring_patch.
"""
1. Already applied → return `True`
2. Qwen VL modeling already in `sys.modules` → warn, return `False` (restart ComfyUI)
3. `ModelOutputArgs` already documents `loss` and `logits` → skip (upstream fixed)
4. Subprocess import probe prints `CLEAN` → skip (no docstring errors without patch)
5. Else install wrapper around `get_args_doc_from_source`
from __future__ import annotations
import importlib
import io
import os
import subprocess
import sys
from contextlib import redirect_stdout
from typing import Any, Callable
_PATCH_TAG = "_qwen_lora_loader_causal_lm_docstring_patch"
_ENV_DISABLE = "TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH"
_DISABLE_VALUES = frozenset({"0", "false", "off", "no", "disable", "disabled"})
_QWEN3_MODULE = "transformers.models.qwen3_vl.modeling_qwen3_vl"
_QWEN25_MODULE = "transformers.models.qwen2_5_vl.modeling_qwen2_5_vl"
_QWEN3_CLASS = "Qwen3VLCausalLMOutputWithPast"
_QWEN25_CLASS = "Qwen2_5_VLCausalLMOutputWithPast"
_ERROR_MARKERS = (
"[ERROR] `loss`",
"[ERROR] `logits`",
)
def _env_disabled() -> bool:
return os.environ.get(_ENV_DISABLE, "").strip().lower() in _DISABLE_VALUES
def _get_auto_docstring_module():
return importlib.import_module("transformers.utils.auto_docstring")
def _get_model_output_args_class(auto_mod):
return getattr(auto_mod, "ModelOutputArgs", None)
def _get_causal_lm_output_with_past(auto_mod):
try:
from transformers.modeling_outputs import CausalLMOutputWithPast
return CausalLMOutputWithPast
except ImportError:
return None
def _parse_docstring(auto_mod, docstring: str, max_indent_level: int = 4) -> dict:
parse_fn = getattr(auto_mod, "parse_docstring", None)
if parse_fn is None:
return {}
result = parse_fn(docstring, max_indent_level=max_indent_level)
if isinstance(result, tuple):
params = result[0]
return params if isinstance(params, dict) else {}
return result if isinstance(result, dict) else {}
def _extract_loss_logits_from_causal_lm_doc(auto_mod) -> dict[str, Any]:
causal_cls = _get_causal_lm_output_with_past(auto_mod)
if causal_cls is None or not getattr(causal_cls, "__doc__", None):
return {}
params = _parse_docstring(auto_mod, causal_cls.__doc__, max_indent_level=4)
out: dict[str, Any] = {}
for key in ("loss", "logits"):
if key in params:
out[key] = params[key]
return out
def _model_output_args_has_loss_logits(auto_mod) -> bool:
moa = _get_model_output_args_class(auto_mod)
if moa is None:
return False
loss = getattr(moa, "loss", None)
logits = getattr(moa, "logits", None)
if not isinstance(loss, dict) or not isinstance(logits, dict):
return False
return bool(loss.get("description")) and bool(logits.get("description"))
def _is_model_output_args(obj: Any, auto_mod) -> bool:
moa = _get_model_output_args_class(auto_mod)
return moa is not None and obj is moa
def _make_wrapped_get_args(
original: Callable[..., dict],
auto_mod,
supplemental: dict[str, Any],
) -> Callable[..., dict]:
def wrapped(args_classes: object | list[object]) -> dict:
result = dict(original(args_classes))
if _is_model_output_args(args_classes, auto_mod):
for key, value in supplemental.items():
if key not in result:
result[key] = value
elif isinstance(args_classes, (list, tuple)):
for cls in args_classes:
if _is_model_output_args(cls, auto_mod):
for key, value in supplemental.items():
if key not in result:
result[key] = value
break
return result
setattr(wrapped, _PATCH_TAG, True)
wrapped._qwen_lora_loader_original = original # type: ignore[attr-defined]
return wrapped
def _qwen_modules_already_imported() -> bool:
return _QWEN3_MODULE in sys.modules or _QWEN25_MODULE in sys.modules
def _count_error_markers(text: str) -> int:
return sum(text.count(m) for m in _ERROR_MARKERS)
def _subprocess_import_probe() -> tuple[bool, str]:
"""
Import Qwen VL output classes in a child process without our patch.
Returns (needs_patch, diagnostic_message).
"""
code = f"""
import io, sys
from contextlib import redirect_stdout
buf = io.StringIO()
try:
with redirect_stdout(buf):
import transformers.models.qwen3_vl.modeling_qwen3_vl as m3
import transformers.models.qwen2_5_vl.modeling_qwen2_5_vl as m25
_ = m3.{_QWEN3_CLASS}
_ = m25.{_QWEN25_CLASS}
except Exception as e:
print("PROBE_EXCEPTION:", e, file=buf)
out = buf.getvalue()
markers = sum(out.count(x) for x in {list(_ERROR_MARKERS)!r})
print("PROBE_MARKERS", markers)
print("PROBE_LEN", len(out))
"""
try:
proc = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=120,
env={k: v for k, v in os.environ.items() if k != _ENV_DISABLE},
)
except (subprocess.TimeoutExpired, OSError) as exc:
return True, f"subprocess probe failed ({exc}); applying patch conservatively"
combined = (proc.stdout or "") + (proc.stderr or "")
markers = 0
for line in combined.splitlines():
if line.startswith("PROBE_MARKERS"):
try:
markers = int(line.split()[-1])
except ValueError:
pass
if proc.returncode != 0 and markers == 0:
return True, f"subprocess probe exit {proc.returncode}; applying patch conservatively"
if markers > 0:
return True, f"subprocess probe saw {markers} loss/logits error marker(s)"
return False, "subprocess probe: no loss/logits error markers without patch"
def apply_transformers_qwen_vl_causal_lm_docstring_patch() -> bool:
"""
Install get_args_doc_from_source wrapper if needed.
Returns True if patch is active (installed or already present).
"""
if _env_disabled():
print(
f"[INFO] ComfyUI-QwenImageLoraLoader: {_ENV_DISABLE} set; "
"skipping CausalLM ModelOutput docstring patch"
)
return False
auto_mod = _get_auto_docstring_module()
get_args = getattr(auto_mod, "get_args_doc_from_source", None)
if get_args is None:
print(
"[WARNING] ComfyUI-QwenImageLoraLoader: "
"transformers.utils.auto_docstring.get_args_doc_from_source not found; "
"skipping docstring patch"
)
return False
if getattr(get_args, _PATCH_TAG, False):
return True
if _model_output_args_has_loss_logits(auto_mod):
print(
"[INFO] ComfyUI-QwenImageLoraLoader: upstream ModelOutputArgs already "
"documents loss/logits; skipping docstring patch"
)
return False
needs_patch, probe_msg = _subprocess_import_probe()
if not needs_patch:
print(
f"[INFO] ComfyUI-QwenImageLoraLoader: {probe_msg}; "
"skipping docstring patch"
)
return False
if _qwen_modules_already_imported():
print(
"[WARNING] ComfyUI-QwenImageLoraLoader: Qwen VL modeling modules already "
"imported before docstring patch; restart ComfyUI for a clean apply. "
f"({probe_msg})"
)
return False
supplemental = _extract_loss_logits_from_causal_lm_doc(auto_mod)
if "loss" not in supplemental or "logits" not in supplemental:
print(
"[WARNING] ComfyUI-QwenImageLoraLoader: could not extract loss/logits "
"from CausalLMOutputWithPast.__doc__; skipping docstring patch"
)
return False
wrapped = _make_wrapped_get_args(get_args, auto_mod, supplemental)
auto_mod.get_args_doc_from_source = wrapped
print(
"[INFO] Patched transformers.utils.auto_docstring.get_args_doc_from_source "
"for Qwen VL CausalLM ModelOutput docstrings (loss/logits); "
"removes when upstream adds them"
)
return True
def remove_transformers_qwen_vl_causal_lm_docstring_patch() -> bool:
"""Restore original get_args_doc_from_source if our wrapper is installed."""
auto_mod = _get_auto_docstring_module()
get_args = getattr(auto_mod, "get_args_doc_from_source", None)
if get_args is None or not getattr(get_args, _PATCH_TAG, False):
return False
original = getattr(get_args, "_qwen_lora_loader_original", None)
if original is not None:
auto_mod.get_args_doc_from_source = original
return True
return False
def verify_patch_in_process() -> dict[str, Any]:
"""Import Qwen VL output classes in-process; return error counts (for tests)."""
auto_mod = _get_auto_docstring_module()
get_args = getattr(auto_mod, "get_args_doc_from_source", None)
buf = io.StringIO()
errors_before = 0
try:
with redirect_stdout(buf):
if _QWEN3_MODULE in sys.modules:
del sys.modules[_QWEN3_MODULE]
if _QWEN25_MODULE in sys.modules:
del sys.modules[_QWEN25_MODULE]
import transformers.models.qwen3_vl.modeling_qwen3_vl as m3
import transformers.models.qwen2_5_vl.modeling_qwen2_5_vl as m25
_ = m3.Qwen3VLCausalLMOutputWithPast
_ = m25.Qwen2_5_VLCausalLMOutputWithPast
except Exception as exc:
return {
"ok": False,
"exception": str(exc),
"errors": -1,
"output": buf.getvalue(),
"patched": bool(get_args and getattr(get_args, _PATCH_TAG, False)),
}
out = buf.getvalue()
errors = _count_error_markers(out)
return {
"ok": errors == 0,
"errors": errors,
"output": out,
"patched": bool(get_args and getattr(get_args, _PATCH_TAG, False)),
}
```
### 4.2 `prestartup_script.py` (complete)
```python
"""
ComfyUI prestartup hook for ComfyUI-QwenImageLoraLoader.
Runs before custom node package __init__ files are loaded so runtime patches
apply before heavy imports (transformers Qwen VL, nunchaku, etc.).
"""
from __future__ import annotations
import os
import sys
def _patch_nunchaku_apply_rotary_emb_compat() -> None:
"""Apply apply_rotary_emb compat patch (v2.4.6); see md/QWEN_IMAGE_APPLY_ROTARY_EMB_COMPAT_FIX.md."""
try:
from patches.nunchaku_patch import apply_nunchaku_patch
apply_nunchaku_patch()
except Exception as exc:
print(
f"[WARNING] ComfyUI-QwenImageLoraLoader prestartup: "
f"nunchaku apply_rotary_emb compat patch failed: {exc}"
)
def _patch_transformers_qwen_vl_causal_lm_docstring() -> None:
"""Apply transformers Qwen VL CausalLM ModelOutput docstring patch (loss/logits)."""
try:
from patches.transformers_qwen_vl_docstring_patch import (
apply_transformers_qwen_vl_causal_lm_docstring_patch,
)
if apply_transformers_qwen_vl_causal_lm_docstring_patch():
print(
"[INFO] ComfyUI-QwenImageLoraLoader prestartup: "
"CausalLM ModelOutput docstring patch applied"
)
except Exception as exc:
print(
f"[WARNING] ComfyUI-QwenImageLoraLoader prestartup: "
f"CausalLM ModelOutput docstring patch failed: {exc}"
)
# Docstring patch first: must run before any Qwen VL modeling import.
_patch_transformers_qwen_vl_causal_lm_docstring()
_patch_nunchaku_apply_rotary_emb_compat()
```
**No environment variables.** When Hugging Face fixes `transformers`, steps 3–4 skip installation automatically on the next ComfyUI start (upstream auto-disable).
---
@@ -521,13 +194,11 @@ Supplemental entries are built once at apply time from `CausalLMOutputWithPast._
```mermaid
flowchart TD
A[ComfyUI starts] --> B[prestartup_script.py]
B --> C{Env disable?}
C -->|yes| Z[Skip patch]
C -->|no| D{ModelOutputArgs has loss+logits?}
D -->|yes| Z
B --> D{ModelOutputArgs has loss+logits?}
D -->|yes| Z[Skip patch — upstream fixed]
D -->|no| E[Subprocess import probe]
E -->|no errors| Z
E -->|errors| F{Qwen modules already imported?}
E -->|clean stdout| Z
E -->|errors or inconclusive| F{Qwen modules already imported?}
F -->|yes| W[WARN: restart needed]
F -->|no| G[Parse CausalLMOutputWithPast.__doc__]
G --> H[Wrap get_args_doc_from_source]
@@ -542,19 +213,18 @@ The wrapper sets `_qwen_lora_loader_causal_lm_docstring_patch = True`. A second
---
## 6. Upstream self-disable (same idea as v2.4.6 rotary patch)
## 6. Automatic upstream disable (same idea as v2.4.6 rotary patch)
The patch is **temporary**. It should stop applying when Hugging Face fixes upstream or when the environment no longer needs it.
**Fully automatic on every ComfyUI start.** Users do not set environment variables or toggles. `prestartup_script.py` runs probes; the wrapper is installed **only while upstream `transformers` still triggers `[ERROR] loss` / `[ERROR] logits`**, and **is not installed** once Hugging Face fixes `ModelOutputArgs` or a clean import probe shows zero errors.
| Scenario | Behavior |
|----------|----------|
| **A. Upstream fix** | `ModelOutputArgs` gains `loss` and `logits` with non-empty `description` → skip patch, log info |
| **B. User disable** | `TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH=0` (or `false` / `off` / `no` / `disable` / `disabled`) → skip patch |
| **C. Subprocess probe** | Fresh Python imports Qwen VL classes without patch; if zero `[ERROR] loss/logits` markers → skip patch |
| **D. Late import** | Qwen VL modules already in `sys.modules` → warn; patch not applied (restart required) |
| **E. Missing API** | No `get_args_doc_from_source` or cannot parse parent doc → skip with warning |
| **A. Upstream fix (schema)** | `ModelOutputArgs` has `loss` and `logits` with non-empty `description` → skip patch, log info |
| **B. Upstream fix (probe)** | Subprocess imports Qwen VL without this patch; zero `[ERROR] loss/logits` on stdout → skip patch, log info |
| **C. Late import** | Qwen VL modules already in `sys.modules` → warn; patch not applied until restart |
| **D. Missing API** | No `get_args_doc_from_source` or cannot parse parent doc → skip with warning |
This mirrors the v2.4.6 pattern: probe upstream, patch only when needed, document removal conditions.
When **A** or **B** applies, no wrapper is installed — the same prestartup / probe-first pattern as v2.4.6 `apply_rotary_emb` compat.
---
@@ -569,6 +239,18 @@ After a clean restart with the patch applied **before** any Qwen VL import:
[INFO] ComfyUI-QwenImageLoraLoader prestartup: CausalLM ModelOutput docstring patch applied
```
When upstream is already fixed, expect skip messages instead (no wrapper):
```text
[INFO] CausalLM ModelOutput docstring patch skipped: transformers ModelOutputArgs already documents loss and logits (upstream fixed — patch not installed)
```
or
```text
[INFO] CausalLM ModelOutput docstring patch skipped: Qwen VL ModelOutput docstrings resolve loss/logits without docstring errors (upstream fixed — patch not installed)
```
Full startup should show **zero** lines containing `[ERROR] \`loss\`` or `[ERROR] \`logits\`` for the Qwen VL output classes.
### 7.2 In-process check (optional)
@@ -576,18 +258,10 @@ Full startup should show **zero** lines containing `[ERROR] \`loss\`` or `[ERROR
From the extension root, with ComfyUI’s Python:
```bat
cmd /c "cd /d D:\USERFILES\ComfyUI\ComfyUI\custom_nodes\ComfyUI-QwenImageLoraLoader && D:\USERFILES\ComfyUI\python_embeded\python.exe -c \"from patches.transformers_qwen_vl_docstring_patch import apply_transformers_qwen_vl_causal_lm_docstring_patch, verify_patch_in_process; apply_transformers_qwen_vl_causal_lm_docstring_patch(); print(verify_patch_in_process())\""
cmd /c "cd /d D:\USERFILES\ComfyUI\ComfyUI\custom_nodes\ComfyUI-QwenImageLoraLoader && D:\USERFILES\ComfyUI\python_embeded\python.exe _verify_docstring_patch.py"
```
Expected: `'ok': True`, `'errors': 0`, `'patched': True`.
### 7.3 Disable probe
```bat
set TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH=0
```
Restart ComfyUI: patch skip message should appear; if upstream is still broken, `[ERROR] loss/logits` may return.
Expected when upstream is still broken: patch applies and Qwen VL imports report zero `[ERROR] loss/logits` markers.
---
@@ -599,7 +273,7 @@ Restart ComfyUI: patch skip message should appear; if upstream is still broken,
| **Cause** | `ModelOutputArgs` lacks `loss`/`logits`; parent `Args:` not parsed at indent 0 |
| **Fix** | Wrap `get_args_doc_from_source` in custom node `prestartup_script.py` |
| **Scope** | ComfyUI-QwenImageLoraLoader only; no `site-packages` changes |
| **Self-disable** | Upstream fix, env var, subprocess probe, idempotent wrapper |
| **Auto-disable** | Upstream `ModelOutputArgs` fix and/or clean subprocess import probe; no user env vars |
| **Related** | [v2.4.6 apply_rotary_emb compat](https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.4.6) — same prestartup / self-disable pattern |
---
@@ -5,14 +5,14 @@ When Hugging Face transformers merges CausalLMOutputWithPast docs into Qwen VL
ModelOutput subclasses, validation falls back to ModelOutputArgs — which lacks
loss/logits — and prints [ERROR] at import time.
Upstream self-disable (same pattern as v2.4.6 apply_rotary_emb compat):
Fully automatic at ComfyUI prestartup (no user env vars):
- Probe upstream ModelOutputArgs before installing any wrapper.
- If upstream already documents loss/logits, skip (no wrapper, harmless).
- If import probe shows clean stdout, skip.
- Disable via TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH=0|false|off|no|disable|disabled
- If upstream already documents loss/logits, skip (no wrapper).
- If a subprocess import probe shows clean stdout, skip.
- Otherwise install the wrapper (default when upstream is still broken).
Fix: wrap get_args_doc_from_source to merge loss/logits into the returned dict
only while upstream ModelOutputArgs still omits those fields.
while upstream ModelOutputArgs still omits those fields.
"""
from __future__ import annotations
@@ -38,11 +38,6 @@ _patch_applied: bool = False
_original_get_args_doc_from_source: Optional[Callable[..., dict]] = None
def _patch_disabled_by_env() -> bool:
value = os.environ.get("TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH", "").strip().lower()
return value in ("0", "false", "no", "off", "disable", "disabled")
def _qwen_vl_modeling_already_imported() -> bool:
return any(name in sys.modules for name in _QWEN_VL_MODELING_MODULES)
@@ -155,7 +150,6 @@ def _import_probe_reports_clean() -> Optional[bool]:
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH": "0"},
)
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("CausalLM docstring import probe failed: %s", exc)
@@ -191,13 +185,6 @@ def apply_transformers_causal_lm_docstring_patch() -> bool:
if _patch_applied:
return True
if _patch_disabled_by_env():
logger.info(
"CausalLM ModelOutput docstring patch skipped: "
"TRANSFORMERS_CAUSAL_LM_DOCSTRING_PATCH is disabled"
)
return False
if _qwen_vl_modeling_already_imported():
logger.warning(
"CausalLM ModelOutput docstring patch skipped: Qwen VL modeling modules "