comfy-env 0.0.8__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.
@@ -0,0 +1,592 @@
1
+ """Load IsolatedEnv configuration from TOML files.
2
+
3
+ This module provides declarative configuration for isolated environments,
4
+ allowing custom nodes to define their requirements in a TOML file instead
5
+ of programmatically.
6
+
7
+ Config file: comfy-env.toml
8
+
9
+ Simplified config (recommended for CUDA packages only):
10
+
11
+ # comfy-env.toml - just list CUDA packages
12
+ [cuda]
13
+ torch-scatter = "2.1.2"
14
+ torch-cluster = "1.6.3"
15
+ spconv = "*" # latest compatible
16
+
17
+ Or as a list:
18
+
19
+ cuda = [
20
+ "torch-scatter==2.1.2",
21
+ "torch-cluster==1.6.3",
22
+ "spconv",
23
+ ]
24
+
25
+ Optional overrides:
26
+
27
+ [env]
28
+ cuda = "12.4" # Override auto-detection
29
+ pytorch = "2.5.1" # Override auto-detection
30
+
31
+ Full format:
32
+
33
+ [env]
34
+ name = "my-node"
35
+ python = "3.10"
36
+ cuda = "auto"
37
+
38
+ [packages]
39
+ requirements = ["my-package>=1.0.0"]
40
+ no_deps = ["torch-scatter==2.1.2"]
41
+
42
+ [sources]
43
+ wheel_sources = ["https://my-wheels.github.io/"]
44
+
45
+ Available auto-derived variables:
46
+ - {cuda_version}: Full CUDA version (e.g., "12.8")
47
+ - {cuda_short}: CUDA version without dot (e.g., "128")
48
+ - {pytorch_version}: Full PyTorch version (e.g., "2.9.1")
49
+ - {pytorch_short}: PyTorch version without dots (e.g., "291")
50
+ - {pytorch_mm}: PyTorch major.minor without dot (e.g., "29")
51
+ """
52
+
53
+ import sys
54
+ from pathlib import Path
55
+ from typing import Optional, Dict, Any, List
56
+
57
+ # Use built-in tomllib (Python 3.11+) or tomli fallback
58
+ if sys.version_info >= (3, 11):
59
+ import tomllib
60
+ else:
61
+ try:
62
+ import tomli as tomllib
63
+ except ImportError:
64
+ tomllib = None # type: ignore
65
+
66
+ from .config import IsolatedEnv, EnvManagerConfig, LocalConfig, NodeReq
67
+ from .detection import detect_cuda_version
68
+
69
+
70
+ # Config file name
71
+ CONFIG_FILE_NAMES = [
72
+ "comfy-env.toml",
73
+ ]
74
+
75
+
76
+ def load_env_from_file(
77
+ path: Path,
78
+ base_dir: Optional[Path] = None,
79
+ ) -> IsolatedEnv:
80
+ """
81
+ Load IsolatedEnv configuration from a TOML file.
82
+
83
+ Args:
84
+ path: Path to the TOML config file
85
+ base_dir: Base directory for resolving relative paths (default: file's parent)
86
+
87
+ Returns:
88
+ Configured IsolatedEnv instance
89
+
90
+ Raises:
91
+ FileNotFoundError: If config file doesn't exist
92
+ ValueError: If config is invalid
93
+ ImportError: If tomli is not installed (Python < 3.11)
94
+
95
+ Example:
96
+ >>> env = load_env_from_file(Path("my_node/comfy-env.toml"))
97
+ >>> print(env.name)
98
+ 'my-node'
99
+ """
100
+ if tomllib is None:
101
+ raise ImportError(
102
+ "TOML parsing requires tomli for Python < 3.11. "
103
+ "Install it with: pip install tomli"
104
+ )
105
+
106
+ path = Path(path)
107
+ if not path.exists():
108
+ raise FileNotFoundError(f"Config file not found: {path}")
109
+
110
+ base_dir = Path(base_dir) if base_dir else path.parent
111
+
112
+ with open(path, "rb") as f:
113
+ data = tomllib.load(f)
114
+
115
+ return _parse_config(data, base_dir)
116
+
117
+
118
+ def discover_env_config(
119
+ node_dir: Path,
120
+ file_names: Optional[List[str]] = None,
121
+ ) -> Optional[IsolatedEnv]:
122
+ """
123
+ Auto-discover and load config from a node directory.
124
+
125
+ Searches for standard config file names in order of priority.
126
+
127
+ Args:
128
+ node_dir: Directory to search for config files
129
+ file_names: Custom list of file names to search (default: CONFIG_FILE_NAMES)
130
+
131
+ Returns:
132
+ IsolatedEnv if config found, None otherwise
133
+
134
+ Example:
135
+ >>> env = discover_env_config(Path("my_custom_node/"))
136
+ >>> if env:
137
+ ... print(f"Found config: {env.name}")
138
+ ... else:
139
+ ... print("No config file found")
140
+ """
141
+ if tomllib is None:
142
+ # Can't parse TOML without the library
143
+ return None
144
+
145
+ node_dir = Path(node_dir)
146
+ file_names = file_names or CONFIG_FILE_NAMES
147
+
148
+ for name in file_names:
149
+ config_path = node_dir / name
150
+ if config_path.exists():
151
+ return load_env_from_file(config_path, node_dir)
152
+
153
+ return None
154
+
155
+
156
+ def _get_default_pytorch_version(cuda_version: Optional[str]) -> str:
157
+ """
158
+ Get default PyTorch version based on CUDA version.
159
+
160
+ Args:
161
+ cuda_version: CUDA version (e.g., "12.4", "12.8") or None
162
+
163
+ Returns:
164
+ PyTorch version string
165
+
166
+ Version Mapping:
167
+ - CUDA 12.4 (Pascal): PyTorch 2.5.1
168
+ - CUDA 12.8 (Turing+): PyTorch 2.8.0
169
+ """
170
+ if cuda_version == "12.4":
171
+ return "2.5.1" # Legacy: Pascal GPUs
172
+ return "2.8.0" # Modern: Turing through Blackwell
173
+
174
+
175
+ def _parse_config(data: Dict[str, Any], base_dir: Path) -> IsolatedEnv:
176
+ """
177
+ Parse TOML data into IsolatedEnv.
178
+
179
+ Supports both simplified and legacy config formats:
180
+
181
+ Simplified (CUDA packages only):
182
+ [packages]
183
+ torch-scatter = "2.1.2"
184
+ torch-cluster = "1.6.3"
185
+
186
+ Or as list:
187
+ packages = ["torch-scatter==2.1.2", "torch-cluster==1.6.3"]
188
+
189
+ Legacy:
190
+ [env]
191
+ name = "my-node"
192
+ [packages]
193
+ no_deps = ["torch-scatter==2.1.2"]
194
+
195
+ Args:
196
+ data: Parsed TOML data
197
+ base_dir: Base directory for resolving relative paths
198
+
199
+ Returns:
200
+ Configured IsolatedEnv instance
201
+ """
202
+ env_section = data.get("env", {})
203
+ packages_section = data.get("packages", {})
204
+ sources_section = data.get("sources", {})
205
+ worker_section = data.get("worker", {})
206
+ variables = dict(data.get("variables", {})) # Copy to avoid mutation
207
+
208
+ # Handle CUDA version - default to "auto" if not specified
209
+ cuda = env_section.get("cuda", "auto")
210
+ if cuda == "auto":
211
+ cuda = detect_cuda_version()
212
+ elif cuda == "null" or cuda == "none":
213
+ cuda = None
214
+
215
+ # Add auto-derived variables based on CUDA
216
+ if cuda:
217
+ variables.setdefault("cuda_version", cuda)
218
+ variables.setdefault("cuda_short", cuda.replace(".", ""))
219
+
220
+ # Handle pytorch version - auto-derive if "auto" or not specified
221
+ pytorch_version = env_section.get("pytorch_version") or env_section.get("pytorch")
222
+ if pytorch_version == "auto" or (pytorch_version is None and cuda):
223
+ pytorch_version = _get_default_pytorch_version(cuda)
224
+
225
+ if pytorch_version:
226
+ variables.setdefault("pytorch_version", pytorch_version)
227
+ # Add short version without dots (e.g., "2.9.1" -> "291")
228
+ pytorch_short = pytorch_version.replace(".", "")
229
+ variables.setdefault("pytorch_short", pytorch_short)
230
+ # Add major.minor without dot (e.g., "2.9.1" -> "29") for wheel naming
231
+ parts = pytorch_version.split(".")[:2]
232
+ pytorch_mm = "".join(parts)
233
+ variables.setdefault("pytorch_mm", pytorch_mm)
234
+
235
+ # Parse CUDA packages - support multiple formats
236
+ # Priority: [cuda] section > cuda = [...] > legacy [packages] section
237
+ no_deps_requirements = []
238
+ requirements = []
239
+
240
+ cuda_section = data.get("cuda", {})
241
+
242
+ if cuda_section:
243
+ # New format: [cuda] section or cuda = [...]
244
+ if isinstance(cuda_section, list):
245
+ # Format: cuda = ["torch-scatter==2.1.2", ...]
246
+ no_deps_requirements = [_substitute_vars(req, variables) for req in cuda_section]
247
+ elif isinstance(cuda_section, dict):
248
+ # Format: [cuda] with package = "version" pairs
249
+ for pkg, ver in cuda_section.items():
250
+ if ver == "*" or ver == "":
251
+ no_deps_requirements.append(pkg)
252
+ else:
253
+ no_deps_requirements.append(f"{pkg}=={ver}")
254
+
255
+ elif isinstance(packages_section, list):
256
+ # Legacy format: packages = ["torch-scatter==2.1.2", ...]
257
+ no_deps_requirements = [_substitute_vars(req, variables) for req in packages_section]
258
+
259
+ elif isinstance(packages_section, dict):
260
+ # Check for simplified format: [packages] with key=value pairs
261
+ # vs legacy format: [packages] with requirements/no_deps lists
262
+
263
+ has_legacy_keys = any(k in packages_section for k in ["requirements", "no_deps", "requirements_file"])
264
+
265
+ if has_legacy_keys:
266
+ # Legacy format
267
+ raw_requirements = packages_section.get("requirements", [])
268
+ requirements = [_substitute_vars(req, variables) for req in raw_requirements]
269
+
270
+ raw_no_deps = packages_section.get("no_deps", [])
271
+ no_deps_requirements = [_substitute_vars(req, variables) for req in raw_no_deps]
272
+ else:
273
+ # Simplified format: [packages] with package = "version" pairs
274
+ # All packages are CUDA packages
275
+ for pkg, ver in packages_section.items():
276
+ if ver == "*" or ver == "":
277
+ no_deps_requirements.append(pkg)
278
+ else:
279
+ no_deps_requirements.append(f"{pkg}=={ver}")
280
+
281
+ # Resolve requirements_file path (relative to base_dir)
282
+ requirements_file = None
283
+ if isinstance(packages_section, dict) and "requirements_file" in packages_section:
284
+ req_file_path = packages_section["requirements_file"]
285
+ requirements_file = base_dir / req_file_path
286
+
287
+ # Get wheel sources and index URLs (optional - registry handles most cases now)
288
+ wheel_sources = sources_section.get("wheel_sources", [])
289
+ index_urls = sources_section.get("index_urls", [])
290
+
291
+ # Parse worker configuration
292
+ worker_package = worker_section.get("package")
293
+ worker_script = worker_section.get("script")
294
+
295
+ return IsolatedEnv(
296
+ name=env_section.get("name", base_dir.name),
297
+ python=env_section.get("python", "3.10"),
298
+ cuda=cuda,
299
+ pytorch_version=pytorch_version,
300
+ requirements=requirements,
301
+ no_deps_requirements=no_deps_requirements,
302
+ requirements_file=requirements_file,
303
+ wheel_sources=wheel_sources,
304
+ index_urls=index_urls,
305
+ worker_package=worker_package,
306
+ worker_script=worker_script,
307
+ )
308
+
309
+
310
+ def _substitute_vars(s: str, variables: Dict[str, str]) -> str:
311
+ """
312
+ Substitute {var_name} placeholders with values from variables dict.
313
+
314
+ Args:
315
+ s: String with placeholders like {var_name}
316
+ variables: Dictionary mapping variable names to values
317
+
318
+ Returns:
319
+ String with placeholders replaced
320
+
321
+ Example:
322
+ >>> _substitute_vars("torch=={pytorch_version}", {"pytorch_version": "2.4.1"})
323
+ 'torch==2.4.1'
324
+ """
325
+ for key, value in variables.items():
326
+ s = s.replace(f"{{{key}}}", str(value))
327
+ return s
328
+
329
+
330
+ # =============================================================================
331
+ # V2 Schema Parser
332
+ # =============================================================================
333
+
334
+ # Reserved table names that are NOT isolated environments
335
+ RESERVED_TABLES = {"local", "node_reqs", "env", "packages", "sources", "cuda", "variables", "worker"}
336
+
337
+
338
+ def load_config(
339
+ path: Path,
340
+ base_dir: Optional[Path] = None,
341
+ ) -> EnvManagerConfig:
342
+ """
343
+ Load full EnvManagerConfig from a TOML file (v2 schema).
344
+
345
+ Supports both v2 schema and legacy format (auto-detected).
346
+
347
+ Args:
348
+ path: Path to the TOML config file
349
+ base_dir: Base directory for resolving relative paths
350
+
351
+ Returns:
352
+ EnvManagerConfig with local, envs, and node_reqs
353
+
354
+ Example:
355
+ >>> config = load_config(Path("comfy-env.toml"))
356
+ >>> if config.has_local:
357
+ ... print(f"Local CUDA packages: {config.local.cuda_packages}")
358
+ >>> for name, env in config.envs.items():
359
+ ... print(f"Isolated env: {name}")
360
+ """
361
+ if tomllib is None:
362
+ raise ImportError(
363
+ "TOML parsing requires tomli for Python < 3.11. "
364
+ "Install it with: pip install tomli"
365
+ )
366
+
367
+ path = Path(path)
368
+ if not path.exists():
369
+ raise FileNotFoundError(f"Config file not found: {path}")
370
+
371
+ base_dir = Path(base_dir) if base_dir else path.parent
372
+
373
+ with open(path, "rb") as f:
374
+ data = tomllib.load(f)
375
+
376
+ return _parse_config_v2(data, base_dir)
377
+
378
+
379
+ def discover_config(
380
+ node_dir: Path,
381
+ file_names: Optional[List[str]] = None,
382
+ ) -> Optional[EnvManagerConfig]:
383
+ """
384
+ Auto-discover and load EnvManagerConfig from a node directory.
385
+
386
+ Args:
387
+ node_dir: Directory to search for config files
388
+ file_names: Custom list of file names to search
389
+
390
+ Returns:
391
+ EnvManagerConfig if found, None otherwise
392
+ """
393
+ if tomllib is None:
394
+ return None
395
+
396
+ node_dir = Path(node_dir)
397
+ file_names = file_names or CONFIG_FILE_NAMES
398
+
399
+ for name in file_names:
400
+ config_path = node_dir / name
401
+ if config_path.exists():
402
+ return load_config(config_path, node_dir)
403
+
404
+ return None
405
+
406
+
407
+ def _parse_config_v2(data: Dict[str, Any], base_dir: Path) -> EnvManagerConfig:
408
+ """
409
+ Parse TOML data into EnvManagerConfig (v2 schema).
410
+
411
+ Schema:
412
+ [local.cuda] - CUDA packages for host
413
+ [local.packages] - Regular packages for host
414
+ [envname] - Isolated env (python, cuda, pytorch)
415
+ [envname.cuda] - CUDA packages for env
416
+ [envname.packages] - Packages for env
417
+ [node_reqs] - Node dependencies
418
+
419
+ Also supports legacy format for backward compatibility.
420
+ """
421
+ # Detect if this is v2 schema or legacy
422
+ is_v2 = "local" in data or _has_named_env(data)
423
+
424
+ if not is_v2:
425
+ # Legacy format - convert to v2 structure
426
+ return _convert_legacy_to_v2(data, base_dir)
427
+
428
+ # Parse v2 schema
429
+ local = _parse_local_section(data.get("local", {}))
430
+ envs = _parse_env_sections(data, base_dir)
431
+ node_reqs = _parse_node_reqs(data.get("node_reqs", {}))
432
+
433
+ return EnvManagerConfig(
434
+ local=local,
435
+ envs=envs,
436
+ node_reqs=node_reqs,
437
+ )
438
+
439
+
440
+ def _has_named_env(data: Dict[str, Any]) -> bool:
441
+ """Check if data has any named environment tables (not reserved names)."""
442
+ env_indicators = ["python", "cuda", "pytorch", "cuda_version", "pytorch_version"]
443
+ for key in data.keys():
444
+ if key not in RESERVED_TABLES and isinstance(data[key], dict):
445
+ # Check if it looks like an env definition
446
+ section = data[key]
447
+ if any(k in section for k in env_indicators):
448
+ return True
449
+ return False
450
+
451
+
452
+ def _parse_local_section(local_data: Dict[str, Any]) -> LocalConfig:
453
+ """Parse [local] section."""
454
+ cuda_packages = {}
455
+ requirements = []
456
+
457
+ # [local.cuda] - CUDA packages
458
+ cuda_section = local_data.get("cuda", {})
459
+ if isinstance(cuda_section, dict):
460
+ for pkg, ver in cuda_section.items():
461
+ cuda_packages[pkg] = ver if ver and ver != "*" else ""
462
+
463
+ # [local.packages] - regular packages
464
+ packages_section = local_data.get("packages", {})
465
+ if isinstance(packages_section, dict):
466
+ requirements = packages_section.get("requirements", [])
467
+ elif isinstance(packages_section, list):
468
+ requirements = packages_section
469
+
470
+ return LocalConfig(
471
+ cuda_packages=cuda_packages,
472
+ requirements=requirements,
473
+ )
474
+
475
+
476
+ def _parse_env_sections(data: Dict[str, Any], base_dir: Path) -> Dict[str, IsolatedEnv]:
477
+ """Parse named environment sections."""
478
+ envs = {}
479
+ env_indicators = ["python", "cuda", "pytorch", "cuda_version", "pytorch_version"]
480
+
481
+ for key, value in data.items():
482
+ if key in RESERVED_TABLES:
483
+ continue
484
+ if not isinstance(value, dict):
485
+ continue
486
+
487
+ # Check if this looks like an env definition
488
+ if not any(k in value for k in env_indicators):
489
+ continue
490
+
491
+ env = _parse_single_env(key, value, base_dir)
492
+ envs[key] = env
493
+
494
+ return envs
495
+
496
+
497
+ def _parse_single_env(name: str, env_data: Dict[str, Any], base_dir: Path) -> IsolatedEnv:
498
+ """Parse a single isolated environment section."""
499
+ # Get basic env config
500
+ python = env_data.get("python", "3.10")
501
+ # Support both "cuda" and "cuda_version" field names (cuda_version avoids conflict with [envname.cuda] table)
502
+ cuda = env_data.get("cuda_version") or env_data.get("cuda", "auto")
503
+ pytorch = env_data.get("pytorch_version") or env_data.get("pytorch", "auto")
504
+
505
+ # Handle auto-detection
506
+ if cuda == "auto":
507
+ cuda = detect_cuda_version()
508
+ elif cuda in ("null", "none", None):
509
+ cuda = None
510
+
511
+ if pytorch == "auto":
512
+ pytorch = _get_default_pytorch_version(cuda)
513
+
514
+ # Parse [envname.cuda] - CUDA packages
515
+ cuda_section = env_data.get("cuda", {})
516
+ no_deps_requirements = []
517
+ if isinstance(cuda_section, dict):
518
+ for pkg, ver in cuda_section.items():
519
+ if ver == "*" or ver == "":
520
+ no_deps_requirements.append(pkg)
521
+ else:
522
+ no_deps_requirements.append(f"{pkg}=={ver}")
523
+
524
+ # Parse [envname.packages] - regular packages
525
+ packages_section = env_data.get("packages", {})
526
+ requirements = []
527
+ if isinstance(packages_section, dict):
528
+ requirements = packages_section.get("requirements", [])
529
+ elif isinstance(packages_section, list):
530
+ requirements = packages_section
531
+
532
+ return IsolatedEnv(
533
+ name=name,
534
+ python=python,
535
+ cuda=cuda,
536
+ pytorch_version=pytorch,
537
+ requirements=requirements,
538
+ no_deps_requirements=no_deps_requirements,
539
+ )
540
+
541
+
542
+ def _parse_node_reqs(node_reqs_data: Dict[str, Any]) -> List[NodeReq]:
543
+ """Parse [node_reqs] section."""
544
+ reqs = []
545
+
546
+ for name, value in node_reqs_data.items():
547
+ if isinstance(value, str):
548
+ # Simple format: VideoHelperSuite = "Kosinkadink/ComfyUI-VideoHelperSuite"
549
+ reqs.append(NodeReq(name=name, repo=value))
550
+ elif isinstance(value, dict):
551
+ # Extended format: VideoHelperSuite = { repo = "..." }
552
+ repo = value.get("repo", "")
553
+ reqs.append(NodeReq(name=name, repo=repo))
554
+
555
+ return reqs
556
+
557
+
558
+ def _convert_legacy_to_v2(data: Dict[str, Any], base_dir: Path) -> EnvManagerConfig:
559
+ """Convert legacy config format to v2 EnvManagerConfig."""
560
+ # Parse using legacy parser to get IsolatedEnv
561
+ legacy_env = _parse_config(data, base_dir)
562
+
563
+ # Check if this is really just a Type 2 config (local CUDA only, no venv)
564
+ # Type 2 indicators: no [env] section with name, or name matches directory
565
+ env_section = data.get("env", {})
566
+ has_explicit_env = bool(env_section.get("name") or env_section.get("python"))
567
+
568
+ if has_explicit_env:
569
+ # This is a Type 1 config (isolated venv)
570
+ return EnvManagerConfig(
571
+ local=LocalConfig(),
572
+ envs={legacy_env.name: legacy_env},
573
+ node_reqs=[],
574
+ )
575
+ else:
576
+ # This is a Type 2 config (local CUDA only)
577
+ cuda_packages = {}
578
+ for req in legacy_env.no_deps_requirements:
579
+ if "==" in req:
580
+ pkg, ver = req.split("==", 1)
581
+ cuda_packages[pkg] = ver
582
+ else:
583
+ cuda_packages[req] = ""
584
+
585
+ return EnvManagerConfig(
586
+ local=LocalConfig(
587
+ cuda_packages=cuda_packages,
588
+ requirements=legacy_env.requirements,
589
+ ),
590
+ envs={},
591
+ node_reqs=[],
592
+ )