@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.
- package/README.md +15 -10
- package/dist/cli/agent.d.ts +119 -1
- package/dist/cli/agent.js +14188 -1962
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +30182 -14832
- package/dist/index.js.map +1 -1
- package/dist/recipes/animus_rings_visualizer.json +37 -0
- package/dist/recipes/body_tracking_reactive.json +127 -0
- package/dist/recipes/mediapipe_body_dots.json +86 -0
- package/dist/recipes/pose_skeleton_mediapipe.json +67 -0
- package/package.json +36 -6
- package/recipes/animus_rings_visualizer.json +37 -0
- package/recipes/body_tracking_reactive.json +127 -0
- package/recipes/mediapipe_body_dots.json +86 -0
- package/recipes/pose_skeleton_mediapipe.json +67 -0
- package/safeskill.manifest.json +37 -0
- package/scripts/fetch-shader-park-td.mjs +79 -0
- package/scripts/tdmcp-lops.mjs +52 -0
- package/td/README.md +1 -1
- package/td/bootstrap.py +41 -9
- package/td/modules/mcp/controllers/api_controller.py +87 -3
- package/td/modules/mcp/install.py +49 -1
- package/td/modules/mcp/services/api_service.py +82 -0
- package/td/modules/mcp/services/connect_service.py +213 -0
- package/td/modules/mcp/services/log_service.py +136 -0
- package/td/modules/mcp/services/param_text_service.py +218 -0
- package/td/modules/utils/version.py +1 -1
- package/td/tests/test_api_controller.py +0 -307
- package/td/tests/test_services.py +0 -209
|
@@ -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)}
|