nodebpy 520.27.0__tar.gz → 520.28.0__tar.gz

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.
Files changed (89) hide show
  1. {nodebpy-520.27.0 → nodebpy-520.28.0}/PKG-INFO +1 -1
  2. {nodebpy-520.27.0 → nodebpy-520.28.0}/pyproject.toml +1 -1
  3. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/__init__.py +2 -1
  4. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/tree.py +26 -1
  5. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/codegen.py +14 -0
  6. nodebpy-520.28.0/src/nodebpy/live.py +361 -0
  7. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/manual.py +2 -1
  8. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/manual.py +2 -1
  9. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/manual.py +2 -1
  10. {nodebpy-520.27.0 → nodebpy-520.28.0}/README.md +0 -0
  11. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/assets/__init__.py +0 -0
  12. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/assets/__main__.py +0 -0
  13. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/assets/_codegen.py +0 -0
  14. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/assets/_library.py +0 -0
  15. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/assets/_pipeline.py +0 -0
  16. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/__init__.py +0 -0
  17. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/_registry.py +0 -0
  18. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/_socket_order.py +0 -0
  19. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/_utils.py +0 -0
  20. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/accessor.py +0 -0
  21. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/asset.py +0 -0
  22. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/items.py +0 -0
  23. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/layout.py +0 -0
  24. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/mixins.py +0 -0
  25. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/node.py +0 -0
  26. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/builder/socket.py +0 -0
  27. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/__init__.py +0 -0
  28. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/diagram.py +0 -0
  29. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/parity.py +0 -0
  30. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/plot.py +0 -0
  31. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/export/web_render.py +0 -0
  32. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/VENDORED.md +0 -0
  33. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  34. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  35. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/balancing.py +0 -0
  36. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  37. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  38. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  39. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  40. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  41. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  42. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  43. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  44. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  45. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
  46. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
  47. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/__init__.py +0 -0
  48. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/_mixins.py +0 -0
  49. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  50. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  51. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  52. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
  53. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  54. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  55. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  56. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  57. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
  58. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  59. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  60. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  61. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  62. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
  63. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  64. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  65. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
  66. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  67. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
  68. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  69. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  70. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/input.py +0 -0
  71. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
  72. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  73. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  74. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  75. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  76. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  77. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  78. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  79. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/color.py +0 -0
  80. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/converter.py +0 -0
  81. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  82. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/group.py +0 -0
  83. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/input.py +0 -0
  84. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/output.py +0 -0
  85. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/script.py +0 -0
  86. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/shader.py +0 -0
  87. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  88. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/vector.py +0 -0
  89. {nodebpy-520.27.0 → nodebpy-520.28.0}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.27.0
3
+ Version: 520.28.0
4
4
  Summary: Build nodes trees in Blender more elegantly with code
5
5
  Author: Brady Johnston
6
6
  Author-email: Brady Johnston <brady.johnston@me.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "520.27.0"
3
+ version = "520.28.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,4 +1,4 @@
1
- from . import builder, export, nodes, types
1
+ from . import builder, export, live, nodes, types
2
2
  from .builder import (
3
3
  ArrangeMethod,
4
4
  SimpleOptions,
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "default_sugiyama_options",
23
23
  "export",
24
24
  "geometry",
25
+ "live",
25
26
  "nodes",
26
27
  "shader",
27
28
  "types",
@@ -1108,11 +1108,28 @@ class TreeBuilder[TreeT: NodeTree]:
1108
1108
  fake_user: bool = False,
1109
1109
  ignore_visibility: bool = False,
1110
1110
  split_inputs: bool | None = None,
1111
+ clear: bool = False,
1111
1112
  ):
1113
+ # ``clear`` rebuilds in place: the tree is emptied (nodes, links and
1114
+ # interface) before the body runs, keeping the datablock so modifiers,
1115
+ # group nodes and pinned editors that reference it stay attached. With
1116
+ # a name, an existing group of that name and tree type is reused
1117
+ # instead of creating a ``Name.001`` duplicate; a group of another
1118
+ # tree type is left alone and a new one is created as usual.
1119
+ # Tree-level properties (description, color_tag, is_tool, fake user,
1120
+ # ...) are kept: only the contents are rebuilt.
1112
1121
  if isinstance(tree, str):
1113
- self.tree = bpy.data.node_groups.new(tree, tree_type) # ty: ignore[invalid-assignment]
1122
+ existing = bpy.data.node_groups.get(tree) if clear else None
1123
+ if existing is not None and existing.bl_idname == tree_type:
1124
+ self.tree = existing
1125
+ else:
1126
+ self.tree = bpy.data.node_groups.new(tree, tree_type) # ty: ignore[invalid-assignment]
1114
1127
  else:
1115
1128
  self.tree = tree # type: ignore
1129
+ if clear:
1130
+ assert self.tree.interface is not None
1131
+ self.tree.nodes.clear()
1132
+ self.tree.interface.clear()
1116
1133
 
1117
1134
  self._menu_defaults: list[_MenuDefault] = []
1118
1135
  self._exited = False
@@ -1133,6 +1150,7 @@ class TreeBuilder[TreeT: NodeTree]:
1133
1150
  arrange: ArrangeMethod = "sugiyama",
1134
1151
  fake_user: bool = False,
1135
1152
  split_inputs: bool | None = None,
1153
+ clear: bool = False,
1136
1154
  ) -> TreeBuilder[GeometryNodeTree]:
1137
1155
  """Create a geometry node tree."""
1138
1156
  return cast(
@@ -1144,6 +1162,7 @@ class TreeBuilder[TreeT: NodeTree]:
1144
1162
  arrange=arrange,
1145
1163
  fake_user=fake_user,
1146
1164
  split_inputs=split_inputs,
1165
+ clear=clear,
1147
1166
  ),
1148
1167
  )
1149
1168
 
@@ -1156,6 +1175,7 @@ class TreeBuilder[TreeT: NodeTree]:
1156
1175
  arrange: ArrangeMethod = "sugiyama",
1157
1176
  fake_user: bool = False,
1158
1177
  split_inputs: bool | None = None,
1178
+ clear: bool = False,
1159
1179
  ) -> TreeBuilder[ShaderNodeTree]:
1160
1180
  """Create a shader node tree."""
1161
1181
  return cast(
@@ -1167,6 +1187,7 @@ class TreeBuilder[TreeT: NodeTree]:
1167
1187
  arrange=arrange,
1168
1188
  fake_user=fake_user,
1169
1189
  split_inputs=split_inputs,
1190
+ clear=clear,
1170
1191
  ),
1171
1192
  )
1172
1193
 
@@ -1179,6 +1200,7 @@ class TreeBuilder[TreeT: NodeTree]:
1179
1200
  arrange: ArrangeMethod = "sugiyama",
1180
1201
  fake_user: bool = False,
1181
1202
  split_inputs: bool | None = None,
1203
+ clear: bool = False,
1182
1204
  ) -> TreeBuilder[CompositorNodeTree]:
1183
1205
  """Create a compositor node tree."""
1184
1206
  return cast(
@@ -1190,6 +1212,7 @@ class TreeBuilder[TreeT: NodeTree]:
1190
1212
  arrange=arrange,
1191
1213
  fake_user=fake_user,
1192
1214
  split_inputs=split_inputs,
1215
+ clear=clear,
1193
1216
  ),
1194
1217
  )
1195
1218
 
@@ -1215,6 +1238,7 @@ class TreeBuilder[TreeT: NodeTree]:
1215
1238
  top_level: Literal["with", "class"] = "with",
1216
1239
  format: bool = True,
1217
1240
  nodebpy_pkg: str = "nodebpy",
1241
+ in_place: bool = False,
1218
1242
  ) -> str:
1219
1243
  """Generate Python source that recreates this tree using nodebpy.
1220
1244
 
@@ -1232,6 +1256,7 @@ class TreeBuilder[TreeT: NodeTree]:
1232
1256
  top_level=top_level,
1233
1257
  format=format,
1234
1258
  nodebpy_pkg=nodebpy_pkg,
1259
+ in_place=in_place,
1235
1260
  )
1236
1261
 
1237
1262
  def to_mermaid(self, fenced: bool = True) -> str:
@@ -3724,6 +3724,7 @@ def to_python(
3724
3724
  external_groups: Collection[str] | None = None,
3725
3725
  typed_groups: Collection[str] | None = None,
3726
3726
  root_interface: GroupInterface | None = None,
3727
+ in_place: bool = False,
3727
3728
  ) -> str:
3728
3729
  """Generate Python code that recreates the given node tree using nodebpy.
3729
3730
 
@@ -3794,6 +3795,17 @@ def to_python(
3794
3795
  Typed-interface parts (docstring, class attributes, accessors and
3795
3796
  ``__init__``) spliced into the top-level tree's class in ``class``
3796
3797
  mode, ahead of ``_build_group``.
3798
+ in_place: bool
3799
+ If True, the ``with`` header is emitted as
3800
+ ``with g.tree("Name", clear=True) as tree:`` so running the source
3801
+ rebuilds the exported tree in place — the same datablock, emptied
3802
+ first — instead of creating a ``Name.001`` copy, keeping modifiers,
3803
+ group nodes and pinned editors attached across re-runs. Only the
3804
+ top-level tree is rebuilt this way: nested groups are emitted as
3805
+ ``Custom*Group`` classes whose ``create_group()`` reuses an existing
3806
+ tree of the same name, so edits to them only take effect through
3807
+ :func:`nodebpy.live.run_source`. Ignored with ``top_level="class"``
3808
+ (a class is rebuilt by ``run_source``, not by a header).
3797
3809
 
3798
3810
  Returns
3799
3811
  -------
@@ -3876,6 +3888,8 @@ def to_python(
3876
3888
  # for either option.
3877
3889
  if snapshot_positions or keep_reroutes:
3878
3890
  ctor_args.append("arrange=None")
3891
+ if in_place:
3892
+ ctor_args.append("clear=True")
3879
3893
  lines.append(f"with {constructor}({', '.join(ctor_args)}) as tree:")
3880
3894
  lines.extend(_assemble_tree_body(emission))
3881
3895
 
@@ -0,0 +1,361 @@
1
+ """Re-run nodebpy source safely inside a live Blender session.
2
+
3
+ A live editor (a Text block, a file on disk) re-executes its code on every
4
+ change. Three things make a naive ``exec`` unpleasant, and :func:`run_source`
5
+ handles all of them:
6
+
7
+ - ``Custom*Group.create_group()`` reuses an existing group of the same
8
+ ``_name`` rather than rebuilding it, so re-running edited class code would
9
+ silently hand back the previous build. Existing groups the code claims are
10
+ renamed out of the way first (:func:`stash_groups`), then their users are
11
+ remapped onto the rebuilt trees and the old ones removed
12
+ (:meth:`GroupStash.replace`); on failure the old names are put back
13
+ (:meth:`GroupStash.restore`).
14
+ - Rebuilding a tree's interface regenerates its socket identifiers, which is
15
+ what Geometry Nodes modifiers key their input values on, so the values
16
+ would be lost. :func:`preserve_modifier_inputs` snapshots them by socket
17
+ name and reapplies them afterwards.
18
+ - The tree the run produced has to be found among whatever the code did:
19
+ the ``tree`` variable of the ``with g.tree(...) as tree:`` form, the newest
20
+ top-level group it created, or the last group class it defined.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ from collections.abc import Iterable, Iterator
27
+ from contextlib import contextmanager, suppress
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, cast
30
+
31
+ import bpy
32
+
33
+ from .builder import TreeBuilder
34
+
35
+ __all__ = [
36
+ "GroupStash",
37
+ "RunResult",
38
+ "group_names_in_source",
39
+ "preserve_modifier_inputs",
40
+ "run_source",
41
+ "stash_groups",
42
+ ]
43
+
44
+
45
+ def group_names_in_source(code: str) -> set[str]:
46
+ """Literal ``_name`` values assigned in class bodies of ``code``.
47
+
48
+ Parsed with :mod:`ast`; both ``_name = "..."`` and ``_name: str = "..."``
49
+ count. Returns an empty set on a :class:`SyntaxError` (``exec`` reports
50
+ it).
51
+ """
52
+ try:
53
+ module = ast.parse(code)
54
+ except SyntaxError:
55
+ return set()
56
+ names: set[str] = set()
57
+ for node in ast.walk(module):
58
+ if not isinstance(node, ast.ClassDef):
59
+ continue
60
+ for stmt in node.body:
61
+ if isinstance(stmt, ast.Assign):
62
+ targets, value = stmt.targets, stmt.value
63
+ elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
64
+ targets, value = [stmt.target], stmt.value
65
+ else:
66
+ continue
67
+ if not (isinstance(value, ast.Constant) and isinstance(value.value, str)):
68
+ continue
69
+ if any(isinstance(t, ast.Name) and t.id == "_name" for t in targets):
70
+ names.add(value.value)
71
+ return names
72
+
73
+
74
+ @dataclass
75
+ class GroupStash:
76
+ """Existing node groups renamed out of the way before a run.
77
+
78
+ Maps each original name to the old tree, now named ``<name>.stale``.
79
+ """
80
+
81
+ stashed: dict[str, bpy.types.NodeTree] = field(default_factory=dict)
82
+
83
+ def restore(self) -> None:
84
+ """Failure path: remove any partial build under an original name and
85
+ give the old trees their names back. Empties the stash, so calling
86
+ it (or :meth:`replace`) again is a no-op."""
87
+ for name, old in self.stashed.items():
88
+ leftover = bpy.data.node_groups.get(name)
89
+ if leftover is not None and leftover != old:
90
+ bpy.data.node_groups.remove(leftover)
91
+ old.name = name
92
+ self.stashed.clear()
93
+
94
+ def replace(self) -> None:
95
+ """Success path: for each name, if the run created a new tree of the
96
+ same type under it, point the old tree's users (group nodes,
97
+ modifiers, pinned editors) at the new one and remove the old;
98
+ otherwise the old tree keeps its original name. Empties the stash,
99
+ so calling it (or :meth:`restore`) again is a no-op."""
100
+ for name, old in self.stashed.items():
101
+ new = bpy.data.node_groups.get(name)
102
+ if new is not None and new != old and new.bl_idname == old.bl_idname:
103
+ old.user_remap(new)
104
+ bpy.data.node_groups.remove(old)
105
+ else:
106
+ old.name = name
107
+ self.stashed.clear()
108
+
109
+
110
+ def stash_groups(names: Iterable[str]) -> GroupStash:
111
+ """Rename each existing ``bpy.data.node_groups[name]`` to ``"<name>.stale"``
112
+ and return the :class:`GroupStash` that undoes or completes the move.
113
+
114
+ A group linked from a library (an appended asset) cannot be renamed and
115
+ is left in place, so ``create_group()`` reuses it as usual.
116
+ """
117
+ stash = GroupStash()
118
+ for name in names:
119
+ if name in stash.stashed:
120
+ continue
121
+ existing = bpy.data.node_groups.get(name)
122
+ if existing is not None and existing.library is None:
123
+ existing.name = f"{name}.stale"
124
+ stash.stashed[name] = existing
125
+ return stash
126
+
127
+
128
+ def _copy_value(value: Any) -> Any:
129
+ # Vector / colour values come back as bpy_prop_array views into the
130
+ # modifier's storage, which the rebuild replaces; keep a plain tuple.
131
+ if hasattr(value, "__len__") and not isinstance(value, (str, bytes, bpy.types.ID)):
132
+ return tuple(value)
133
+ return value
134
+
135
+
136
+ def _input_sockets(
137
+ tree: bpy.types.NodeTree,
138
+ ) -> dict[str, bpy.types.NodeTreeInterfaceSocket]:
139
+ """Interface input sockets by name (first wins on duplicate names)."""
140
+ sockets: dict[str, bpy.types.NodeTreeInterfaceSocket] = {}
141
+ assert tree.interface is not None
142
+ for item in tree.interface.items_tree:
143
+ if item.item_type != "SOCKET":
144
+ continue
145
+ assert isinstance(item, bpy.types.NodeTreeInterfaceSocket)
146
+ if item.in_out == "INPUT" and item.name not in sockets:
147
+ sockets[item.name] = item
148
+ return sockets
149
+
150
+
151
+ def _modifier_inputs(modifier: bpy.types.NodesModifier) -> Any:
152
+ """``modifier.properties.inputs``: one RNA property per interface input,
153
+ named by socket identifier, each wrapping the stored ``value`` (Blender
154
+ 5.x; the type stubs predate it)."""
155
+ properties = cast("Any", modifier.properties)
156
+ return properties.inputs
157
+
158
+
159
+ def _nodes_modifiers(pointers: set[int]) -> Iterator[bpy.types.NodesModifier]:
160
+ for obj in bpy.data.objects:
161
+ for modifier in obj.modifiers:
162
+ if not isinstance(modifier, bpy.types.NodesModifier):
163
+ continue
164
+ group = modifier.node_group
165
+ if group is not None and group.as_pointer() in pointers:
166
+ yield modifier
167
+
168
+
169
+ # Per-input state a modifier stores besides ``value``: whether the input
170
+ # reads a named attribute instead (``type`` is ``VALUE`` or ``ATTRIBUTE``)
171
+ # and which one.
172
+ _INPUT_ATTRS = ("value", "type", "attribute_name")
173
+
174
+
175
+ @dataclass
176
+ class _InputState:
177
+ socket_type: str
178
+ attrs: dict[str, Any]
179
+
180
+
181
+ @contextmanager
182
+ def preserve_modifier_inputs(trees: Iterable[bpy.types.NodeTree]) -> Iterator[None]:
183
+ """Keep Geometry Nodes modifier input values across an interface rebuild.
184
+
185
+ Snapshots the input state of every Geometry Nodes modifier (on any
186
+ object) whose ``node_group`` is one of ``trees``, keyed by interface
187
+ socket *name*: the value, and whether the input reads a named attribute
188
+ instead (``type`` and ``attribute_name``). On exit — also when the body
189
+ raised, so a failed in-place rebuild keeps the values of the sockets it
190
+ did build — each entry whose socket name still exists on the modifier's
191
+ (possibly rebuilt, possibly remapped) tree is reapplied; sockets whose
192
+ type changed are skipped, as is anything that fails to apply — the
193
+ reapply step never raises.
194
+ """
195
+ pointers = {tree.as_pointer() for tree in trees}
196
+ snapshot: list[tuple[bpy.types.NodesModifier, dict[str, _InputState]]] = []
197
+ for modifier in _nodes_modifiers(pointers):
198
+ assert modifier.node_group is not None
199
+ inputs = _modifier_inputs(modifier)
200
+ values: dict[str, _InputState] = {}
201
+ for name, socket in _input_sockets(modifier.node_group).items():
202
+ wrapper = getattr(inputs, socket.identifier, None)
203
+ if wrapper is None or not hasattr(wrapper, "value"):
204
+ continue # a geometry socket, or one with no stored value
205
+ attrs = {
206
+ attr: _copy_value(getattr(wrapper, attr))
207
+ for attr in _INPUT_ATTRS
208
+ if hasattr(wrapper, attr)
209
+ }
210
+ values[name] = _InputState(socket.socket_type, attrs)
211
+ snapshot.append((modifier, values))
212
+
213
+ try:
214
+ yield
215
+ finally:
216
+ for modifier, values in snapshot:
217
+ # A modifier the run removed, a tree it freed, ...: never raise.
218
+ with suppress(Exception):
219
+ _reapply_inputs(modifier, values)
220
+
221
+
222
+ def _reapply_inputs(
223
+ modifier: bpy.types.NodesModifier, values: dict[str, _InputState]
224
+ ) -> None:
225
+ group = modifier.node_group
226
+ if group is None:
227
+ return
228
+ inputs = _modifier_inputs(modifier)
229
+ sockets = _input_sockets(group)
230
+ for name, state in values.items():
231
+ socket = sockets.get(name)
232
+ if socket is None or socket.socket_type != state.socket_type:
233
+ continue
234
+ wrapper = getattr(inputs, socket.identifier, None)
235
+ if wrapper is None or not hasattr(wrapper, "value"):
236
+ continue
237
+ for attr, value in state.attrs.items():
238
+ with suppress(Exception): # best effort, per attribute
239
+ setattr(wrapper, attr, value)
240
+
241
+
242
+ @dataclass
243
+ class RunResult:
244
+ """What :func:`run_source` produced."""
245
+
246
+ tree: bpy.types.NodeTree | None
247
+ """The tree the run produced, or ``None`` if it made no tree."""
248
+ created: list[bpy.types.NodeTree]
249
+ """Every node group that exists after the run and did not before."""
250
+ namespace: dict[str, Any]
251
+ """The globals the code ran in."""
252
+
253
+
254
+ def _is_group_class(value: Any, module_name: str | None) -> bool:
255
+ return (
256
+ isinstance(value, type)
257
+ and getattr(value, "__module__", None) == module_name
258
+ and callable(getattr(value, "create_group", None))
259
+ and bool(getattr(value, "_name", None))
260
+ and bool(getattr(value, "_tree_idname", None))
261
+ )
262
+
263
+
264
+ def _produced_tree(
265
+ namespace: dict[str, Any], before: set[int], prior: Any
266
+ ) -> bpy.types.NodeTree | None:
267
+ """The tree a run produced: the ``tree`` variable's, else the newest
268
+ top-level group created, else the last group class defined (built now).
269
+
270
+ ``before`` holds the ``session_uid`` of every group that existed before
271
+ the run; ``prior`` is whatever ``tree`` was bound to before it (a value
272
+ the caller's namespace carried over from an earlier run), which does not
273
+ count as produced by this one."""
274
+ bound = namespace.get("tree")
275
+ if bound is not prior:
276
+ if isinstance(bound, TreeBuilder):
277
+ return bound.tree
278
+ if isinstance(bound, bpy.types.NodeTree):
279
+ return bound
280
+
281
+ new_groups = [g for g in bpy.data.node_groups if g.session_uid not in before]
282
+ if new_groups:
283
+ nested = {
284
+ sub.as_pointer()
285
+ for group in new_groups
286
+ for node in group.nodes
287
+ if (sub := getattr(node, "node_tree", None)) is not None
288
+ }
289
+ top_level = [g for g in new_groups if g.as_pointer() not in nested]
290
+ # bpy.data.node_groups is sorted by name; session_uid counts up with
291
+ # creation, so it picks the group the code made last.
292
+ return max(top_level or new_groups, key=lambda g: g.session_uid)
293
+
294
+ module_name = namespace.get("__name__")
295
+ for value in reversed(list(namespace.values())):
296
+ if _is_group_class(value, module_name):
297
+ tree = value.create_group()
298
+ if isinstance(tree, bpy.types.NodeTree):
299
+ return tree
300
+ return None
301
+
302
+
303
+ def run_source(
304
+ code: str,
305
+ *,
306
+ filename: str = "<nodebpy>",
307
+ namespace: dict[str, Any] | None = None,
308
+ ) -> RunResult:
309
+ """Execute nodebpy source for a live re-run.
310
+
311
+ Existing groups the code's classes claim by ``_name`` are stashed so
312
+ ``create_group()`` builds fresh, Geometry Nodes modifier input values on
313
+ every pre-existing tree are preserved across the rebuild, and the
314
+ stashed trees' users are remapped onto the new builds afterwards. On any
315
+ exception the stash is restored and the exception re-raised unchanged;
316
+ ``filename`` is what its traceback names. Nothing is printed or captured.
317
+
318
+ Parameters
319
+ ----------
320
+ code
321
+ The source to run.
322
+ filename
323
+ Filename for the compiled code, so tracebacks point at the caller's
324
+ Text block or file.
325
+ namespace
326
+ Extra globals merged over the defaults (``__name__``, ``__file__``
327
+ and ``bpy``). A ``tree`` it carries over from an earlier run's
328
+ :attr:`RunResult.namespace` is not mistaken for this run's tree.
329
+
330
+ A run that fails partway through a ``with g.tree(..., clear=True)`` body
331
+ leaves that tree half built (the class form is atomic: the old build is
332
+ restored). Groups the failed run created under other names are left
333
+ behind as well.
334
+ """
335
+ stash = stash_groups(group_names_in_source(code))
336
+ before = {group.session_uid for group in bpy.data.node_groups}
337
+ globals_: dict[str, Any] = {
338
+ "__name__": "__nodebpy_live__",
339
+ "__file__": filename,
340
+ "bpy": bpy,
341
+ }
342
+ if namespace:
343
+ globals_.update(namespace)
344
+ prior = globals_.get("tree")
345
+
346
+ existing = list(bpy.data.node_groups)
347
+ with preserve_modifier_inputs(existing):
348
+ try:
349
+ exec(compile(code, filename, "exec"), globals_) # noqa: S102
350
+ # Resolved before the stash is replaced: building a defined-only
351
+ # class must not find the old tree back under its name.
352
+ tree = _produced_tree(globals_, before, prior)
353
+ except BaseException:
354
+ stash.restore()
355
+ raise
356
+ # Inside the preserve context: remapping a modifier onto the rebuilt
357
+ # tree resets its inputs just like an in-place rebuild does.
358
+ stash.replace()
359
+
360
+ created = [g for g in bpy.data.node_groups if g.session_uid not in before]
361
+ return RunResult(tree=tree, created=created, namespace=globals_)
@@ -42,9 +42,10 @@ def tree(
42
42
  collapse: bool = False,
43
43
  arrange: ArrangeMethod = "sugiyama",
44
44
  fake_user: bool = False,
45
+ clear: bool = False,
45
46
  ) -> TreeBuilder[CompositorNodeTree]:
46
47
  return TreeBuilder.compositor(
47
- name, collapse=collapse, arrange=arrange, fake_user=fake_user
48
+ name, collapse=collapse, arrange=arrange, fake_user=fake_user, clear=clear
48
49
  )
49
50
 
50
51
 
@@ -152,8 +152,9 @@ def tree(
152
152
  *,
153
153
  collapse: bool = False,
154
154
  arrange: ArrangeMethod = "sugiyama",
155
+ clear: bool = False,
155
156
  ) -> TreeBuilder[GeometryNodeTree]:
156
- return TreeBuilder.geometry(name, collapse=collapse, arrange=arrange)
157
+ return TreeBuilder.geometry(name, collapse=collapse, arrange=arrange, clear=clear)
157
158
 
158
159
 
159
160
  _ColorRampColorInterpolations = Literal[
@@ -52,9 +52,10 @@ def tree(
52
52
  collapse: bool = False,
53
53
  arrange: ArrangeMethod = "sugiyama",
54
54
  fake_user: bool = False,
55
+ clear: bool = False,
55
56
  ) -> TreeBuilder[ShaderNodeTree]:
56
57
  return TreeBuilder.shader(
57
- name, collapse=collapse, arrange=arrange, fake_user=fake_user
58
+ name, collapse=collapse, arrange=arrange, fake_user=fake_user, clear=clear
58
59
  )
59
60
 
60
61
 
File without changes