feat: add in-app LM Remote configuration
This commit is contained in:
@@ -1,62 +1,535 @@
|
||||
"""Configuration for ComfyUI-LM-Remote."""
|
||||
"""Validated, reloadable configuration for ComfyUI-LM-Remote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
_CONFIG_FILE = _PACKAGE_DIR / "config.json"
|
||||
_LEGACY_CONFIG_FILE = _PACKAGE_DIR / "config.json"
|
||||
_CONFIG_DIRECTORY_NAME = "ComfyUI-LM-Remote"
|
||||
_CONFIG_FILE_NAME = "config.json"
|
||||
_KNOWN_FIELDS = frozenset({"remote_url", "timeout", "path_mappings"})
|
||||
_MAX_TIMEOUT = 300
|
||||
_MAX_MAPPINGS = 100
|
||||
_MAX_VALUE_LENGTH = 4096
|
||||
_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
class RemoteConfig:
|
||||
"""Holds remote LoRA Manager connection settings."""
|
||||
class ConfigValidationError(ValueError):
|
||||
"""Raised when a proposed configuration value is invalid."""
|
||||
|
||||
def __init__(self):
|
||||
self.remote_url: str = ""
|
||||
self.timeout: int = 30
|
||||
self.path_mappings: dict[str, str] = {}
|
||||
self._load()
|
||||
def __init__(self, field: str, message: str):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _load(self):
|
||||
# Environment variable takes priority
|
||||
env_url = os.environ.get("LM_REMOTE_URL", "")
|
||||
env_timeout = os.environ.get("LM_REMOTE_TIMEOUT", "")
|
||||
|
||||
# Load config.json defaults
|
||||
if _CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.remote_url = data.get("remote_url", "")
|
||||
self.timeout = int(data.get("timeout", 30))
|
||||
self.path_mappings = data.get("path_mappings", {})
|
||||
except Exception as exc:
|
||||
logger.warning("[LM-Remote] Failed to read config.json: %s", exc)
|
||||
class ConfigConflictError(RuntimeError):
|
||||
"""Raised when a browser attempts to replace a stale configuration."""
|
||||
|
||||
# Env overrides
|
||||
if env_url:
|
||||
self.remote_url = env_url
|
||||
if env_timeout:
|
||||
self.timeout = int(env_timeout)
|
||||
|
||||
# Strip trailing slash
|
||||
self.remote_url = self.remote_url.rstrip("/")
|
||||
@dataclass(frozen=True)
|
||||
class ConfigSnapshot:
|
||||
"""One coherent set of effective runtime values."""
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.remote_url)
|
||||
generation: int
|
||||
remote_url: str
|
||||
timeout: int
|
||||
path_mappings: tuple[tuple[str, str], ...]
|
||||
|
||||
def mappings_dict(self) -> dict[str, str]:
|
||||
return dict(self.path_mappings)
|
||||
|
||||
def map_path(self, remote_path: str) -> str:
|
||||
"""Apply remote->local path prefix mappings."""
|
||||
for remote_prefix, local_prefix in self.path_mappings.items():
|
||||
if remote_path.startswith(remote_prefix):
|
||||
return local_prefix + remote_path[len(remote_prefix):]
|
||||
"""Map a path using only the values captured by this snapshot."""
|
||||
if not isinstance(remote_path, str):
|
||||
return remote_path
|
||||
normalized_path = remote_path.replace("\\", "/")
|
||||
for remote_prefix, local_prefix in self.path_mappings:
|
||||
is_root = remote_prefix == "/"
|
||||
if normalized_path == remote_prefix:
|
||||
remainder = ""
|
||||
elif is_root and normalized_path.startswith("/"):
|
||||
remainder = normalized_path[1:]
|
||||
elif normalized_path.startswith(f"{remote_prefix}/"):
|
||||
remainder = normalized_path[len(remote_prefix) + 1 :]
|
||||
else:
|
||||
continue
|
||||
|
||||
if not remainder:
|
||||
return local_prefix
|
||||
separator = (
|
||||
"\\" if "\\" in local_prefix and "/" not in local_prefix else os.sep
|
||||
)
|
||||
local_base = local_prefix.rstrip("/\\")
|
||||
return f"{local_base}{separator}{remainder.replace('/', separator)}"
|
||||
return remote_path
|
||||
|
||||
|
||||
def _default_user_config_file(environ: Mapping[str, str]) -> Path:
|
||||
explicit_path = environ.get("LM_REMOTE_CONFIG", "").strip()
|
||||
if explicit_path:
|
||||
return Path(explicit_path).expanduser()
|
||||
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
|
||||
user_directory = Path(folder_paths.get_user_directory())
|
||||
return user_directory / _CONFIG_DIRECTORY_NAME / _CONFIG_FILE_NAME
|
||||
except Exception:
|
||||
# Outside ComfyUI (for example, documentation tools), preserve the
|
||||
# historical package-level behaviour.
|
||||
return _LEGACY_CONFIG_FILE
|
||||
|
||||
|
||||
def _normalize_url(value: object, *, allow_empty: bool = True) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigValidationError("remote_url", "Remote URL must be text.")
|
||||
value = value.strip()
|
||||
if not value:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise ConfigValidationError("remote_url", "Enter a remote LoRA Manager URL.")
|
||||
if _CONTROL_CHARACTERS.search(value):
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Remote URL contains invalid characters."
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
# Accessing .port performs its own range and syntax validation.
|
||||
parsed.port
|
||||
except ValueError as exc:
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Remote URL has an invalid port."
|
||||
) from exc
|
||||
|
||||
if parsed.scheme.lower() not in {"http", "https"}:
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Remote URL must use http:// or https://."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Remote URL must include a host name."
|
||||
)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Credentials are not allowed in the remote URL."
|
||||
)
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ConfigValidationError(
|
||||
"remote_url", "Remote URL cannot contain a query or fragment."
|
||||
)
|
||||
|
||||
path = parsed.path.rstrip("/")
|
||||
return urlunsplit((parsed.scheme.lower(), parsed.netloc, path, "", ""))
|
||||
|
||||
|
||||
def _normalize_timeout(value: object) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ConfigValidationError("timeout", "Timeout must be a whole number.")
|
||||
if value < 1 or value > _MAX_TIMEOUT:
|
||||
raise ConfigValidationError(
|
||||
"timeout", f"Timeout must be between 1 and {_MAX_TIMEOUT} seconds."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_remote_prefix(value: str) -> str:
|
||||
normalized = value.replace("\\", "/")
|
||||
if normalized != "/":
|
||||
normalized = normalized.rstrip("/")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_mapping_value(field: str, value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigValidationError("path_mappings", f"{field} path must be text.")
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ConfigValidationError("path_mappings", f"{field} path cannot be empty.")
|
||||
if len(value) > _MAX_VALUE_LENGTH:
|
||||
raise ConfigValidationError("path_mappings", f"{field} path is too long.")
|
||||
if _CONTROL_CHARACTERS.search(value):
|
||||
raise ConfigValidationError(
|
||||
"path_mappings", f"{field} path contains invalid characters."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_mappings(value: object) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigValidationError("path_mappings", "Path mappings must be an object.")
|
||||
if len(value) > _MAX_MAPPINGS:
|
||||
raise ConfigValidationError(
|
||||
"path_mappings", f"No more than {_MAX_MAPPINGS} path mappings are allowed."
|
||||
)
|
||||
|
||||
normalized: dict[str, str] = {}
|
||||
for remote_value, local_value in value.items():
|
||||
remote_prefix = _normalize_remote_prefix(
|
||||
_normalize_mapping_value("Remote", remote_value)
|
||||
)
|
||||
if not remote_prefix:
|
||||
raise ConfigValidationError(
|
||||
"path_mappings",
|
||||
"Remote path cannot consist only of path separators.",
|
||||
)
|
||||
local_prefix = _normalize_mapping_value("Local", local_value)
|
||||
if remote_prefix in normalized:
|
||||
raise ConfigValidationError(
|
||||
"path_mappings", f"Duplicate remote path prefix: {remote_prefix}"
|
||||
)
|
||||
normalized[remote_prefix] = local_prefix
|
||||
|
||||
# Specific mappings must win over broader parent mappings.
|
||||
return tuple(
|
||||
sorted(normalized.items(), key=lambda pair: len(pair[0]), reverse=True)
|
||||
)
|
||||
|
||||
|
||||
def validate_config(data: object, *, allow_empty_url: bool = True) -> dict[str, object]:
|
||||
"""Validate and normalize a complete stored configuration."""
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigValidationError("config", "Configuration must be a JSON object.")
|
||||
unknown = set(data) - _KNOWN_FIELDS
|
||||
if unknown:
|
||||
names = ", ".join(sorted(str(name) for name in unknown))
|
||||
raise ConfigValidationError(
|
||||
"config", f"Unknown configuration field(s): {names}"
|
||||
)
|
||||
|
||||
return {
|
||||
"remote_url": _normalize_url(
|
||||
data.get("remote_url", ""), allow_empty=allow_empty_url
|
||||
),
|
||||
"timeout": _normalize_timeout(data.get("timeout", 30)),
|
||||
"path_mappings": dict(_normalize_mappings(data.get("path_mappings", {}))),
|
||||
}
|
||||
|
||||
|
||||
def _revision_for(data: Mapping[str, object]) -> str:
|
||||
encoded = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
|
||||
def _revision_for_file(path: Path) -> str:
|
||||
return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
|
||||
|
||||
|
||||
class RemoteConfig:
|
||||
"""Thread-safe configuration with legacy fallback and atomic persistence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_file: str | Path | None = None,
|
||||
legacy_config_file: str | Path | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self._environ = environ if environ is not None else os.environ
|
||||
self._explicit_target = config_file is None and bool(
|
||||
self._environ.get("LM_REMOTE_CONFIG", "").strip()
|
||||
)
|
||||
self._config_file = (
|
||||
Path(config_file)
|
||||
if config_file
|
||||
else _default_user_config_file(self._environ)
|
||||
)
|
||||
self._legacy_config_file = (
|
||||
Path(legacy_config_file) if legacy_config_file else _LEGACY_CONFIG_FILE
|
||||
)
|
||||
self._lock = threading.RLock()
|
||||
self._snapshot = ConfigSnapshot(0, "", 30, ())
|
||||
self._configured: dict[str, object] = {
|
||||
"remote_url": "",
|
||||
"timeout": 30,
|
||||
"path_mappings": {},
|
||||
}
|
||||
self._overrides: dict[str, str | None] = {
|
||||
"remote_url": None,
|
||||
"timeout": None,
|
||||
}
|
||||
self._revision = _revision_for({})
|
||||
self._source = "defaults"
|
||||
self._warnings: list[str] = []
|
||||
self.reload()
|
||||
|
||||
def _read_source(self) -> tuple[dict[str, object], str]:
|
||||
if self._config_file.exists():
|
||||
path = self._config_file
|
||||
source = "explicit" if self._explicit_target else "user"
|
||||
elif self._explicit_target:
|
||||
return {}, "explicit"
|
||||
elif self._legacy_config_file.exists():
|
||||
path = self._legacy_config_file
|
||||
source = "legacy"
|
||||
else:
|
||||
return {}, "defaults"
|
||||
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigValidationError(
|
||||
"config", "Configuration file must contain an object."
|
||||
)
|
||||
return data, source
|
||||
|
||||
def _source_location(self) -> tuple[Path | None, str]:
|
||||
if self._config_file.exists():
|
||||
source = "explicit" if self._explicit_target else "user"
|
||||
return self._config_file, source
|
||||
if self._explicit_target:
|
||||
return None, "explicit"
|
||||
if self._legacy_config_file.exists():
|
||||
return self._legacy_config_file, "legacy"
|
||||
return None, "defaults"
|
||||
|
||||
def _effective_values(
|
||||
self, configured: dict[str, object]
|
||||
) -> tuple[dict[str, object], dict[str, str | None], list[str]]:
|
||||
effective = {
|
||||
"remote_url": configured["remote_url"],
|
||||
"timeout": configured["timeout"],
|
||||
"path_mappings": dict(configured["path_mappings"]),
|
||||
}
|
||||
overrides: dict[str, str | None] = {"remote_url": None, "timeout": None}
|
||||
warnings: list[str] = []
|
||||
|
||||
env_url = self._environ.get("LM_REMOTE_URL", "")
|
||||
if env_url:
|
||||
try:
|
||||
effective["remote_url"] = _normalize_url(env_url)
|
||||
overrides["remote_url"] = "LM_REMOTE_URL"
|
||||
except ConfigValidationError as exc:
|
||||
warnings.append(f"Ignoring invalid LM_REMOTE_URL: {exc}")
|
||||
|
||||
env_timeout = self._environ.get("LM_REMOTE_TIMEOUT", "")
|
||||
if env_timeout:
|
||||
try:
|
||||
if isinstance(env_timeout, str) and env_timeout.strip().isdigit():
|
||||
parsed_timeout: object = int(env_timeout.strip())
|
||||
else:
|
||||
parsed_timeout = env_timeout
|
||||
effective["timeout"] = _normalize_timeout(parsed_timeout)
|
||||
overrides["timeout"] = "LM_REMOTE_TIMEOUT"
|
||||
except ConfigValidationError as exc:
|
||||
warnings.append(f"Ignoring invalid LM_REMOTE_TIMEOUT: {exc}")
|
||||
|
||||
return effective, overrides, warnings
|
||||
|
||||
def reload(self) -> ConfigSnapshot:
|
||||
"""Reload persisted and environment-managed values without partial mutation."""
|
||||
with self._lock:
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
raw, source = self._read_source()
|
||||
known_values = {key: raw[key] for key in _KNOWN_FIELDS if key in raw}
|
||||
configured = validate_config(known_values)
|
||||
revision = _revision_for(raw)
|
||||
except (OSError, json.JSONDecodeError, ConfigValidationError) as exc:
|
||||
logger.warning("[LM-Remote] Failed to read configuration: %s", exc)
|
||||
source_path, source = self._source_location()
|
||||
raw = {}
|
||||
configured = validate_config({})
|
||||
try:
|
||||
revision = (
|
||||
_revision_for_file(source_path)
|
||||
if source_path is not None
|
||||
else _revision_for(raw)
|
||||
)
|
||||
except OSError:
|
||||
revision = _revision_for(raw)
|
||||
warnings.append(f"Stored configuration could not be loaded: {exc}")
|
||||
|
||||
effective, overrides, env_warnings = self._effective_values(configured)
|
||||
warnings.extend(env_warnings)
|
||||
generation = self._snapshot.generation + 1
|
||||
snapshot = ConfigSnapshot(
|
||||
generation=generation,
|
||||
remote_url=str(effective["remote_url"]),
|
||||
timeout=int(effective["timeout"]),
|
||||
path_mappings=_normalize_mappings(effective["path_mappings"]),
|
||||
)
|
||||
self._configured = configured
|
||||
self._overrides = overrides
|
||||
self._revision = revision
|
||||
self._source = source
|
||||
self._warnings = warnings
|
||||
self._snapshot = snapshot
|
||||
return snapshot
|
||||
|
||||
@property
|
||||
def snapshot(self) -> ConfigSnapshot:
|
||||
with self._lock:
|
||||
return self._snapshot
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return self.snapshot.generation
|
||||
|
||||
@property
|
||||
def remote_url(self) -> str:
|
||||
return self.snapshot.remote_url
|
||||
|
||||
@property
|
||||
def timeout(self) -> int:
|
||||
return self.snapshot.timeout
|
||||
|
||||
@property
|
||||
def path_mappings(self) -> dict[str, str]:
|
||||
return self.snapshot.mappings_dict()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.snapshot.remote_url)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
"""Return browser-safe configured/effective values and source metadata."""
|
||||
with self._lock:
|
||||
snapshot = self._snapshot
|
||||
return {
|
||||
"configured": {
|
||||
"remote_url": self._configured["remote_url"],
|
||||
"timeout": self._configured["timeout"],
|
||||
"path_mappings": dict(self._configured["path_mappings"]),
|
||||
},
|
||||
"effective": {
|
||||
"remote_url": snapshot.remote_url,
|
||||
"timeout": snapshot.timeout,
|
||||
"path_mappings": snapshot.mappings_dict(),
|
||||
},
|
||||
"overrides": dict(self._overrides),
|
||||
"revision": self._revision,
|
||||
"generation": snapshot.generation,
|
||||
"storage": {
|
||||
"source": self._source,
|
||||
"writable": self._storage_writable(),
|
||||
},
|
||||
"warnings": list(self._warnings),
|
||||
"restart_required": False,
|
||||
}
|
||||
|
||||
def _storage_writable(self) -> bool:
|
||||
"""Return whether an atomic write can be created beside the target."""
|
||||
target = self._config_file
|
||||
try:
|
||||
if target.exists() and target.is_dir():
|
||||
return False
|
||||
|
||||
ancestor = target.parent
|
||||
while not ancestor.exists():
|
||||
parent = ancestor.parent
|
||||
if parent == ancestor:
|
||||
return False
|
||||
ancestor = parent
|
||||
if not ancestor.is_dir():
|
||||
return False
|
||||
|
||||
mode = ancestor.stat().st_mode
|
||||
write_bits = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
|
||||
execute_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
if not mode & write_bits or not mode & execute_bits:
|
||||
return False
|
||||
return os.access(ancestor, os.W_OK | os.X_OK)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def save(
|
||||
self, data: object, *, expected_revision: str | None = None
|
||||
) -> ConfigSnapshot:
|
||||
"""Atomically persist a complete configuration and activate it."""
|
||||
normalized = validate_config(data)
|
||||
serializable = {
|
||||
"remote_url": normalized["remote_url"],
|
||||
"timeout": normalized["timeout"],
|
||||
"path_mappings": normalized["path_mappings"],
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
current_raw, _ = self._read_source()
|
||||
except (OSError, json.JSONDecodeError, ConfigValidationError):
|
||||
source_path, _ = self._source_location()
|
||||
try:
|
||||
current_revision = (
|
||||
_revision_for_file(source_path)
|
||||
if source_path is not None
|
||||
else _revision_for({})
|
||||
)
|
||||
except OSError as revision_exc:
|
||||
raise ConfigConflictError(
|
||||
"The stored configuration changed and can no longer be read."
|
||||
) from revision_exc
|
||||
current_raw = {}
|
||||
else:
|
||||
current_revision = _revision_for(current_raw)
|
||||
if expected_revision is not None and expected_revision != current_revision:
|
||||
raise ConfigConflictError(
|
||||
"The configuration changed in another window. Reload it before saving."
|
||||
)
|
||||
|
||||
# Preserve future/third-party keys from an existing file while replacing
|
||||
# only fields owned by LM Remote.
|
||||
output = dict(current_raw)
|
||||
output.update(serializable)
|
||||
self._write_atomic(output)
|
||||
return self.reload()
|
||||
|
||||
def _write_atomic(self, data: Mapping[str, object]) -> None:
|
||||
target = self._config_file
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_mode = (
|
||||
stat.S_IMODE(target.stat().st_mode) if target.exists() else 0o600
|
||||
)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=target.parent,
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as handle:
|
||||
temporary_path = Path(handle.name)
|
||||
json.dump(data, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary_path, existing_mode)
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
try:
|
||||
directory_fd = os.open(target.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except OSError:
|
||||
# Directory fsync is not supported by every filesystem.
|
||||
pass
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
try:
|
||||
temporary_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def map_path(self, remote_path: str) -> str:
|
||||
"""Apply the longest boundary-aware remote-to-local path mapping."""
|
||||
return self.snapshot.map_path(remote_path)
|
||||
|
||||
|
||||
remote_config = RemoteConfig()
|
||||
|
||||
Reference in New Issue
Block a user