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"})