Files
2025-10-15 06:03:41 -04:00

219 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
"""Entry point for the h4 Toolkit custom nodes package.
Responsibilities handled here:
- Dependency validation and on-demand installation for Python packages.
- Optional update notification (no automatic upgrades).
- Import of the node classes with defensive error handling.
- Colourised startup table so toolkit status is front-and-centre in the console.
"""
from __future__ import annotations
import importlib
import importlib.util
import json
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from importlib import metadata
from typing import Dict, List, Tuple
try:
from colorama import Fore, Style, init as colorama_init
except ImportError: # pragma: no cover - bootstrap path
class _BlankColour: # type: ignore
RESET_ALL = ""
def __getattr__(self, _name: str) -> str:
return ""
Fore = Style = _BlankColour() # type: ignore
def colorama_init(*_args, **_kwargs): # type: ignore
return None
colorama_init(autoreset=True)
DEPENDENCIES: Dict[str, str] = {
"colorama": "0.4.6",
"numpy": "1.24.0",
"torch": "1.13.0",
}
TOOLKIT_NAME = "h4_ToolKit"
TOOLKIT_VERSION = "1.0.0"
def _emit(message: str, colour: str = Fore.LIGHTWHITE_EX) -> None:
print(f"{colour}[{TOOLKIT_NAME}] {message}{Style.RESET_ALL}")
def _ensure_dependency(name: str, minimum_version: str) -> None:
spec = importlib.util.find_spec(name)
if spec is None:
_emit(f"Installing missing dependency: {name}", Fore.YELLOW)
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", f"{name}>={minimum_version}"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as exc: # pragma: no cover - installation failures logged
_emit(f"Failed to install {name}: {exc.stderr.decode(errors='ignore')}", Fore.RED)
return
else:
installed_version = metadata.version(name)
if _needs_update(installed_version, minimum_version):
_emit(
f"Dependency {name} is below recommended version {minimum_version} (installed {installed_version}).",
Fore.YELLOW,
)
_notify_if_newer(name)
def _needs_update(installed: str, minimum: str) -> bool:
return _normalise_version(installed) < _normalise_version(minimum)
def _normalise_version(version: str) -> List[int]:
parts = []
for fragment in version.replace("-", ".").split('.'):
if fragment.isdigit():
parts.append(int(fragment))
else:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return parts[:3]
def _notify_if_newer(package_name: str) -> None:
try:
with urllib.request.urlopen(f"https://pypi.org/pypi/{package_name}/json", timeout=2.0) as response:
payload = json.loads(response.read().decode("utf-8"))
latest_version = payload["info"]["version"]
except (urllib.error.URLError, KeyError, TimeoutError, ValueError): # pragma: no cover - network best effort
return
try:
installed_version = metadata.version(package_name)
except metadata.PackageNotFoundError:
return
if _normalise_version(latest_version) > _normalise_version(installed_version):
_emit(
f"Update available for {package_name}: installed {installed_version}, latest {latest_version}",
Fore.CYAN,
)
def ensure_dependencies() -> None:
for package, minimum_version in DEPENDENCIES.items():
_ensure_dependency(package, minimum_version)
@dataclass
class NodeStatus:
name: str
version: str
status: str
colour: str
symbol: str
STATUS_COLOURS = {
"ok": (Fore.GREEN, "✔"),
"error": (Fore.RED, "✘"),
}
def _split_display_entry(display_value: str) -> Tuple[str, str]:
"""Derive human-friendly name and version from a display string."""
name = display_value.strip()
version = ""
if " (v" in display_value and display_value.strip().endswith(")"):
base, suffix = display_value.rsplit(" (v", 1)
name = base.strip()
version = suffix[:-1].strip()
elif " v" in display_value:
base, suffix = display_value.rsplit(" v", 1)
name = base.strip()
version = suffix.strip()
if not version:
version = TOOLKIT_VERSION
return name, version
def _render_status_table(status_rows: List[NodeStatus]) -> None:
def _measure(getter) -> int:
return max((len(getter(row)) for row in status_rows), default=0)
name_width = max(len("Node"), _measure(lambda row: row.name))
version_width = max(len("Version"), _measure(lambda row: row.version))
status_width = max(len("Status"), _measure(lambda row: row.status))
marker_width = max(len("Mark"), _measure(lambda row: row.symbol))
table_width = name_width + version_width + status_width + marker_width + 13 # pipes + spacing
title = f" {TOOLKIT_NAME} v{TOOLKIT_VERSION} ".center(table_width - 2, "=")
top_border = f"+{title}+"
separator = (
f"+{'-' * (name_width + 2)}+{'-' * (version_width + 2)}+"
f"{'-' * (status_width + 2)}+{'-' * (marker_width + 2)}+"
)
_emit(top_border, Fore.LIGHTMAGENTA_EX)
_emit(
f"| {'Node':<{name_width}} | {'Version':<{version_width}} | {'Status':<{status_width}} | {'Mark':<{marker_width}} |",
Fore.LIGHTWHITE_EX,
)
_emit(separator, Fore.LIGHTWHITE_EX)
for row in status_rows:
colour, symbol = row.colour, row.symbol
payload = (
f"| {row.name:<{name_width}} | {row.version:<{version_width}} | {row.status:<{status_width}} | {symbol:<{marker_width}} |"
)
_emit(payload, colour)
bottom_border = f"+{'=' * (table_width - 2)}+"
_emit(bottom_border, Fore.LIGHTBLUE_EX)
ensure_dependencies()
status_log: List[NodeStatus] = []
_exported_class_names: List[str] = []
try:
from . import nodes as _nodes # noqa: F401
NODE_CLASS_MAPPINGS = _nodes.NODE_CLASS_MAPPINGS # noqa: F401
NODE_DISPLAY_NAME_MAPPINGS = _nodes.NODE_DISPLAY_NAME_MAPPINGS # noqa: F401
NODE_TOOLTIP_MAPPINGS = _nodes.NODE_TOOLTIP_MAPPINGS # noqa: F401
ok_colour, ok_symbol = STATUS_COLOURS["ok"]
for class_key, class_obj in NODE_CLASS_MAPPINGS.items():
display_name = NODE_DISPLAY_NAME_MAPPINGS.get(class_key, class_obj.__name__)
name, version = _split_display_entry(display_name)
status_log.append(NodeStatus(name, version, "Loaded", ok_colour, ok_symbol))
globals()[class_obj.__name__] = class_obj
_exported_class_names.append(class_obj.__name__)
except Exception as exc: # pragma: no cover - import failures should not kill startup
err_colour, err_symbol = STATUS_COLOURS["error"]
status_log.append(NodeStatus("h4 : The Engine (Simple Sampler+Plot)", "-", f"Failed: {exc}", err_colour, err_symbol))
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_TOOLTIP_MAPPINGS = {}
_render_status_table(status_log)
WEB_DIRECTORY = "js"
__all__ = _exported_class_names + [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"NODE_TOOLTIP_MAPPINGS",
"WEB_DIRECTORY",
]