comfy-env 0.0.18__tar.gz → 0.0.21__tar.gz

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 (49) hide show
  1. comfy_env-0.0.21/.github/workflows/publish.yml +67 -0
  2. {comfy_env-0.0.18 → comfy_env-0.0.21}/PKG-INFO +2 -2
  3. {comfy_env-0.0.18 → comfy_env-0.0.21}/README.md +1 -1
  4. {comfy_env-0.0.18 → comfy_env-0.0.21}/pyproject.toml +1 -1
  5. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/decorator.py +28 -9
  6. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/config.py +19 -0
  7. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/config_file.py +36 -3
  8. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/install.py +104 -12
  9. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/torch_mp.py +204 -1
  10. comfy_env-0.0.18/.github/workflows/publish.yml +0 -28
  11. comfy_env-0.0.18/examples/basic_node/__init__.py +0 -5
  12. comfy_env-0.0.18/examples/basic_node/comfy-env.toml +0 -65
  13. comfy_env-0.0.18/examples/basic_node/nodes.py +0 -157
  14. comfy_env-0.0.18/examples/basic_node/worker.py +0 -79
  15. comfy_env-0.0.18/examples/decorator_node/__init__.py +0 -9
  16. comfy_env-0.0.18/examples/decorator_node/nodes.py +0 -182
  17. comfy_env-0.0.18/src/comfy_env/tools.py +0 -221
  18. {comfy_env-0.0.18 → comfy_env-0.0.21}/.gitignore +0 -0
  19. {comfy_env-0.0.18 → comfy_env-0.0.21}/LICENSE +0 -0
  20. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/__init__.py +0 -0
  21. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/cli.py +0 -0
  22. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/__init__.py +0 -0
  23. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/cuda_gpu_detection.py +0 -0
  24. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/manager.py +0 -0
  25. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/platform/__init__.py +0 -0
  26. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/platform/base.py +0 -0
  27. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/platform/darwin.py +0 -0
  28. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/platform/linux.py +0 -0
  29. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/platform/windows.py +0 -0
  30. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/env/security.py +0 -0
  31. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/errors.py +0 -0
  32. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/__init__.py +0 -0
  33. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/bridge.py +0 -0
  34. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/protocol.py +0 -0
  35. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/tensor.py +0 -0
  36. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/torch_bridge.py +0 -0
  37. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/transport.py +0 -0
  38. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/ipc/worker.py +0 -0
  39. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/registry.py +0 -0
  40. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/resolver.py +0 -0
  41. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/stubs/__init__.py +0 -0
  42. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/stubs/folder_paths.py +0 -0
  43. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/wheel_sources.yml +0 -0
  44. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/__init__.py +0 -0
  45. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/base.py +0 -0
  46. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/pool.py +0 -0
  47. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/tensor_utils.py +0 -0
  48. {comfy_env-0.0.18 → comfy_env-0.0.21}/src/comfy_env/workers/venv.py +0 -0
  49. {comfy_env-0.0.18 → comfy_env-0.0.21}/untitled.txt +0 -0
@@ -0,0 +1,67 @@
1
+ name: Bump Version & Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ bump-and-publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ contents: write
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ token: ${{ secrets.GITHUB_TOKEN }}
20
+
21
+ - name: Setup Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.11'
25
+
26
+ - name: Get current version
27
+ id: get_version
28
+ run: |
29
+ current=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/' | tr -d '\r')
30
+ echo "current=$current" >> $GITHUB_OUTPUT
31
+
32
+ - name: Bump patch version
33
+ id: bump_version
34
+ run: |
35
+ current="${{ steps.get_version.outputs.current }}"
36
+ IFS='.' read -r major minor patch <<< "$current"
37
+ new_patch=$((patch + 1))
38
+ new_version="${major}.${minor}.${new_patch}"
39
+ sed -i "s/^version = \".*\"/version = \"${new_version}\"/" pyproject.toml
40
+ echo "new_version=$new_version" >> $GITHUB_OUTPUT
41
+ echo "Bumped version: $current -> $new_version"
42
+
43
+ - name: Commit version bump
44
+ run: |
45
+ git config user.name "github-actions[bot]"
46
+ git config user.email "github-actions[bot]@users.noreply.github.com"
47
+ git add pyproject.toml
48
+ git commit -m "Bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
49
+ git tag "v${{ steps.bump_version.outputs.new_version }}"
50
+ git push origin main --tags
51
+
52
+ - name: Install build tools
53
+ run: pip install build
54
+
55
+ - name: Build package
56
+ run: python -m build
57
+
58
+ - name: Create GitHub Release
59
+ uses: softprops/action-gh-release@v1
60
+ with:
61
+ tag_name: v${{ steps.bump_version.outputs.new_version }}
62
+ name: v${{ steps.bump_version.outputs.new_version }}
63
+ generate_release_notes: true
64
+ files: dist/*
65
+
66
+ - name: Publish to PyPI
67
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comfy-env
3
- Version: 0.0.18
3
+ Version: 0.0.21
4
4
  Summary: Environment management for ComfyUI custom nodes - CUDA wheel resolution and process isolation
5
5
  Project-URL: Homepage, https://github.com/PozzettiAndrea/comfy-env
6
6
  Project-URL: Repository, https://github.com/PozzettiAndrea/comfy-env
@@ -126,7 +126,7 @@ comfy-env resolve nvdiffrast==0.4.0
126
126
  comfy-env doctor
127
127
  ```
128
128
 
129
- ## Configuration
129
+ ## Configurations
130
130
 
131
131
  ### comfy-env.toml
132
132
 
@@ -98,7 +98,7 @@ comfy-env resolve nvdiffrast==0.4.0
98
98
  comfy-env doctor
99
99
  ```
100
100
 
101
- ## Configuration
101
+ ## Configurations
102
102
 
103
103
  ### comfy-env.toml
104
104
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "comfy-env"
3
- version = "0.0.18"
3
+ version = "0.0.21"
4
4
  description = "Environment management for ComfyUI custom nodes - CUDA wheel resolution and process isolation"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -113,6 +113,31 @@ def _clone_tensor_if_needed(obj: Any, smart_clone: bool = True) -> Any:
113
113
  return obj
114
114
 
115
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
+
116
141
  # ---------------------------------------------------------------------------
117
142
  # Worker Management
118
143
  # ---------------------------------------------------------------------------
@@ -262,10 +287,7 @@ def isolated(
262
287
  # Get source file info for sys.path setup
263
288
  source_file = Path(inspect.getfile(cls))
264
289
  node_dir = source_file.parent
265
- if node_dir.name == "nodes":
266
- node_package_dir = node_dir.parent
267
- else:
268
- node_package_dir = node_dir
290
+ node_package_dir = _find_node_package_dir(source_file)
269
291
 
270
292
  # Build sys.path for worker
271
293
  sys_path_additions = [str(node_dir)]
@@ -367,13 +389,10 @@ def isolated(
367
389
  call_kwargs = {k: _clone_tensor_if_needed(v) for k, v in call_kwargs.items()}
368
390
 
369
391
  # Get module name for import in worker
392
+ # Note: ComfyUI uses full filesystem paths as module names for custom nodes.
393
+ # The worker's _execute_method_call handles this by using file-based imports.
370
394
  module_name = cls.__module__
371
395
 
372
- # Handle ComfyUI's dynamic import which can set __module__ to a path
373
- if module_name.startswith('/') or module_name.startswith('\\'):
374
- # Module name is a filesystem path - use the source file stem instead
375
- module_name = source_file.stem
376
-
377
396
  # Call worker using appropriate method
378
397
  if worker_config.python is None:
379
398
  # TorchMPWorker - use call_method protocol (avoids pickle issues)
@@ -5,6 +5,18 @@ from pathlib import Path
5
5
  from typing import Dict, List, Optional
6
6
 
7
7
 
8
+ @dataclass
9
+ class SystemConfig:
10
+ """Configuration for system-level packages.
11
+
12
+ These are OS-level packages (apt, brew, etc.) that need to be installed
13
+ before Python packages can work properly.
14
+ """
15
+ linux: List[str] = field(default_factory=list) # apt packages
16
+ darwin: List[str] = field(default_factory=list) # brew packages (future)
17
+ windows: List[str] = field(default_factory=list) # winget packages (future)
18
+
19
+
8
20
  @dataclass
9
21
  class LocalConfig:
10
22
  """Configuration for local (host environment) installs.
@@ -37,6 +49,7 @@ class EnvManagerConfig:
37
49
  Full configuration parsed from comfy-env.toml.
38
50
 
39
51
  Supports the v2 schema:
52
+ [system] - System-level packages (apt, brew, etc.)
40
53
  [local.cuda] - CUDA packages for host environment
41
54
  [local.packages] - Regular packages for host environment
42
55
  [envname] - Isolated env definition
@@ -45,11 +58,17 @@ class EnvManagerConfig:
45
58
  [node_reqs] - Node dependencies
46
59
  [tools] - External tools (e.g., blender = "4.2")
47
60
  """
61
+ system: SystemConfig = field(default_factory=SystemConfig)
48
62
  local: LocalConfig = field(default_factory=LocalConfig)
49
63
  envs: Dict[str, "IsolatedEnv"] = field(default_factory=dict)
50
64
  node_reqs: List[NodeReq] = field(default_factory=list)
51
65
  tools: Dict[str, ToolConfig] = field(default_factory=dict)
52
66
 
67
+ @property
68
+ def has_system(self) -> bool:
69
+ """Check if there are system packages to install."""
70
+ return bool(self.system.linux or self.system.darwin or self.system.windows)
71
+
53
72
  @property
54
73
  def has_local(self) -> bool:
55
74
  """Check if there are local packages to install."""
@@ -63,7 +63,7 @@ else:
63
63
  except ImportError:
64
64
  tomllib = None # type: ignore
65
65
 
66
- from .config import IsolatedEnv, EnvManagerConfig, LocalConfig, NodeReq, ToolConfig
66
+ from .config import IsolatedEnv, EnvManagerConfig, LocalConfig, NodeReq, SystemConfig, ToolConfig
67
67
  from .cuda_gpu_detection import detect_cuda_version
68
68
 
69
69
 
@@ -332,7 +332,7 @@ def _substitute_vars(s: str, variables: Dict[str, str]) -> str:
332
332
  # =============================================================================
333
333
 
334
334
  # Reserved table names that are NOT isolated environments
335
- RESERVED_TABLES = {"local", "node_reqs", "env", "packages", "sources", "cuda", "variables", "worker", "tools"}
335
+ RESERVED_TABLES = {"local", "node_reqs", "env", "packages", "sources", "cuda", "variables", "worker", "tools", "system"}
336
336
 
337
337
 
338
338
  def load_config(
@@ -426,12 +426,14 @@ def _parse_full_config(data: Dict[str, Any], base_dir: Path) -> EnvManagerConfig
426
426
  return _convert_simple_to_full(data, base_dir)
427
427
 
428
428
  # Parse full schema
429
+ system = _parse_system_section(data.get("system", {}))
429
430
  local = _parse_local_section(data.get("local", {}))
430
431
  envs = _parse_env_sections(data, base_dir)
431
432
  node_reqs = _parse_node_reqs(data.get("node_reqs", {}))
432
433
  tools = _parse_tools_section(data.get("tools", {}))
433
434
 
434
435
  return EnvManagerConfig(
436
+ system=system,
435
437
  local=local,
436
438
  envs=envs,
437
439
  node_reqs=node_reqs,
@@ -613,6 +615,34 @@ def _parse_tools_section(tools_data: Dict[str, Any]) -> Dict[str, ToolConfig]:
613
615
  return tools
614
616
 
615
617
 
618
+ def _parse_system_section(system_data: Dict[str, Any]) -> SystemConfig:
619
+ """Parse [system] section.
620
+
621
+ Supports:
622
+ [system]
623
+ linux = ["libgl1", "libopengl0"]
624
+ darwin = ["mesa"] # future
625
+ windows = ["vcredist"] # future
626
+ """
627
+ linux = system_data.get("linux", [])
628
+ darwin = system_data.get("darwin", [])
629
+ windows = system_data.get("windows", [])
630
+
631
+ # Ensure all are lists
632
+ if not isinstance(linux, list):
633
+ linux = [linux] if linux else []
634
+ if not isinstance(darwin, list):
635
+ darwin = [darwin] if darwin else []
636
+ if not isinstance(windows, list):
637
+ windows = [windows] if windows else []
638
+
639
+ return SystemConfig(
640
+ linux=linux,
641
+ darwin=darwin,
642
+ windows=windows,
643
+ )
644
+
645
+
616
646
  def _convert_simple_to_full(data: Dict[str, Any], base_dir: Path) -> EnvManagerConfig:
617
647
  """Convert simple config format to full EnvManagerConfig.
618
648
 
@@ -622,8 +652,9 @@ def _convert_simple_to_full(data: Dict[str, Any], base_dir: Path) -> EnvManagerC
622
652
  # Parse using simple parser to get IsolatedEnv
623
653
  simple_env = _parse_config(data, base_dir)
624
654
 
625
- # Parse tools section (shared between simple and full format)
655
+ # Parse tools and system sections (shared between simple and full format)
626
656
  tools = _parse_tools_section(data.get("tools", {}))
657
+ system = _parse_system_section(data.get("system", {}))
627
658
 
628
659
  # Check if this has explicit env settings (isolated venv) vs just CUDA packages (local install)
629
660
  env_section = data.get("env", {})
@@ -632,6 +663,7 @@ def _convert_simple_to_full(data: Dict[str, Any], base_dir: Path) -> EnvManagerC
632
663
  if has_explicit_env:
633
664
  # Isolated venv config
634
665
  return EnvManagerConfig(
666
+ system=system,
635
667
  local=LocalConfig(),
636
668
  envs={simple_env.name: simple_env},
637
669
  node_reqs=[],
@@ -648,6 +680,7 @@ def _convert_simple_to_full(data: Dict[str, Any], base_dir: Path) -> EnvManagerC
648
680
  cuda_packages[req] = ""
649
681
 
650
682
  return EnvManagerConfig(
683
+ system=system,
651
684
  local=LocalConfig(
652
685
  cuda_packages=cuda_packages,
653
686
  requirements=simple_env.requirements,
@@ -24,13 +24,111 @@ import sys
24
24
  from pathlib import Path
25
25
  from typing import Any, Callable, Dict, List, Optional, Union
26
26
 
27
- from .env.config import IsolatedEnv, ToolConfig
27
+ from .env.config import IsolatedEnv, SystemConfig
28
28
  from .env.config_file import discover_env_config, load_env_from_file, load_config, discover_config
29
29
  from .env.manager import IsolatedEnvManager
30
30
  from .errors import CUDANotFoundError, DependencyError, InstallError, WheelNotFoundError
31
31
  from .registry import PACKAGE_REGISTRY, get_cuda_short2, is_registered
32
32
  from .resolver import RuntimeEnv, WheelResolver, parse_wheel_requirement
33
- from .tools import install_tool
33
+
34
+
35
+ def _install_system_packages(
36
+ system_config: SystemConfig,
37
+ log: Callable[[str], None],
38
+ dry_run: bool = False,
39
+ ) -> bool:
40
+ """
41
+ Install system-level packages (apt, brew, etc.).
42
+
43
+ Args:
44
+ system_config: SystemConfig with package lists per OS.
45
+ log: Logging callback.
46
+ dry_run: If True, show what would be installed without installing.
47
+
48
+ Returns:
49
+ True if installation succeeded or no packages needed.
50
+ """
51
+ platform = sys.platform
52
+
53
+ if platform.startswith("linux"):
54
+ packages = system_config.linux
55
+ if not packages:
56
+ return True
57
+
58
+ log(f"Installing {len(packages)} system package(s) via apt...")
59
+
60
+ if dry_run:
61
+ log(f" Would install: {', '.join(packages)}")
62
+ return True
63
+
64
+ # Check if apt-get is available
65
+ if not shutil.which("apt-get"):
66
+ log(" Warning: apt-get not found. Cannot install system packages.")
67
+ log(f" Please install manually: {', '.join(packages)}")
68
+ return True # Don't fail - just warn
69
+
70
+ # Check if we can use sudo
71
+ sudo_available = shutil.which("sudo") is not None
72
+
73
+ try:
74
+ if sudo_available:
75
+ # Try with sudo
76
+ log(" Running apt-get update...")
77
+ update_result = subprocess.run(
78
+ ["sudo", "apt-get", "update"],
79
+ capture_output=True,
80
+ text=True,
81
+ )
82
+ if update_result.returncode != 0:
83
+ log(f" Warning: apt-get update failed: {update_result.stderr.strip()}")
84
+ # Continue anyway - packages might already be cached
85
+
86
+ log(f" Installing: {', '.join(packages)}")
87
+ install_cmd = ["sudo", "apt-get", "install", "-y"] + packages
88
+ install_result = subprocess.run(
89
+ install_cmd,
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+
94
+ if install_result.returncode != 0:
95
+ log(f" Warning: apt-get install failed: {install_result.stderr.strip()}")
96
+ log(f" Please install manually: sudo apt-get install {' '.join(packages)}")
97
+ return True # Don't fail - just warn
98
+ else:
99
+ log(" System packages installed successfully.")
100
+ return True
101
+ else:
102
+ log(" Warning: sudo not available. Cannot install system packages automatically.")
103
+ log(f" Please install manually: sudo apt-get install {' '.join(packages)}")
104
+ return True # Don't fail - just warn
105
+
106
+ except Exception as e:
107
+ log(f" Warning: Failed to install system packages: {e}")
108
+ log(f" Please install manually: sudo apt-get install {' '.join(packages)}")
109
+ return True # Don't fail - just warn
110
+
111
+ elif platform == "darwin":
112
+ packages = system_config.darwin
113
+ if not packages:
114
+ return True
115
+
116
+ log(f"System packages for macOS: {', '.join(packages)}")
117
+ log(" Note: macOS system package installation not yet implemented.")
118
+ log(f" Please install manually with Homebrew: brew install {' '.join(packages)}")
119
+ return True
120
+
121
+ elif platform == "win32":
122
+ packages = system_config.windows
123
+ if not packages:
124
+ return True
125
+
126
+ log(f"System packages for Windows: {', '.join(packages)}")
127
+ log(" Note: Windows system package installation not yet implemented.")
128
+ log(f" Please install manually.")
129
+ return True
130
+
131
+ return True
34
132
 
35
133
 
36
134
  def install(
@@ -86,16 +184,10 @@ def install(
86
184
  "Create comfy-env.toml or specify path explicitly."
87
185
  )
88
186
 
89
- # Install tools first (e.g., Blender)
90
- # Tools are installed to ComfyUI root (shared across all nodes)
91
- if full_config.tools:
92
- log(f"Installing {len(full_config.tools)} tool(s)...")
93
- comfyui_root = node_dir.parent.parent # custom_nodes/../.. = ComfyUI/
94
- for name, tool_config in full_config.tools.items():
95
- if dry_run:
96
- log(f" Would install {name} {tool_config.version}")
97
- else:
98
- install_tool(tool_config, log, comfyui_root)
187
+ # Install system packages first (apt, brew, etc.)
188
+ # These need to be installed before Python packages that depend on them
189
+ if full_config.has_system:
190
+ _install_system_packages(full_config.system, log, dry_run)
99
191
 
100
192
  # Get environment config
101
193
  env_config = full_config.default_env
@@ -106,6 +106,190 @@ def _worker_loop(queue_in, queue_out, sys_path_additions=None):
106
106
  break
107
107
 
108
108
 
109
+ class PathBasedModuleFinder:
110
+ """
111
+ Meta path finder that handles ComfyUI's path-based module names.
112
+
113
+ ComfyUI uses full filesystem paths as module names for custom nodes.
114
+ This finder intercepts imports of such modules and loads them from disk.
115
+ """
116
+
117
+ def find_spec(self, fullname, path, target=None):
118
+ import importlib.util
119
+ import os
120
+
121
+ # Only handle path-based module names (starting with /)
122
+ if not fullname.startswith('/'):
123
+ return None
124
+
125
+ # Parse the module name to find base path and submodule parts
126
+ parts = fullname.split('.')
127
+ base_path = parts[0]
128
+ submodule_parts = parts[1:] if len(parts) > 1 else []
129
+
130
+ # Walk through parts to find where path ends and module begins
131
+ for i, part in enumerate(submodule_parts):
132
+ test_path = os.path.join(base_path, part)
133
+ if os.path.exists(test_path):
134
+ base_path = test_path
135
+ else:
136
+ # Remaining parts are module names
137
+ submodule_parts = submodule_parts[i:]
138
+ break
139
+ else:
140
+ # All parts were path components
141
+ submodule_parts = []
142
+
143
+ # Determine the file to load
144
+ if submodule_parts:
145
+ # We're importing a submodule
146
+ current_path = base_path
147
+ for part in submodule_parts[:-1]:
148
+ current_path = os.path.join(current_path, part)
149
+
150
+ submod = submodule_parts[-1]
151
+ submod_file = os.path.join(current_path, submod + '.py')
152
+ submod_pkg = os.path.join(current_path, submod, '__init__.py')
153
+
154
+ if os.path.exists(submod_file):
155
+ return importlib.util.spec_from_file_location(fullname, submod_file)
156
+ elif os.path.exists(submod_pkg):
157
+ return importlib.util.spec_from_file_location(
158
+ fullname, submod_pkg,
159
+ submodule_search_locations=[os.path.join(current_path, submod)]
160
+ )
161
+ else:
162
+ # Top-level path-based module
163
+ if os.path.isdir(base_path):
164
+ init_path = os.path.join(base_path, "__init__.py")
165
+ if os.path.exists(init_path):
166
+ return importlib.util.spec_from_file_location(
167
+ fullname, init_path,
168
+ submodule_search_locations=[base_path]
169
+ )
170
+ elif os.path.isfile(base_path):
171
+ return importlib.util.spec_from_file_location(fullname, base_path)
172
+
173
+ return None
174
+
175
+
176
+ # Global flag to track if we've installed the finder
177
+ _path_finder_installed = False
178
+
179
+
180
+ def _ensure_path_finder_installed():
181
+ """Install the PathBasedModuleFinder if not already installed."""
182
+ import sys
183
+ global _path_finder_installed
184
+ if not _path_finder_installed:
185
+ sys.meta_path.insert(0, PathBasedModuleFinder())
186
+ _path_finder_installed = True
187
+ logger.debug("[comfy_env] Installed PathBasedModuleFinder for path-based module names")
188
+
189
+
190
+ def _load_path_based_module(module_name: str):
191
+ """
192
+ Load a module that has a filesystem path as its name.
193
+
194
+ ComfyUI uses full filesystem paths as module names for custom nodes.
195
+ This function handles that case by using file-based imports.
196
+ """
197
+ import importlib.util
198
+ import os
199
+ import sys
200
+
201
+ # Check if it's already in sys.modules
202
+ if module_name in sys.modules:
203
+ return sys.modules[module_name]
204
+
205
+ # Check if module_name contains submodule parts (e.g., "/path/to/pkg.submod.subsubmod")
206
+ # In this case, we need to load the parent packages first
207
+ if '.' in module_name:
208
+ parts = module_name.split('.')
209
+ # Find where the path ends and module parts begin
210
+ # The path part won't exist as a directory when combined with module parts
211
+ base_path = parts[0]
212
+ submodule_parts = []
213
+
214
+ for i, part in enumerate(parts[1:], 1):
215
+ test_path = os.path.join(base_path, part)
216
+ if os.path.exists(test_path):
217
+ base_path = test_path
218
+ else:
219
+ # This and remaining parts are module names, not path components
220
+ submodule_parts = parts[i:]
221
+ break
222
+
223
+ if submodule_parts:
224
+ # Load parent package first
225
+ parent_module = _load_path_based_module(base_path)
226
+
227
+ # Now load submodules
228
+ current_module = parent_module
229
+ current_name = base_path
230
+ for submod in submodule_parts:
231
+ current_name = f"{current_name}.{submod}"
232
+ if current_name in sys.modules:
233
+ current_module = sys.modules[current_name]
234
+ else:
235
+ # Try to import as attribute or load from file
236
+ if hasattr(current_module, submod):
237
+ current_module = getattr(current_module, submod)
238
+ else:
239
+ # Try to load the submodule file
240
+ if hasattr(current_module, '__path__'):
241
+ for parent_path in current_module.__path__:
242
+ submod_file = os.path.join(parent_path, submod + '.py')
243
+ submod_pkg = os.path.join(parent_path, submod, '__init__.py')
244
+ if os.path.exists(submod_file):
245
+ spec = importlib.util.spec_from_file_location(current_name, submod_file)
246
+ current_module = importlib.util.module_from_spec(spec)
247
+ current_module.__package__ = f"{base_path}.{'.'.join(submodule_parts[:-1])}" if len(submodule_parts) > 1 else base_path
248
+ sys.modules[current_name] = current_module
249
+ spec.loader.exec_module(current_module)
250
+ break
251
+ elif os.path.exists(submod_pkg):
252
+ spec = importlib.util.spec_from_file_location(current_name, submod_pkg,
253
+ submodule_search_locations=[os.path.dirname(submod_pkg)])
254
+ current_module = importlib.util.module_from_spec(spec)
255
+ sys.modules[current_name] = current_module
256
+ spec.loader.exec_module(current_module)
257
+ break
258
+ else:
259
+ raise ModuleNotFoundError(f"Cannot find submodule {submod} in {current_name}")
260
+ return current_module
261
+
262
+ # Simple path-based module (no submodule parts)
263
+ if os.path.isdir(module_name):
264
+ init_path = os.path.join(module_name, "__init__.py")
265
+ submodule_search_locations = [module_name]
266
+ else:
267
+ init_path = module_name
268
+ submodule_search_locations = None
269
+
270
+ if not os.path.exists(init_path):
271
+ raise ModuleNotFoundError(f"Cannot find module at path: {module_name}")
272
+
273
+ spec = importlib.util.spec_from_file_location(
274
+ module_name,
275
+ init_path,
276
+ submodule_search_locations=submodule_search_locations
277
+ )
278
+ module = importlib.util.module_from_spec(spec)
279
+
280
+ # Set up package attributes for relative imports
281
+ if os.path.isdir(module_name):
282
+ module.__path__ = [module_name]
283
+ module.__package__ = module_name
284
+ else:
285
+ module.__package__ = module_name.rsplit('.', 1)[0] if '.' in module_name else ''
286
+
287
+ sys.modules[module_name] = module
288
+ spec.loader.exec_module(module)
289
+
290
+ return module
291
+
292
+
109
293
  def _execute_method_call(module_name: str, class_name: str, method_name: str,
110
294
  self_state: dict, kwargs: dict) -> Any:
111
295
  """
@@ -114,9 +298,28 @@ def _execute_method_call(module_name: str, class_name: str, method_name: str,
114
298
  This function imports the class fresh and calls the original (un-decorated) method.
115
299
  """
116
300
  import importlib
301
+ import os
302
+ import sys
117
303
 
118
304
  # Import the module
119
- module = importlib.import_module(module_name)
305
+ logger.debug(f"Attempting to import module_name={module_name}")
306
+
307
+ # Check if module_name is a filesystem path (ComfyUI uses paths as module names)
308
+ # This happens because ComfyUI's load_custom_node uses the full path as sys_module_name
309
+ if module_name.startswith('/') or (os.sep in module_name and not module_name.startswith('.')):
310
+ # Check if the base path exists to confirm it's a path-based module
311
+ base_path = module_name.split('.')[0] if '.' in module_name else module_name
312
+ if os.path.exists(base_path):
313
+ logger.debug(f"Detected path-based module name, using file-based import")
314
+ # Install the meta path finder to handle relative imports within the package
315
+ _ensure_path_finder_installed()
316
+ module = _load_path_based_module(module_name)
317
+ else:
318
+ # Doesn't look like a valid path, try standard import
319
+ module = importlib.import_module(module_name)
320
+ else:
321
+ # Standard module name - use importlib.import_module
322
+ module = importlib.import_module(module_name)
120
323
  cls = getattr(module, class_name)
121
324
 
122
325
  # Create instance with proper __slots__ handling
@@ -1,28 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- environment: pypi
11
- permissions:
12
- id-token: write # Required for trusted publishing
13
-
14
- steps:
15
- - uses: actions/checkout@v4
16
-
17
- - uses: actions/setup-python@v5
18
- with:
19
- python-version: "3.11"
20
-
21
- - name: Install build tools
22
- run: pip install build
23
-
24
- - name: Build package
25
- run: python -m build
26
-
27
- - name: Publish to PyPI
28
- uses: pypa/gh-action-pypi-publish@release/v1