Part of the Wrangle reference. Everything on this page goes in a #@python block in the node's Code field, and runs once per Run - before the expression half compiles.
Block syntax - #@python / #@wrangle
A line whose trimmed text is exactly #@python opens a Python block; the next #@wrangle line, or end of document, closes it - every other line is the Wrangle expression. Multiple #@python blocks in one document coalesce (their python text concatenates, in document order, into one script); ##@python (doubled #) is the escape for a literal comment line that must not open a block. Both marker words are ordinary # comments to whichever language they end up inert in, so a malformed marker degrades to a comment rather than a hard error.
#@python
import math
#@wrangle
P.z += py("scale")
Pipeline order
Fixed, every time the stage actually runs: the Python script runs first, once, on the working mesh - it may write named attributes and detail values. Then the compiler builds this run's schema (which now includes anything the script just wrote). Then the expression half compiles against that schema. Then the expression evaluates. A script that creates @thick can be read by @thick in the expression on the same run; there is no separate Run needed in between. A document that is nothing but a #@python block is legal - the stage's own writes publish with no expression pass at all (Mesh Domain/Corner).
@thick from the script, P.z += @thick in the expression
Reading script output
Per-element values (an attribute the script wrote with mm.set_attrib) are read exactly like any other named attribute: @name. Whole-run values (mm.set_detail) are read with py()/pyv() below - they are not per-element, so they are not attributes.
@thick, py("scale")
py("key") -> scalar
The named Python-stage detail value, as a scalar - baked at compile time from the last successful run (the script does not run again to answer this). Unknown key is a compile error listing the keys the script actually set on its last run, or saying the stage has not run yet when there are none. A vector-typed key is a compile error naming pyv() instead.
wmap("Weights") = py("thickness_scale")
pyv("key") -> vector
The vector twin of py() - same resolution, same "unknown key" / "has not run yet" errors, same "use py() instead" error for a scalar-typed key.
P += pyv("offset")
Execution
The stage runs every Run, in full, whenever the #@python body is non-empty - there is no separate run mode and no cache to keep warm or invalidate. A #@requires package that is already satisfied costs nothing beyond the up-front check (see requires below); only an actual install pays for itself, once, the Run it happens on. A script that needs to skip its own expensive work on a later Run guards itself in Python (e.g. an os.path.exists check around a download into mm.cache_dir - see the code editor's Fetch Once example).
#@requires numpy scipy==1.11
One or more package specs, whitespace separated, on their own line inside a #@python block - the fast path for a dependency: the next time the stage actually runs, anything not already present installs automatically into the managed packages folder, before the script's own imports execute. Offline (no network, or the install fails), the script still runs and its own import line fails with an ordinary ImportError naming what is still missing - #@requires cannot make a package appear out of nowhere, it only automates the same manual step. A version pin (scipy==1.11) installs that exact version; an unpinned name installs whatever is currently latest.
#@requires numpy
import numpy as np
Interpreter and packages
#@requires is the fast path for numpy/scipy/... - it installs into the same managed folder every Wrangle node's Python stage already searches. The script runs against this app's own bundled Python 3.11 - never your system Python, never a virtualenv - and a compiled package built for a different Python fails with an ordinary ImportError, which the stage appends a hint to naming this exact requirement. A folder of your own packages can also be added under Additional Python Paths.
import numpy as np
Custom interpreter - Settings > Scripting > Python Interpreter Path
Empty (default) runs this app's own bundled Python 3.11 in-process. A python.exe path here switches the stage to run that interpreter out-of-process instead - any version, your own environment, your own already-installed packages - at the cost of a slower per-Run interpreter start-up. Use it for a package this app's managed installer cannot reach, or a Python version numpy/scipy actually ships wheels for. A path that does not exist is a node error naming this setting, with no interpreter ever spawned.
Isolated built-in - Settings > Scripting > Isolate Built-in Interpreter
Runs this app's own bundled interpreter out-of-process too, even with no custom path set - the escape hatch for a script that imports a native wheel you do not fully trust: a segfault inside a compiled extension takes the whole app down when it happens in-process, but only takes the isolated child down when this is on, surfacing as an ordinary node error instead. Off by default, since the common case pays nothing for a robustness feature it does not need.
Settings on this node > Python Isolated
Runs this node's Python in the isolated (out-of-process) transport even on the shipped, in-process-capable interpreter - the same transport a custom Python Interpreter Path or Isolate Built-in Interpreter selects, chosen per node instead of app-wide: crash/hang containment and native-package conflict avoidance for one node you do not fully trust, without switching every other Wrangle node's Python to the slower start-up. Off by default. Redundant, not conflicting, once a custom interpreter path or Isolate Built-in Interpreter already forces isolation.
Isolated mode's hard kill
Any isolated case (a custom path, Isolate Built-in Interpreter, or this node's own Python Isolated setting) gets a real, hard OS-level kill on cancel or timeout instead of the in-process stage's cooperative watchdog, which can only ask a running interpreter to raise - a script stuck inside a C extension with the GIL released can ignore that ask forever, but cannot decline an OS-level TerminateProcess. This is isolation's actual robustness benefit, not merely a different way of running the same script.
Settings on this node > Interactive Script
Tick this on when the script opens its own window (tkinter, PySide, PyQt, ...) and waits on it. Needs an isolated interpreter (tick this node's own Python Isolated setting, or set a custom Python Interpreter Path / Isolate Built-in Interpreter app-wide) - in-process is refused, naming the fix, because a GUI toolkit's own event loop would hang this whole application unkillably in-process (the cooperative watchdog cannot reach it). tkinter ships with this app's interpreter but fails in-process with a raw TclError anyway (no window-server pump on this thread); PySide/PyQt import fine in-process but app.exec() is exactly the unkillable hang - a built-in sys.meta_path guard (in-process only, never in the isolated child - the isolated process is precisely where these toolkits are allowed) refuses the import itself with a message naming this setting, so the failure is a clear one instead of either of those two shapes. Also disables the Python Timeout for this run - a user-chosen Cancel (a real, hard kill in isolated mode) is the only expected exit while the script's window is open, not a timer. mm.ui.host() implies this setting and isolation automatically for that run.
Python limits - Python Timeout (seconds), Isolate Built-in Interpreter
Python Timeout (default 300s) aborts a script that runs longer, cooperatively - it can only ask a running interpreter to stop, which one stuck inside a C extension with the GIL released can ignore forever; turn on Settings > Scripting > Isolate Built-in Interpreter for a real, hard OS-level kill instead when that matters more than in-process start-up speed.
Custom interpreter and packages
A custom Python Interpreter Path never sees this app's managed PyPackages folder (#@requires installs cp311 wheels there, built for this app's own embedded Python - loading one into a different interpreter version risks a crash uglier than a plain ImportError). Additional Python Paths still reaches a custom interpreter; install packages into its own environment for everything else - a #@requires line targeting the managed folder is a no-op for it either way.
Errors
A Python-stage failure surfaces on the same status strip a compile error does, remapped from the extracted script's own line number back to the merged document's line so the underline lands on what you actually see on screen; the node's own message names what went wrong (Python Last Error/Python Last Error Traceback), the node goes to its failed state, and - the same promise the expression itself keeps - the mesh is left exactly as it arrived, never half-written.
Security
The Python stage runs only when Run is pressed - never on graph load, paste, undo or redo. That press can be yours, or the in-app AI agent's (node_action, if you are running an agent session) - the agent can also author PythonScript before pressing it, the same set_node_properties edit you would make by hand (it lands inside the document's #@python block in the human-facing editor, same as if you had typed it there). Python is on by default app-wide now (Settings > Scripting can switch it off per machine) and gated by licence besides; the guarantees that matter are unchanged regardless of that default - explicit press only, never on load/paste/undo, licence-gated. #@requires does not widen any of this: it installs through the same pip, into the same managed folder, behind the same gates as any other #@requires install - convenience, not capability; read a #@requires line the same way you would read the script under it. Isolation (a custom interpreter, Isolate Built-in Interpreter, or a node's own Python Isolated setting) is a robustness feature, not a security boundary - a spawned child process inherits this signed application's own privileges exactly like an in-process call would.
Determinism
Everything else in this language is deterministic - same input, same output, every machine, every run. A Python script is not bound by that: one that calls random.random(), reads the system clock, or opens a network connection makes this node's output non-reproducible, and nothing here can detect or warn about it. An unpinned #@requires spec adds its own source of drift - numpy installs whatever is currently latest, which can differ machine to machine and month to month - pin a version (numpy==1.26) wherever the rest of the graph needs to be reproducible. Keep the script itself deterministic too if that matters.
Code completion
The code editor's mm. popup always offers the built-in name list (see the Python API page) instantly, on every keystroke. When Settings > Scripting is on, it also offers real completions from the bundled jedi engine - stdlib/package names (import numpy offers what numpy actually exports) and real call signatures for every mm. member - layered in about 300ms after you stop typing (jedi's own richer entry wins when both offer the same name). Off (or the module failed to load): the built-in list is all you get, exactly as before this existed.
import js -> offers "json"
Python topology
mm.set_mesh() and the incremental working copy.
Two ways, pick one
mm.set_mesh() replaces the whole subject in one call - the right tool for "I computed an entirely new mesh". mm.add_point()/mm.add_tri()/mm.remove_tri()/mm.remove_point() edit the existing subject incrementally - the right tool for "I am adding/removing a few things". A single run may use one or the other, never both (a script that calls set_mesh() and also add_point() in the same run is a compile-time-shaped ScriptError naming the rule).
mm.set_mesh(points, tris)
# or
i = mm.add_point(p)
Virtual ids
Every id add_point()/add_tri() hands back is virtual: a dense counter this run owns (starting right after the pre-run mesh's own last id), never the engine's own internal id. This is deliberate, not a limitation - the engine's real ids can be reused by any later add once something earlier is removed (its own "free list"), which would make an id you saved into a python variable silently start naming a different point later in the same run. Virtual ids never do that: once handed to you, an id names the same thing (or is dead - see is_point()/is_tri()) for the rest of the run.
i = mm.add_point(p); assert mm.is_point(i)
Survival table
is_point(i)/is_tri(t) answer against all of this run's history: the pre-run subject's own points/triangles, plus everything add_point()/add_tri() created, minus everything remove_point()/remove_tri() has since killed (remove_tri(keep_points=False) also kills any corner left with no other live triangle; remove_point() also kills every triangle still touching it, leaving their other corners alive). max_points()/max_prims() are the ceiling those ids run up to - not a live count, exactly like the expression language's own maxpoints()/maxprims().
for i in range(mm.max_points()):
if mm.is_point(i): ...
mm.P after topology
Before any topology call, mm.P is Wave 1's plain snapshot. After the first add_point()/add_tri()/remove_tri()/remove_point() call, mm.P re-materializes from the growing working copy instead - reading it again after a later add_point() sees the new row too. Holding onto an old mm.P result across an add_point() call and still using it is undefined (a live memoryview blocks the working copy's own bytearray from growing) - re-read mm.P fresh after any topology call if you need it.
mm.add_point(p); p2 = mm.P # fresh, includes p
What cannot be combined
set_mesh() and the edit list are mutually exclusive (see two ways, pick one above) - a hard error naming both. mm.set_P/set_N/set_Cd/set_uv/set_wmap/set_attrib can never be combined with an edit list that removes anything (remove_tri()/remove_point()) in the same run, because a removal can make an id stop naming the row it used to - split into two Wrangle nodes, or use set_mesh() to replace everything atomically instead. An adds-only edit list (add_point()/add_tri() only, nothing removed) is exempt - ids only ever grow, so a channel write's row-per-id promise still holds.
mm.add_point(p); mm.set_P(new_positions) # OK, adds-only
Caps
Both set_mesh() and the edit list share the same per-run growth ceilings as the expression's own addpoint()/addprim(). set_mesh() enforces its cap as a hard error (an atomic replacement cannot be "truncated" down to the cap without publishing a mesh you never asked for) - build incrementally instead. The edit list enforces the same cap per-op instead: an add_point()/add_tri() call past the cap is silently skipped (counted, not an error) and every other op in the list still applies.
mm.builder(from_subject=False)
A third option, built on top of set_mesh() rather than a third mechanism: a plain python-side accumulator with its own .add_point()/.add_tri()/.remove_tri() (local list indices, not the same ids as the working copy above) and .commit(normals=..., uvs=..., colors=..., attribs=...), which flattens everything into one mm.set_mesh() call for you. Use it when "build a mesh from a loop" is more natural than hand-flattening arrays yourself.
b = mm.builder(from_subject=True); b.remove_tri(0); b.commit()