Files
pythonocc-step-editor/step_editor/scdm_backend.py
T

624 lines
22 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence
try: # pragma: no cover - exercised only on Windows hosts with registry access.
import winreg
except ImportError: # pragma: no cover
winreg = None # type: ignore[assignment]
SCDM_EXE_NAME = "SpaceClaim.exe"
SCDM_CACHE_RELATIVE_PATH = Path("local") / "scdm_backend.json"
SCDM_PATH_ENV_VARS = (
"STEP_EDITOR_SCDM_EXE",
"STEP_EDITOR_SPACECLAIM_EXE",
"SPACECLAIM_EXE",
)
SCDM_DISABLE_ENV = "STEP_EDITOR_DISABLE_SCDM"
SCDM_TIMEOUT_ENV = "STEP_EDITOR_SCDM_TIMEOUT"
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmBackendInfo:
path: Path
source: str
version: str = ""
verified_at: str = ""
run_script_ok: bool = False
license_ok: bool | None = None
message: str = ""
def to_cache(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_CACHE_SCHEMA_VERSION,
"path": str(self.path),
"source": self.source,
"version": self.version,
"verifiedAt": self.verified_at,
"runScriptOk": bool(self.run_script_ok),
"licenseOk": self.license_ok,
"message": self.message,
}
@classmethod
def from_cache(cls, payload: Mapping[str, object]) -> "ScdmBackendInfo | None":
raw_path = str(payload.get("path") or "").strip()
if not raw_path:
return None
path = Path(os.path.expandvars(raw_path)).expanduser()
if not _is_spaceclaim_exe(path):
return None
return cls(
path=path,
source=str(payload.get("source") or "cache"),
version=str(payload.get("version") or _version_from_path(path)),
verified_at=str(payload.get("verifiedAt") or ""),
run_script_ok=bool(payload.get("runScriptOk")),
license_ok=_optional_bool(payload.get("licenseOk")),
message=str(payload.get("message") or ""),
)
def project_root(project_root_override: str | Path | None = None) -> Path:
return Path(project_root_override).expanduser() if project_root_override else Path(__file__).resolve().parent.parent
def default_scdm_cache_path(project_root_override: str | Path | None = None) -> Path:
return project_root(project_root_override) / SCDM_CACHE_RELATIVE_PATH
def is_scdm_disabled(env: Mapping[str, str] | None = None) -> bool:
value = (env or os.environ).get(SCDM_DISABLE_ENV, "")
return value.strip().lower() in {"1", "true", "yes", "on"}
def load_scdm_backend_cache(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> ScdmBackendInfo | None:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
return ScdmBackendInfo.from_cache(payload)
def save_scdm_backend_cache(
backend: ScdmBackendInfo,
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> Path:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(backend.to_cache(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def discover_scdm_backend_candidates(
*,
manual_path: str | Path | None = None,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> tuple[ScdmBackendInfo, ...]:
env_map = env or os.environ
candidates: list[ScdmBackendInfo] = []
if manual_path:
candidates.extend(_info_for_user_value(manual_path, "manual"))
if include_env:
for env_name in SCDM_PATH_ENV_VARS:
raw_value = env_map.get(env_name, "").strip()
if raw_value:
candidates.extend(_info_for_user_value(raw_value, f"env:{env_name}"))
if include_registry:
candidates.extend(_registry_candidates())
if include_common:
candidates.extend(_common_install_candidates(common_roots=common_roots, env=env_map))
if include_path:
found = shutil.which(SCDM_EXE_NAME)
if found:
candidates.extend(_info_for_user_value(found, "PATH"))
return _dedupe_candidates(candidates)
def resolve_scdm_backend(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
manual_path: str | Path | None = None,
prefer_cache: bool = True,
save_cache: bool = True,
validate: bool = False,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: float | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
env_map = env or os.environ
if is_scdm_disabled(env_map):
return {"ok": False, "reason": "disabled", "backend": None, "message": "SCDM backend is disabled by environment."}
if prefer_cache:
cached = load_scdm_backend_cache(project_root_override=project_root_override, cache_path=cache_path)
if cached is not None:
if not validate or cached.run_script_ok:
return _resolution_payload(cached, reason="cache", message="Using cached SCDM backend.")
checked = verify_scdm_backend(cached, timeout_seconds=timeout_seconds, runner=runner)
if checked.get("ok"):
verified = _verified_backend_from_result(cached, checked)
if save_cache:
save_scdm_backend_cache(verified, project_root_override=project_root_override, cache_path=cache_path)
return _resolution_payload(verified, reason="cache-verified", message="Cached SCDM backend passed smoke test.")
failures: list[dict[str, object]] = []
candidates = discover_scdm_backend_candidates(
manual_path=manual_path,
include_env=include_env,
include_registry=include_registry,
include_common=include_common,
include_path=include_path,
common_roots=common_roots,
env=env_map,
)
for candidate in candidates:
backend = candidate
if validate:
checked = verify_scdm_backend(candidate, timeout_seconds=timeout_seconds, runner=runner)
if not checked.get("ok"):
failures.append(
{
"path": str(candidate.path),
"source": candidate.source,
"reason": checked.get("reason"),
"message": checked.get("message"),
}
)
continue
backend = _verified_backend_from_result(candidate, checked)
if save_cache:
save_scdm_backend_cache(backend, project_root_override=project_root_override, cache_path=cache_path)
reason = "discovered-verified" if validate else "discovered"
return _resolution_payload(backend, reason=reason, message=f"SCDM backend resolved from {backend.source}.")
return {
"ok": False,
"reason": "missing-spaceclaim",
"backend": None,
"candidates": (),
"failures": tuple(failures),
"message": "SpaceClaim.exe was not found. Ask the user to configure the SCDM path manually.",
}
def verify_scdm_backend(
backend: ScdmBackendInfo | str | Path,
*,
timeout_seconds: float | None = None,
work_dir: str | Path | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
info = backend
else:
matches = _info_for_user_value(backend, "manual")
if not matches:
return {
"ok": False,
"reason": "missing-exe",
"path": str(Path(str(backend)).expanduser()),
"message": "SpaceClaim.exe does not exist.",
}
info = matches[0]
if not _is_spaceclaim_exe(info.path):
return {"ok": False, "reason": "missing-exe", "path": str(info.path), "message": "SpaceClaim.exe does not exist."}
timeout = timeout_seconds if timeout_seconds is not None else _timeout_seconds()
temp_context = None
if work_dir is None:
temp_context = tempfile.TemporaryDirectory(prefix="step_editor_scdm_")
work_root = Path(temp_context.name)
else:
work_root = Path(work_dir).expanduser()
work_root.mkdir(parents=True, exist_ok=True)
try:
script_path = work_root / "scdm_smoke.py"
report_path = work_root / "scdm_smoke_result.json"
script_path.write_text(_smoke_script(report_path), encoding="utf-8")
command = scdm_run_script_command(info.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(work_root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "path": str(info.path), "message": "SCDM smoke test timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "path": str(info.path), "message": str(exc)}
returncode = int(getattr(completed, "returncode", -1))
stdout = str(getattr(completed, "stdout", "") or "")
stderr = str(getattr(completed, "stderr", "") or "")
if returncode != 0:
return {
"ok": False,
"reason": "run-script-failed",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": (stderr or stdout or f"SCDM returned {returncode}.").strip(),
}
if not report_path.is_file():
return {
"ok": False,
"reason": "missing-report",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": "SCDM smoke script finished but did not write a report.",
}
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "reason": "bad-report", "path": str(info.path), "message": str(exc)}
if not isinstance(report, dict) or report.get("ok") is not True:
return {"ok": False, "reason": "negative-report", "path": str(info.path), "message": str(report)}
return {
"ok": True,
"reason": "ok",
"path": str(info.path),
"source": info.source,
"version": str(report.get("version") or info.version or _version_from_path(info.path)),
"verifiedAt": _utc_now(),
"runScriptOk": True,
"licenseOk": True,
"returncode": returncode,
"message": str(report.get("message") or "SCDM /RunScript smoke test passed."),
}
finally:
if temp_context is not None:
temp_context.cleanup()
def scdm_run_script_command(spaceclaim_exe: str | Path, script_path: str | Path) -> list[str]:
exe = Path(spaceclaim_exe).expanduser().resolve(strict=False)
script = Path(script_path).expanduser().resolve(strict=False)
return [
str(exe),
f"/RunScript={script}",
"/Headless=True",
"/ExitAfterScript=True",
]
def _info_for_user_value(value: str | Path, source: str) -> list[ScdmBackendInfo]:
path = _spaceclaim_path_from_value(value)
if path is None:
return []
return [ScdmBackendInfo(path=path, source=source, version=_version_from_path(path))]
def _spaceclaim_path_from_value(value: str | Path) -> Path | None:
text = os.path.expandvars(str(value)).strip().strip('"')
if not text:
return None
path = Path(text).expanduser()
possible = [path]
if path.is_dir():
possible = [
path / SCDM_EXE_NAME,
path / "SCDM" / SCDM_EXE_NAME,
]
for candidate in possible:
if _is_spaceclaim_exe(candidate):
return candidate.resolve(strict=False)
return None
def _is_spaceclaim_exe(path: Path) -> bool:
return path.name.lower() == SCDM_EXE_NAME.lower() and path.is_file()
def _registry_candidates() -> list[ScdmBackendInfo]:
if os.name != "nt" or winreg is None:
return []
candidates: list[ScdmBackendInfo] = []
app_path_keys = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SpaceClaim.exe",
r"SOFTWARE\Classes\Applications\SpaceClaim.exe\shell\open\command",
)
roots = ((winreg.HKEY_CURRENT_USER, "HKCU"), (winreg.HKEY_LOCAL_MACHINE, "HKLM"))
views = (0, getattr(winreg, "KEY_WOW64_64KEY", 0), getattr(winreg, "KEY_WOW64_32KEY", 0))
for root, root_label in roots:
for access in views:
for key_path in app_path_keys:
for raw_value in _registry_key_values(root, key_path, access):
for path in _paths_from_registry_value(raw_value):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\{key_path}"))
candidates.extend(_uninstall_registry_candidates(root, root_label, access))
return candidates
def _registry_key_values(root: int, key_path: str, access: int) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, key_path, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
for name in ("", "Path", "InstallPath", "InstallLocation"):
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
continue
if isinstance(value, str) and value.strip():
values.append(value)
except OSError:
return []
return values
def _uninstall_registry_candidates(root: int, root_label: str, access: int) -> list[ScdmBackendInfo]:
uninstall_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
candidates: list[ScdmBackendInfo] = []
try:
with winreg.OpenKey(root, uninstall_key, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
index = 0
while True:
try:
subkey_name = winreg.EnumKey(key, index) # type: ignore[union-attr]
except OSError:
break
index += 1
try:
with winreg.OpenKey(key, subkey_name, 0, winreg.KEY_READ | access) as subkey: # type: ignore[union-attr]
display_name = _registry_string(subkey, "DisplayName")
install_location = _registry_string(subkey, "InstallLocation")
except OSError:
continue
if "spaceclaim" not in display_name.lower() and "ansys" not in display_name.lower():
continue
for path in _paths_from_registry_value(install_location):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\Uninstall"))
except OSError:
return []
return candidates
def _registry_string(key: object, name: str) -> str:
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
return ""
return value if isinstance(value, str) else ""
def _paths_from_registry_value(value: str) -> list[str]:
text = value.strip()
if not text:
return []
exe = _extract_exe_from_command(text)
if exe:
return [exe]
return [
text,
str(Path(text) / SCDM_EXE_NAME),
str(Path(text) / "SCDM" / SCDM_EXE_NAME),
]
def _extract_exe_from_command(command: str) -> str:
text = command.strip()
if not text:
return ""
if text.startswith('"'):
end = text.find('"', 1)
if end > 1:
first = text[1:end]
return first if first.lower().endswith(".exe") else ""
lowered = text.lower()
index = lowered.find(".exe")
if index >= 0:
return text[: index + 4]
return ""
def _common_install_candidates(
*,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> list[ScdmBackendInfo]:
roots = list(common_roots) if common_roots is not None else _default_common_roots(env or os.environ)
candidates: list[ScdmBackendInfo] = []
for root in roots:
base = Path(os.path.expandvars(str(root))).expanduser()
if not base.is_dir():
continue
direct_paths = (
base / SCDM_EXE_NAME,
base / "SCDM" / SCDM_EXE_NAME,
)
for path in direct_paths:
candidates.extend(_info_for_user_value(path, f"common:{base}"))
version_dirs = sorted((item for item in base.glob("v*") if item.is_dir()), key=_version_sort_key, reverse=True)
for version_dir in version_dirs:
candidates.extend(_info_for_user_value(version_dir / "SCDM" / SCDM_EXE_NAME, f"common:{base}"))
return candidates
def _default_common_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
roots: list[Path] = []
for env_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
raw = env.get(env_name, "")
if raw:
roots.append(Path(raw) / "ANSYS Inc")
for drive in ("C", "D", "E"):
roots.append(Path(f"{drive}:/Program Files/ANSYS Inc"))
roots.append(Path(f"{drive}:/softwaresInstallDir/ANSYS Inc"))
return tuple(_dedupe_paths(roots))
def _dedupe_candidates(candidates: Iterable[ScdmBackendInfo]) -> tuple[ScdmBackendInfo, ...]:
result: list[ScdmBackendInfo] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate.path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(candidate)
return tuple(result)
def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
result: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def _version_sort_key(path: Path) -> tuple[int, str]:
match = re.search(r"v(\d+)", path.name, flags=re.IGNORECASE)
return (int(match.group(1)) if match else -1, path.name.lower())
def _version_from_path(path: Path) -> str:
for part in path.parts:
match = re.fullmatch(r"v\d+", part, flags=re.IGNORECASE)
if match:
return part
return ""
def _verified_backend_from_result(candidate: ScdmBackendInfo, result: Mapping[str, object]) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=candidate.path,
source=candidate.source,
version=str(result.get("version") or candidate.version),
verified_at=str(result.get("verifiedAt") or _utc_now()),
run_script_ok=bool(result.get("runScriptOk")),
license_ok=_optional_bool(result.get("licenseOk")),
message=str(result.get("message") or candidate.message),
)
def _resolution_payload(backend: ScdmBackendInfo, *, reason: str, message: str) -> dict[str, object]:
return {
"ok": True,
"reason": reason,
"backend": backend,
"path": str(backend.path),
"source": backend.source,
"version": backend.version,
"verifiedAt": backend.verified_at,
"runScriptOk": backend.run_script_ok,
"licenseOk": backend.license_ok,
"message": message,
}
def _smoke_script(report_path: Path) -> str:
report_literal = repr(str(report_path))
return (
"from __future__ import print_function\n"
f"report_path = {report_literal}\n"
"version = ''\n"
"try:\n"
" version = str(Application.Version)\n"
"except Exception:\n"
" version = ''\n"
"payload = '{\"ok\": true, \"version\": \"' + version.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\", \"message\": \"RunScript reached\"}'\n"
"handle = open(report_path, 'w')\n"
"handle.write(payload)\n"
"handle.close()\n"
)
def _timeout_seconds() -> float:
try:
return max(float(os.environ.get(SCDM_TIMEOUT_ENV, "") or 25.0), 0.1)
except ValueError:
return 25.0
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _optional_bool(value: object) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
__all__ = [
"SCDM_CACHE_RELATIVE_PATH",
"SCDM_DISABLE_ENV",
"SCDM_EXE_NAME",
"SCDM_PATH_ENV_VARS",
"SCDM_TIMEOUT_ENV",
"ScdmBackendInfo",
"default_scdm_cache_path",
"discover_scdm_backend_candidates",
"is_scdm_disabled",
"load_scdm_backend_cache",
"project_root",
"resolve_scdm_backend",
"save_scdm_backend_cache",
"scdm_run_script_command",
"verify_scdm_backend",
]