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.
Files changed (55) hide show
  1. comfy_env/__init__.py +70 -122
  2. comfy_env/cli.py +78 -7
  3. comfy_env/config/__init__.py +19 -0
  4. comfy_env/config/parser.py +151 -0
  5. comfy_env/config/types.py +64 -0
  6. comfy_env/install.py +83 -361
  7. comfy_env/isolation/__init__.py +9 -0
  8. comfy_env/isolation/wrap.py +351 -0
  9. comfy_env/nodes.py +2 -2
  10. comfy_env/pixi/__init__.py +48 -0
  11. comfy_env/pixi/core.py +356 -0
  12. comfy_env/{resolver.py → pixi/resolver.py} +1 -14
  13. comfy_env/prestartup.py +60 -0
  14. comfy_env/templates/comfy-env-instructions.txt +30 -87
  15. comfy_env/templates/comfy-env.toml +68 -136
  16. comfy_env/workers/__init__.py +21 -32
  17. comfy_env/workers/base.py +1 -1
  18. comfy_env/workers/{torch_mp.py → mp.py} +47 -14
  19. comfy_env/workers/{venv.py → subprocess.py} +405 -441
  20. {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/METADATA +2 -1
  21. comfy_env-0.0.66.dist-info/RECORD +34 -0
  22. comfy_env/decorator.py +0 -700
  23. comfy_env/env/__init__.py +0 -47
  24. comfy_env/env/config.py +0 -201
  25. comfy_env/env/config_file.py +0 -740
  26. comfy_env/env/manager.py +0 -636
  27. comfy_env/env/security.py +0 -267
  28. comfy_env/ipc/__init__.py +0 -55
  29. comfy_env/ipc/bridge.py +0 -476
  30. comfy_env/ipc/protocol.py +0 -265
  31. comfy_env/ipc/tensor.py +0 -371
  32. comfy_env/ipc/torch_bridge.py +0 -401
  33. comfy_env/ipc/transport.py +0 -318
  34. comfy_env/ipc/worker.py +0 -221
  35. comfy_env/isolation.py +0 -310
  36. comfy_env/pixi.py +0 -760
  37. comfy_env/stub_imports.py +0 -270
  38. comfy_env/stubs/__init__.py +0 -1
  39. comfy_env/stubs/comfy/__init__.py +0 -6
  40. comfy_env/stubs/comfy/model_management.py +0 -58
  41. comfy_env/stubs/comfy/utils.py +0 -29
  42. comfy_env/stubs/folder_paths.py +0 -71
  43. comfy_env/workers/pool.py +0 -241
  44. comfy_env-0.0.64.dist-info/RECORD +0 -48
  45. /comfy_env/{env/cuda_gpu_detection.py → pixi/cuda_detection.py} +0 -0
  46. /comfy_env/{env → pixi}/platform/__init__.py +0 -0
  47. /comfy_env/{env → pixi}/platform/base.py +0 -0
  48. /comfy_env/{env → pixi}/platform/darwin.py +0 -0
  49. /comfy_env/{env → pixi}/platform/linux.py +0 -0
  50. /comfy_env/{env → pixi}/platform/windows.py +0 -0
  51. /comfy_env/{registry.py → pixi/registry.py} +0 -0
  52. /comfy_env/{wheel_sources.yml → pixi/wheel_sources.yml} +0 -0
  53. {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/WHEEL +0 -0
  54. {comfy_env-0.0.64.dist-info → comfy_env-0.0.66.dist-info}/entry_points.txt +0 -0
  55. {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