Bpy.ops.node.group_make() function not executing in active material slot

I’m trying to write a script that groups all existing materials attached to an object into their own individual node groups. I’ve figured out how to loop through the material slots and select all nodes except for the “Material Output” node, but for some reason when I run bpy.ops.node.group_make() it executes repeatedly on the first material slot, creating a bunch of sandwiched node groups in that material.

Any idea why this isn’t working correctly?

Code here:

import bpy

o = bpy.context.active_object
if o.data is not None and hasattr(o.data, 'materials'):
    material_slots = o.material_slots
    for mi in range(len(material_slots)):
        bpy.context.area.ui_type = 'ShaderNodeTree'
        m = material_slots[mi].material
        mname = material_slots[mi].name
        o.active_material_index = mi
        for n in m.node_tree.nodes:
            nname = n.name
            if nname != "Material Output":
                n.select = True
            else: 
                n.select = False
        bpy.ops.node.group_make()
        bpy.ops.node.group_edit(exit=False)
        bpy.context.area.ui_type = 'TEXT_EDITOR'

any errors? anything in the console?

No, running blender from the command line and not getting anything in the console. Just runs as expected and ends with the last material slot selected, but all of the materials are untouched except the first, which has sandwiched node groups of the same number as the number of loops. Using Blender 2.93.6.

Edit: the nodes have been selected as expected in the other materials, but the group_make() function has only operated on the material in the first slot

ok, my best guess without actually opening blender or testing anything- is that the context isn’t being refreshed in your loop, so when the group_make operator runs the selection has not actually changed. You might have better luck creating a context override and passing it in rather than relying on actual selection states. Then again, scanning through the C code for the operator it looks like there might be some dependencies on the UI being in a certain state.

Personally if I were doing this I would avoid all of that and just build the data directly by traversing the node tree and creating a new item in bpy.data.node_groups

Ok, got it, was imagining it could be something like that too. Yeah, that makes sense, I’d prefer to build the data directly like that also. Would there be an easy way in that case to make an exact copy of all of the nodes in each material with their links and attribute values intact, or would I have to loop through each node individually and copy the data?

yeah, no easy .copy() method so far as I’m aware, though you could probably write some generic loops that will get you halfway there:

for n in material_nodes:
    node_type = str(type(n)).split('.')[-1]
    new_node = my_node_group.nodes.new(node_type)
    for prop in dir(n)
        if prop == 'inputs':
            # you'll have to handle certain property groups manually, outputs, inputs, etc.
            pass
        else:
            try:
                setattr(new_node, prop, getattr(n, prop))
            except AttributeError:
                # some attributes are going to be read only
                pass

the important things to copy over are node attributes, specifically inputs/outputs- you’ll also need all of the links information from the node tree. the links will probably be the most complicated part since you can’t just copy the data over verbatim, you need to substitute the newly created nodes. I would probably create a look-up table for the material->copy nodes and reference it while rebuilding the links.

it honestly sounds more complicated than it is