Files
wildminder-ComfyUI-KEEP/tools/scan_compat.py
T
WildAi aba4cfa739 Round 13: implement keep-fix5 (Phases 1-4; 4.2 skipped as optional)
Implements .dev/docs/keep-fix5-implement1.txt against the 9e6c50f tree:

Phase 1 - the paste pipeline: the face upscaler joins the batched core
(the _batched_paste/_batched_paste_multiface aborts are deleted), mode
is forced chunked with a bg model, face_upscale_model threads through
the paste chunk bodies (parse masks stay on the 512 faces, the ROI
grid_sample warps the upscaled faces), upscale_faces_batch_gpu (B=1,
FP16 autocast on CUDA), and _tiled_scale_and_resize_batch replaces
_tiled_scale_batch + the round-12 bg grouping (B=1 FP16 loop, one
bicubic-antialias resize straight to the paste canvas; the no-model
branch keeps _resize_bg_frames). bg_chunk_size and its pins are removed
as dead machinery. parse_soft_masks and _run_upscaler run under
autocast(enabled=(device.type == 'cuda')).

Phase 2 - facelib: RetinaFace caches priors by (h, w, device) and
detect_faces filters by confidence and runs torchvision.ops.nms on the
device before the single CPU transfer (output format preserved);
read_image gains check_gray=True, only the three sequence call sites
pass False.

Phase 3 - archs: SDPA in CrossAttention._attention, AttnBlock and both
gmflow attention forms; get_flow pinned to float32 with autocast
disabled; pos_emb cast to query_emb.dtype; keep_net under autocast at
both sequence call sites.

Phase 4 - the vendored basicsr pruning: data/, losses/, models/,
train.py, test.py, 23 unused archs, 7 unused utils deleted (78 files);
DCNv2Pack removed from arch_util; the three __init__.py rewrites;
enumeration manifests regenerated. metrics/ and ops/ stay on disk
byte-identical, off the import path. 4.2 skipped (explicitly optional).

Autocast is CUDA-only everywhere, so the CPU gate keeps exercising the
FP32 paths; no model is loaded. Gate: 556 passed, 0 failed (embedded
python; 557 -> 556 = the dead bg_chunk_size pins out, new pins in).
GPU throughput and the FP16/SDPA numerics stay owner-measured.
2026-11-03 20:23:44 +03:00

347 lines
12 KiB
Python

"""P0.3 - vendored-dependency compatibility scanner.
Walks the **runtime import closure** of the current package and greps every
file in it for Python 3.13 / numpy 2 incompatibilities.
Entry points (plan P0.3; moved to ``src/`` by P3.2):
src/keep_model_configs.py
src/keep_model_loader.py
src/keep_processor.py
The closure is computed with a static import tracer:
* absolute imports of ``basicsr`` / ``facelib`` resolve into ``modules/deps``
(the vendored tree lives there since P2.5);
* relative imports resolve inside the current package;
* importing a package also imports its parent packages' ``__init__.py``;
* the **eager folder scans** of the vendored ``basicsr`` packages are dynamic
``importlib.import_module`` loops - they are modelled explicitly (see
:data:`EAGER_SCAN_PACKAGES`), so every matching module of those folders
belongs to the closure.
Usage (embedded python, from the repository root):
python tools/scan_compat.py
python tools/scan_compat.py --json out.json
Exit codes:
* ``0`` - the scan ran and found **no** incompatible pattern in the closure;
* ``1`` - the scan ran and found **at least one** incompatible pattern
(``finding_count > 0``). This is a *result*, not a tool crash: the fix list
is in ``.dev/docs/audit/2026-10-22-compat-scan.md``.
"""
from __future__ import annotations
import argparse
import ast
import json
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEPS_DIR = REPO_ROOT / "modules" / "deps"
ENTRY_POINTS = (
"src/keep_model_configs.py",
"src/keep_model_loader.py",
"src/keep_processor.py",
)
#: Top-level packages that are vendored and therefore resolved inside
#: ``modules/deps`` (the vendored tree is part of the node package since
#: P2.5 - it is never put on ``sys.path``).
VENDORED_ROOTS = ("basicsr", "facelib")
#: The vendored ``basicsr`` packages whose ``__init__.py`` scans its own folder
#: at import time and imports every matching module **eagerly** through
#: ``importlib.import_module``. Each of those modules is therefore part of the
#: runtime closure, even though no static import statement mentions it.
#:
#: 2026-11-03, keep-fix5-implement1 Phase 4.1: no vendored package init has a
#: folder scan any more (P2.4 replaced them with explicit import lists, and
#: Phase 4.1 deleted ``data/``, ``losses/`` and ``models/`` outright) - the
#: mapping is kept as a safety net so a reintroduced upstream-style scan is
#: modelled instead of silently understating the closure.
#:
#: package folder -> (file suffix, source of the scan in the vendored tree)
EAGER_SCAN_PACKAGES: dict[str, str] = {
"archs": "_arch.py",
"data": "_dataset.py",
"losses": "_loss.py",
"models": "_model.py",
}
#: (pattern id, human description, regex)
PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
(
"re_import",
"`from re import ...` - `re.T` etc. were removed in Python 3.11",
re.compile(r"^\s*from\s+re\s+import\s+\w+", re.MULTILINE),
),
(
"np_float",
"np.float - removed in numpy 1.24",
re.compile(r"\bnp\.float\b"),
),
(
"np_int",
"np.int - removed in numpy 1.24",
re.compile(r"\bnp\.int\b"),
),
(
"np_bool",
"np.bool - removed in numpy 1.24",
re.compile(r"\bnp\.bool\b"),
),
(
"np_object",
"np.object - removed in numpy 1.24",
re.compile(r"\bnp\.object\b"),
),
(
"np_str",
"np.str - removed in numpy 1.24",
re.compile(r"\bnp\.str\b"),
),
(
"np_nan",
"np.NaN - removed in numpy 2.0",
re.compile(r"\bnp\.NaN\b"),
),
(
"distutils",
"distutils - removed from the stdlib in Python 3.12",
re.compile(r"\bdistutils\b"),
),
(
"import_imp",
"`import imp` - removed in Python 3.12",
re.compile(r"^\s*import\s+imp\b", re.MULTILINE),
),
(
"torch_cuda_amp",
"torch.cuda.amp - deprecated in torch 2.4+",
re.compile(r"\btorch\.cuda\.amp\b"),
),
(
"pdb",
"pdb - debug break left in the runtime closure",
re.compile(r"^\s*(import\s+pdb\b|pdb\.\w+)", re.MULTILINE),
),
(
"torch_profiler",
"torch.profiler - heavyweight debug import",
re.compile(r"\btorch\.profiler\b"),
),
(
"collections_mapping",
"`from collections import ...Mapping` - moved to collections.abc in 3.10",
re.compile(r"^\s*from\s+collections\s+import\s+.*\bMapping\b", re.MULTILINE),
),
(
"import_pip",
"`import pip` - no pip execution inside the package",
re.compile(r"^\s*import\s+pip\b", re.MULTILINE),
),
(
"pip_main",
"`pip.main(...)` - pip execution inside the package",
re.compile(r"\bpip\.main\s*\("),
),
)
def scan_source(text: str, path: str = "<memory>") -> list[dict]:
"""Return every finding of the known patterns in ``text``."""
findings: list[dict] = []
lines = text.splitlines()
for pattern_id, description, regex in PATTERNS:
for match in regex.finditer(text):
line_number = text.count("\n", 0, match.start()) + 1
snippet = lines[line_number - 1].strip() if line_number <= len(lines) else ""
findings.append(
{
"path": path,
"line": line_number,
"pattern": pattern_id,
"description": description,
"snippet": snippet[:160],
}
)
findings.sort(key=lambda f: (f["path"], f["line"], f["pattern"]))
return findings
def _module_candidates(base: Path, parts: list[str]) -> list[Path]:
"""Candidate files for the module ``parts`` below ``base``."""
if not parts:
return []
module_path = base.joinpath(*parts)
candidates = [module_path.with_suffix(".py")]
package_init = module_path / "__init__.py"
if package_init.is_file():
candidates.append(package_init)
return candidates
def _package_inits(base: Path, parts: list[str]) -> list[Path]:
"""``__init__.py`` of every parent package of ``parts`` below ``base``."""
inits = []
for depth in range(1, len(parts)):
parent = base.joinpath(*parts[:depth]) / "__init__.py"
if parent.is_file():
inits.append(parent)
return inits
def _imported_module_names(tree: ast.AST, current_file: Path) -> list[tuple[int, list[str]]]:
"""(level, module parts) for every import statement of ``tree``."""
imports: list[tuple[int, list[str]]] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.append((0, alias.name.split(".")))
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
base = module.split(".") if module else []
imports.append((node.level or 0, base))
# ``from <package> import <submodule>`` loads the submodule file as
# well (e.g. ``from basicsr.models import lr_scheduler`` in
# ``basicsr/models/base_model.py:8``). The resolver drops the
# candidates that are not files, so plain symbol imports cost
# nothing.
for alias in node.names:
if alias.name != "*":
imports.append((node.level or 0, base + [alias.name]))
return imports
def _resolve(level: int, parts: list[str], current_file: Path) -> list[Path]:
"""Resolve one import statement to the files it pulls in."""
if level:
# relative import: walk up from the current package directory
package_dir = current_file.parent
for _ in range(level - 1):
package_dir = package_dir.parent
return [
candidate
for candidate in _module_candidates(package_dir, parts)
if candidate.is_file()
] + [
parent
for parent in _package_inits(package_dir, parts + ["__dummy__"])
if parent.is_file()
]
if not parts:
return []
if parts[0] not in VENDORED_ROOTS:
return [] # third-party / ComfyUI: not our closure
return [
candidate
for candidate in _module_candidates(DEPS_DIR, parts)
if candidate.is_file()
] + [parent for parent in _package_inits(DEPS_DIR, parts) if parent.is_file()]
def _has_dynamic_folder_scan(init_file: Path, suffix: str) -> bool:
"""True if the package ``__init__.py`` really scans its own folder.
The vendored ``basicsr`` packages used to import every matching module of
their folder at import time (a dynamic ``importlib.import_module`` loop).
P2.4 replaced those loops with explicit import lists, so the closure
computation must only model the scan when it is still there.
"""
try:
text = init_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return False
return "importlib.import_module" in text and suffix in text
def find_import_closure(entry_points: list[Path]) -> list[Path]:
"""Static runtime import closure of ``entry_points`` (sorted, de-duplicated)."""
closure: set[Path] = set()
queue: list[Path] = [p.resolve() for p in entry_points]
while queue:
current = queue.pop()
if current in closure or not current.is_file():
continue
closure.add(current)
try:
tree = ast.parse(current.read_text(encoding="utf-8"), filename=str(current))
except (SyntaxError, UnicodeDecodeError):
continue
for level, parts in _imported_module_names(tree, current):
for dependency in _resolve(level, parts, current):
if dependency.resolve() not in closure:
queue.append(dependency.resolve())
# the eager folder scans of basicsr/archs|data|losses|models/__init__.py
# (P2.4: only modelled when the package init really contains such a
# scan - the explicit import lists have none)
if current.name == "__init__.py":
suffix = EAGER_SCAN_PACKAGES.get(current.parent.name)
if suffix and _has_dynamic_folder_scan(current, suffix):
for module in sorted(current.parent.glob(f"*{suffix}")):
if module.resolve() not in closure:
queue.append(module.resolve())
return sorted(closure, key=lambda p: str(p).lower())
def scan_paths(paths: list[Path], repo_root: Path = REPO_ROOT) -> list[dict]:
findings: list[dict] = []
for path in paths:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
try:
relative = path.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
relative = str(path)
findings.extend(scan_source(text, relative))
findings.sort(key=lambda f: (f["path"], f["line"], f["pattern"]))
return findings
def scan_repo(repo_root: Path = REPO_ROOT) -> dict:
entry_points = [repo_root / rel for rel in ENTRY_POINTS]
closure = find_import_closure(entry_points)
findings = scan_paths(closure, repo_root)
by_pattern: dict[str, int] = {}
for finding in findings:
by_pattern[finding["pattern"]] = by_pattern.get(finding["pattern"], 0) + 1
return {
"entry_points": list(ENTRY_POINTS),
"closure_file_count": len(closure),
"closure_files": [
p.resolve().relative_to(repo_root.resolve()).as_posix() for p in closure
],
"finding_count": len(findings),
"findings_by_pattern": by_pattern,
"findings": findings,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", dest="json_path", default=None)
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args(argv)
report = scan_repo()
text = json.dumps(report, indent=2)
if args.json_path:
Path(args.json_path).write_text(text + "\n", encoding="utf-8")
if args.quiet:
print(json.dumps({k: v for k, v in report.items() if k != "findings"}, indent=2))
else:
print(text)
return 1 if report["finding_count"] else 0
if __name__ == "__main__":
raise SystemExit(main())