nodebpy 520.0.1__tar.gz → 520.1.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 (72) hide show
  1. {nodebpy-520.0.1 → nodebpy-520.1.0}/PKG-INFO +3 -1
  2. {nodebpy-520.0.1 → nodebpy-520.1.0}/pyproject.toml +5 -1
  3. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/__init__.py +3 -5
  4. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/__init__.py +3 -0
  5. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/_utils.py +25 -2
  6. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/accessor.py +20 -8
  7. nodebpy-520.1.0/src/nodebpy/builder/items.py +247 -0
  8. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/mixins.py +10 -0
  9. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/node.py +172 -79
  10. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/socket.py +557 -152
  11. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/tree.py +104 -19
  12. nodebpy-520.1.0/src/nodebpy/export/__init__.py +7 -0
  13. nodebpy-520.1.0/src/nodebpy/export/codegen.py +4347 -0
  14. {nodebpy-520.0.1/src/nodebpy → nodebpy-520.1.0/src/nodebpy/export}/diagram.py +64 -44
  15. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/utils.py +1 -1
  16. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/converter.py +32 -5
  17. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/groups.py +70 -7
  18. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/manual.py +272 -244
  19. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/zone.py +283 -156
  20. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/shader.py +2 -2
  21. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/types.py +9 -1
  22. {nodebpy-520.0.1 → nodebpy-520.1.0}/README.md +0 -0
  23. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/builder/_registry.py +0 -0
  24. {nodebpy-520.0.1/src/nodebpy → nodebpy-520.1.0/src/nodebpy/builder}/arrange.py +0 -0
  25. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  26. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  27. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  28. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  29. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  30. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  31. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  32. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  33. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  34. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  35. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  36. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
  37. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/__init__.py +0 -0
  38. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  39. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  40. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
  41. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  42. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  43. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  44. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  45. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
  46. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
  47. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  48. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  49. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  50. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  51. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  52. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  53. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  54. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
  55. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  56. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/input.py +0 -0
  57. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
  58. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  59. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  60. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  61. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  62. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  63. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/color.py +0 -0
  64. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/converter.py +0 -0
  65. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  66. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/group.py +0 -0
  67. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/input.py +0 -0
  68. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/manual.py +0 -0
  69. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/output.py +0 -0
  70. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/script.py +0 -0
  71. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  72. {nodebpy-520.0.1 → nodebpy-520.1.0}/src/nodebpy/nodes/shader/vector.py +0 -0
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.0.1
3
+ Version: 520.1.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>
7
7
  License: GPL-3.0-or-later
8
8
  Requires-Dist: bpy==5.2.* ; extra == 'bpy'
9
+ Requires-Dist: ruff>=0.14.11 ; extra == 'format'
9
10
  Requires-Dist: networkx>=3.6.1 ; extra == 'networkx'
10
11
  Requires-Python: >=3.13
11
12
  Provides-Extra: bpy
13
+ Provides-Extra: format
12
14
  Provides-Extra: networkx
13
15
  Description-Content-Type: text/markdown
14
16
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "520.0.1"
3
+ version = "520.1.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -19,6 +19,10 @@ networkx = [
19
19
  bpy = [
20
20
  "bpy==5.2.*",
21
21
  ]
22
+ # Used by `to_python(format=True)` to tidy generated source via `ruff format`.
23
+ format = [
24
+ "ruff>=0.14.11",
25
+ ]
22
26
 
23
27
  [build-system]
24
28
  requires = ["uv_build>=0.8.15,<0.9.0"]
@@ -1,7 +1,5 @@
1
- from . import diagram, nodes
2
- from .builder import (
3
- TreeBuilder,
4
- )
1
+ from . import export, nodes
2
+ from .builder import TreeBuilder
5
3
  from .nodes import compositor, geometry, shader
6
4
 
7
5
  __all__ = [
@@ -9,6 +7,6 @@ __all__ = [
9
7
  "compositor",
10
8
  "geometry",
11
9
  "shader",
12
- "diagram",
10
+ "export",
13
11
  "TreeBuilder",
14
12
  ]
@@ -1,5 +1,6 @@
1
1
  from ._utils import SocketError, denormalize_name, normalize_name
2
2
  from .accessor import SocketAccessor
3
+ from .items import Item, ItemsMixin
3
4
  from .mixins import LinkingMixin, OperatorMixin
4
5
  from .node import (
5
6
  BaseNode,
@@ -75,6 +76,8 @@ __all__ = [
75
76
  "OperatorMixin",
76
77
  "LinkingMixin",
77
78
  "DynamicInputsMixin",
79
+ "Item",
80
+ "ItemsMixin",
78
81
  # Node groups
79
82
  "NodeGroupBuilder",
80
83
  "CustomCompositorGroup",
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Protocol, runtime_checkable
3
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
4
4
 
5
5
  import bpy
6
6
  from bpy.types import NodeSocket
7
7
 
8
+ if TYPE_CHECKING:
9
+ from ..types import SOCKET_TYPES
10
+
8
11
 
9
12
  class SocketError(Exception):
10
13
  """Raised when a socket operation fails."""
@@ -49,6 +52,12 @@ def _allow_innactive_sockets(node: bpy.types.Node) -> bool:
49
52
  "ShaderNodeMixShader",
50
53
  # "ShaderNodeMix",
51
54
  "GeometryNodeSwitch",
55
+ # Group inputs that are only used behind an internal switch poll as
56
+ # inactive until the group is evaluated — including the input we are
57
+ # about to link, which would itself activate the socket.
58
+ "GeometryNodeGroup",
59
+ "ShaderNodeGroup",
60
+ "CompositorNodeGroup",
52
61
  )
53
62
 
54
63
 
@@ -62,7 +71,7 @@ def _resolve_promotion(
62
71
 
63
72
  Returns (dominant_socket, effective_other, effective_reverse).
64
73
  """
65
- other_type = getattr(other, "type", None)
74
+ other_type = _output_socket_type(other)
66
75
  self_prec = _TYPE_PRECEDENCE.get(self_socket.type, 1)
67
76
  other_prec = _TYPE_PRECEDENCE.get(other_type, -1) if other_type is not None else -1
68
77
 
@@ -89,3 +98,17 @@ class _SocketLike(Protocol):
89
98
  """Protocol for objects that wrap a single Blender NodeSocket and expose ``.socket``."""
90
99
 
91
100
  socket: NodeSocket
101
+
102
+
103
+ def _output_socket_type(value: Any) -> "SOCKET_TYPES | None":
104
+ """The Blender socket ``type`` of *value*'s default output socket.
105
+
106
+ Resolves a raw ``NodeSocket`` or any socket/node wrapper to the type string
107
+ Blender uses (e.g. ``"VECTOR"``, ``"VALUE"``). Returns ``None`` for plain
108
+ Python values (ints, floats, tuples) that carry no socket type.
109
+ """
110
+ if isinstance(value, NodeSocket):
111
+ return value.type # type: ignore[return-value]
112
+ if isinstance(value, (_SocketLike, _NodeLike)):
113
+ return value._default_output_socket.type # type: ignore[return-value]
114
+ return None
@@ -48,6 +48,7 @@ class SocketAccessor:
48
48
  if isinstance(key, int):
49
49
  return key
50
50
  ids = [s.identifier for s in self._collection]
51
+ names = [s.name for s in self._collection]
51
52
  denorm = denormalize_name(key)
52
53
  for candidate in (key, denorm):
53
54
  if candidate in ids:
@@ -56,16 +57,27 @@ class SocketAccessor:
56
57
  normalized_ids = [normalize_name(id) for id in ids]
57
58
  if key in normalized_ids:
58
59
  return normalized_ids.index(key)
59
- names = [s.name for s in self._collection]
60
- for key in (key, denorm):
61
- if key in names:
62
- if names.count(key) > 1:
60
+ for candidate in (key, denorm):
61
+ if candidate in names:
62
+ if names.count(candidate) > 1:
63
63
  raise RuntimeError(
64
- f"{self._direction.title()} name '{key}' is ambiguous on "
65
- f"{self._node.bl_idname} (appears {names.count(key)} times). "
66
- f"Use the socket identifier instead."
64
+ f"{self._direction.title()} name '{candidate}' is ambiguous "
65
+ f"on {self._node.bl_idname} (appears {names.count(candidate)} "
66
+ f"times). Use the socket identifier instead."
67
67
  )
68
- return names.index(key)
68
+ return names.index(candidate)
69
+ # Normalized name match: 'flip_and_cyclic' matches name 'Flip and Cyclic'
70
+ # (denormalize_name can't recover the original capitalisation of small
71
+ # connector words like "and"/"to").
72
+ normalized_names = [normalize_name(n) for n in names]
73
+ if key in normalized_names:
74
+ if normalized_names.count(key) > 1:
75
+ raise RuntimeError(
76
+ f"{self._direction.title()} name '{key}' is ambiguous on "
77
+ f"{self._node.bl_idname} (appears {normalized_names.count(key)} "
78
+ f"times). Use the socket identifier instead."
79
+ )
80
+ return normalized_names.index(key)
69
81
  raise RuntimeError(
70
82
  f"{self._direction.title()} '{key}' not found on "
71
83
  f"{self._node.bl_idname}. Available sockets (id: name): {list(zip(ids, names))}"
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, cast
4
+
5
+ from bpy.types import Node, NodeSocket
6
+ from mathutils import Euler
7
+
8
+ from ..types import _is_default_value
9
+ from ._registry import _wrap_socket
10
+ from ._utils import _SocketLike
11
+ from .node import DynamicInputsMixin
12
+ from .socket import Socket
13
+
14
+ if TYPE_CHECKING:
15
+ from ..types import InputLinkable
16
+ from .tree import TreeBuilder
17
+
18
+
19
+ def _infer_value_type(value: Any) -> str | None:
20
+ """Item ``socket_type`` for a plain default value, or None."""
21
+ match value:
22
+ case bool():
23
+ return "BOOLEAN"
24
+ case int():
25
+ return "INT"
26
+ case float():
27
+ return "FLOAT"
28
+ case str():
29
+ return "STRING"
30
+ case tuple() | list():
31
+ return "VECTOR"
32
+ case Euler():
33
+ return "ROTATION"
34
+ case _:
35
+ return None
36
+
37
+
38
+ class Item:
39
+ """Handle for one item of an items-driven node.
40
+
41
+ Names the item's socket *roles* rather than socket plumbing: ``input``
42
+ is the node's input socket for the item, ``output`` the matching
43
+ output socket.
44
+
45
+ Holds the item's collection index rather than the bpy item itself —
46
+ bpy collection item references are invalidated when the collection
47
+ grows.
48
+ """
49
+
50
+ def __init__(self, owner: ItemsMixin, item: Any):
51
+ self._owner = owner
52
+ self._index = next(
53
+ i for i, candidate in enumerate(self._collection) if candidate == item
54
+ )
55
+
56
+ @property
57
+ def _collection(self):
58
+ return self._owner._items
59
+
60
+ @property
61
+ def _item(self):
62
+ return self._collection[self._index]
63
+
64
+ def __repr__(self) -> str:
65
+ return f"{type(self).__name__}({self.name!r}, {self.socket_type!r})"
66
+
67
+ @property
68
+ def name(self) -> str:
69
+ return self._item.name
70
+
71
+ @property
72
+ def socket_type(self) -> str:
73
+ # some collections (capture_items, grid_items) call this data_type
74
+ item = self._item
75
+ return getattr(item, "socket_type", None) or item.data_type
76
+
77
+ @property
78
+ def input(self) -> Socket:
79
+ """The node's input socket for this item."""
80
+ return _wrap_socket(self._owner._item_socket(self._item))
81
+
82
+ @property
83
+ def output(self) -> Socket:
84
+ """The node's output socket for this item."""
85
+ return _wrap_socket(self._owner._item_socket(self._item, output=True))
86
+
87
+
88
+ class ItemsMixin(DynamicInputsMixin):
89
+ """Socket machinery for nodes whose sockets are driven by a bpy item
90
+ collection (``capture_items``, ``bake_items``, ``format_items``, ...).
91
+
92
+ Subclasses declare class attributes instead of overriding methods:
93
+
94
+ - ``_items_collection``: name of the collection on ``_items_node``
95
+ - ``_socket_data_types``: socket types considered when inferring an
96
+ item's type from a source socket
97
+ - ``_type_map``: socket type -> item ``socket_type`` renames
98
+ (e.g. ``VALUE`` -> ``FLOAT``)
99
+
100
+ Must come *before* ``BaseNode`` in the bases so that
101
+ ``_find_best_socket_pair`` (the ``>>``-implicit-add behaviour) takes
102
+ precedence over ``LinkingMixin``'s.
103
+ """
104
+
105
+ _items_collection: str
106
+
107
+ if TYPE_CHECKING:
108
+ node: Node
109
+ tree: TreeBuilder
110
+
111
+ def _establish_links(self, **kwargs: Any) -> None: ...
112
+
113
+ @property
114
+ def _items_node(self) -> Node:
115
+ """Node owning the items collection.
116
+
117
+ Zone input nodes override this to return ``paired_output``, where
118
+ the shared collection lives.
119
+ """
120
+ return self.node
121
+
122
+ @property
123
+ def _items(self):
124
+ return getattr(self._items_node, self._items_collection)
125
+
126
+ def _new_item(self, name: str, type: str):
127
+ """Create a new collection item.
128
+
129
+ Override to adapt collections whose ``.new()`` signature differs
130
+ from ``(socket_type, name)``.
131
+ """
132
+ return self._items.new(socket_type=type, name=name)
133
+
134
+ def _item_socket(self, item, *, output: bool = False) -> NodeSocket:
135
+ """The node socket belonging to ``item``."""
136
+ sockets = self.node.outputs if output else self.node.inputs
137
+ matches = [s for s in sockets if s.name == item.name]
138
+ if len(matches) == 1:
139
+ return matches[0]
140
+ # Name collides — e.g. a capture item named "Selection" alongside the
141
+ # built-in CaptureAttribute "Selection" socket. Item sockets are the
142
+ # trailing N sockets (one per collection item), so resolve by the
143
+ # item's position in the collection instead.
144
+ items = list(self._items)
145
+ idx = next(i for i, it in enumerate(items) if it == item)
146
+ real = [s for s in sockets if not s.identifier.startswith("__extend__")]
147
+ return real[len(real) - len(items) + idx]
148
+
149
+ def _add_socket(self, name: str, type: str) -> NodeSocket:
150
+ return self._item_socket(self._new_item(name, type))
151
+
152
+ def _resolve_capture(
153
+ self,
154
+ value: InputLinkable,
155
+ *,
156
+ name: str | None,
157
+ types: tuple[str, ...] | None = None,
158
+ ) -> tuple[NodeSocket, str, str]:
159
+ """Resolve the source socket, item type and item name for a capture."""
160
+ accessor = getattr(value, "o", None)
161
+ sources = (
162
+ [cast("NodeSocket", value)] if accessor is None else accessor._available
163
+ )
164
+ socket_source, type = self._match_compatible_data(sources, types)
165
+ if type in self._type_map:
166
+ type = self._type_map[type]
167
+ if name is None:
168
+ default_socket = getattr(value, "_default_output_socket", None)
169
+ name = socket_source.name if default_socket is None else default_socket.name
170
+ if isinstance(socket_source, _SocketLike):
171
+ socket_source = socket_source.socket
172
+ return socket_source, type, name
173
+
174
+ def _declared_item_type(self, value: Any) -> str | None:
175
+ """The item ``socket_type`` if ``value`` is a socket-type string
176
+ (e.g. ``"FLOAT"``) valid for this node, else ``None``."""
177
+ if not isinstance(value, str):
178
+ return None
179
+ if value in self._socket_data_types:
180
+ return self._type_map.get(value, value)
181
+ if value in {self._type_map.get(t, t) for t in self._socket_data_types}:
182
+ return value
183
+ return None
184
+
185
+ def _add_unlinked_input(self, name: str, value: Any) -> bool:
186
+ """Items may also be declared with a plain default value
187
+ (``items={"label": "hello"}``) — the item type is inferred from
188
+ the Python type and the value becomes the socket default."""
189
+ if super()._add_unlinked_input(name, value):
190
+ return True
191
+ type = _infer_value_type(value)
192
+ if type is None:
193
+ return False
194
+ socket = self._add_socket(
195
+ name=name, type=self._declared_item_type(type) or type
196
+ )
197
+ socket.default_value = value # ty: ignore[unresolved-attribute]
198
+ return True
199
+
200
+ def capture(self, value: InputLinkable, *, name: str | None = None) -> Socket:
201
+ """Add an item linked from ``value`` and return its output socket.
202
+
203
+ The item is auto-named after the source socket unless ``name`` is
204
+ given.
205
+ """
206
+ source, type, name = self._resolve_capture(value, name=name)
207
+ item = self._new_item(name, type)
208
+ self.tree.link(source, self._item_socket(item))
209
+ return _wrap_socket(self._item_socket(item, output=True))
210
+
211
+ def add_item(
212
+ self, name: str, value: Any = None, *, type: str | None = None
213
+ ) -> Item:
214
+ """Add a single item and return its handle.
215
+
216
+ ``value`` may be a linkable (linked to the item's input) or a plain
217
+ default value; otherwise ``type`` (a socket-type string such as
218
+ ``"FLOAT"``) declares the item unlinked.
219
+ """
220
+ if value is not None and not _is_default_value(value):
221
+ source, inferred, _ = self._resolve_capture(value, name=name)
222
+ item = self._new_item(name, type or inferred)
223
+ self.tree.link(source, self._item_socket(item))
224
+ return Item(self, item)
225
+ if type is None:
226
+ type = _infer_value_type(value)
227
+ if type is None:
228
+ raise TypeError(f"item {name!r} requires a value or an explicit type=")
229
+ item = self._new_item(name, self._declared_item_type(type) or type)
230
+ if value is not None:
231
+ self._item_socket(item).default_value = value # ty: ignore[unresolved-attribute]
232
+ return Item(self, item)
233
+
234
+ def add_items(self, items: Mapping[str, InputLinkable | str]) -> dict[str, Item]:
235
+ """Add an item per mapping entry and return their handles by name.
236
+
237
+ Values may be linkables (linked to the new item's input) or
238
+ socket-type strings such as ``"FLOAT"`` (declare an unlinked item).
239
+ """
240
+ handles = {}
241
+ for key, value in items.items():
242
+ type = self._declared_item_type(value)
243
+ if type is not None:
244
+ handles[key] = self.add_item(key, type=type)
245
+ else:
246
+ handles[key] = self.add_item(key, cast("InputLinkable", value))
247
+ return handles
@@ -330,6 +330,16 @@ class LinkingMixin:
330
330
  if possible_combos:
331
331
  return sorted(possible_combos, key=lambda x: x[0])[0][1]
332
332
 
333
+ # A node with a virtual ``__extend__`` input (Viewer, …) accepts any
334
+ # source: linking to it makes Blender create a typed socket. Fall back
335
+ # to it when nothing else matched, pairing with the source's first
336
+ # available output.
337
+ extend = next(
338
+ (i for i in inputs if i.identifier.startswith("__extend__")), None
339
+ )
340
+ if extend is not None and outputs:
341
+ return extend, outputs[0]
342
+
333
343
  src_name = getattr(getattr(source, "node", None), "name", repr(source))
334
344
  tgt_name = getattr(getattr(target, "node", None), "name", repr(target))
335
345
  raise SocketError(