nodebpy 520.0.1__tar.gz → 520.2.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 (80) hide show
  1. {nodebpy-520.0.1 → nodebpy-520.2.0}/PKG-INFO +3 -1
  2. {nodebpy-520.0.1 → nodebpy-520.2.0}/pyproject.toml +5 -1
  3. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/__init__.py +3 -5
  4. nodebpy-520.2.0/src/nodebpy/assets/__init__.py +17 -0
  5. nodebpy-520.2.0/src/nodebpy/assets/__main__.py +57 -0
  6. nodebpy-520.2.0/src/nodebpy/assets/_codegen.py +318 -0
  7. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/__init__.py +20 -1
  8. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/_utils.py +25 -2
  9. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/accessor.py +20 -8
  10. nodebpy-520.2.0/src/nodebpy/builder/asset.py +139 -0
  11. nodebpy-520.2.0/src/nodebpy/builder/items.py +247 -0
  12. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/mixins.py +10 -0
  13. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/node.py +172 -79
  14. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/socket.py +557 -152
  15. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/tree.py +104 -19
  16. nodebpy-520.2.0/src/nodebpy/export/__init__.py +7 -0
  17. nodebpy-520.2.0/src/nodebpy/export/codegen.py +4439 -0
  18. {nodebpy-520.0.1/src/nodebpy → nodebpy-520.2.0/src/nodebpy/export}/diagram.py +64 -44
  19. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/utils.py +1 -1
  20. nodebpy-520.2.0/src/nodebpy/nodes/_mixins.py +247 -0
  21. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/__init__.py +37 -1
  22. nodebpy-520.2.0/src/nodebpy/nodes/compositor/assets.py +783 -0
  23. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/converter.py +11 -43
  24. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/interface.py +5 -15
  25. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/__init__.py +140 -12
  26. nodebpy-520.2.0/src/nodebpy/nodes/geometry/assets.py +3342 -0
  27. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/attribute.py +5 -4
  28. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/converter.py +1563 -332
  29. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/geometry.py +156 -47
  30. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/grid.py +72 -69
  31. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/groups.py +70 -7
  32. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/input.py +69 -0
  33. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/interface.py +5 -21
  34. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/manual.py +178 -1400
  35. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/zone.py +283 -156
  36. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/__init__.py +10 -0
  37. nodebpy-520.2.0/src/nodebpy/nodes/shader/assets.py +157 -0
  38. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/converter.py +5 -12
  39. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/types.py +9 -1
  40. {nodebpy-520.0.1 → nodebpy-520.2.0}/README.md +0 -0
  41. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/builder/_registry.py +0 -0
  42. {nodebpy-520.0.1/src/nodebpy → nodebpy-520.2.0/src/nodebpy/builder}/arrange.py +0 -0
  43. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  44. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  45. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  46. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  47. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  48. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  49. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  50. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  51. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  52. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  53. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  54. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
  55. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/__init__.py +0 -0
  56. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  57. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  58. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  59. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  60. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  61. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
  62. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  63. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  64. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  65. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  66. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  67. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  68. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  69. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  70. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  71. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/color.py +0 -0
  72. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  73. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/group.py +0 -0
  74. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/input.py +0 -0
  75. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/manual.py +0 -0
  76. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/output.py +0 -0
  77. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/script.py +0 -0
  78. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/shader.py +0 -0
  79. {nodebpy-520.0.1 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  80. {nodebpy-520.0.1 → nodebpy-520.2.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.2.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.2.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
  ]
@@ -0,0 +1,17 @@
1
+ """Typed APIs for node-group assets.
2
+
3
+ ``generate_asset_api`` builds typed :class:`~nodebpy.builder.AssetNodeGroup`
4
+ classes for the node groups in a ``.blend`` asset library. The bundled-essentials
5
+ APIs generated for nodebpy itself live alongside this module as
6
+ ``nodebpy.assets.geometry`` / ``.shader`` / ``.compositor``.
7
+ """
8
+
9
+ from ..builder import AssetLibrary, BundledLibrary, PackageLibrary
10
+ from ._codegen import generate_asset_api
11
+
12
+ __all__ = [
13
+ "generate_asset_api",
14
+ "AssetLibrary",
15
+ "BundledLibrary",
16
+ "PackageLibrary",
17
+ ]
@@ -0,0 +1,57 @@
1
+ """Regenerate the bundled-essentials asset APIs.
2
+
3
+ ``python -m nodebpy.assets`` introspects Blender's bundled node-group asset
4
+ libraries and writes typed classes to ``nodebpy/nodes/{geometry,shader,
5
+ compositor}/assets.py``, where ``python -m gen`` re-exports them so they are
6
+ available alongside the built-in nodes (e.g. ``g.SmoothByAngle()``). Run *before*
7
+ ``python -m gen`` and through the ruff/ty post-processing (see the Makefile).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from ..builder import BundledLibrary
16
+ from ._codegen import generate_asset_api
17
+
18
+ # Bundled libraries shipped with Blender, grouped by output module. Each library
19
+ # holds node groups of a single tree type.
20
+ _ESSENTIALS: dict[str, tuple[str, ...]] = {
21
+ "geometry": (
22
+ "geometry_nodes_essentials.blend",
23
+ "geometry_nodes_dynamics_assets.blend",
24
+ "procedural_hair_node_assets.blend",
25
+ "principal_components.blend",
26
+ ),
27
+ "shader": ("shading_nodes_essentials.blend",),
28
+ "compositor": ("compositing_nodes_essentials.blend",),
29
+ }
30
+
31
+
32
+ def generate_essentials(nodes_dir: Path) -> dict[str, list[str]]:
33
+ """Generate the bundled-essentials asset modules into
34
+ ``<nodes_dir>/<tree>/assets.py``; returns the class names written per tree
35
+ (libraries not present in this Blender install are skipped)."""
36
+ written: dict[str, list[str]] = {}
37
+ for tree, filenames in _ESSENTIALS.items():
38
+ libraries = [
39
+ BundledLibrary(f)
40
+ for f in filenames
41
+ if os.path.exists(BundledLibrary(f).path())
42
+ ]
43
+ if not libraries: # pragma: no cover - depends on the Blender install
44
+ print(f" {tree}: no bundled libraries present, skipping")
45
+ continue
46
+ names = generate_asset_api(libraries, Path(nodes_dir) / tree / "assets.py")
47
+ written[tree] = names
48
+ print(f" nodes/{tree}/assets.py: {len(names)} asset classes")
49
+ return written
50
+
51
+
52
+ def main() -> None: # pragma: no cover - CLI wrapper over generate_essentials
53
+ generate_essentials(Path(__file__).parent.parent / "nodes")
54
+
55
+
56
+ if __name__ == "__main__": # pragma: no cover
57
+ main()
@@ -0,0 +1,318 @@
1
+ """Generate typed ``nodebpy`` API classes for node-group assets.
2
+
3
+ Given one or more asset ``.blend`` libraries, :func:`generate_asset_api` appends
4
+ each node group, introspects its interface, and writes a Python module of typed
5
+ :class:`~nodebpy.builder.AssetNodeGroup` subclasses — so an asset reads and
6
+ type-checks like any other node. The emitted classes append the asset at runtime
7
+ rather than rebuilding it.
8
+
9
+ This is a *shipped*, reusable tool: other projects call it on their own asset
10
+ libraries (via :class:`~nodebpy.builder.PackageLibrary`) to generate APIs for
11
+ their assets.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import bpy
22
+
23
+ from ..builder import AssetLibrary, BundledLibrary, asset_group_base
24
+ from ..builder._utils import normalize_name
25
+
26
+ # bl_socket_type substring → (Socket accessor class, Input* parameter type).
27
+ # Order matters: more specific keys (IntVector before Int) come first.
28
+ _SOCKET_TYPES: dict[str, tuple[str, str]] = {
29
+ "NodeSocketFloat": ("FloatSocket", "InputFloat"),
30
+ "NodeSocketIntVector": ("IntegerVectorSocket", "InputIntegerVector"),
31
+ "NodeSocketInt": ("IntegerSocket", "InputInteger"),
32
+ "NodeSocketBool": ("BooleanSocket", "InputBoolean"),
33
+ "NodeSocketVector": ("VectorSocket", "InputVector"),
34
+ "NodeSocketColor": ("ColorSocket", "InputColor"),
35
+ "NodeSocketRotation": ("RotationSocket", "InputRotation"),
36
+ "NodeSocketMatrix": ("MatrixSocket", "InputMatrix"),
37
+ "NodeSocketString": ("StringSocket", "InputString"),
38
+ "NodeSocketMenu": ("MenuSocket", "InputMenu"),
39
+ "NodeSocketGeometry": ("GeometrySocket", "InputGeometry"),
40
+ "NodeSocketObject": ("ObjectSocket", "InputObject"),
41
+ "NodeSocketMaterial": ("MaterialSocket", "InputMaterial"),
42
+ "NodeSocketImage": ("ImageSocket", "InputImage"),
43
+ "NodeSocketCollection": ("CollectionSocket", "InputCollection"),
44
+ "NodeSocketBundle": ("BundleSocket", "InputBundle"),
45
+ "NodeSocketClosure": ("ClosureSocket", "InputClosure"),
46
+ "NodeSocketShader": ("ShaderSocket", "InputShader"),
47
+ "NodeSocketFont": ("FontSocket", "InputFont"),
48
+ "NodeSocketSound": ("SoundSocket", "InputSound"),
49
+ "NodeSocketVirtual": ("Socket", "InputLinkable"),
50
+ }
51
+
52
+
53
+ def _socket_types(bl_socket_type: str) -> tuple[str, str]:
54
+ for key, value in _SOCKET_TYPES.items():
55
+ if key in bl_socket_type:
56
+ return value
57
+ return ("Socket", "InputLinkable")
58
+
59
+
60
+ def _class_name(name: str) -> str:
61
+ """A valid, readable Python class name for an asset group display name."""
62
+ cleaned = "".join(c if c.isalnum() or c.isspace() else " " for c in name)
63
+ parts = cleaned.split()
64
+ cleaned = "".join(p[:1].upper() + p[1:] for p in parts)
65
+ if cleaned and cleaned[0].isdigit():
66
+ cleaned = "_" + cleaned
67
+ return cleaned or "AssetGroup"
68
+
69
+
70
+ def _format_default(socket: bpy.types.NodeSocket) -> str:
71
+ """Source for a socket's scalar default value, or ``None``.
72
+
73
+ Only plain scalars are emitted as parameter defaults. Vector/colour/matrix
74
+ defaults vary in arity (2D UVs, 3D vectors, 4-component colours) and don't
75
+ always fit the parameter's ``Input*`` type, so they're left as ``None`` —
76
+ the appended group keeps its own socket default regardless.
77
+ """
78
+ value = getattr(socket, "default_value", None)
79
+ if isinstance(value, bool):
80
+ return str(value)
81
+ if isinstance(value, int):
82
+ return str(value)
83
+ if isinstance(value, float):
84
+ return str(round(value, 6))
85
+ if isinstance(value, str):
86
+ return repr(value)
87
+ return "None"
88
+
89
+
90
+ @dataclass
91
+ class _Socket:
92
+ name: str
93
+ identifier: str
94
+ socket_class: str # e.g. "GeometrySocket"
95
+ input_type: str # e.g. "InputGeometry"
96
+ default: str # source for the default value
97
+ attr: str # normalized accessor/param name
98
+
99
+
100
+ @dataclass
101
+ class _AssetClass:
102
+ class_name: str
103
+ asset_name: str
104
+ description: str
105
+ library_source: str
106
+ tree_idname: str
107
+ inputs: list[_Socket]
108
+ outputs: list[_Socket]
109
+
110
+
111
+ def _collect(sockets) -> list[_Socket]:
112
+ raw = [
113
+ s
114
+ for s in sockets
115
+ if s.identifier != "__extend__" and not getattr(s, "is_inactive", False)
116
+ ]
117
+ # The accessor resolves attribute names by identifier first, then name, so a
118
+ # group socket's readable name works as the attr/param when it's unambiguous;
119
+ # fall back to the opaque-but-unique identifier only on a name collision.
120
+ name_counts = Counter(normalize_name(s.name) for s in raw)
121
+ out: list[_Socket] = []
122
+ for s in raw:
123
+ socket_class, input_type = _socket_types(type(s).__name__)
124
+ norm_name = normalize_name(s.name)
125
+ attr = (
126
+ norm_name if name_counts[norm_name] == 1 else normalize_name(s.identifier)
127
+ )
128
+ out.append(
129
+ _Socket(
130
+ name=s.name,
131
+ identifier=s.identifier,
132
+ socket_class=socket_class,
133
+ input_type=input_type,
134
+ default=_format_default(s),
135
+ attr=attr,
136
+ )
137
+ )
138
+ return out
139
+
140
+
141
+ def _introspect(library: AssetLibrary, names: set[str] | None) -> list[_AssetClass]:
142
+ """Append each requested group from the library and introspect its
143
+ interface into :class:`_AssetClass` records."""
144
+ path = library.path()
145
+ library_source = _library_source(library)
146
+
147
+ with bpy.data.libraries.load(path, link=False, assets_only=True) as (src, _):
148
+ available = list(src.node_groups)
149
+ wanted = [n for n in available if names is None or n in names]
150
+
151
+ classes: list[_AssetClass] = []
152
+ for name in wanted:
153
+ # Always append from *this* library — never reuse a same-named group by
154
+ # global lookup, since names collide across tree types (a geometry and a
155
+ # compositor "Combine Spherical" both exist) and we'd introspect the
156
+ # wrong one. Blender renames the appended copy on a clash; that's fine,
157
+ # we only read its interface (the emitted _asset_name uses the original).
158
+ with bpy.data.libraries.load(path, link=False, assets_only=True) as (
159
+ src,
160
+ dst,
161
+ ):
162
+ dst.node_groups = [name]
163
+ group = dst.node_groups[0]
164
+
165
+ host = bpy.data.node_groups.new("_introspect_host", group.bl_idname)
166
+ try:
167
+ node_type = {
168
+ "GeometryNodeTree": "GeometryNodeGroup",
169
+ "ShaderNodeTree": "ShaderNodeGroup",
170
+ "CompositorNodeTree": "CompositorNodeGroup",
171
+ }[group.bl_idname]
172
+ node = host.nodes.new(node_type)
173
+ node.node_tree = group
174
+ classes.append(
175
+ _AssetClass(
176
+ class_name=_class_name(name),
177
+ asset_name=name,
178
+ description=(group.description or name).strip(),
179
+ library_source=library_source,
180
+ tree_idname=group.bl_idname,
181
+ inputs=_collect(node.inputs),
182
+ outputs=_collect(node.outputs),
183
+ )
184
+ )
185
+ finally:
186
+ bpy.data.node_groups.remove(host)
187
+ return classes
188
+
189
+
190
+ def _library_source(library: AssetLibrary) -> str:
191
+ """Source expression that reconstructs ``library`` in the generated module."""
192
+ if isinstance(library, BundledLibrary):
193
+ return f"BundledLibrary({library.filename!r})"
194
+ from ..builder import PackageLibrary
195
+
196
+ if isinstance(library, PackageLibrary):
197
+ return f"PackageLibrary(__file__, {library.relative!r})"
198
+ raise TypeError(f"Cannot serialise asset library: {library!r}")
199
+
200
+
201
+ def _accessor(sockets: list[_Socket], kind: str) -> str:
202
+ if not sockets:
203
+ return f" class {kind}(SocketAccessor):\n pass"
204
+ lines = [f" class {kind}(SocketAccessor):"]
205
+ for s in sockets:
206
+ lines.append(f" {s.attr}: {s.socket_class}")
207
+ doc = s.name
208
+ if doc and doc != s.attr:
209
+ lines.append(f' """{doc}"""')
210
+ return "\n".join(lines)
211
+
212
+
213
+ def _render_class(cls: _AssetClass) -> str:
214
+ base = asset_group_base(cls.tree_idname).__name__
215
+ inputs_cls = _accessor(cls.inputs, "_Inputs")
216
+ outputs_cls = _accessor(cls.outputs, "_Outputs")
217
+
218
+ params = [f"{s.attr}: {s.input_type} = {s.default}" for s in cls.inputs]
219
+ signature = (
220
+ "(\n self,\n " + ",\n ".join(params) + ",\n )"
221
+ if params
222
+ else "(self)"
223
+ )
224
+ key_args = ", ".join(f'"{s.identifier}": {s.attr}' for s in cls.inputs)
225
+
226
+ return f'''class {cls.class_name}({base}):
227
+ """{cls.description}"""
228
+
229
+ _name = {cls.asset_name!r}
230
+ _asset_name = {cls.asset_name!r}
231
+ _library = {cls.library_source}
232
+
233
+ {inputs_cls}
234
+
235
+ {outputs_cls}
236
+
237
+ if TYPE_CHECKING:
238
+ @property
239
+ def i(self) -> _Inputs: ...
240
+ @property
241
+ def o(self) -> _Outputs: ...
242
+
243
+ def __init__{signature}:
244
+ super().__init__(**{{{key_args}}})
245
+ '''
246
+
247
+
248
+ def _render_module(classes: list[_AssetClass]) -> str:
249
+ socket_classes = sorted(
250
+ {s.socket_class for c in classes for s in c.inputs + c.outputs}
251
+ )
252
+ input_types = sorted({s.input_type for c in classes for s in c.inputs})
253
+ bases = sorted({asset_group_base(c.tree_idname).__name__ for c in classes})
254
+ libraries = sorted(
255
+ {
256
+ "BundledLibrary"
257
+ if c.library_source.startswith("BundledLibrary")
258
+ else "PackageLibrary"
259
+ for c in classes
260
+ }
261
+ )
262
+
263
+ builder_imports = sorted(
264
+ set(bases) | set(libraries) | {"SocketAccessor"} | set(socket_classes)
265
+ )
266
+
267
+ lines = [
268
+ "# Auto-generated by nodebpy.assets.generate_asset_api — do not edit manually.",
269
+ "from typing import TYPE_CHECKING",
270
+ "",
271
+ f"from nodebpy.builder import (\n {',\n '.join(builder_imports)},\n)",
272
+ f"from nodebpy.types import (\n {',\n '.join(input_types)},\n)"
273
+ if input_types
274
+ else "",
275
+ ]
276
+ header = "\n".join(line for line in lines if line) + "\n\n\n"
277
+ ordered = sorted(classes, key=lambda c: c.class_name)
278
+ body = "\n\n".join(_render_class(c) for c in ordered)
279
+ all_names = ",\n ".join(f'"{c.class_name}"' for c in ordered)
280
+ footer = (
281
+ f"\n\n__all__ = (\n {all_names},\n)\n" if ordered else "\n__all__ = ()\n"
282
+ )
283
+ return header + body + footer
284
+
285
+
286
+ def generate_asset_api(
287
+ libraries: AssetLibrary | Sequence[AssetLibrary],
288
+ output_path: str | Path,
289
+ *,
290
+ names: set[str] | None = None,
291
+ ) -> list[str]:
292
+ """Generate typed asset classes for ``libraries`` into ``output_path``.
293
+
294
+ Parameters
295
+ ----------
296
+ libraries:
297
+ One or more :class:`~nodebpy.builder.AssetLibrary` instances
298
+ (:class:`~nodebpy.builder.BundledLibrary` for Blender's bundled assets,
299
+ :class:`~nodebpy.builder.PackageLibrary` for a ``.blend`` shipped inside
300
+ your own package).
301
+ output_path:
302
+ The ``.py`` file to write.
303
+ names:
304
+ Restrict generation to these asset (node-group) names; defaults to all.
305
+
306
+ Returns the list of generated class names.
307
+ """
308
+ if isinstance(libraries, AssetLibrary):
309
+ libraries = [libraries]
310
+
311
+ classes: list[_AssetClass] = []
312
+ for library in libraries:
313
+ classes.extend(_introspect(library, names))
314
+
315
+ output_path = Path(output_path)
316
+ output_path.parent.mkdir(parents=True, exist_ok=True)
317
+ output_path.write_text(_render_module(classes), encoding="utf-8")
318
+ return [c.class_name for c in classes]
@@ -1,5 +1,15 @@
1
1
  from ._utils import SocketError, denormalize_name, normalize_name
2
2
  from .accessor import SocketAccessor
3
+ from .asset import (
4
+ AssetCompositorGroup,
5
+ AssetGeometryGroup,
6
+ AssetLibrary,
7
+ AssetShaderGroup,
8
+ BundledLibrary,
9
+ PackageLibrary,
10
+ asset_group_base,
11
+ )
12
+ from .items import Item, ItemsMixin
3
13
  from .mixins import LinkingMixin, OperatorMixin
4
14
  from .node import (
5
15
  BaseNode,
@@ -75,12 +85,21 @@ __all__ = [
75
85
  "OperatorMixin",
76
86
  "LinkingMixin",
77
87
  "DynamicInputsMixin",
88
+ "Item",
89
+ "ItemsMixin",
78
90
  # Node groups
79
91
  "NodeGroupBuilder",
80
92
  "CustomCompositorGroup",
81
93
  "CustomGeometryGroup",
82
94
  "CustomShaderGroup",
83
- "CustomShaderGroup",
95
+ # Asset-backed node groups
96
+ "AssetLibrary",
97
+ "BundledLibrary",
98
+ "PackageLibrary",
99
+ "AssetGeometryGroup",
100
+ "AssetShaderGroup",
101
+ "AssetCompositorGroup",
102
+ "asset_group_base",
84
103
  # Runtime socket types
85
104
  "FloatSocket",
86
105
  "FloatSocketGrid",
@@ -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))}"