Post 3 of 10 — Blender Add-on Development with Python
In this series: We’re building a Batch Render Manager add-on from scratch. Post 1 introduced the operator basics and got a Hello World add-on running. Post 2 converted it into a proper Python package with a clean six-file structure. This post builds out the first real functionality — five operators that will drive the render queue.
In the last post, we turned our single-file Hello World into a proper Python package. We’ve got six files, a clean structure, and an add-on that installs without complaint — even if it doesn’t do anything yet.
That changes today. We’re going to build out operators.py and add the first real functionality to the Batch Render Manager: the ability to add jobs to a render queue, remove them, start the queue, stop it, and retry a failed job.
Along the way, we’ll dig into some operator features you’ll use in almost every add-on you write: poll() methods for controlling when an operator is available, invoke() for handling user input before executing, and how Blender’s undo system works with operators.
What Is an Operator, Really?
We touched on this in Post 1, but let’s be more precise.
An operator in Blender is a discrete, undo-able action. Nearly every user-facing action in Blender — from moving an object, deleting a mesh, to changing a render setting — is implemented as an operator under the hood. When you press G to grab an object, you’re invoking transform.translate. When you click the Render button, you’re invoking render.render.
Your add-on’s operators work the same way. They show up in the search menu (F3), they can be assigned to shortcuts, they log in the Info bar, and they participate in the undo stack.
Every operator is a Python class that inherits from bpy.types.Operator and defines at minimum:
bl_idname— the unique identifier used to call itbl_label— the human-readable nameexecute(self, context)— the method that does the work
We saw all of this in Post 1. Now we’re going to layer in the more interesting stuff.
The Operators We’re Building
For the queue system, we will build five operators:
| Operator | bl_idname | What It Does |
|---|---|---|
| Add Job | batch_render.add_job | Adds a new item to the queue |
| Remove Job | batch_render.remove_job | Removes the selected queue item |
| Start Queue | batch_render.start_queue | Begins processing the render queue |
| Stop Queue | batch_render.stop_queue | Cancels an in-progress queue |
| Retry Job | batch_render.retry_job | Resets a failed job so it can run again |
We’re not going to be able to hook these up end-to-end yet, because the actual queue data won’t be built until Post 5, and the render runner comes in Post 6. But we can write the operators fully, with all their logic stubbed out in a way that’s clear and ready to connect.
Building operators.py
Let’s open operators.py and start writing. We’ll build each operator in turn, then wire up registration at the bottom.
Add Job
class BATCHRENDER_OT_add_job(bpy.types.Operator):
"""Add a new render job to the queue"""
bl_idname = "batch_render.add_job"
bl_label = "Add Job"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# We'll connect this to the actual queue property in Post 5.
# For now, just confirm the operator runs.
self.report({'INFO'}, "Job added to queue")
return {'FINISHED'}PythonNotice bl_options = {'REGISTER', 'UNDO'}. Let’s talk about what that means.
'REGISTER' tells Blender to log this operator in the Info bar (the header at the very top of the screen that shows recent actions). It also makes the operator show up when you search with F3. For anything the user explicitly triggers, you want this.
'UNDO' pushes an undo step when the operator finishes. This means the user can Ctrl+Z to reverse it. Not every operator needs undo support — a purely informational operator (like one that just prints something) doesn’t modify data, so there’s nothing to undo. But for any operator that changes state, it’s a good idea.
See the Blender Python API reference for the full list of potential bl_options.
Remove Job
class BATCHRENDER_OT_remove_job(bpy.types.Operator):
"""Remove the selected job from the queue"""
bl_idname = "batch_render.remove_job"
bl_label = "Remove Job"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
# Only available if there are jobs in the queue.
# We'll replace this with a real queue check in Post 5.
return True
def execute(self, context):
self.report({'INFO'}, "Job removed from queue")
return {'FINISHED'}PythonWe have a poll() method here — we’ll get into this properly in a moment. For now it just returns True.
Start Queue
class BATCHRENDER_OT_start_queue(bpy.types.Operator):
"""Start processing the render queue"""
bl_idname = "batch_render.start_queue"
bl_label = "Start Queue"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
# Only available when we have jobs and aren't already running.
return True
def execute(self, context):
self.report({'INFO'}, "Queue started")
return {'FINISHED'}PythonNo 'UNDO' here — starting a render queue isn’t something you’d want to undo with Ctrl+Z. The undo stack is for data changes, not process control.
Stop Queue
class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
"""Stop the currently running render queue"""
bl_idname = "batch_render.stop_queue"
bl_label = "Stop Queue"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
# Only available when the queue is actively running.
return True
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
self.report({'INFO'}, "Queue stopped")
return {'FINISHED'}PythonRetry Job
class BATCHRENDER_OT_retry_job(bpy.types.Operator):
"""Reset a failed job so it can be run again"""
bl_idname = "batch_render.retry_job"
bl_label = "Retry Job"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
# Only available when the selected job has a FAILED status.
return True
def execute(self, context):
self.report({'INFO'}, "Job queued for retry")
return {'FINISHED'}PythonRegistration
At the bottom of operators.py, we register them all:
classes = [
BATCHRENDER_OT_add_job,
BATCHRENDER_OT_remove_job,
BATCHRENDER_OT_start_queue,
BATCHRENDER_OT_stop_queue,
BATCHRENDER_OT_retry_job,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)PythonNaming Conventions
Before we go further, let’s talk about that class name: BATCHRENDER_OT_add_job. That’s not just a quirky stylistic choice — Blender has a naming convention for add-on classes, and following it matters.
The format is: PREFIX_TYPE_description
PREFIX— a short, uppercase identifier for your add-on. We’re usingBATCHRENDER. This prevents name collisions if two add-ons both happen to define an operator calledadd_job.TYPE— a two-letter code indicating what kind of Blender class this is:OT— OperatorPT— PanelMT— MenuUL— UI ListPG— Property GroupAP— Addon Preferences
description— lowercase with underscores, describing what the class does
Blender won’t crash if you ignore this convention, but you’ll get warnings in newer versions, and it makes your code harder to navigate when you have dozens of classes.
The bl_idname follows a different convention: it uses a lowercase category.action format (batch_render.add_job), which is Blender’s built-in style for all operator IDs.
poll(): Controlling When an Operator Is Available
The poll() method is one of the most useful patterns in Blender’s operator system, and it’s worth understanding properly.
poll() is a class method — notice the cls parameter instead of self — that Blender calls before the operator runs, to decide whether it should be available at all. A class method is called on the class instead of on an instance of the class. In this case, we want to know if this method is allowed to run in the current context. If poll() returns False, the button tied to that operator will be grayed out, and calling it via F3 or a shortcut will do nothing.
Here’s the @classmethod decorator it requires:
@classmethod
def poll(cls, context):
return True # or whatever condition makes sensePythonThat @classmethod decorator is easy to forget, and if you omit it, Blender will silently fail or throw a confusing error. Make it a habit to add it any time you write poll().
For our operators, the conditions will be things like:
- Remove Job — is there a queue item currently selected?
- Start Queue — are there jobs in the queue, and is the queue not already running?
- Stop Queue — is the queue actively running right now?
- Retry Job — does the selected job have a
FAILEDstatus?
We’re stubbing these with return True for now because the queue data doesn’t exist yet. In Post 5, when we build out the PropertyGroup that holds our queue items, we’ll come back and replace these stubs with real checks.
invoke(): Asking Before Acting
There’s a third method you’ll encounter often: invoke(). It sits between the user clicking a button and execute() running.
The most common use is to show a confirmation dialog before a destructive action:
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)PythonWhen this is present, clicking the button will pop up an “OK?” dialog. The user clicks OK, and then execute() runs.
For our Batch Render Manager, BATCHRENDER_OT_stop_queue is a good candidate. Stopping an in-progress render queue means abandoning work in progress, so a quick confirmation makes sense — and we already have it wired up in the class above.
invoke() must return a set, just like execute(). The options are:
{'FINISHED'}— done, no further interaction{'CANCELLED'}— the user dismissed it{'RUNNING_MODAL'}— the operator is now in modal mode (more on this in Post 7){'PASS_THROUGH'}— pass the event to the next handler
invoke_confirm() handles the return value for you, so you don’t have to think about it here. Under the hood, it returns {'RUNNING_MODAL'} while the dialog is open and {'FINISHED'} or {'CANCELLED'} based on what the user clicks.
How report() Works
You’ve seen self.report({'INFO'}, "some message") in every operator we’ve written. Let’s clarify what this actually does.
report() sends a message to Blender’s Info system. The first argument is a set containing the message type, and the second is the message string. The types you’ll use most are:
{'INFO'}— neutral informational message, shows in blue{'WARNING'}— something worth noting, shows in yellow{'ERROR'}— something went wrong, shows in red and creates a popup
These messages appear in the Info bar at the top of the screen and are logged in the Info editor. For our queue runner, we’ll use {'ERROR'} to surface problems like a missing .blend file or a render that fails.
One thing to know: report() only works during execute() or invoke(). You can’t call it in poll() — that runs too early and too often.
The Full operators.py
Here’s the complete file, with all five operators and imports included:
import bpy
class BATCHRENDER_OT_add_job(bpy.types.Operator):
"""Add a new render job to the queue"""
bl_idname = "batch_render.add_job"
bl_label = "Add Job"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
self.report({'INFO'}, "Job added to queue")
return {'FINISHED'}
class BATCHRENDER_OT_remove_job(bpy.types.Operator):
"""Remove the selected job from the queue"""
bl_idname = "batch_render.remove_job"
bl_label = "Remove Job"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
self.report({'INFO'}, "Job removed from queue")
return {'FINISHED'}
class BATCHRENDER_OT_start_queue(bpy.types.Operator):
"""Start processing the render queue"""
bl_idname = "batch_render.start_queue"
bl_label = "Start Queue"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
self.report({'INFO'}, "Queue started")
return {'FINISHED'}
class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
"""Stop the currently running render queue"""
bl_idname = "batch_render.stop_queue"
bl_label = "Stop Queue"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
return True
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
self.report({'INFO'}, "Queue stopped")
return {'FINISHED'}
class BATCHRENDER_OT_retry_job(bpy.types.Operator):
"""Reset a failed job so it can be run again"""
bl_idname = "batch_render.retry_job"
bl_label = "Retry Job"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
self.report({'INFO'}, "Job queued for retry")
return {'FINISHED'}
classes = [
BATCHRENDER_OT_add_job,
BATCHRENDER_OT_remove_job,
BATCHRENDER_OT_start_queue,
BATCHRENDER_OT_stop_queue,
BATCHRENDER_OT_retry_job,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)PythonTesting the Operator
With our new code, we are ready to test. Reload the add-on (disable and re-enable in Preferences, or F8 in Blender, or the reload command via the Blender Development extension).
Go to the Python console on the Scripting tab and type:
import bpy
dir(bpy.ops.batch_render)PythonYou should see a list with our five operators:
['add_job', 'remove_job', 'retry_job', 'start_queue', 'stop_queue']PythonThen try calling one directly:
bpy.ops.batch_render.add_job()PythonYou should see:
Info: Job added to queue
{'FINISHED'}PythonWhat We Have Now
At the end of this post, we have:
- Five operators covering the core actions of the Batch Render Manager
- A solid understanding of
bl_options,poll(),invoke(), andreport() - Blender’s class naming convention locked in
- Everything registered and callable from the Python console
The operators are stubbed — they report messages but don’t actually touch any queue data yet. That’s intentional. We’re building the pieces in order, and the data model comes next.
What’s Coming Next
In Post 4, we’ll build the panel that gives these operators a home in the UI. We’ll put a queue list panel in the Properties editor’s Render tab, add toolbar buttons for our operators, and stub out the per-item sub-panel that will eventually show override settings. We’ll also cover UIList — Blender’s way of rendering scrollable, selectable lists of items.
Once the panel is in place, the add-on will actually look like something, even if the queue itself is still empty. That’s a satisfying milestone to hit.
See you there.
Have questions or ran into a snag? Drop a comment below. If you are interested in more content Subscribe to the newsletter — I share work-in-progress updates, upcoming tutorials, and early looks at what I’m building.
