@dpantani/tdmcp 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ """First-class connect/disconnect — survives TDMCP_BRIDGE_ALLOW_EXEC=0.
2
+
3
+ Pure functions; reach TD globals via ``import td`` INSIDE each function so the
4
+ module imports cleanly off-TD (mirrors ``mcp/services/api_service.py``). Raise
5
+ ``ValueError``/``LookupError``/``IndexError`` on hard failure; the router turns
6
+ them into the standard 400 ``{ok:false,error:{message}}`` envelope.
7
+
8
+ Connector model (probed live on TD 2025.32820):
9
+ - ``op.inputConnectors`` / ``op.outputConnectors`` are indexed lists of
10
+ ``Connector`` objects; an unwired ``noiseTOP`` still exposes its slots.
11
+ - ``inputConnector.connect(outputConnector | op)`` makes the wire.
12
+ - ``inputConnector.connections`` lists the upstream OUTPUT connectors; each
13
+ exposes ``.owner`` (the source op) and ``.index`` (the source output index).
14
+ - Multi-input TOPs PACK their inputs contiguously: wiring into a non-trailing
15
+ slot, or removing a middle wire, renumbers the rest. So ``connect`` reports
16
+ the ``actual_input`` it lands on (re-scanned after the wire), and
17
+ ``disconnect`` prefers by-source-path filtering over a fixed index.
18
+
19
+ This module is intentionally flat and self-contained: the integrator drops it in
20
+ as ``mcp/services/connect_service.py`` unchanged.
21
+ """
22
+
23
+
24
+ def _resolve(op, path):
25
+ node = op(path)
26
+ if node is None:
27
+ raise LookupError(path)
28
+ return node
29
+
30
+
31
+ def _same_parent(src, dst):
32
+ src_parent = src.parent()
33
+ dst_parent = dst.parent()
34
+ if src_parent is None or dst_parent is None:
35
+ return False
36
+ return src_parent.path == dst_parent.path
37
+
38
+
39
+ def _src_owner(connection):
40
+ """The source op behind an upstream output connector.
41
+
42
+ ``.owner`` is the probed attribute; ``.op`` is a harmless legacy fallback.
43
+ """
44
+ owner = getattr(connection, "owner", None)
45
+ if owner is not None:
46
+ return owner
47
+ return getattr(connection, "op", None)
48
+
49
+
50
+ def connect(source_path, target_path, source_output=0, target_input=0):
51
+ """Wire ``source.outputConnectors[source_output]`` ->
52
+ ``target.inputConnectors[target_input]``.
53
+
54
+ Returns the §3.2 dict including the live ``actual_input`` slot (which may
55
+ differ from ``requested_input`` because multi-input TOPs pack). Raises on
56
+ not-found / cross-container / connector-index-out-of-range.
57
+ """
58
+ import td
59
+
60
+ op = td.op
61
+
62
+ source_output = int(source_output)
63
+ target_input = int(target_input)
64
+
65
+ src = op(source_path)
66
+ dst = op(target_path)
67
+ if src is None or dst is None:
68
+ raise LookupError(
69
+ "connect: source or target not found (%s -> %s)" % (source_path, target_path)
70
+ )
71
+
72
+ # TD wires only connect operators sharing a parent. A cross-container connect
73
+ # silently no-ops (no exception, no wire), so reject it with an actionable msg.
74
+ if not _same_parent(src, dst):
75
+ raise ValueError(
76
+ "connect: cannot wire across containers (%s -> %s); use a Select/In OP"
77
+ % (source_path, target_path)
78
+ )
79
+
80
+ in_connectors = dst.inputConnectors
81
+ out_connectors = src.outputConnectors
82
+ if target_input < 0 or target_input >= len(in_connectors):
83
+ raise IndexError(
84
+ "connect: target_input %d out of range (%d input connectors on %s)"
85
+ % (target_input, len(in_connectors), target_path)
86
+ )
87
+ if source_output < 0 or source_output >= len(out_connectors):
88
+ raise IndexError(
89
+ "connect: source_output %d out of range (%d output connectors on %s)"
90
+ % (source_output, len(out_connectors), source_path)
91
+ )
92
+
93
+ out_conn = out_connectors[source_output]
94
+
95
+ # Snapshot which input slots already carry this exact (src, source_output) wire
96
+ # BEFORE connecting, so afterwards we can identify the NEW slot. Multi-input TOPs
97
+ # pack contiguously, and the same source output may already feed another input —
98
+ # scanning only after the connect could report a pre-existing slot.
99
+ def _slots_carrying_src():
100
+ found = set()
101
+ for ic in dst.inputConnectors:
102
+ for oc in ic.connections:
103
+ owner = _src_owner(oc)
104
+ if owner is not None and owner.path == src.path and oc.index == source_output:
105
+ found.add(ic.index)
106
+ return found
107
+
108
+ before = _slots_carrying_src()
109
+ in_connectors[target_input].connect(out_conn)
110
+ after = _slots_carrying_src()
111
+
112
+ # The slot that appeared is the one TD actually used (packing may differ from
113
+ # the requested index). Fall back to the requested index if the diff is empty
114
+ # (e.g. the wire already existed) or ambiguous.
115
+ _new = sorted(after - before)
116
+ actual_input = _new[0] if _new else target_input
117
+
118
+ return {
119
+ "source_path": src.path,
120
+ "target_path": dst.path,
121
+ "requested_input": target_input,
122
+ "actual_input": actual_input,
123
+ "source_output": source_output,
124
+ "connected": True,
125
+ }
126
+
127
+
128
+ def disconnect(to_path, from_path=None, to_input=None):
129
+ """Remove input wire(s) into ``to_path``.
130
+
131
+ ``from_path`` ``None`` removes every wire into ``to_path`` (scoped by
132
+ ``to_input`` if given). Fail-forward: per-wire problems are collected as
133
+ ``warnings`` rather than raised; only a missing ``to_path`` is fatal.
134
+ Returns the §3.2 dict ``{to_path, from_path, to_input, removed, warnings}``.
135
+ """
136
+ import td
137
+
138
+ op = td.op
139
+
140
+ if to_input is not None:
141
+ to_input = int(to_input)
142
+
143
+ to = op(to_path)
144
+ if to is None:
145
+ raise LookupError("disconnect: node not found: %s" % to_path)
146
+
147
+ removed = []
148
+ warnings = []
149
+
150
+ # PASS 1 — snapshot every wire to remove BEFORE mutating anything. On a packing
151
+ # multi-input op, disconnecting repacks the remaining wires to lower indices, so
152
+ # iterating the live connectors while disconnecting can skip a wire that just
153
+ # moved into a slot we already passed. The `connection` (upstream OUTPUT
154
+ # connector) is stable across repacks, so pass 2 re-finds each wire by it.
155
+ targets = [] # list of (input_index, connection, src_path)
156
+ for connector in to.inputConnectors:
157
+ index = connector.index
158
+ if to_input is not None and index != to_input:
159
+ continue
160
+ try:
161
+ connections = list(connector.connections)
162
+ except Exception as exc: # noqa: BLE001
163
+ warnings.append("inputConnectors[%d].connections error: %s" % (index, exc))
164
+ continue
165
+ for connection in connections:
166
+ src_op = _src_owner(connection)
167
+ if src_op is None:
168
+ warnings.append("Could not resolve upstream op for inputConnectors[%d]" % index)
169
+ continue
170
+ if from_path is not None and src_op.path != from_path:
171
+ continue
172
+ targets.append((index, connection, src_op.path))
173
+
174
+ # PASS 2 — disconnect each snapshotted wire. Re-find the connector that currently
175
+ # holds it (repacking may have moved it to a lower index) and scope the disconnect
176
+ # to that one input<-output link; connection.disconnect() would tear down the
177
+ # source output's wires to EVERY downstream target. Fall back to clearing the
178
+ # holding input slot (still only affects to_path, never the source's outputs).
179
+ for index, connection, src_path in targets:
180
+ holder = None
181
+ for connector in to.inputConnectors:
182
+ try:
183
+ if connection in connector.connections:
184
+ holder = connector
185
+ break
186
+ except Exception: # noqa: BLE001
187
+ continue
188
+ if holder is None:
189
+ # Already gone (removed alongside another wire's repack) — the net effect
190
+ # we wanted is achieved, so still count it.
191
+ removed.append({"input": index, "from": src_path})
192
+ continue
193
+ try:
194
+ holder.disconnect(connection)
195
+ removed.append({"input": index, "from": src_path})
196
+ except Exception as exc1: # noqa: BLE001
197
+ try:
198
+ holder.disconnect()
199
+ removed.append({"input": index, "from": src_path})
200
+ except Exception as exc2: # noqa: BLE001
201
+ warnings.append(
202
+ "disconnect failed for inputConnectors[%d] from %s: "
203
+ "connector.disconnect(conn) -> %s; connector.disconnect() -> %s"
204
+ % (index, src_path, exc1, exc2)
205
+ )
206
+
207
+ return {
208
+ "to_path": to.path,
209
+ "from_path": from_path,
210
+ "to_input": to_input,
211
+ "removed": removed,
212
+ "warnings": warnings,
213
+ }
@@ -0,0 +1,136 @@
1
+ """GET /api/logs — read the bridge Error DAT's structured cook errors/warnings.
2
+
3
+ Survives TDMCP_BRIDGE_ALLOW_EXEC=0 (it is a read endpoint, not exec). Reads the
4
+ bridge's Error DAT rows instead of char-iterating `op.errors()` (which returns a
5
+ STRING, not a list — the legacy walk's latent bug).
6
+
7
+ Error DAT columns (confirmed live): source | message | absframe | frame |
8
+ severity | type. We map columns BY HEADER NAME (row 0), not by fixed index, so a
9
+ future column reorder can't silently misalign the data (§7-R6). Falls back to
10
+ {available: False, ...} when the Error DAT is missing (older bridge) so the
11
+ client can use the legacy op-walk.
12
+
13
+ Pure module of top-level functions. `op` is bound from `td` at import time
14
+ (mirroring api_service.py) so the module imports cleanly off-TD and the test
15
+ harness can patch `op` per-test.
16
+ """
17
+
18
+ import td
19
+
20
+ op = td.op
21
+
22
+ # Canonical column names we surface, in their confirmed live order. Used as a
23
+ # fallback when a header row is absent or unrecognized.
24
+ _EXPECTED_COLUMNS = ("source", "message", "absframe", "frame", "severity", "type")
25
+ _INT_COLUMNS = ("absframe", "frame")
26
+
27
+
28
+ def _cell(dat, row, col):
29
+ """Read dat[row, col] as a plain string, tolerating Cell wrappers."""
30
+ try:
31
+ return str(dat[row, col])
32
+ except Exception: # noqa: BLE001
33
+ return ""
34
+
35
+
36
+ def _header_map(dat):
37
+ """Map column NAME -> column index from row 0.
38
+
39
+ Falls back to the expected fixed order if a header cell is blank or the row
40
+ is unreadable. Keying by name (not [0..5]) is cheap insurance against a
41
+ column reorder (§7-R6).
42
+ """
43
+ mapping = {}
44
+ num_cols = int(getattr(dat, "numCols", 0) or 0)
45
+ for col in range(num_cols):
46
+ name = _cell(dat, 0, col).strip().lower()
47
+ if name:
48
+ mapping[name] = col
49
+ # If the header was missing/garbled, fall back to positional expectations so
50
+ # we still return useful rows.
51
+ if not mapping:
52
+ for idx, name in enumerate(_EXPECTED_COLUMNS):
53
+ if idx < num_cols:
54
+ mapping[name] = idx
55
+ return mapping
56
+
57
+
58
+ def get_logs(
59
+ severity="all",
60
+ max_lines=200,
61
+ scope=None,
62
+ error_dat_path="/project1/tdmcp_bridge/error_log",
63
+ ):
64
+ """Read the bridge Error DAT's rows.
65
+
66
+ Returns {lines, count, error_dat, available, warnings}. `scope` filters by a
67
+ `source` path prefix when provided (the Error DAT's own `fromop` already
68
+ bounds capture; this is an extra client-side narrowing). Severity is one of
69
+ all|error|warning. Newest rows are near the bottom, so we keep the LAST
70
+ max_lines after filtering.
71
+ """
72
+ report = {
73
+ "lines": [],
74
+ "count": 0,
75
+ "error_dat": error_dat_path,
76
+ "available": True,
77
+ "warnings": [],
78
+ }
79
+ dat = op(error_dat_path) # noqa: F821 - TD global
80
+ if dat is None:
81
+ report["available"] = False
82
+ report["warnings"].append(
83
+ "Error DAT not found at %s; reinstall the bridge to enable structured "
84
+ "logs (falling back to the op-walk)." % error_dat_path
85
+ )
86
+ return report
87
+
88
+ num_rows = int(getattr(dat, "numRows", 0) or 0)
89
+ if num_rows <= 1:
90
+ # Header only (or empty) — no captured rows yet.
91
+ return report
92
+
93
+ header = _header_map(dat)
94
+ want_sev = str(severity or "all").strip().lower()
95
+ scope_prefix = str(scope).rstrip("/") if scope else None
96
+
97
+ lines = []
98
+ # Skip row 0 (header); iterate data rows in append order (newest near bottom).
99
+ for row in range(1, num_rows):
100
+ entry = {}
101
+ for name, col in header.items():
102
+ if name not in _EXPECTED_COLUMNS:
103
+ continue
104
+ raw = _cell(dat, row, col)
105
+ if name in _INT_COLUMNS:
106
+ try:
107
+ entry[name] = int(raw)
108
+ except Exception: # noqa: BLE001
109
+ pass # leave it out rather than emit a bogus int
110
+ else:
111
+ entry[name] = raw
112
+ # severity filter
113
+ if want_sev in ("error", "warning"):
114
+ if str(entry.get("severity", "")).strip().lower() != want_sev:
115
+ continue
116
+ # optional scope (source path prefix) filter
117
+ if scope_prefix:
118
+ src = str(entry.get("source", ""))
119
+ if not (src == scope_prefix or src.startswith(scope_prefix + "/")):
120
+ continue
121
+ lines.append(entry)
122
+
123
+ # Keep the newest N (rows are append-order, newest last).
124
+ try:
125
+ cap = int(max_lines)
126
+ except Exception: # noqa: BLE001
127
+ cap = 200
128
+ if cap > 0 and len(lines) > cap:
129
+ report["warnings"].append(
130
+ "Truncated to %d of %d matching rows (newest kept)." % (cap, len(lines))
131
+ )
132
+ lines = lines[-cap:]
133
+
134
+ report["lines"] = lines
135
+ report["count"] = len(lines)
136
+ return report
@@ -0,0 +1,218 @@
1
+ """Param-mode + DAT-text endpoints — survive TDMCP_BRIDGE_ALLOW_EXEC=0.
2
+
3
+ Promote reactive authoring (parameter mode / expression / bind / constant) and
4
+ whole-text DAT editing off `/api/exec` so they keep working when arbitrary code
5
+ execution is disabled in TouchDesigner.
6
+
7
+ Pure module of top-level functions taking primitives. `op` is bound from `td`
8
+ at import time (mirroring mcp/services/api_service.py) so the module imports
9
+ cleanly off-TD and the test harness can patch `op` per-test. Hard failures raise
10
+ ValueError / LookupError; the router turns them into the 400 envelope.
11
+
12
+ ParMode is NOT importable as `td.ParMode` or a bare global (confirmed live —
13
+ all three import forms raise). Resolve the enum class from a LIVE parameter:
14
+ `ModeCls = type(par.mode)` then `ModeCls.EXPRESSION / .BIND / .CONSTANT /
15
+ .EXPORT`. (`tdutils.TDDefinitions.ParMode` is the real home if ever needed.)
16
+ This also fixes the latent bug where `_par.mode = ParMode.EXPRESSION` silently
17
+ fell into an except branch every time.
18
+ """
19
+
20
+ import math
21
+
22
+ import td
23
+
24
+ # TouchDesigner injects globals (op, ParMode, operator classes) only into
25
+ # DAT/Textport scope, not into imported modules — so reach them via `td`.
26
+ op = td.op
27
+
28
+
29
+ def _json_safe(value):
30
+ """Coerce a parameter value into something json.dumps can serialize."""
31
+ if value is None or isinstance(value, (str, bool)):
32
+ return value
33
+ if isinstance(value, int):
34
+ return value
35
+ if isinstance(value, float):
36
+ return value if math.isfinite(value) else str(value)
37
+ if isinstance(value, (list, tuple)):
38
+ return [_json_safe(v) for v in value]
39
+ if isinstance(value, dict):
40
+ return {str(k): _json_safe(v) for k, v in value.items()}
41
+ try:
42
+ _path = getattr(value, "path", None)
43
+ if _path is not None:
44
+ return str(_path)
45
+ except Exception: # noqa: BLE001
46
+ pass
47
+ try:
48
+ return str(value)
49
+ except Exception: # noqa: BLE001
50
+ return None
51
+
52
+
53
+ def _normalize_mode(par):
54
+ """Return the UPPER mode name (CONSTANT/EXPRESSION/EXPORT/BIND) for a Par."""
55
+ try:
56
+ _raw = par.mode
57
+ except Exception: # noqa: BLE001
58
+ return "UNKNOWN"
59
+ if _raw is None:
60
+ return "UNKNOWN"
61
+ # par.mode.name is the clean enum name; fall back to str().split() parsing.
62
+ name = getattr(_raw, "name", None)
63
+ if name:
64
+ return str(name).upper()
65
+ return str(_raw).split(".")[-1].upper()
66
+
67
+
68
+ def read_param_modes(path, keys=None, non_default_only=False):
69
+ """Report each parameter's mode + value + expression strings.
70
+
71
+ Returns {path, type, name, parameters:[{name, mode, value?, expr?,
72
+ bind_expr?, export_op?}], warnings}. Raises LookupError if the node is
73
+ missing so the router answers 400.
74
+ """
75
+ node = op(path) # noqa: F821 - TD global
76
+ if node is None:
77
+ raise LookupError("Node not found: %s" % path)
78
+ report = {
79
+ "path": path,
80
+ "type": getattr(node, "type", "") or "",
81
+ "name": getattr(node, "name", "") or "",
82
+ "parameters": [],
83
+ "warnings": [],
84
+ }
85
+ _keys = set(keys) if keys else None
86
+ for par in node.pars():
87
+ try:
88
+ pname = par.name
89
+ if _keys is not None and pname not in _keys:
90
+ continue
91
+ mode = _normalize_mode(par)
92
+ if non_default_only and mode == "CONSTANT":
93
+ continue
94
+ entry = {"name": pname, "mode": mode}
95
+ try:
96
+ entry["value"] = _json_safe(par.eval())
97
+ except Exception as exc: # noqa: BLE001
98
+ report["warnings"].append("Could not eval %s: %s" % (pname, exc))
99
+ try:
100
+ _expr = par.expr
101
+ if _expr:
102
+ entry["expr"] = str(_expr)
103
+ except Exception: # noqa: BLE001
104
+ pass
105
+ try:
106
+ _be = getattr(par, "bindExpr", "")
107
+ if _be:
108
+ entry["bind_expr"] = str(_be)
109
+ except Exception: # noqa: BLE001
110
+ pass
111
+ try:
112
+ _eop = par.exportOP
113
+ if _eop is not None:
114
+ entry["export_op"] = _eop.path
115
+ except Exception: # noqa: BLE001
116
+ pass
117
+ report["parameters"].append(entry)
118
+ except Exception as exc: # noqa: BLE001
119
+ report["warnings"].append("Error reading parameter: %s" % exc)
120
+ return report
121
+
122
+
123
+ def set_param_mode(path, param, mode, expr=None, value=None):
124
+ """Set one parameter's mode to expression / bind / constant.
125
+
126
+ Uses ModeCls = type(par.mode) for the enum (ParMode is not importable).
127
+ Returns {path, param, mode, readback_mode, readback_expr}. Raises on
128
+ not-found / unknown param / missing expr / bad value.
129
+ """
130
+ node = op(path) # noqa: F821 - TD global
131
+ if node is None:
132
+ raise LookupError("Node not found: %s" % path)
133
+ par = getattr(node.par, param, None)
134
+ if par is None:
135
+ raise ValueError("No such parameter: %s on %s" % (param, path))
136
+
137
+ # Resolve the ParMode enum class from a live parameter — ParMode is NOT a
138
+ # bare global / td.ParMode. This is the robust path AND the bug fix.
139
+ mode_cls = type(par.mode)
140
+
141
+ norm = str(mode or "expression").strip().lower()
142
+ if norm == "expression":
143
+ if not expr:
144
+ raise ValueError("expr is required for mode 'expression' (param %s)" % param)
145
+ par.expr = expr
146
+ par.mode = mode_cls.EXPRESSION
147
+ elif norm == "bind":
148
+ if not expr:
149
+ raise ValueError("expr is required for mode 'bind' (param %s)" % param)
150
+ par.bindExpr = expr
151
+ par.mode = mode_cls.BIND
152
+ elif norm == "constant":
153
+ if value is None:
154
+ raise ValueError("value is required for mode 'constant' (param %s)" % param)
155
+ par.val = value
156
+ par.mode = mode_cls.CONSTANT
157
+ else:
158
+ raise ValueError("Unknown mode %r (expected expression|bind|constant)" % mode)
159
+
160
+ # Read back from the attribute that matches the mode just written: bind lives in
161
+ # par.bindExpr, expression in par.expr; a constant has no expression (par.expr may
162
+ # hold a stale one, so report empty rather than something misleading).
163
+ readback_expr = ""
164
+ try:
165
+ if norm == "bind":
166
+ readback_expr = str(getattr(par, "bindExpr", "") or "")
167
+ elif norm == "expression":
168
+ readback_expr = str(getattr(par, "expr", "") or "")
169
+ except Exception: # noqa: BLE001
170
+ readback_expr = ""
171
+ return {
172
+ "path": path,
173
+ "param": param,
174
+ "mode": norm,
175
+ "readback_mode": _normalize_mode(par),
176
+ "readback_expr": readback_expr,
177
+ }
178
+
179
+
180
+ def is_dat(path):
181
+ """True when ``path`` resolves to a DAT. Used to disambiguate the ``…/text``
182
+ route from a node literally named ``text``: the WebServer DAT decodes %2F, so the
183
+ router cannot tell a 'text' endpoint suffix from a 'text' node name by shape."""
184
+ return bool(getattr(op(path), "isDAT", False)) # noqa: F821 - TD global
185
+
186
+
187
+ def get_dat_text(path):
188
+ """Return {path, text, is_table, num_rows, num_cols} for a DAT.
189
+
190
+ Raises LookupError if the node is missing, ValueError if it is not a DAT.
191
+ """
192
+ node = op(path) # noqa: F821 - TD global
193
+ if node is None:
194
+ raise LookupError("Node not found: %s" % path)
195
+ if not getattr(node, "isDAT", False):
196
+ raise ValueError("%s is not a DAT." % path)
197
+ return {
198
+ "path": path,
199
+ "text": node.text,
200
+ "is_table": bool(getattr(node, "isTable", False)),
201
+ "num_rows": int(getattr(node, "numRows", 0) or 0),
202
+ "num_cols": int(getattr(node, "numCols", 0) or 0),
203
+ }
204
+
205
+
206
+ def put_dat_text(path, text):
207
+ """Overwrite a DAT's whole `.text`. Returns {path, old_length, new_length}.
208
+
209
+ Raises LookupError if the node is missing, ValueError if it is not a DAT.
210
+ """
211
+ node = op(path) # noqa: F821 - TD global
212
+ if node is None:
213
+ raise LookupError("Node not found: %s" % path)
214
+ if not getattr(node, "isDAT", False):
215
+ raise ValueError("%s is not a DAT." % path)
216
+ old_length = len(node.text)
217
+ node.text = text
218
+ return {"path": path, "old_length": old_length, "new_length": len(text)}
@@ -1,3 +1,3 @@
1
1
  """Version of the tdmcp TouchDesigner bridge."""
2
2
 
3
- BRIDGE_VERSION = "0.4.0"
3
+ BRIDGE_VERSION = "0.6.1"