comfy-env 0.0.64__py3-none-any.whl → 0.0.66__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.
- comfy_env/__init__.py +70 -122
- comfy_env/cli.py +78 -7
- comfy_env/config/__init__.py +19 -0
- comfy_env/config/parser.py +151 -0
- comfy_env/config/types.py +64 -0
- comfy_env/install.py +83 -361
- comfy_env/isolation/__init__.py +9 -0
- comfy_env/isolation/wrap.py +351 -0
- comfy_env/nodes.py +2 -2
- comfy_env/pixi/__init__.py +48 -0
- comfy_env/pixi/core.py +356 -0
- comfy_env/{resolver.py → pixi/resolver.py} +1 -14
- comfy_env/prestartup.py +60 -0
- comfy_env/templates/comfy-env-instructions.txt +30 -87
- comfy_env/templates/comfy-env.toml +68 -136
- comfy_env/workers/__init__.py +21 -32
- comfy_env/workers/base.py +1 -1
- comfy_env/workers/{torch_mp.py → mp.py} +47 -14
- comfy_env/workers/{venv.py → subprocess.py} +405 -441
- {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/METADATA +2 -1
- comfy_env-0.0.66.dist-info/RECORD +34 -0
- comfy_env/decorator.py +0 -700
- comfy_env/env/__init__.py +0 -47
- comfy_env/env/config.py +0 -201
- comfy_env/env/config_file.py +0 -740
- comfy_env/env/manager.py +0 -636
- comfy_env/env/security.py +0 -267
- comfy_env/ipc/__init__.py +0 -55
- comfy_env/ipc/bridge.py +0 -476
- comfy_env/ipc/protocol.py +0 -265
- comfy_env/ipc/tensor.py +0 -371
- comfy_env/ipc/torch_bridge.py +0 -401
- comfy_env/ipc/transport.py +0 -318
- comfy_env/ipc/worker.py +0 -221
- comfy_env/isolation.py +0 -310
- comfy_env/pixi.py +0 -760
- comfy_env/stub_imports.py +0 -270
- comfy_env/stubs/__init__.py +0 -1
- comfy_env/stubs/comfy/__init__.py +0 -6
- comfy_env/stubs/comfy/model_management.py +0 -58
- comfy_env/stubs/comfy/utils.py +0 -29
- comfy_env/stubs/folder_paths.py +0 -71
- comfy_env/workers/pool.py +0 -241
- comfy_env-0.0.64.dist-info/RECORD +0 -48
- /comfy_env/{env/cuda_gpu_detection.py → pixi/cuda_detection.py} +0 -0
- /comfy_env/{env → pixi}/platform/__init__.py +0 -0
- /comfy_env/{env → pixi}/platform/base.py +0 -0
- /comfy_env/{env → pixi}/platform/darwin.py +0 -0
- /comfy_env/{env → pixi}/platform/linux.py +0 -0
- /comfy_env/{env → pixi}/platform/windows.py +0 -0
- /comfy_env/{registry.py → pixi/registry.py} +0 -0
- /comfy_env/{wheel_sources.yml → pixi/wheel_sources.yml} +0 -0
- {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/WHEEL +0 -0
- {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/entry_points.txt +0 -0
- {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/licenses/LICENSE +0 -0
comfy_env/decorator.py
DELETED
|
@@ -1,700 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Decorator-based API for easy subprocess isolation.
|
|
3
|
-
|
|
4
|
-
This module provides the @isolated decorator that makes it simple to run
|
|
5
|
-
ComfyUI node methods in isolated subprocess environments.
|
|
6
|
-
|
|
7
|
-
Architecture:
|
|
8
|
-
The decorator wraps the node's FUNCTION method. When called in the HOST
|
|
9
|
-
process, it forwards the call to an isolated worker (TorchMPWorker for
|
|
10
|
-
same-venv, PersistentVenvWorker for different venv).
|
|
11
|
-
|
|
12
|
-
When imported in the WORKER subprocess (COMFYUI_ISOLATION_WORKER=1),
|
|
13
|
-
the decorator is a transparent no-op.
|
|
14
|
-
|
|
15
|
-
Example:
|
|
16
|
-
from comfy_env import isolated
|
|
17
|
-
|
|
18
|
-
@isolated(env="myenv")
|
|
19
|
-
class MyNode:
|
|
20
|
-
FUNCTION = "process"
|
|
21
|
-
RETURN_TYPES = ("IMAGE",)
|
|
22
|
-
|
|
23
|
-
def process(self, image):
|
|
24
|
-
# This code runs in isolated subprocess
|
|
25
|
-
import heavy_package
|
|
26
|
-
return (heavy_package.run(image),)
|
|
27
|
-
|
|
28
|
-
Implementation:
|
|
29
|
-
This decorator is thin sugar over the workers module. Internally it uses:
|
|
30
|
-
- TorchMPWorker: Same Python, zero-copy tensor transfer via torch.mp.Queue
|
|
31
|
-
- PersistentVenvWorker: Different venv, tensor transfer via torch.save/load
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
import os
|
|
35
|
-
import sys
|
|
36
|
-
import atexit
|
|
37
|
-
import inspect
|
|
38
|
-
import logging
|
|
39
|
-
import threading
|
|
40
|
-
import time
|
|
41
|
-
from dataclasses import dataclass
|
|
42
|
-
from functools import wraps
|
|
43
|
-
from pathlib import Path
|
|
44
|
-
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
45
|
-
|
|
46
|
-
logger = logging.getLogger("comfy_env")
|
|
47
|
-
|
|
48
|
-
# Enable verbose logging by default (can be disabled)
|
|
49
|
-
VERBOSE_LOGGING = os.environ.get("COMFYUI_ISOLATION_QUIET", "0") != "1"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _log(env_name: str, msg: str):
|
|
53
|
-
"""Log with environment prefix."""
|
|
54
|
-
if VERBOSE_LOGGING:
|
|
55
|
-
print(f"[{env_name}] {msg}")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _is_worker_mode() -> bool:
|
|
59
|
-
"""Check if we're running inside the worker subprocess."""
|
|
60
|
-
return os.environ.get("COMFYUI_ISOLATION_WORKER") == "1"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _describe_tensor(t) -> str:
|
|
64
|
-
"""Get human-readable tensor description."""
|
|
65
|
-
try:
|
|
66
|
-
import torch
|
|
67
|
-
if isinstance(t, torch.Tensor):
|
|
68
|
-
size_mb = t.numel() * t.element_size() / (1024 * 1024)
|
|
69
|
-
return f"Tensor({list(t.shape)}, {t.dtype}, {t.device}, {size_mb:.1f}MB)"
|
|
70
|
-
except:
|
|
71
|
-
pass
|
|
72
|
-
return str(type(t).__name__)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _describe_args(args: dict) -> str:
|
|
76
|
-
"""Describe arguments for logging."""
|
|
77
|
-
parts = []
|
|
78
|
-
for k, v in args.items():
|
|
79
|
-
parts.append(f"{k}={_describe_tensor(v)}")
|
|
80
|
-
return ", ".join(parts) if parts else "(no args)"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _clone_tensor_if_needed(obj: Any, smart_clone: bool = True) -> Any:
|
|
84
|
-
"""
|
|
85
|
-
Defensively clone tensors to prevent mutation/re-share bugs.
|
|
86
|
-
|
|
87
|
-
This handles:
|
|
88
|
-
1. Input tensors that might be mutated in worker
|
|
89
|
-
2. Output tensors received via IPC that can't be re-shared
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
obj: Object to process (tensor or nested structure)
|
|
93
|
-
smart_clone: If True, use smart CUDA IPC detection (only clone
|
|
94
|
-
when necessary). If False, always clone.
|
|
95
|
-
"""
|
|
96
|
-
if smart_clone:
|
|
97
|
-
# Use smart detection - only clones CUDA tensors that can't be re-shared
|
|
98
|
-
from .workers.tensor_utils import prepare_for_ipc_recursive
|
|
99
|
-
return prepare_for_ipc_recursive(obj)
|
|
100
|
-
|
|
101
|
-
# Fallback: always clone (original behavior)
|
|
102
|
-
try:
|
|
103
|
-
import torch
|
|
104
|
-
if isinstance(obj, torch.Tensor):
|
|
105
|
-
return obj.clone()
|
|
106
|
-
elif isinstance(obj, (list, tuple)):
|
|
107
|
-
cloned = [_clone_tensor_if_needed(x, smart_clone=False) for x in obj]
|
|
108
|
-
return type(obj)(cloned)
|
|
109
|
-
elif isinstance(obj, dict):
|
|
110
|
-
return {k: _clone_tensor_if_needed(v, smart_clone=False) for k, v in obj.items()}
|
|
111
|
-
except ImportError:
|
|
112
|
-
pass
|
|
113
|
-
return obj
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _find_node_package_dir(source_file: Path) -> Path:
|
|
117
|
-
"""
|
|
118
|
-
Find the node package root directory by searching for comfy-env.toml.
|
|
119
|
-
|
|
120
|
-
Walks up from the source file's directory until it finds a config file,
|
|
121
|
-
or falls back to heuristics if not found.
|
|
122
|
-
"""
|
|
123
|
-
from .env.config_file import CONFIG_FILE_NAMES
|
|
124
|
-
|
|
125
|
-
current = source_file.parent
|
|
126
|
-
|
|
127
|
-
# Walk up the directory tree looking for config file
|
|
128
|
-
while current != current.parent: # Stop at filesystem root
|
|
129
|
-
for config_name in CONFIG_FILE_NAMES:
|
|
130
|
-
if (current / config_name).exists():
|
|
131
|
-
return current
|
|
132
|
-
current = current.parent
|
|
133
|
-
|
|
134
|
-
# Fallback: use old heuristic if no config found
|
|
135
|
-
node_dir = source_file.parent
|
|
136
|
-
if node_dir.name == "nodes":
|
|
137
|
-
return node_dir.parent
|
|
138
|
-
return node_dir
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# ---------------------------------------------------------------------------
|
|
142
|
-
# Worker Management
|
|
143
|
-
# ---------------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
@dataclass
|
|
146
|
-
class WorkerConfig:
|
|
147
|
-
"""Configuration for an isolated worker."""
|
|
148
|
-
env_name: str
|
|
149
|
-
python: Optional[str] = None # None = same Python (TorchMPWorker)
|
|
150
|
-
working_dir: Optional[Path] = None
|
|
151
|
-
sys_path: Optional[List[str]] = None
|
|
152
|
-
timeout: float = 600.0
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# Global worker cache
|
|
156
|
-
_workers: Dict[str, Any] = {}
|
|
157
|
-
_workers_lock = threading.Lock()
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _get_or_create_worker(config: WorkerConfig, log_fn: Callable):
|
|
161
|
-
"""Get or create a worker for the given configuration.
|
|
162
|
-
|
|
163
|
-
Thread-safe: worker creation happens inside the lock to prevent
|
|
164
|
-
race conditions where multiple threads create duplicate workers.
|
|
165
|
-
"""
|
|
166
|
-
cache_key = f"{config.env_name}:{config.python or 'same'}"
|
|
167
|
-
|
|
168
|
-
with _workers_lock:
|
|
169
|
-
if cache_key in _workers:
|
|
170
|
-
worker = _workers[cache_key]
|
|
171
|
-
if worker.is_alive():
|
|
172
|
-
return worker
|
|
173
|
-
# Worker died, recreate
|
|
174
|
-
log_fn(f"Worker died, recreating...")
|
|
175
|
-
|
|
176
|
-
# Create new worker INSIDE the lock (fixes race condition)
|
|
177
|
-
if config.python is None:
|
|
178
|
-
# Same Python - use TorchMPWorker (fast, zero-copy)
|
|
179
|
-
from .workers import TorchMPWorker
|
|
180
|
-
log_fn(f"Creating TorchMPWorker (same Python, zero-copy tensors)")
|
|
181
|
-
worker = TorchMPWorker(
|
|
182
|
-
name=config.env_name,
|
|
183
|
-
sys_path=config.sys_path,
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
# Different Python - use PersistentVenvWorker
|
|
187
|
-
from .workers.venv import PersistentVenvWorker
|
|
188
|
-
log_fn(f"Creating PersistentVenvWorker (python={config.python})")
|
|
189
|
-
worker = PersistentVenvWorker(
|
|
190
|
-
python=config.python,
|
|
191
|
-
working_dir=config.working_dir,
|
|
192
|
-
sys_path=config.sys_path,
|
|
193
|
-
name=config.env_name,
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
_workers[cache_key] = worker
|
|
197
|
-
return worker
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def shutdown_all_processes():
|
|
201
|
-
"""Shutdown all cached workers. Called at exit."""
|
|
202
|
-
with _workers_lock:
|
|
203
|
-
for name, worker in _workers.items():
|
|
204
|
-
try:
|
|
205
|
-
worker.shutdown()
|
|
206
|
-
except Exception as e:
|
|
207
|
-
logger.debug(f"Error shutting down {name}: {e}")
|
|
208
|
-
_workers.clear()
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
atexit.register(shutdown_all_processes)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
# ---------------------------------------------------------------------------
|
|
215
|
-
# The @isolated Decorator
|
|
216
|
-
# ---------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
def isolated(
|
|
219
|
-
env: str,
|
|
220
|
-
requirements: Optional[List[str]] = None,
|
|
221
|
-
config: Optional[str] = None,
|
|
222
|
-
python: Optional[str] = None,
|
|
223
|
-
cuda: Optional[str] = "auto",
|
|
224
|
-
timeout: float = 600.0,
|
|
225
|
-
log_callback: Optional[Callable[[str], None]] = None,
|
|
226
|
-
import_paths: Optional[List[str]] = None,
|
|
227
|
-
clone_tensors: bool = True,
|
|
228
|
-
same_venv: bool = False,
|
|
229
|
-
):
|
|
230
|
-
"""
|
|
231
|
-
Class decorator that runs node methods in isolated subprocess.
|
|
232
|
-
|
|
233
|
-
The decorated class's FUNCTION method will be executed in an isolated
|
|
234
|
-
Python environment. Tensors are transferred efficiently via PyTorch's
|
|
235
|
-
native IPC mechanisms (CUDA IPC for GPU, shared memory for CPU).
|
|
236
|
-
|
|
237
|
-
By default, auto-discovers config file (comfy_env_reqs.toml) and
|
|
238
|
-
uses full venv isolation with PersistentVenvWorker. Use same_venv=True
|
|
239
|
-
for lightweight same-venv isolation with TorchMPWorker.
|
|
240
|
-
|
|
241
|
-
Args:
|
|
242
|
-
env: Name of the isolated environment (used for logging/caching)
|
|
243
|
-
requirements: [DEPRECATED] Use config file instead
|
|
244
|
-
config: Path to TOML config file. If None, auto-discovers in node directory.
|
|
245
|
-
python: Path to Python executable (overrides config-based detection)
|
|
246
|
-
cuda: [DEPRECATED] Detected automatically
|
|
247
|
-
timeout: Timeout for calls in seconds (default: 10 minutes)
|
|
248
|
-
log_callback: Optional callback for logging
|
|
249
|
-
import_paths: Paths to add to sys.path in worker
|
|
250
|
-
clone_tensors: Clone tensors at boundary to prevent mutation bugs (default: True)
|
|
251
|
-
same_venv: If True, use TorchMPWorker (same venv, just process isolation).
|
|
252
|
-
If False (default), use full venv isolation with auto-discovered config.
|
|
253
|
-
|
|
254
|
-
Example:
|
|
255
|
-
# Full venv isolation (default) - auto-discovers comfy_env_reqs.toml
|
|
256
|
-
@isolated(env="sam3d")
|
|
257
|
-
class MyNode:
|
|
258
|
-
FUNCTION = "process"
|
|
259
|
-
|
|
260
|
-
def process(self, image):
|
|
261
|
-
import heavy_lib
|
|
262
|
-
return heavy_lib.run(image)
|
|
263
|
-
|
|
264
|
-
# Lightweight same-venv isolation (opt-in)
|
|
265
|
-
@isolated(env="sam3d", same_venv=True)
|
|
266
|
-
class MyLightNode:
|
|
267
|
-
FUNCTION = "process"
|
|
268
|
-
...
|
|
269
|
-
"""
|
|
270
|
-
def decorator(cls):
|
|
271
|
-
# In worker mode, decorator is a no-op
|
|
272
|
-
if _is_worker_mode():
|
|
273
|
-
return cls
|
|
274
|
-
|
|
275
|
-
# --- HOST MODE: Wrap the FUNCTION method ---
|
|
276
|
-
|
|
277
|
-
func_name = getattr(cls, 'FUNCTION', None)
|
|
278
|
-
if not func_name:
|
|
279
|
-
raise ValueError(
|
|
280
|
-
f"Node class {cls.__name__} must have FUNCTION attribute."
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
original_method = getattr(cls, func_name, None)
|
|
284
|
-
if original_method is None:
|
|
285
|
-
raise ValueError(
|
|
286
|
-
f"Node class {cls.__name__} has FUNCTION='{func_name}' but "
|
|
287
|
-
f"no method with that name."
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
# Get source file info for sys.path setup
|
|
291
|
-
source_file = Path(inspect.getfile(cls))
|
|
292
|
-
node_dir = source_file.parent
|
|
293
|
-
node_package_dir = _find_node_package_dir(source_file)
|
|
294
|
-
|
|
295
|
-
# Build sys.path for worker
|
|
296
|
-
sys_path_additions = [str(node_dir)]
|
|
297
|
-
if import_paths:
|
|
298
|
-
for p in import_paths:
|
|
299
|
-
full_path = node_dir / p
|
|
300
|
-
sys_path_additions.append(str(full_path.resolve()))
|
|
301
|
-
|
|
302
|
-
# Resolve python path for venv isolation
|
|
303
|
-
resolved_python = python
|
|
304
|
-
env_config = None
|
|
305
|
-
|
|
306
|
-
# If same_venv=True, skip venv isolation entirely
|
|
307
|
-
if same_venv:
|
|
308
|
-
_log(env, "Using same-venv isolation (TorchMPWorker)")
|
|
309
|
-
resolved_python = None
|
|
310
|
-
|
|
311
|
-
# Otherwise, try to get a venv python path
|
|
312
|
-
elif python:
|
|
313
|
-
# Explicit python path provided
|
|
314
|
-
resolved_python = python
|
|
315
|
-
|
|
316
|
-
else:
|
|
317
|
-
# Auto-discover or use explicit config
|
|
318
|
-
if config:
|
|
319
|
-
# Explicit config file specified
|
|
320
|
-
config_file = node_package_dir / config
|
|
321
|
-
if config_file.exists():
|
|
322
|
-
from .env.config_file import load_env_from_file
|
|
323
|
-
env_config = load_env_from_file(config_file, node_package_dir)
|
|
324
|
-
else:
|
|
325
|
-
_log(env, f"Warning: Config file not found: {config_file}")
|
|
326
|
-
else:
|
|
327
|
-
# Auto-discover config file - try v2 API first
|
|
328
|
-
from .env.config_file import discover_config, discover_env_config
|
|
329
|
-
v2_config = discover_config(node_package_dir)
|
|
330
|
-
if v2_config and env in v2_config.envs:
|
|
331
|
-
# v2 schema: get the named environment
|
|
332
|
-
env_config = v2_config.envs[env]
|
|
333
|
-
_log(env, f"Auto-discovered v2 config: {env_config.name}")
|
|
334
|
-
else:
|
|
335
|
-
# Fall back to v1 API
|
|
336
|
-
env_config = discover_env_config(node_package_dir)
|
|
337
|
-
if env_config:
|
|
338
|
-
_log(env, f"Auto-discovered config: {env_config.name}")
|
|
339
|
-
|
|
340
|
-
# If we have a config, set up the venv
|
|
341
|
-
if env_config:
|
|
342
|
-
from .env.manager import IsolatedEnvManager
|
|
343
|
-
manager = IsolatedEnvManager(base_dir=node_package_dir)
|
|
344
|
-
|
|
345
|
-
if not manager.is_ready(env_config):
|
|
346
|
-
_log(env, f"Setting up isolated environment...")
|
|
347
|
-
manager.setup(env_config)
|
|
348
|
-
|
|
349
|
-
resolved_python = str(manager.get_python(env_config))
|
|
350
|
-
else:
|
|
351
|
-
# No config found - fall back to same-venv isolation
|
|
352
|
-
_log(env, "No config found, using same-venv isolation (TorchMPWorker)")
|
|
353
|
-
resolved_python = None
|
|
354
|
-
|
|
355
|
-
# Create worker config
|
|
356
|
-
worker_config = WorkerConfig(
|
|
357
|
-
env_name=env,
|
|
358
|
-
python=resolved_python,
|
|
359
|
-
working_dir=node_dir,
|
|
360
|
-
sys_path=sys_path_additions,
|
|
361
|
-
timeout=timeout,
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
# Setup logging
|
|
365
|
-
log_fn = log_callback or (lambda msg: _log(env, msg))
|
|
366
|
-
|
|
367
|
-
# Create the proxy method
|
|
368
|
-
@wraps(original_method)
|
|
369
|
-
def proxy(self, *args, **kwargs):
|
|
370
|
-
# Get or create worker
|
|
371
|
-
worker = _get_or_create_worker(worker_config, log_fn)
|
|
372
|
-
|
|
373
|
-
# Bind arguments to get kwargs dict
|
|
374
|
-
sig = inspect.signature(original_method)
|
|
375
|
-
try:
|
|
376
|
-
bound = sig.bind(self, *args, **kwargs)
|
|
377
|
-
bound.apply_defaults()
|
|
378
|
-
call_kwargs = dict(bound.arguments)
|
|
379
|
-
del call_kwargs['self']
|
|
380
|
-
except TypeError:
|
|
381
|
-
call_kwargs = kwargs
|
|
382
|
-
|
|
383
|
-
# Log entry with argument descriptions
|
|
384
|
-
if VERBOSE_LOGGING:
|
|
385
|
-
log_fn(f"-> {cls.__name__}.{func_name}({_describe_args(call_kwargs)})")
|
|
386
|
-
|
|
387
|
-
start_time = time.time()
|
|
388
|
-
|
|
389
|
-
try:
|
|
390
|
-
# Clone tensors defensively if enabled
|
|
391
|
-
if clone_tensors:
|
|
392
|
-
call_kwargs = {k: _clone_tensor_if_needed(v) for k, v in call_kwargs.items()}
|
|
393
|
-
|
|
394
|
-
# Get module name for import in worker
|
|
395
|
-
# Note: ComfyUI uses full filesystem paths as module names for custom nodes.
|
|
396
|
-
# The worker's _execute_method_call handles this by using file-based imports.
|
|
397
|
-
module_name = cls.__module__
|
|
398
|
-
|
|
399
|
-
# Call worker using appropriate method
|
|
400
|
-
if worker_config.python is None:
|
|
401
|
-
# TorchMPWorker - use call_method protocol (avoids pickle issues)
|
|
402
|
-
result = worker.call_method(
|
|
403
|
-
module_name=module_name,
|
|
404
|
-
class_name=cls.__name__,
|
|
405
|
-
method_name=func_name,
|
|
406
|
-
self_state=self.__dict__.copy(),
|
|
407
|
-
kwargs=call_kwargs,
|
|
408
|
-
timeout=timeout,
|
|
409
|
-
)
|
|
410
|
-
else:
|
|
411
|
-
# PersistentVenvWorker - call by module/class/method path
|
|
412
|
-
result = worker.call_method(
|
|
413
|
-
module_name=source_file.stem,
|
|
414
|
-
class_name=cls.__name__,
|
|
415
|
-
method_name=func_name,
|
|
416
|
-
self_state=self.__dict__.copy() if hasattr(self, '__dict__') else None,
|
|
417
|
-
kwargs=call_kwargs,
|
|
418
|
-
timeout=timeout,
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
# Clone result tensors defensively
|
|
422
|
-
if clone_tensors:
|
|
423
|
-
result = _clone_tensor_if_needed(result)
|
|
424
|
-
|
|
425
|
-
elapsed = time.time() - start_time
|
|
426
|
-
if VERBOSE_LOGGING:
|
|
427
|
-
result_desc = _describe_tensor(result) if not isinstance(result, tuple) else f"tuple({len(result)} items)"
|
|
428
|
-
log_fn(f"<- {cls.__name__}.{func_name} returned {result_desc} [{elapsed:.2f}s]")
|
|
429
|
-
|
|
430
|
-
return result
|
|
431
|
-
|
|
432
|
-
except Exception as e:
|
|
433
|
-
elapsed = time.time() - start_time
|
|
434
|
-
log_fn(f"[FAIL] {cls.__name__}.{func_name} failed after {elapsed:.2f}s: {e}")
|
|
435
|
-
raise
|
|
436
|
-
|
|
437
|
-
# Store original method before replacing (for worker to access)
|
|
438
|
-
cls._isolated_original_method = original_method
|
|
439
|
-
|
|
440
|
-
# Replace method with proxy
|
|
441
|
-
setattr(cls, func_name, proxy)
|
|
442
|
-
|
|
443
|
-
# Store metadata
|
|
444
|
-
cls._isolated_env = env
|
|
445
|
-
cls._isolated_node_dir = node_dir
|
|
446
|
-
|
|
447
|
-
return cls
|
|
448
|
-
|
|
449
|
-
return decorator
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
# ---------------------------------------------------------------------------
|
|
453
|
-
# The @auto_isolate Decorator (Function-level)
|
|
454
|
-
# ---------------------------------------------------------------------------
|
|
455
|
-
|
|
456
|
-
def _parse_import_error(e: ImportError) -> Optional[str]:
|
|
457
|
-
"""Extract the module name from an ImportError."""
|
|
458
|
-
# Python's ImportError has a 'name' attribute with the module name
|
|
459
|
-
if hasattr(e, 'name') and e.name:
|
|
460
|
-
return e.name
|
|
461
|
-
|
|
462
|
-
# Fallback: parse from message "No module named 'xxx'"
|
|
463
|
-
msg = str(e)
|
|
464
|
-
if "No module named" in msg:
|
|
465
|
-
# Extract 'xxx' from "No module named 'xxx'" or "No module named 'xxx.yyy'"
|
|
466
|
-
import re
|
|
467
|
-
match = re.search(r"No module named ['\"]([^'\"\.]+)", msg)
|
|
468
|
-
if match:
|
|
469
|
-
return match.group(1)
|
|
470
|
-
|
|
471
|
-
return None
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def _find_env_for_module(
|
|
475
|
-
module_name: str,
|
|
476
|
-
source_file: Path,
|
|
477
|
-
) -> Optional[Tuple[str, Path, Path]]:
|
|
478
|
-
"""
|
|
479
|
-
Find which isolated environment contains the given module.
|
|
480
|
-
|
|
481
|
-
Searches comfy-env.toml configs starting from the source file's directory,
|
|
482
|
-
looking for the module in cuda packages, requirements, etc.
|
|
483
|
-
|
|
484
|
-
Args:
|
|
485
|
-
module_name: The module that failed to import (e.g., "cumesh")
|
|
486
|
-
source_file: Path to the source file containing the function
|
|
487
|
-
|
|
488
|
-
Returns:
|
|
489
|
-
Tuple of (env_name, python_path, node_dir) or None if not found
|
|
490
|
-
"""
|
|
491
|
-
from .env.config_file import discover_config, CONFIG_FILE_NAMES
|
|
492
|
-
|
|
493
|
-
# Normalize module name (cumesh, pytorch3d, etc.)
|
|
494
|
-
module_lower = module_name.lower().replace("-", "_").replace(".", "_")
|
|
495
|
-
|
|
496
|
-
# Search for config file starting from source file's directory
|
|
497
|
-
node_dir = source_file.parent
|
|
498
|
-
while node_dir != node_dir.parent:
|
|
499
|
-
for config_name in CONFIG_FILE_NAMES:
|
|
500
|
-
config_path = node_dir / config_name
|
|
501
|
-
if config_path.exists():
|
|
502
|
-
# Found a config, check if it has our module
|
|
503
|
-
config = discover_config(node_dir)
|
|
504
|
-
if config is None:
|
|
505
|
-
continue
|
|
506
|
-
|
|
507
|
-
# Check all environments in the config
|
|
508
|
-
for env_name, env_config in config.envs.items():
|
|
509
|
-
# Check cuda/no_deps_requirements
|
|
510
|
-
if env_config.no_deps_requirements:
|
|
511
|
-
for req in env_config.no_deps_requirements:
|
|
512
|
-
req_name = req.split("==")[0].split(">=")[0].split("<")[0].strip()
|
|
513
|
-
req_lower = req_name.lower().replace("-", "_")
|
|
514
|
-
if req_lower == module_lower:
|
|
515
|
-
# Found it! Get the python path
|
|
516
|
-
env_path = node_dir / f"_env_{env_name}"
|
|
517
|
-
if not env_path.exists():
|
|
518
|
-
# Try pixi path
|
|
519
|
-
env_path = node_dir / ".pixi" / "envs" / "default"
|
|
520
|
-
|
|
521
|
-
if env_path.exists():
|
|
522
|
-
python_path = env_path / "bin" / "python"
|
|
523
|
-
if not python_path.exists():
|
|
524
|
-
python_path = env_path / "Scripts" / "python.exe"
|
|
525
|
-
if python_path.exists():
|
|
526
|
-
return (env_name, python_path, node_dir)
|
|
527
|
-
|
|
528
|
-
# Check regular requirements too
|
|
529
|
-
if env_config.requirements:
|
|
530
|
-
for req in env_config.requirements:
|
|
531
|
-
req_name = req.split("==")[0].split(">=")[0].split("<")[0].split("[")[0].strip()
|
|
532
|
-
req_lower = req_name.lower().replace("-", "_")
|
|
533
|
-
if req_lower == module_lower:
|
|
534
|
-
env_path = node_dir / f"_env_{env_name}"
|
|
535
|
-
if not env_path.exists():
|
|
536
|
-
env_path = node_dir / ".pixi" / "envs" / "default"
|
|
537
|
-
|
|
538
|
-
if env_path.exists():
|
|
539
|
-
python_path = env_path / "bin" / "python"
|
|
540
|
-
if not python_path.exists():
|
|
541
|
-
python_path = env_path / "Scripts" / "python.exe"
|
|
542
|
-
if python_path.exists():
|
|
543
|
-
return (env_name, python_path, node_dir)
|
|
544
|
-
|
|
545
|
-
# Config found but module not in it, stop searching
|
|
546
|
-
break
|
|
547
|
-
|
|
548
|
-
node_dir = node_dir.parent
|
|
549
|
-
|
|
550
|
-
return None
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
# Cache for auto_isolate workers
|
|
554
|
-
_auto_isolate_workers: Dict[str, Any] = {}
|
|
555
|
-
_auto_isolate_lock = threading.Lock()
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def _get_auto_isolate_worker(env_name: str, python_path: Path, node_dir: Path):
|
|
559
|
-
"""Get or create a worker for auto_isolate."""
|
|
560
|
-
cache_key = f"{env_name}:{python_path}"
|
|
561
|
-
|
|
562
|
-
with _auto_isolate_lock:
|
|
563
|
-
if cache_key in _auto_isolate_workers:
|
|
564
|
-
worker = _auto_isolate_workers[cache_key]
|
|
565
|
-
if worker.is_alive():
|
|
566
|
-
return worker
|
|
567
|
-
|
|
568
|
-
# Create new PersistentVenvWorker
|
|
569
|
-
from .workers.venv import PersistentVenvWorker
|
|
570
|
-
|
|
571
|
-
worker = PersistentVenvWorker(
|
|
572
|
-
python=str(python_path),
|
|
573
|
-
working_dir=node_dir,
|
|
574
|
-
sys_path=[str(node_dir)],
|
|
575
|
-
name=f"auto-{env_name}",
|
|
576
|
-
)
|
|
577
|
-
|
|
578
|
-
_auto_isolate_workers[cache_key] = worker
|
|
579
|
-
return worker
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
def auto_isolate(func: Callable) -> Callable:
|
|
583
|
-
"""
|
|
584
|
-
Decorator that automatically runs a function in an isolated environment
|
|
585
|
-
when an ImportError occurs for a package that exists in the isolated env.
|
|
586
|
-
|
|
587
|
-
This provides seamless isolation - just write normal code with imports,
|
|
588
|
-
and if the import fails in the host environment but the package is
|
|
589
|
-
configured in comfy-env.toml, the function automatically retries in
|
|
590
|
-
the isolated environment.
|
|
591
|
-
|
|
592
|
-
Example:
|
|
593
|
-
from comfy_env import auto_isolate
|
|
594
|
-
|
|
595
|
-
@auto_isolate
|
|
596
|
-
def process_with_cumesh(mesh, target_faces):
|
|
597
|
-
import cumesh # If this fails, function retries in isolated env
|
|
598
|
-
import torch
|
|
599
|
-
|
|
600
|
-
v = torch.tensor(mesh.vertices).cuda()
|
|
601
|
-
f = torch.tensor(mesh.faces).cuda()
|
|
602
|
-
|
|
603
|
-
cm = cumesh.CuMesh()
|
|
604
|
-
cm.init(v, f)
|
|
605
|
-
cm.simplify(target_faces)
|
|
606
|
-
|
|
607
|
-
result_v, result_f = cm.read()
|
|
608
|
-
return result_v.cpu().numpy(), result_f.cpu().numpy()
|
|
609
|
-
|
|
610
|
-
How it works:
|
|
611
|
-
1. Function runs normally in the host environment
|
|
612
|
-
2. If ImportError occurs, decorator catches it
|
|
613
|
-
3. Extracts the module name from the error (e.g., "cumesh")
|
|
614
|
-
4. Searches comfy-env.toml for which env has that module
|
|
615
|
-
5. Re-runs the entire function in that isolated environment
|
|
616
|
-
6. Returns the result as if nothing happened
|
|
617
|
-
|
|
618
|
-
Benefits:
|
|
619
|
-
- Zero overhead when imports succeed (fast path)
|
|
620
|
-
- Auto-detects which environment to use from the failed import
|
|
621
|
-
- Function is the isolation boundary (clean, debuggable)
|
|
622
|
-
- Works with any import pattern (top of function, conditional, etc.)
|
|
623
|
-
|
|
624
|
-
Note:
|
|
625
|
-
Arguments and return values are serialized via torch.save/load,
|
|
626
|
-
so they should be tensors, numpy arrays, or pickle-able objects.
|
|
627
|
-
"""
|
|
628
|
-
# Get source file for environment detection
|
|
629
|
-
source_file = Path(inspect.getfile(func))
|
|
630
|
-
|
|
631
|
-
@wraps(func)
|
|
632
|
-
def wrapper(*args, **kwargs):
|
|
633
|
-
try:
|
|
634
|
-
# Fast path: try running in host environment
|
|
635
|
-
return func(*args, **kwargs)
|
|
636
|
-
|
|
637
|
-
except ImportError as e:
|
|
638
|
-
# Extract module name from error
|
|
639
|
-
module_name = _parse_import_error(e)
|
|
640
|
-
if module_name is None:
|
|
641
|
-
# Can't determine module, re-raise
|
|
642
|
-
raise
|
|
643
|
-
|
|
644
|
-
# Find which env has this module
|
|
645
|
-
env_info = _find_env_for_module(module_name, source_file)
|
|
646
|
-
if env_info is None:
|
|
647
|
-
# Module not in any known isolated env, re-raise
|
|
648
|
-
raise
|
|
649
|
-
|
|
650
|
-
env_name, python_path, node_dir = env_info
|
|
651
|
-
|
|
652
|
-
_log(env_name, f"Import '{module_name}' failed in host, retrying in isolated env...")
|
|
653
|
-
_log(env_name, f" Python: {python_path}")
|
|
654
|
-
|
|
655
|
-
# Get or create worker
|
|
656
|
-
worker = _get_auto_isolate_worker(env_name, python_path, node_dir)
|
|
657
|
-
|
|
658
|
-
# Prepare arguments - convert numpy arrays to lists for IPC
|
|
659
|
-
import numpy as np
|
|
660
|
-
|
|
661
|
-
def convert_for_ipc(obj):
|
|
662
|
-
if isinstance(obj, np.ndarray):
|
|
663
|
-
return obj.tolist()
|
|
664
|
-
elif hasattr(obj, 'vertices') and hasattr(obj, 'faces'):
|
|
665
|
-
# Trimesh-like object - convert to dict
|
|
666
|
-
return {
|
|
667
|
-
'__trimesh__': True,
|
|
668
|
-
'vertices': obj.vertices.tolist() if hasattr(obj.vertices, 'tolist') else list(obj.vertices),
|
|
669
|
-
'faces': obj.faces.tolist() if hasattr(obj.faces, 'tolist') else list(obj.faces),
|
|
670
|
-
}
|
|
671
|
-
elif isinstance(obj, (list, tuple)):
|
|
672
|
-
converted = [convert_for_ipc(x) for x in obj]
|
|
673
|
-
return type(obj)(converted) if isinstance(obj, tuple) else converted
|
|
674
|
-
elif isinstance(obj, dict):
|
|
675
|
-
return {k: convert_for_ipc(v) for k, v in obj.items()}
|
|
676
|
-
return obj
|
|
677
|
-
|
|
678
|
-
converted_args = [convert_for_ipc(arg) for arg in args]
|
|
679
|
-
converted_kwargs = {k: convert_for_ipc(v) for k, v in kwargs.items()}
|
|
680
|
-
|
|
681
|
-
# Call via worker
|
|
682
|
-
start_time = time.time()
|
|
683
|
-
|
|
684
|
-
result = worker.call_module(
|
|
685
|
-
module=source_file.stem,
|
|
686
|
-
func=func.__name__,
|
|
687
|
-
*converted_args,
|
|
688
|
-
**converted_kwargs,
|
|
689
|
-
)
|
|
690
|
-
|
|
691
|
-
elapsed = time.time() - start_time
|
|
692
|
-
_log(env_name, f"<- {func.__name__} completed in isolated env [{elapsed:.2f}s]")
|
|
693
|
-
|
|
694
|
-
return result
|
|
695
|
-
|
|
696
|
-
# Mark the function as auto-isolate enabled
|
|
697
|
-
wrapper._auto_isolate = True
|
|
698
|
-
wrapper._source_file = source_file
|
|
699
|
-
|
|
700
|
-
return wrapper
|