diff --git a/pyproject.toml b/pyproject.toml
index 698beff..c67fca4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,14 @@ dependencies = [
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
+ # CI test deps (Linux runners). Without these, pytest tests/ fails:
+ # - pytest-asyncio: test_solaris_ordering async tests ("not natively supported")
+ # - numpy: test_frame_validator ("No module named 'numpy'")
+ # - anthropic: test_host_layer ("anthropic SDK is not installed") -- the
+ # vendored SDK is Windows-only binaries, unusable on the Linux CI runner.
+ "pytest-asyncio>=0.21",
+ "numpy>=1.24",
+ "anthropic>=0.40.0",
]
websocket = [
"websockets>=11.0",
diff --git a/python/synapse/inspector/tool_inspect_stage.py b/python/synapse/inspector/tool_inspect_stage.py
index 3aa9387..77a34ea 100644
--- a/python/synapse/inspector/tool_inspect_stage.py
+++ b/python/synapse/inspector/tool_inspect_stage.py
@@ -198,6 +198,9 @@ def _synapse_extract_node(n):
def _synapse_extract_flat_ast(target_path):
+ # hou / json / SCHEMA_VERSION / _synapse_extract_node all resolve via the
+ # globals() re-publish before the top-level try (split globals/locals exec
+ # would otherwise hide them inside this function). See that note for why.
parent = hou.node(target_path)
if parent is None:
return json.dumps({
@@ -222,6 +225,27 @@ def _synapse_extract_flat_ast(target_path):
}, sort_keys=True)
+# The bridge execs this script with split globals/locals -- exec(code, G, L)
+# where G != L (see handlers.py _run_compiled / api_adapter _run_in_namespace).
+# Top-level defs, imports and constants bind into L, but functions resolve
+# names via their __globals__ (= G), so from INSIDE the functions the other
+# top-level function (_synapse_extract_node), the module constants
+# (SCHEMA_VERSION, _MAX_ERR_LEN) and the imports are all invisible -> NameError,
+# swallowed as extraction_script_crash. `globals()` here (module level) IS G,
+# so re-publish the names the functions need into G. Without this, a clean
+# scene crashes at the _synapse_extract_node call and any errored/warned node
+# double-faults on _MAX_ERR_LEN inside its own except handler.
+globals().update({
+ "hou": hou,
+ "json": json,
+ "traceback": traceback,
+ "SCHEMA_VERSION": SCHEMA_VERSION,
+ "_MAX_ERR_LEN": _MAX_ERR_LEN,
+ "_synapse_extract_node": _synapse_extract_node,
+ "_synapse_extract_flat_ast": _synapse_extract_flat_ast,
+})
+
+
try:
print(_synapse_extract_flat_ast(%(target_path_literal)s))
except Exception as ex:
diff --git a/python/synapse/server/render_notify.py b/python/synapse/server/render_notify.py
index 4d606b4..13a2453 100644
--- a/python/synapse/server/render_notify.py
+++ b/python/synapse/server/render_notify.py
@@ -139,7 +139,7 @@ def send_toast(title: str, body: str) -> bool:
capture_output=True,
text=True,
timeout=10,
- creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
+ creationflags=(getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0),
)
if result.returncode != 0:
logger.debug("Toast PowerShell failed: %s", result.stderr[:200])
diff --git a/tests/test_design_system.py b/tests/test_design_system.py
index e2b1ce4..5b59c31 100644
--- a/tests/test_design_system.py
+++ b/tests/test_design_system.py
@@ -20,7 +20,16 @@
# ── Setup ─────────────────────────────────────────────────
-_SYNAPSE_HOME = os.path.join(os.path.expanduser("~"), ".synapse")
+# Prefer the DEPLOYED copy (~/.synapse) so this also validates a real install;
+# fall back to the REPO source when nothing is deployed (CI runners, fresh dev
+# checkouts). The layout is identical either way: /{design,houdini,install.py}.
+_DEPLOYED_HOME = os.path.join(os.path.expanduser("~"), ".synapse")
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_SYNAPSE_HOME = (
+ _DEPLOYED_HOME
+ if os.path.isdir(os.path.join(_DEPLOYED_HOME, "design"))
+ else _REPO_ROOT
+)
_DESIGN_DIR = os.path.join(_SYNAPSE_HOME, "design")
_HOUDINI_DIR = os.path.join(_SYNAPSE_HOME, "houdini")
_SVG_DIR = os.path.join(_DESIGN_DIR, "icons", "svg")
diff --git a/tests/test_inspect_mock.py b/tests/test_inspect_mock.py
index f5decff..f832f45 100644
--- a/tests/test_inspect_mock.py
+++ b/tests/test_inspect_mock.py
@@ -646,3 +646,91 @@ def test_error_message_length_capped(self):
error_state="error",
error_message="x" * 501,
)
+
+
+# -----------------------------------------------------------------------------
+# Split-scope exec — the extraction script must survive the bridge's
+# exec(code, globals, locals) with globals != locals (handlers.py _run_compiled
+# / api_adapter _run_in_namespace). Regression for the inspector crash where
+# _synapse_extract_node / SCHEMA_VERSION / _MAX_ERR_LEN were defined at the
+# script's top level (-> locals) but referenced from inside its functions
+# (-> resolved via globals), yielding extraction_script_crash on EVERY scene
+# (and a double-fault on errored nodes via _MAX_ERR_LEN in the except handler).
+# The transport-mocked tests above never exec the real script, so they missed it.
+# -----------------------------------------------------------------------------
+
+
+class TestExtractionScriptSplitScope:
+ @staticmethod
+ def _child(name, node_type="null", errs=(), warns=()):
+ from unittest.mock import MagicMock
+ c = MagicMock()
+ c.name.return_value = name
+ c.type.return_value.name.return_value = node_type
+ c.path.return_value = "/stage/" + name
+ c.errors.return_value = list(errs)
+ c.warnings.return_value = list(warns)
+ c.isDisplayFlagSet.return_value = False
+ c.isBypassed.return_value = False
+ c.lastModifiedPrims.return_value = []
+ c.inputs.return_value = []
+ c.outputs.return_value = []
+ return c
+
+ def _run_template(self, children):
+ """Exec the REAL extraction template the way the bridge does: split
+ globals/locals, with `hou` NOT injected into globals (the inspector
+ transport doesn't). Returns the parsed JSON the script prints."""
+ import io
+ import sys
+ import types
+ from contextlib import redirect_stdout
+
+ from synapse.inspector.tool_inspect_stage import _EXTRACTION_SCRIPT_TEMPLATE
+
+ parent = type("P", (), {})()
+ parent.children = lambda: list(children)
+ mock_hou = types.ModuleType("hou")
+ mock_hou.node = lambda p: parent if p == "/stage" else None
+
+ script = _EXTRACTION_SCRIPT_TEMPLATE % {
+ "schema_version": SCHEMA_VERSION,
+ "target_path_literal": repr("/stage"),
+ }
+ saved = sys.modules.get("hou")
+ sys.modules["hou"] = mock_hou
+ try:
+ g = {"__builtins__": __builtins__} # split scope; hou NOT in globals
+ l = {}
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ exec(compile(script, "", "exec"), g, l)
+ finally:
+ if saved is not None:
+ sys.modules["hou"] = saved
+ else:
+ sys.modules.pop("hou", None)
+ return json.loads(buf.getvalue().strip())
+
+ def test_clean_scene_does_not_crash(self):
+ data = self._run_template([self._child("a"), self._child("b")])
+ assert data.get("synapse_error") != "extraction_script_crash", data
+ assert data["schema_version"] == SCHEMA_VERSION
+ assert len(data["nodes"]) == 2
+ assert all(n["error_state"] == "clean" for n in data["nodes"])
+
+ def test_errored_and_warned_nodes_survive_split_scope(self):
+ # The error/warning paths AND the per-node except handler reference
+ # _MAX_ERR_LEN -- a top-level constant invisible inside the function
+ # under split scope. Pre-fix this double-faulted into a script crash.
+ data = self._run_template([
+ self._child("clean"),
+ self._child("boom", errs=["kaboom detail"]),
+ self._child("warn", warns=["a warning"]),
+ ])
+ assert data.get("synapse_error") != "extraction_script_crash", data
+ by_name = {n["node_name"]: n for n in data["nodes"]}
+ assert by_name["boom"]["error_state"] == "error"
+ assert "kaboom detail" in (by_name["boom"]["error_message"] or "")
+ assert by_name["warn"]["error_state"] == "warning"
+ assert by_name["clean"]["error_state"] == "clean"
diff --git a/tests/test_layout_and_matlib.py b/tests/test_layout_and_matlib.py
index fb023c1..ca8c0bd 100644
--- a/tests/test_layout_and_matlib.py
+++ b/tests/test_layout_and_matlib.py
@@ -249,7 +249,7 @@ class TestMaterialLibraryAutoPopulate:
"""Test that create_node('materiallibrary') auto-scaffolds MaterialX."""
@pytest.fixture
- def mock_env(self):
+ def mock_env(self, monkeypatch):
"""Set up mock hou environment for handler testing."""
# Stub hdefereval for synchronous execution
if "hdefereval" not in sys.modules:
@@ -262,6 +262,16 @@ def mock_env(self):
from synapse.server import main_thread
main_thread._HOU_AVAILABLE = True
main_thread._USE_DEFERRED = False
+
+ # Rebind the `hou` the node handler actually uses to THIS module's stub,
+ # scoped via monkeypatch so it auto-restores after the test and never
+ # leaks to later suites. handlers_node binds `hou` at import time from
+ # sys.modules["hou"]; in the full suite it can be bound to a different
+ # stub, so patch.object(_mock_hou, "node", ...) would never reach the
+ # handler -> hou.node() returns a default mock -> raw createNode chain.
+ from synapse.server import handlers_node
+ monkeypatch.setattr(handlers_node, "hou", _mock_hou, raising=False)
+ monkeypatch.setattr(handlers_node, "HOU_AVAILABLE", True, raising=False)
return _mock_hou
def _make_parent_node(self):
diff --git a/tests/test_render.py b/tests/test_render.py
index 07b2787..317890f 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -202,7 +202,8 @@ def _parm(n):
patch.object(_handlers_hou, "frame", return_value=frame, create=True), \
patch.object(_handlers_hou, "text", MagicMock(expandString=MagicMock(return_value="/tmp/houdini_temp")), create=True), \
patch("pathlib.Path.exists", return_value=True), \
- patch("pathlib.Path.stat", return_value=MagicMock(st_size=1024)):
+ patch("pathlib.Path.stat", return_value=MagicMock(st_size=1024)), \
+ patch("pathlib.Path.mkdir", return_value=None):
return handler._handle_render(payload)
def test_returns_image_path_and_engine(self, handler):
@@ -298,6 +299,7 @@ def _fake_exists(self_path):
patch.object(_handlers_hou, "text", MagicMock(expandString=MagicMock(return_value="/tmp/houdini_temp")), create=True), \
patch("pathlib.Path.exists", _fake_exists), \
patch("pathlib.Path.stat", return_value=MagicMock(st_size=2048)), \
+ patch("pathlib.Path.mkdir", return_value=None), \
patch("time.sleep"):
result = handler._handle_render({"node": "/stage/usdrender_rop1"})
@@ -322,6 +324,7 @@ def test_no_flipbook_fallback_for_non_usdrender(self, handler):
patch.object(_handlers_hou, "text", MagicMock(expandString=MagicMock(return_value="/tmp/houdini_temp")), create=True), \
patch("pathlib.Path.exists", return_value=False), \
patch("pathlib.Path.stat", return_value=MagicMock(st_size=0)), \
+ patch("pathlib.Path.mkdir", return_value=None), \
patch("time.sleep"):
with pytest.raises(RuntimeError, match="output wasn't created"):
handler._handle_render({"node": "/out/mantra1"})