Post 9 of 10 — Blender Add-on Development with Python
In this series: We’re building a Batch Render Manager add-on from scratch. Post 1 covered the basics. Post 2 set up the package structure. Post 3 built the five core operators. Post 4 built the panel UI. Post 5 built the data model. Post 6 wired up the render runner. Post 7 made the UI responsive with modal operators. Post 8 added preferences, persistence, and notifications. This post adds error handling, queue resume, and packages the add-on for distribution.
We’re one post from the finish line. The Batch Render Manager is functionally complete — it queues jobs, renders them without blocking the UI, persists the queue between sessions, and sends notifications when things finish. What it doesn’t do yet is handle the inevitable: a bad file path discovered before the queue starts, Blender failing mid-render, a job that needs to be retried cleanly.
This post finishes Feature 4 — error handling and recovery — and then packages the whole thing for distribution. Here’s what we’re building:
- Queue resume on startup — hook
load_queue()into Blender’sload_posthandler so the queue restores automatically when Blender opens - Pre-flight validation — check every job before the queue starts and surface problems in the UI, not the console
- Error log — capture stderr output from failed renders and write it to a file so you can actually diagnose what went wrong
- Retry logic — wire up the Retry Job operator to work cleanly with the saved queue and error log
- Filepath alert — a small UI detail deferred from earlier: flag jobs with no file path set before the user starts a queue run
- Packaging — produce a legacy
.zipfor manual installation, and introduceblender_manifest.tomlfor the Extensions format
Queue Resume: The load_post Handler
In Post 8, we built load_queue() but left it unconnected to Blender’s startup sequence. The missing piece is an app handler — a callback that Blender fires at specific lifecycle events.
Blender exposes these in bpy.app.handlers. The one we want is load_post, which fires after any file is loaded — including the startup file Blender opens when it first launches. Registering a callback there means our queue loads automatically every time Blender starts or opens a new .blend file.
Other handlers you’ll encounter: save_pre / save_post for before and after saves, render_complete and render_cancel for render events, and depsgraph_update_post for scene change notifications. They all follow the same registration pattern.
Here’s the registration pattern:
import bpy
from bpy.app.handlers import persistent
@persistent
def my_handler(dummy):
pass # your logic here
def register():
bpy.app.handlers.load_post.append(my_handler)
def unregister():
bpy.app.handlers.load_post.remove(my_handler)PythonTwo things worth explaining here.
@persistent — without this decorator, Blender removes your handler callback from the list every time a new file is loaded. That would mean it fires once on the very first load, then vanishes. @persistent tells Blender to keep the callback registered across file loads. You want this on any handler that should stay active for the lifetime of the add-on.
dummy parameter — handlers in load_post receive one argument (the path of the loaded file as a string in some Blender versions, or nothing meaningful in others). We don’t need it, but the function signature must accept it. dummy is a conventional name for an unused parameter.
Add the handler registration to __init__.py:
from bpy.app.handlers import persistent
from . import operators, panels, properties, preferences, file_io, notifications
@persistent
def load_queue_on_startup(dummy):
ctx = bpy.context
if ctx and ctx.scene and any(j.status == 'RUNNING' for j in ctx.scene.batch_render_jobs):
return
file_io.load_queue()
def register():
properties.register()
operators.register()
panels.register()
preferences.register()
bpy.app.handlers.load_post.append(load_queue_on_startup)
def unregister():
bpy.app.handlers.load_post.remove(load_queue_on_startup)
preferences.unregister()
panels.unregister()
operators.unregister()
properties.unregister()PythonNote that load_queue_on_startup is removed in unregister() before the module-level cleanup. If you forget this, the handler will stay registered even after the add-on is disabled — pointing at a function in a module that no longer exists, which will throw errors the next time a file loads. The guard at the top of the handler covers one other edge case: because load_post fires on any file open, not just startup, we bail out early if a job is RUNNING to avoid overwriting in-memory queue state mid-render.
Pre-Flight Validation
Right now, run_queue() validates each job individually as it runs — it checks for a missing file path, and that’s about it. If job 5 of 10 has a bad configuration, you won’t find out until jobs 1 through 4 have already rendered.
A better pattern is a pre-flight sweep: check every enabled, pending job before the queue starts, collect all the problems, surface them to the user, and let them decide whether to proceed. This is what professional tools do — fail fast and visibly rather than failing late and silently.
We’ll put validate_queue() in its own validation.py:
# validation.py
import gzip
import os
def _is_blend_file(filepath):
try:
with open(filepath, 'rb') as f:
header = f.read(7)
if header == b'BLENDER':
return True
if header[:2] == b'\x1f\x8b':
with gzip.open(filepath, 'rb') as f:
return f.read(7) == b'BLENDER'
return False
except OSError:
return False
def validate_queue(context):
"""
Check all enabled, pending jobs for common problems before the queue starts.
Returns a list of (job_name, error_message) tuples.
An empty list means the queue is ready to run.
"""
jobs = context.scene.batch_render_jobs
errors = []
for job in jobs:
if not job.enabled or job.status != 'PENDING':
continue
if not job.filepath:
errors.append((job.name, "No file path set."))
continue
if not os.path.isfile(job.filepath):
errors.append((job.name, f"File not found: {job.filepath}"))
continue
if not _is_blend_file(job.filepath):
errors.append((job.name, f"Not a valid .blend file: {job.filepath}"))
if job.frame_end < job.frame_start:
errors.append((job.name, f"Frame end ({job.frame_end}) is before frame start ({job.frame_start})."))
return errorsPythonWe then call this at the start of run_queue(). If there are errors, we log them and bail out before any rendering starts:
# In queue_runner.py, at the top of run_queue():
def run_queue(context):
from . import validation
errors = validation.validate_queue(context)
if errors:
print("[Batch Render] Pre-flight validation failed. Fix the following before starting:")
for job_name, msg in errors:
print(f" - {job_name}: {msg}")
# We'll also surface these via operator report in the wired-up operator.
return errors
# ... rest of run_queue() ...
return []PythonWe change run_queue() to return a list — empty on success, a list of error tuples if validation fails. The Start Queue operator can then use this to report problems via self.report():
# In BATCHRENDER_OT_start_queue.execute():
def execute(self, context):
errors = queue_runner.run_queue(context)
if errors:
# Report the first error to the UI. The rest are in the console.
first_job, first_msg = errors[0]
self.report(
{'ERROR'},
f"Queue validation failed — '{first_job}': {first_msg}"
+ (f" (and {len(errors) - 1} more)" if len(errors) > 1 else "")
)
return {'CANCELLED'}
return {'FINISHED'}Pythonself.report({'ERROR'}, ...) pops up an error message in Blender’s header and logs it to the Info editor. The user sees the first problem immediately and knows to check the console for the full list. That’s a meaningful improvement over a silent failure mid-queue.
Error Log
When a render fails, subprocess.run() captures the stderr output. Right now we print it to the console, which works during development but is easy to miss — and impossible to review after the fact. Let’s write it to a log file instead.
Add a write_error_log() function to file_io.py:
# In file_io.py
import datetime
def write_error_log(job_name, stderr_output):
"""
Append a failed job's error output to the error log in the config directory.
Each entry is timestamped and separated for readability.
"""
config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager", create=True)
log_path = os.path.join(config_dir, "error_log.txt")
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
separator = "-" * 60
entry = (
f"\n{separator}\n"
f"[{timestamp}] FAILED: {job_name}\n"
f"{separator}\n"
f"{stderr_output.strip()}\n"
)
try:
with open(log_path, 'a', encoding='utf-8') as f:
f.write(entry)
print(f"[Batch Render] Error log updated: {log_path}")
except OSError as e:
print(f"[Batch Render] Could not write error log: {e}")PythonWe append rather than overwrite, so the log accumulates across runs — useful for spotting patterns in recurring failures. Each entry is timestamped and separated with a clear visual divider.
Update queue_runner.py to call this when a job fails:
# In queue_runner.py, inside the subprocess result handling:
if result.returncode != 0:
job.status = 'FAILED'
file_io.save_queue()
file_io.write_error_log(job.name, result.stderr)
failed_jobs.append(job.name)
# ... notification ...PythonThe log lives alongside queue.json in the user config directory, so it’s easy to find. We should also add an operator that opens the error log in the user’s default text editor — a small quality-of-life feature that saves digging through the filesystem:
# In operators.py
import subprocess
import platform
class BATCHRENDER_OT_open_error_log(bpy.types.Operator):
"""Open the error log in the system's default text editor"""
bl_idname = "batch_render.open_error_log"
bl_label = "Open Error Log"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager")
log_path = os.path.join(config_dir, "error_log.txt")
return os.path.isfile(log_path)
def execute(self, context):
config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager")
log_path = os.path.join(config_dir, "error_log.txt")
try:
if platform.system() == 'Windows':
os.startfile(log_path)
elif platform.system() == 'Darwin':
subprocess.run(['open', log_path])
else:
subprocess.run(['xdg-open', log_path])
except Exception as e:
self.report({'ERROR'}, f"Could not open log: {e}")
return {'CANCELLED'}
return {'FINISHED'}PythonAdd this operator to the classes list in operators.py, and add a button for it in the panel. A sensible place is the bottom of the main queue panel’s draw() method — only show it when the log exists (the operator’s poll() handles the graying):
# In BATCHRENDER_PT_queue.draw(), after the progress section:
layout.separator()
layout.operator("batch_render.open_error_log", icon='TEXT')PythonRetry Logic
The Retry Job operator has been stubbed since Post 3 — it resets a failed job to PENDING. Now that we have JSON persistence and the error log, we need to make sure the retry path is clean end-to-end.
The operator itself is already correct:
def execute(self, context):
jobs = context.scene.batch_render_jobs
index = context.scene.batch_render_active_job_index
jobs[index].status = 'PENDING'
self.report({'INFO'}, f"Job '{jobs[index].name}' reset to PENDING")
return {'FINISHED'}PythonWhat we need to add is the save call so the reset persists to the JSON file, and a note that retrying doesn’t clear the error log — you can keep reading the previous failure’s output while the retry runs:
def execute(self, context):
jobs = context.scene.batch_render_jobs
index = context.scene.batch_render_active_job_index
job = jobs[index]
job.status = 'PENDING'
file_io.save_queue()
self.report({'INFO'}, f"'{job.name}' reset to PENDING — previous errors still in the log.")
return {'FINISHED'}PythonAdd the file_io import at the top of operators.py if it isn’t there already (it should be from Post 8).
The retry flow is now: job fails → error is logged → user reads the log, fixes whatever caused the failure → clicks Retry → job status resets to PENDING → next queue run picks it up. The error log from the previous attempt stays around in case you need to compare.
Filepath Alert in the Job Overrides Panel
This is a small UI detail that was deferred from earlier in the series: when a job has no file path set, the override fields are technically editable but meaningless. We should make it obvious that something needs to be filled in before the job will run.
Blender’s layout system has a mechanism for this: box.alert = True. Setting this on any layout container turns it red — a clear visual signal that something needs attention.
In panels.py, update BATCHRENDER_PT_job_overrides.draw(). At the top of the draw method, before the override sections, add a check for an empty filepath:
def draw(self, context):
layout = self.layout
scene = context.scene
jobs = scene.batch_render_jobs
index = scene.batch_render_active_job_index
if not jobs or index >= len(jobs):
layout.label(text="No job selected.", icon='INFO')
return
job = jobs[index]
# Filepath field — always shown, alert state if empty.
filepath_box = layout.box()
filepath_box.alert = not bool(job.filepath)
filepath_box.prop(job, "filepath")
if not job.filepath:
filepath_box.label(text="Set a .blend file path to enable this job.", icon='ERROR')
layout.separator()
# --- Resolution ---
box = layout.box()
row = box.row()
row.prop(job, "override_resolution", text="Resolution Override")
sub = box.column()
sub.enabled = job.override_resolution
sub.prop(job, "res_x")
sub.prop(job, "res_y")
# ... rest of the override sections unchanged ...Pythonbox.alert = not bool(job.filepath) evaluates to True when the filepath string is empty (falsy), turning the box red. The label below it explains the issue in plain language. When the user fills in a path, the alert clears automatically on the next redraw.
This is the kind of passive guidance that prevents support questions. The user doesn’t have to discover at queue-start that they forgot a file path — they see it the moment they select the job.
Packaging
Now let’s talk about getting this add-on out the door.
The Legacy Zip
The simplest distribution format is the legacy zip. Zip the batch_render_manager folder, and it’s installable via Edit > Preferences > Add-ons > Install in any Blender version. This is what we’ve been using throughout the series.
The only thing to get right is the zip structure. The folder must be at the root of the zip — not nested inside another folder:
batch_render_manager.zip
└── batch_render_manager/
├── __init__.py
├── operators.py
├── panels.py
├── properties.py
├── preferences.py
├── queue_runner.py
├── file_io.py
├── validation.py
└── notifications.py
If you zip the contents of the folder instead of the folder itself, Blender won’t recognize it as an add-on package. A common mistake when using your OS’s “right-click > Compress” — it sometimes zips the contents rather than the folder. The reliable way to do it on any platform is from the command line:
# macOS / Linux — run from the parent directory of batch_render_manager/
zip -r batch_render_manager.zip batch_render_manager/
# Windows PowerShell — from the parent directory
Compress-Archive -Path batch_render_manager -DestinationPath batch_render_manager.zip
Before packaging, make sure to clean up any development artifacts: __pycache__ folders, .pyc files, any test files or scratch scripts you added during development. None of these will break anything if they’re included, but they bloat the zip and look sloppy.
# Remove Python cache files before zipping
find batch_render_manager/ -name "__pycache__" -type d -exec rm -rf {} +
find batch_render_manager/ -name "*.pyc" -delete
The Extensions Format
For Blender 4.2 and later, the preferred distribution method is the Extensions format. We covered the conceptual difference in Post 1: the Python code is identical, but the packaging gains a blender_manifest.toml file that declares metadata, permissions, and dependencies in a format Blender’s extension platform understands.
Converting our legacy add-on to an extension is mostly additive — we’re not changing any of the existing Python code. We just need to add the manifest file.
Create blender_manifest.toml inside the batch_render_manager folder:
schema_version = "1.0.0"
id = "batch_render_manager"
version = "0.1.0"
name = "Batch Render Manager"
tagline = "Queue multiple .blend files for sequential background rendering"
maintainer = "Harlepengren <[email protected]>"
type = "add-on"
blender_version_min = "4.2.0"
license = ["SPDX:GPL-2.0-or-later"]
[permissions]
network = "Send completion notifications via email and Discord webhooks"
files = "Read .blend files; write render output and queue state to disk"TOMLLet’s look at each section.
id — the extension’s unique identifier. Must be lowercase with underscores, no spaces. This is what identifies your extension on the platform and must be unique across all published extensions.
version — follows semantic versioning (major.minor.patch). Unlike bl_info‘s tuple, this is a string.
tagline — a short one-line description shown in the Extensions browser. Keep it under 64 characters.
type — "add-on" for a standard add-on, "theme" for a theme extension.
blender_version_min — the minimum Blender version that supports this extension. For Extensions format this is 4.2.0 at minimum (that’s when Extensions launched).
license — uses SPDX license identifiers. GPL-2.0-or-later is the most common choice for Blender add-ons, since Blender itself is GPL licensed and add-ons that ship with Blender are GPL. For your own add-on you can choose differently, but GPL-2.0-or-later is a safe default and required for submission to extensions.blender.org.
[permissions] — this section is specific to the Extensions platform. It declares what system resources your add-on accesses, and users see these declared permissions before installing. Our add-on uses the network (notifications) and the filesystem (reading blend files, writing queue and log data). Being honest and specific here matters — vague or missing permissions can get an extension rejected from the platform. Each description must be a single sentence of 64 characters or fewer with no end punctuation — Blender enforces this at install time.
Note that bl_info in __init__.py coexists with blender_manifest.toml. When Blender 4.2+ loads the add-on as an extension, it reads metadata from the manifest. Older Blender versions fall back to bl_info. For cross-version compatibility, keep both.
Packaging as an Extension
Extensions are also distributed as zip files, but with a slightly different structure — the files go at the root of the zip rather than inside a named subdirectory:
batch_render_manager.zip
├── __init__.py
├── blender_manifest.toml
├── operators.py
├── panels.py
├── properties.py
├── preferences.py
├── queue_runner.py
├── file_io.py
├── validation.py
└── notifications.py
From the command line, from inside the batch_render_manager/ directory:
# macOS / Linux
zip -r ../batch_render_manager.zip .
# Windows PowerShell
Compress-Archive -Path * -DestinationPath ..\batch_render_manager.zip
Blender can also build the extension zip for you using the command-line tool introduced in 4.2:
blender --command extension build --source-dir batch_render_manager/ --output-dir .
This is the most reliable method — it handles the zip structure correctly, validates the manifest, and produces a file named batch_render_manager-0.1.0.zip (version number included). If the manifest has errors, the build command will tell you.
The Updated File Structure
Our final package layout looks like this:
batch_render_manager/
├── __init__.py
├── blender_manifest.toml
├── operators.py
├── panels.py
├── properties.py
├── preferences.py
├── queue_runner.py
├── file_io.py
├── validation.py
└── notifications.py
We started with six files in Post 2. We’ve added file_io.py, validation.py, notifications.py, and blender_manifest.toml since then, and every file has been built incrementally throughout the series.
The Complete Updated __init__.py
Here’s the final version of __init__.py with the handler registration in place:
bl_info = {
"name": "Batch Render Manager",
"author": "Your Name",
"version": (0, 1, 0),
"blender": (4, 0, 0),
"location": "View3D > Sidebar > Batch Render",
"description": "Queue multiple render jobs with per-job setting overrides and completion notifications.",
"warning": "",
"doc_url": "",
"category": "Render",
}
import bpy
from bpy.app.handlers import persistent
from . import operators, panels, properties, preferences, file_io, notifications
@persistent
def load_queue_on_startup(dummy):
ctx = bpy.context
if ctx and ctx.scene and any(j.status == 'RUNNING' for j in ctx.scene.batch_render_jobs):
return
file_io.load_queue()
def register():
properties.register()
operators.register()
panels.register()
preferences.register()
bpy.app.handlers.load_post.append(load_queue_on_startup)
def unregister():
bpy.app.handlers.load_post.remove(load_queue_on_startup)
preferences.unregister()
panels.unregister()
operators.unregister()
properties.unregister()PythonTesting the Full Flow
With all of this in place, here’s a complete end-to-end test sequence worth running before shipping.
Queue resume:
- Add a few jobs, set valid file paths, save nothing intentionally.
- Close Blender entirely.
- Reopen Blender. Open the N-panel and go to Batch Render.
- The queue should be restored from
queue.json, with all jobs at PENDING (or DONE/FAILED from the last run).
Pre-flight validation:
- Add a job with no file path set.
- Click Start Queue.
- You should see an error in the header: “Queue validation failed — ‘Job 1’: No file path set.”
- No renders should have started.
Filepath alert:
- With a job selected, open the Job Overrides sub-panel.
- If the file path is empty, the filepath box should be red with an explanatory label.
- Set a valid path — the alert clears.
Error log:
- Add a job pointing to a valid
.blendfile that will fail during rendering — a good option is a file with an output path set to a directory Blender can’t write to (wrong permissions or a path that doesn’t exist). - Start the queue. The job should fail.
- Click Open Error Log. The log file should open with a timestamped entry showing Blender’s stderr output.
Retry:
- With a failed job selected, click Retry Job.
- The job status resets to PENDING. The error log is unchanged.
- Start the queue. The job runs again.
Extension packaging:
- From inside the
batch_render_managerdirectory, runblender --command extension build --source-dir . --output-dir .. - The output should be a
.zipwith a version-numbered filename. - Install the zip in a fresh Blender 4.2+ instance. The add-on should enable cleanly.
What We Have Now
After nine posts, here’s the complete feature checklist:
- ✅ Feature 1 — Multi-.blend file queue with UIList, status tracking, and a non-blocking modal runner
- ✅ Feature 2 — Per-item render setting overrides (resolution, samples, output path, frame range) injected via
--python-expr - ✅ Feature 3 — Completion notifications via desktop (plyer), email (SMTP), and Discord webhooks
- ✅ Feature 4 — Pre-flight validation, error logging, retry logic, and queue resume on startup
All four features complete. The add-on is packaged in both legacy zip and Extensions formats. There’s one post left.
What’s Coming Next
Post 10 is the finale. We’ll do a full walkthrough of the completed add-on from end to end, cross-version testing between Blender 4.0 and 4.2+, a review of things we’d do differently if starting fresh, and a final packaged release ready for Blender Market and extensions.blender.org.
It’s also where we’ll take stock of what we’ve learned across the series — not just the API mechanics, but the patterns and instincts that make add-on development feel manageable rather than mysterious.
See you there.
Have questions or ran into a snag? Drop a comment below. And if you want work-in-progress updates, upcoming tutorials, and early looks at what I’m building, subscribe to the newsletter.
