Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions python/synapse/inspector/tool_inspect_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion python/synapse/server/render_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
11 changes: 10 additions & 1 deletion tests/test_design_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <base>/{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")
Expand Down
88 changes: 88 additions & 0 deletions tests/test_inspect_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<inspect_split_scope>", "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"
12 changes: 11 additions & 1 deletion tests/test_layout_and_matlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"})

Expand All @@ -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"})
Expand Down