comfy-env 0.0.49__py3-none-any.whl → 0.0.50__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 CHANGED
@@ -34,6 +34,8 @@ from .env.security import (
34
34
  from .ipc.bridge import WorkerBridge
35
35
  from .ipc.worker import BaseWorker, register
36
36
  from .decorator import isolated, shutdown_all_processes
37
+ from .isolation import enable_isolation
38
+ from .stub_imports import setup_isolated_imports, cleanup_stubs
37
39
 
38
40
  # New in-place installation API
39
41
  from .install import install, verify_installation
@@ -144,6 +146,11 @@ __all__ = [
144
146
  # Legacy Decorator API
145
147
  "isolated",
146
148
  "shutdown_all_processes",
149
+ # New: Enable isolation for entire node pack
150
+ "enable_isolation",
151
+ # Import stubbing for isolated packages
152
+ "setup_isolated_imports",
153
+ "cleanup_stubs",
147
154
  ]
148
155
 
149
156
  # Add torch-based IPC if available
comfy_env/decorator.py CHANGED
@@ -41,7 +41,7 @@ import time
41
41
  from dataclasses import dataclass
42
42
  from functools import wraps
43
43
  from pathlib import Path
44
- from typing import Any, Callable, Dict, List, Optional, Union
44
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
45
45
 
46
46
  logger = logging.getLogger("comfy_env")
47
47
 
@@ -447,3 +447,254 @@ def isolated(
447
447
  return cls
448
448
 
449
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
comfy_env/env/config.py CHANGED
@@ -154,6 +154,8 @@ class IsolatedEnv:
154
154
  worker_script: Optional[str] = None # e.g., "worker.py" -> worker.py
155
155
  # Conda configuration (uses pixi backend when present)
156
156
  conda: Optional["CondaConfig"] = None
157
+ # Runtime isolation - run node FUNCTION methods in isolated subprocess
158
+ isolated: bool = False
157
159
 
158
160
  def __post_init__(self):
159
161
  """Validate and normalize configuration."""
@@ -572,6 +572,9 @@ def _parse_single_env(name: str, env_data: Dict[str, Any], base_dir: Path) -> Is
572
572
  elif isinstance(darwin_section, list):
573
573
  darwin_reqs = darwin_section
574
574
 
575
+ # Parse isolated flag for runtime process isolation
576
+ isolated = env_data.get("isolated", False)
577
+
575
578
  return IsolatedEnv(
576
579
  name=name,
577
580
  python=python,
@@ -583,6 +586,7 @@ def _parse_single_env(name: str, env_data: Dict[str, Any], base_dir: Path) -> Is
583
586
  linux_requirements=linux_reqs,
584
587
  darwin_requirements=darwin_reqs,
585
588
  conda=conda_config,
589
+ isolated=isolated,
586
590
  )
587
591
 
588
592
 
comfy_env/install.py CHANGED
@@ -1,21 +1,18 @@
1
1
  """
2
2
  Installation API for comfy-env.
3
3
 
4
- This module provides the main `install()` function that handles both:
5
- - In-place installation (CUDA wheels into current environment)
6
- - Isolated installation (create separate venv with dependencies)
4
+ This module provides the main `install()` function that handles:
5
+ - Named environments [envname] pixi (isolated Python environment)
6
+ - Local packages [local] → uv/pip (in-place to current Python)
7
7
 
8
8
  Example:
9
9
  from comfy_env import install
10
10
 
11
- # In-place install (auto-discovers config)
11
+ # Auto-discovers config and installs
12
12
  install()
13
13
 
14
- # In-place with explicit config
14
+ # With explicit config path
15
15
  install(config="comfy-env.toml")
16
-
17
- # Isolated environment
18
- install(config="comfy-env.toml", mode="isolated")
19
16
  """
20
17
 
21
18
  import inspect
@@ -27,7 +24,6 @@ from typing import Callable, Dict, List, Optional, Set, Union
27
24
 
28
25
  from .env.config import IsolatedEnv, LocalConfig, NodeReq, SystemConfig
29
26
  from .env.config_file import load_config, discover_config
30
- from .env.manager import IsolatedEnvManager
31
27
  from .errors import CUDANotFoundError, InstallError
32
28
  from .pixi import pixi_install
33
29
  from .registry import PACKAGE_REGISTRY, get_cuda_short2
@@ -139,31 +135,36 @@ def _install_node_dependencies(
139
135
 
140
136
 
141
137
  def install(
138
+ config: Optional[Union[str, Path]] = None,
139
+ node_dir: Optional[Path] = None,
142
140
  log_callback: Optional[Callable[[str], None]] = None,
143
141
  dry_run: bool = False,
144
142
  ) -> bool:
145
143
  """
146
- Install dependencies from comfy-env.toml, auto-discovered from caller's directory.
144
+ Install dependencies from comfy-env.toml.
147
145
 
148
146
  Example:
149
147
  from comfy_env import install
150
148
  install()
151
149
 
152
150
  Args:
151
+ config: Optional path to comfy-env.toml. Auto-discovered if not provided.
152
+ node_dir: Optional node directory. Auto-discovered from caller if not provided.
153
153
  log_callback: Optional callback for logging. Defaults to print.
154
154
  dry_run: If True, show what would be installed without installing.
155
155
 
156
156
  Returns:
157
157
  True if installation succeeded.
158
158
  """
159
- # Auto-discover caller's directory
160
- frame = inspect.stack()[1]
161
- caller_file = frame.filename
162
- node_dir = Path(caller_file).parent.resolve()
159
+ # Auto-discover caller's directory if not provided
160
+ if node_dir is None:
161
+ frame = inspect.stack()[1]
162
+ caller_file = frame.filename
163
+ node_dir = Path(caller_file).parent.resolve()
163
164
 
164
165
  log = log_callback or print
165
166
 
166
- full_config = _load_full_config(None, node_dir)
167
+ full_config = _load_full_config(config, node_dir)
167
168
  if full_config is None:
168
169
  raise FileNotFoundError(
169
170
  f"No comfy-env.toml found in {node_dir}. "
@@ -177,28 +178,36 @@ def install(
177
178
  _install_system_packages(full_config.system, log, dry_run)
178
179
 
179
180
  env_config = full_config.default_env
180
- if env_config is None and not full_config.has_local:
181
- log("No packages to install")
182
- return True
183
-
184
- if env_config:
185
- log(f"Found configuration: {env_config.name}")
186
-
187
- if env_config and env_config.uses_conda:
188
- log("Environment uses conda packages - using pixi backend")
189
- return pixi_install(env_config, node_dir, log, dry_run)
190
181
 
191
182
  # Get user wheel_sources overrides
192
183
  user_wheel_sources = full_config.wheel_sources if hasattr(full_config, 'wheel_sources') else {}
193
184
 
194
185
  if env_config:
195
- if env_config.python:
196
- return _install_isolated(env_config, node_dir, log, dry_run)
197
- else:
198
- return _install_inplace(env_config, node_dir, log, dry_run, user_wheel_sources)
186
+ # Named environment → always pixi
187
+ log(f"Found environment: {env_config.name}")
188
+ python_ver = env_config.python or "3.11" # Default to 3.11 if not specified
189
+ if not env_config.python:
190
+ log(f" No Python version specified, defaulting to {python_ver}")
191
+ env_config = IsolatedEnv(
192
+ name=env_config.name,
193
+ python=python_ver,
194
+ cuda=env_config.cuda,
195
+ pytorch=env_config.pytorch,
196
+ requirements=env_config.requirements,
197
+ no_deps_requirements=env_config.no_deps_requirements,
198
+ linux_requirements=env_config.linux_requirements,
199
+ darwin_requirements=env_config.darwin_requirements,
200
+ windows_requirements=env_config.windows_requirements,
201
+ conda=env_config.conda,
202
+ isolated=env_config.isolated,
203
+ )
204
+ log(f" Using pixi backend (Python {python_ver})")
205
+ return pixi_install(env_config, node_dir, log, dry_run)
199
206
  elif full_config.has_local:
207
+ # [local] section → uv in-place install
200
208
  return _install_local(full_config.local, node_dir, log, dry_run, user_wheel_sources)
201
209
  else:
210
+ log("No packages to install")
202
211
  return True
203
212
 
204
213
 
@@ -212,81 +221,6 @@ def _load_full_config(config: Optional[Union[str, Path]], node_dir: Path):
212
221
  return discover_config(node_dir)
213
222
 
214
223
 
215
- def _install_isolated(
216
- env_config: IsolatedEnv,
217
- node_dir: Path,
218
- log: Callable[[str], None],
219
- dry_run: bool,
220
- ) -> bool:
221
- """Install in isolated mode using IsolatedEnvManager."""
222
- log(f"Installing in isolated mode: {env_config.name}")
223
-
224
- if dry_run:
225
- log("Dry run - would create isolated environment:")
226
- log(f" Python: {env_config.python}")
227
- log(f" CUDA: {env_config.cuda or 'auto-detect'}")
228
- if env_config.requirements:
229
- log(f" Requirements: {len(env_config.requirements)} packages")
230
- return True
231
-
232
- manager = IsolatedEnvManager(base_dir=node_dir, log_callback=log)
233
- env_dir = manager.setup(env_config)
234
- log(f"Isolated environment ready: {env_dir}")
235
- return True
236
-
237
-
238
- def _install_inplace(
239
- env_config: IsolatedEnv,
240
- node_dir: Path,
241
- log: Callable[[str], None],
242
- dry_run: bool,
243
- user_wheel_sources: Dict[str, str],
244
- ) -> bool:
245
- """Install in-place into current environment."""
246
- log("Installing in-place mode")
247
-
248
- if sys.platform == "win32":
249
- log("Installing MSVC runtime for Windows...")
250
- if not dry_run:
251
- _pip_install(["msvc-runtime"], no_deps=False, log=log)
252
-
253
- env = RuntimeEnv.detect()
254
- log(f"Detected environment: {env}")
255
-
256
- if not env.cuda_version:
257
- cuda_packages = env_config.no_deps_requirements or []
258
- if cuda_packages:
259
- raise CUDANotFoundError(package=", ".join(cuda_packages))
260
-
261
- cuda_packages = env_config.no_deps_requirements or []
262
- regular_packages = env_config.requirements or []
263
-
264
- if dry_run:
265
- log("\nDry run - would install:")
266
- for req in cuda_packages:
267
- package, version = parse_wheel_requirement(req)
268
- url = _resolve_wheel_url(package, version, env, user_wheel_sources)
269
- log(f" {package}: {url[:80]}...")
270
- if regular_packages:
271
- log(" Regular packages:")
272
- for pkg in regular_packages:
273
- log(f" {pkg}")
274
- return True
275
-
276
- if cuda_packages:
277
- log(f"\nInstalling {len(cuda_packages)} CUDA packages...")
278
- for req in cuda_packages:
279
- package, version = parse_wheel_requirement(req)
280
- _install_cuda_package(package, version, env, user_wheel_sources, log)
281
-
282
- if regular_packages:
283
- log(f"\nInstalling {len(regular_packages)} regular packages...")
284
- _pip_install(regular_packages, no_deps=False, log=log)
285
-
286
- log("\nInstallation complete!")
287
- return True
288
-
289
-
290
224
  def _install_local(
291
225
  local_config: LocalConfig,
292
226
  node_dir: Path,