Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Foreword — From Script to Structure

Most Blender add-ons don’t begin as add-ons. They begin as small Python scripts — little helpers written to remove friction from repetitive tasks. That was certainly my path. I wrote small functions, pasted them into Blender’s Script Editor, ran them manually, and moved on. They worked, and that was enough.

Eventually, though, the thought appeared: “I wish this were just a button.”

That is usually the turning point. You open the official Blender add-on tutorial. It starts simply, but very quickly you find yourself reading about bpy.types.Operator, registration blocks, panels, properties, and execution flow. It’s not wrong — it’s thorough and powerful — but it can feel like stepping into a formal contract with Blender’s runtime. I glanced at it more than once and quietly closed the tab. There’s a moment when you think, “Not today.”

Most people do not learn add-on architecture because they are curious. They learn it because they hit a productivity barrier hard enough that they have no choice. That was my experience. And learning it was not smooth. There were small issues everywhere — registration mistakes, panels not appearing, properties not updating, reload quirks. Each issue was minor. Together they were exhausting.

That experience led to a simple question: what if building a Blender add-on felt more like assembling Lego blocks?

In Lego, you don’t begin by designing the entire structure. You start with a block. Then another. The connectors are predictable. Pieces snap together cleanly. Complexity emerges from composition, not from rewriting foundations every time.

What if each Python function were a block? What if shared inputs were the connectors? What if larger systems were assembled from smaller, reusable pieces?

QuickAddon was built from that frustration. Not to replace Blender’s architecture. Not to hide it. But to make add-on development composable — to let you start with a function that works and snap structure around it.

This book follows that same progression. We begin with small helper functions. We turn them into tools. We assemble them into systems. Only later do we peel back the structure underneath. If you already understand Blender’s internals, you will recognize what is happening. If you don’t, you won’t need to — at least not immediately.

The goal is simple: build tools like Lego. Snap them together. Let structure grow when you need it.

And yes, we will still link the official Blender tutorial — not as a threat, but as a rite of passage.

You will need it too as I need it everyday, as it will demystify everything. You will also find the reference material here.

The Official Blender Add-on Tutorial

Allow me to introduce the QuickAddon next.

Introduction

QuickAddon

QuickAddon turns plain Python functions into fully working Blender add-ons.

You write the business logic.

QuickAddon generates the Blender-facing structure:

  • Operators
  • Panels
  • Shared-property UI
  • A standalone host wrapper
  • An embeddable plugin module

No boilerplate. No Blender ceremony. No manual class wiring.

We’ll walk through a small example so you get the idea. In Chapter 3 we will do this for real; for now, treat this as an illustration.


60-Second Example

Assume you already have a proven Python function. We’ll use a tiny hello.py.

File: hello.py

def say_hello(name: str):
    print(f"Hello {name}")

Now run:

quickaddon doctor hello.py

You’ll see that quickaddon has added a small header/preamble at the top of your file.

That preamble is intentional. Among other things, it makes the decorator available as op without you needing to import anything inside your script.

Now add the decorator to your function:

@op(
    label="Say Hello",
    space="SEQUENCE_EDITOR",
    category="My Tools",
    shared={
        "name": "project.name",
    },
)
def say_hello(name: str):
    print(f"Hello {name}")

Then run:

quickaddon build hello.py --out my_addon_folder

The add-on hello has been created in my_addon_folder.

Install the generated addon in Blender.

Open:

Video Sequencer → N Panel → My Tools

You will see:

  • A text field for name
  • A button labeled Say Hello

Click it.

You just built a Blender add-on.

No classes. No register/unregister boilerplate. No property definitions. No UI layout code.

Just a function.

If you’re tempted to try this right now, go ahead. But if you want the smoothest learning path, wait until Chapter 3—we’ll do this exact workflow there, step by step. For now, treat this as a preview.


What Just Happened?

QuickAddon did three things:

  1. Wrapped your function in a Blender Operator.
  2. Generated shared properties for project.name.
  3. Created a standalone host wrapper that injects routing logic.

You didn’t see any of it.

That’s the point.


The Philosophy

Plugins declare what they need. Hosts decide where data lives. Shared keys form contracts.

QuickAddon sits in the middle and wires everything together.

You get:

  • Clean business logic
  • Reusable components
  • Embeddable tools
  • No fragile path strings
  • No copy-paste boilerplate across add-ons

What This Means in Practice

Typical Blender add-ons accumulate:

  • Boilerplate
  • PropertyGroup definitions
  • PointerProperty wiring
  • UI layout code
  • Register/unregister juggling

QuickAddon removes that scaffolding from your day-to-day work.

You focus on:

def your_logic(...):
    ...

…and let the generator handle the structure.


Next

Now that you’ve seen the shape of it, we’ll explain the mental model.

Mental Model

QuickAddon exists for two kinds of developers:

  • Those who want to build Blender tools without writing boilerplate.
  • Those whose existing add-ons are growing and need structural separation.

This chapter explains the system in one pass.

If it feels abstract at first, that is intentional. Each concept becomes concrete in later chapters.


The Problem It Solves

Traditional Blender add-ons tend to mix:

  • Tool logic
  • UI layout
  • Property definitions
  • Registration plumbing
  • Storage routing

in the same file.

As an add-on grows, this coupling becomes difficult to reason about.

QuickAddon enforces separation between these concerns.

That separation is the foundation of scalability.


Core Separation

QuickAddon separates three concerns:

  1. Tool Logic
  2. Shared Values
  3. Storage Ownership

Understanding these three layers is enough to understand the entire system.


Tool Logic

You write decorated Python functions.

The generator produces:

  • Operators
  • Panels
  • Property definitions
  • Registration code

Your source file contains behavior. The generated code contains structure.

You do not manually define Blender classes. You do not write register() blocks. You do not manually route properties.

Runtime context (such as context, scene, etc.) is supplied explicitly through the Injection Contract.

QuickAddon can then surface those generated operators in more than one way.

The default path is panel-first:

  • QuickAddon generates panel UI
  • QuickAddon lists the operator there
  • The tool feels like a standard add-on button

But the system is intentionally broader than that.

Generated operators may also be:

  • Drawn by a host add-on instead of the default generated panel
  • Invoked from menus
  • Bound to keymaps
  • Triggered by other add-on code as helper operators

This is the purpose of the panel decorator flag.

  • panel=True means QuickAddon should auto-surface the operator in its generated panel UI
  • panel=False means generate the operator normally, but let some other UI surface own how it is exposed

That distinction matters because QuickAddon is not only a panel generator. It is also an operator generator and composition system.


Shared Values

Shared parameters are declared in the decorator:

shared={"audio_path": "project.audio_path"}

A shared key:

  • Is a stable string identifier
  • Is not a Blender property path
  • Is an opaque routing key

Multiple tools may reference the same shared key.

The key does not define where data lives. It defines a contract.


Storage Ownership

A host is any add-on that provides storage routing and panel integration for one or more plugins.

In standalone mode, the generated add-on acts as its own host.

In embedded mode, another add-on becomes the host.

Hosts control:

  • Where shared values are stored
  • How shared UI is drawn
  • How plugin instances are scoped
  • Which UI surfaces expose which operators

Plugins declare intent. Hosts enforce routing.


Instance Isolation (Later)

As systems grow, hosts may need multiple independent instances of the same plugin.

That isolation model is introduced later in the book after the v1 routing model is established.


Structural Overview

Plugin Function
        ↓
Generated Operator
        ↓
HostAPI (Routing)
        ↓
Host Storage

Each layer has one responsibility.

No layer reaches into another layer’s storage directly.


Design Constraints

QuickAddon enforces:

  • Deterministic host injection
  • Centralized shared routing
  • Stateless plugin behavior
  • No implicit global state

These constraints favor predictability over flexibility.

In large add-on systems, predictability scales better than cleverness.


Summary

You write Python functions.

QuickAddon generates structure.

Hosts control storage and lifecycle.

That is the entire model.

First Plugin

First Plugin

Since you are intending to write an add-on, it is useful to know where Blender expects add-ons to live.

Open Blender.

Go to the Scripting workspace and locate the Python Console.

Type:

>>> import addon_utils
>>> addon_utils.paths()
[
    '/Applications/Blender-454.app/Contents/Resources/4.5/scripts/addons_core',
    '/Users/user/Library/Application Support/Blender/4.5/scripts/addons'
]

On my system, user-installed add-ons live in:

/Users/user/Library/Application Support/Blender/4.5/scripts/addons

Your path may differ depending on OS and Blender version.

It is helpful to store this location in an environment variable so you don’t have to type it repeatedly.

Add it to your shell configuration file (for example, ~/.zshrc or ~/.bashrc). In this book, we will assume the variable is named:

BLENDER_ADDON

From now on, we will use $BLENDER_ADDON whenever we refer to your add-on directory.


From Script Editor to Button

Assume this is your starting point:

You wrote a function. You pasted it into Blender’s Script Editor. You ran it. It works.

Example:

from pathlib import Path

def setup_from_audio(
    audio_path: Path,
    bpm: int = 120,
):
    print("Audio:", audio_path)
    print("BPM:", bpm)

Here, audio_path: Path does two important things:

  • QuickAddon gives Blender a file-path style UI for the field
  • your function receives a real Path object at runtime

If you want file-path UI but still want a plain string at runtime, use a str annotation and add a subtype hint later with param_subtypes.

You tested it manually. Now you want a button inside Blender that runs it.

You do not want to learn add-on boilerplate.


Step 1 – Save It to a File

Save the script as:

audiodeck.py

Step 2 – Insert the Required Header

Run:

quickaddon doctor audiodeck.py

This inserts the canonical header block.

Do not edit the header.

Your file now contains something like:

# QUICKADDON_HEADER_BEGIN <hash>
...
# QUICKADDON_HEADER_END <hash>

from pathlib import Path

def setup_from_audio(
    audio_path: Path,
    bpm: int = 120,
):
    print("Audio:", audio_path)
    print("BPM:", bpm)

The header enables QuickAddon to process your file correctly. It must remain intact.


Step 3 – Add the @op Decorator

Add metadata above your function:

@op(
    label="Setup From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
)
def setup_from_audio(
    audio_path: Path,
    bpm: int = 120,
):
    print("Audio:", audio_path)
    print("BPM:", bpm)

That is the only structural change.

You did not convert it into a class. You did not define a Blender Operator manually. You did not import bpy.


Step 4 – Build the Add-on

Now build it into the Blender add-on directory we identified earlier:

quickaddon build audiodeck.py \
  --out $BLENDER_ADDON \
  --force

Open Blender.

Go to Edit → Preferences → Add-ons.

Search for Setup From Audio and enable it.

From now on, Blender will load this add-on automatically.

Open:

SEQUENCE_EDITOR → N-panel → QuickAddon

You now have:

  • A button labeled Setup From Audio
  • A popup dialog for audio_path and bpm

Click Run.

Your original function executes.

That’s it.

Quick note:

  • Path means your function receives a Path
  • str means your function receives a str
  • UI hints like file pickers are separate from runtime type intent

For example, this keeps a string while still using Blender’s filepath picker UI:

@op(
    label="Setup From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
    param_subtypes={"audio_path": "FILE_PATH"},
)
def setup_from_audio(
    audio_path: str = "",
    bpm: int = 120,
):
    print(type(audio_path), audio_path)
    print("BPM:", bpm)

Step 5 – Add More Functions

Let’s add a second function to the same file.

@op(
    label="Setup From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
)
def setup_from_audio(
    audio_path: Path,
    bpm: int = 120,
):
    print("Setup:", audio_path, bpm)


@op(
    label="Render From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
)
def render_from_audio(
    audio_path: Path,
):
    print("Render:", audio_path)

Rebuild and re-enable.

What you will see:

  • Two buttons
  • Two popup dialogs
  • Each function asks for audio_path independently

That is perfectly valid if the two audio paths should be separate.

But if both tools are meant to operate on the same audio file, repeatedly entering the path becomes unnecessary.

This is where shared values come in.


Now Make It Shared

Modify both decorators:

@op(
    label="Setup From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
    shared={
        "audio_path": "project.audio_path",
    },
)
def setup_from_audio(
    audio_path: Path,
    bpm: int = 120,
):
    print("Setup:", audio_path, bpm)


@op(
    label="Render From Audio",
    space="SEQUENCE_EDITOR",
    category="QuickAddon",
    shared={
        "audio_path": "project.audio_path",
    },
)
def render_from_audio(
    audio_path: Path,
):
    print("Render:", audio_path)

Rebuild.

Now you will see:

  • audio_path appears once in the panel.
  • It no longer appears in popup dialogs.
  • Both functions use the same stored value.
  • The value persists across runs.

You enter the path once.

Both functions use it.

That is what “shared” means.

You did not write storage code. You only declared that both functions reference the same shared key.

Persistence is handled for you.


If this feels clean and predictable, that is intentional.

In the next chapter, we will examine how the generated host makes this possible.

Standalone Add-on

So far, you wrote a function and built an add-on.

Now let’s look at what was actually generated.


What Actually Gets Generated

Build into a temporary folder so we can inspect the output:

quickaddon build audiodeck.py \
  --out ./dist \
  --force

Now inspect the generated directory:

ls -R ./dist

You should see:

dist/
└── audiodeck/
    ├── __init__.py
    ├── generated_ops.py
    └── user_code.py

user_code.py is simply a copy of your original Python file. Nothing special happens there. From this point forward, we will treat it as implicit and focus on the other two files.

Those two files define the pattern.


File 1 – generated_ops.py

Open:

dist/audiodeck/generated_ops.py

This file is substantial — but you did not have to write it.

Inside you will find:

  • Generated Operator classes
  • Shared parameter definitions
  • UI draw helpers
  • Registration hooks

Example (trimmed):

import bpy

# Auto-generated by quickaddon
# Source addon: Audiogen

_SHARED_PTR_NAME = "qa_shared__audiogen"

_HOST_API = None  # injected by host at register time

class _FallbackHostAPI:
    ...  

class QA_SHARED_ROOT(bpy.types.PropertyGroup):
    project__audio_path: bpy.props.StringProperty(name="audio_path", default='', subtype="FILE_PATH")


class QA_OT_SETUP_FROM_AUDIO(bpy.types.Operator):
    ...

class QA_OT_RENDER_FROM_AUDIO(bpy.types.Operator):
    ...
    ...

# Panel -> ops mapping, used by host integration hooks
PANEL_OPS = {
    "QuickAddon": [
        ("Setup From Audio", "qa_audiogen.setup_from_audio"),
        ("Render From Audio", "qa_audiogen.render_from_audio"),
    ],
}

def draw_shared(layout, context):
    ...

def draw_tools(layout, context, *, category=None):
    ...

def draw(layout, context, *, category=None, with_shared=True):
    """
    Host integration hook:
      - with_shared=True draws shared inputs first.
      - category controls which tool group(s) to draw.
    """
    if with_shared:
        draw_shared(layout, context)
        layout.separator()
    draw_tools(layout, context, category=category)


class QA_PT_SEQUENCE_EDITOR_QUICKADDON(bpy.types.Panel):
    ...  
    def draw(self, context):
        layout = self.layout
        draw(layout, context, category="QuickAddon", with_shared=True)

_BASE_CLASSES = [
    QA_SHARED_ROOT,
    QA_OT_SETUP_FROM_AUDIO,
    QA_OT_RENDER_FROM_AUDIO,
]

_PANEL_CLASSES = [
    QA_PT_SEQUENCE_EDITOR_QUICKADDON,
]

# Prevent double-register in nested/plugin scenarios
_IS_REGISTERED = False
_REGISTERED_MODE = None

def register(*, mode="plugin", host_api=None):
    ...
    for c in _BASE_CLASSES:
        bpy.utils.register_class(c)

    # namespaced shared ptr
    setattr(
        bpy.types.Scene,
        _SHARED_PTR_NAME,
        bpy.props.PointerProperty(type=QA_SHARED_ROOT),
    )
    ...

def unregister():
    ...
    if hasattr(bpy.types.Scene, _SHARED_PTR_NAME):
        delattr(bpy.types.Scene, _SHARED_PTR_NAME)

    for c in reversed(_BASE_CLASSES):
        bpy.utils.unregister_class(c)
    ...

This file represents your Python functions transformed into Blender operators.

It:

  • Defines operator classes.
  • Defines shared properties.
  • Exposes drawing helpers.
  • Registers base classes.

It does not decide where shared values are ultimately stored when embedded in a larger system. It describes behavior and inputs.

That distinction becomes important later.


File 2 – __init__.py

Open:

dist/audiodeck/__init__.py

This file is lightweight.

You will see:

  • Add-on metadata (bl_info)
  • Registration logic
  • A host wrapper
  • Standalone panel wiring

Example (trimmed):

bl_info = {
    "name": "Audiogen",
    ...
}

from . import generated_ops

class _StandaloneHostAPI:
    ...

def register():
    generated_ops.register(
        mode="plugin",
        host_api=_StandaloneHostAPI(),
    )

def unregister():
    generated_ops.unregister()

This file connects the generated add-on to Blender.

It provides:

  • Default shared storage
  • HostAPI injection
  • Add-on entry points

In standalone mode, this wrapper acts as the host.


The Visual Pattern

When you enable the add-on, the layers look like this:

Your Functions
      ↓ used by
generated_ops.py  (behavior + operators)
      ↓ used by
__init__.py       (standalone host wrapper)
      ↓ used by
Blender UI + Storage

Two files. Two responsibilities.

You wrote one function. QuickAddon generated the layers and wiring around it.

You now recognize the shape of a standalone QuickAddon add-on.

In the next chapter, we will see how this same structure enables embedding inside another host.

Embedding Plugins

This is where we start assembling systems from our Lego pieces.

In the previous chapter, we generated and used audiodeck.

Now consider this situation:

  • audiodeck was generated first.
  • You enabled it. It works.
  • Later, you generated audio_encode.
  • It also works standalone.

Their functionality is related.

Now you want:

audio_encode to appear inside audiodeck’s panel without enabling audio_encode separately.

We are staying entirely inside the QuickAddon ecosystem. Let’s walk through the process.


Step 1 – Start from Two Generated Add-ons

After building both, your add-ons directory looks like:

.../scripts/addons/
├── audiodeck/
│   ├── __init__.py
│   └── generated_ops.py
└── audio_encode/
    ├── __init__.py
    └── generated_ops.py

Each add-on works independently in standalone mode.


Step 2 – Embed audio_encode Inside audiodeck

We will embed audio_encode physically inside audiodeck.

Final layout:

.../scripts/addons/
└── audiodeck/
    ├── __init__.py
    ├── generated_ops.py
    └── audio_encode/
        ├── __init__.py
        └── generated_ops.py

What changed?

  • We moved audio_encode/ inside audiodeck/.
  • Blender now sees only audiodeck as an add-on to enable.

audio_encode becomes a module inside the host.


Step 3 – Modify audiodeck

Open:

audiodeck/__init__.py

Add the import

from .audio_encode import generated_ops as audio_encode_ops

Update register()

Extend register():

def register():
    # audiodeck registration (already generated)
    ...

    # Register audio_encode in plugin mode and mount one named instance
    audio_encode_ops.register(mode="plugin", host_api=_HOST_API)
    audio_encode_ops.mount_instance("encode")

Update unregister() accordingly:

def unregister():
    audio_encode_ops.unregister()

    # audiodeck unregister logic
    ...

Important:

We use mode="plugin" and then explicitly mount a named instance.

This means:

  • audio_encode registers its operators.
  • audio_encode prepares its hosted runtime.
  • audio_encode does not create its own panel.
  • audio_encode does not create a hidden default instance.

Even in the standalone case in the previous chapter, we used mode="plugin" - this is the default and most common mode.

The other modes exist purely for QuickAddon author’s debugging convenience.


Update audiodeck’s Panel draw()

Inside audiodeck’s panel class:

def draw(self, context):
    layout = self.layout

    _HOST_API.draw_registered(layout, context)

That is the entire integration layer.

No changes were made to audio_encode itself.


Step 4 – Enable Only audiodeck

In Blender:

  • Disable audio_encode (if it was previously enabled).
  • Enable audiodeck.

Result:

  • Only one panel appears (audiodeck).
  • audio_encode buttons appear inside it.
  • Shared inputs render correctly.
  • audio_encode functions execute normally.

What Just Happened

Standalone audio_encode:

audio_encode __init__.py
    └── creates its own panel

Embedded audio_encode:

audiodeck __init__.py
    ├── registers audio_encode (plugin mode)
    ├── mounts a named instance
    └── lets HostAPI render registered plugin UI

audio_encode still contains:

  • Its operators
  • Its shared keys
  • Its generated plumbing

Only audiodeck now controls:

  • Panel ownership
  • UI composition
  • Instance naming

What Has NOT Changed

  • The audio_encode tool script
  • Its shared key declarations
  • Its operator logic
  • Its build process

Embedding modifies only the host (audiodeck).

The plugin remains untouched.


Where Are Shared Values Stored?

At this stage:

  • audio_encode still uses its generated fallback storage.
  • audiodeck does not yet own audio_encode’s shared values.

In the next chapter, we will transfer ownership.

That is where the HostAPI contract becomes visible.


You now understand:

  • How two generated add-ons relate
  • How one becomes the host
  • How a plugin is physically embedded
  • How registration is redirected
  • How UI is delegated

Next, we will make audiodeck own audio_encode’s shared values.

Host Ownership of Shared Values

In the previous chapter:

  • audiodeck became the host.
  • audio_encode was embedded inside it.
  • The UI was unified.
  • Only audiodeck was enabled.

However, one important thing has not changed yet.

audio_encode is still storing its shared values in fallback storage.


Current State

Right now, shared values for audio_encode live in:

context.scene.qa_shared

So even though:

  • audiodeck controls the panel
  • audio_encode renders inside it

The data is still owned by generated fallback storage.

audiodeck controls the UI, but not the data.


Why This Matters

Suppose audiodeck already defines its own property group:

class AudioDeckProps(bpy.types.PropertyGroup):
    audio_path: bpy.props.StringProperty(
        name="Audio Path",
        subtype="FILE_PATH"
    )

And suppose it is registered as:

bpy.types.Scene.audiodeck_props = bpy.props.PointerProperty(
    type=AudioDeckProps
)

Now we have two places storing the same concept:

context.scene.audiodeck_props.audio_path
context.scene.qa_shared.project__audio_path

Two values. One meaning.

That duplication is unnecessary.

If audiodeck is the host, it should own the data.


The Mechanism: HostAPI

When audio_encode reads or draws a shared value, it does not directly access storage.

Instead, it calls:

host_api.get(...)
host_api.draw(...)

Until now, we were relying on the default fallback host.

Now we will provide our own.


Step 1 – Implement a Minimal HostAPI in audiodeck

Open:

audiodeck/__init__.py

Add the following class:

class AudioDeckHostAPI:

    def get(self, context, key, fallback_prop):
        if key == "project.audio_path":
            return context.scene.audiodeck_props.audio_path

        # fallback for unknown keys
        return getattr(context.scene.qa_shared, fallback_prop)

    def draw(self, layout, context, key, fallback_prop, label=None):
        if key == "project.audio_path":
            layout.prop(
                context.scene.audiodeck_props,
                "audio_path",
                text=label
            )
        else:
            layout.prop(
                context.scene.qa_shared,
                fallback_prop,
                text=label
            )

This class does two things:

  • Routes "project.audio_path" into audiodeck_props.
  • Falls back to generated storage for all other shared keys.

The plugin does not change. Only the host’s routing logic changes.


Step 2 – Inject the HostAPI

Modify the registration of audio_encode inside audiodeck.register():

audio_encode_ops.register(
    mode="plugin",
    host_api=AudioDeckHostAPI()
)

That is the entire ownership transfer.


What Changed?

Before:

audio_encode
    └── qa_shared

After:

audio_encode
    └── HostAPI
          └── audiodeck_props

Now:

  • audio_encode reads from audiodeck storage.
  • audio_encode draws audiodeck properties.
  • Fallback storage remains available for other shared keys.

What Did NOT Change

  • audio_encode tool script
  • audio_encode shared decorator
  • audio_encode operator logic
  • audio_encode build process

Only audiodeck changed.

The plugin remains generated, reusable, and composable.


What You Have Now

At this point:

  • UI ownership belongs to audiodeck.
  • Data ownership belongs to audiodeck.
  • audio_encode remains independent and reusable.
  • The relationship is defined entirely through the HostAPI contract.

In the next chapter, we will make this routing scalable — so you do not have to write if key == ... for every shared value.

HostAPI – The Routing Contract

In the previous chapter, we transferred shared value ownership from fallback storage into audiodeck.

We did this by injecting a HostAPI implementation.

Now we formalize what that means.

This chapter defines the HostAPI contract.


Why HostAPI Exists

A generated plugin never directly accesses storage.

It does not read from:

context.scene.qa_shared

It does not read from:

context.scene.audiodeck_props

Instead, whenever a shared value is:

  • Read
  • Drawn in the UI

The plugin calls:

host_api.get(context, key, fallback_prop)
host_api.draw(layout, context, key, fallback_prop, label=...)

The plugin speaks only in shared keys.

The host decides where those keys live.

That separation is the purpose of HostAPI.


Core Terms

Shared Key

A stable identifier defined in the plugin decorator:

shared={"audio_path": "project.audio_path"}

The plugin only knows:

"project.audio_path"

It does not know how that key is stored.


Fallback Property

A generated property name derived from the shared key:

project__audio_path

This exists so the plugin can function in standalone mode.

If the host does not serve a key, fallback storage is used.


Required Methods

A HostAPI implementation must define:

class HostAPI:

    def get(self, context, key: str, fallback_prop: str):
        """
        Return the value for shared key `key`.

        - `key` is the shared key (e.g. "project.audio_path")
        - `fallback_prop` is the generated fallback property name
        """
        ...

    def draw(self, layout, context, key: str, fallback_prop: str, *, label=None):
        """
        Draw the UI control for shared key `key`.

        Must draw something:
        - host-owned property
        - or fallback property
        """
        ...

These two methods form the entire routing contract.

Nothing else is required.


Fallback Behavior

If a host does not serve a shared key:

return getattr(context.scene.qa_shared, fallback_prop)

and

layout.prop(context.scene.qa_shared, fallback_prop, text=label)

This ensures:

  • Plugins remain functional without a host.
  • Hosts can adopt routing incrementally.
  • Shared ownership is optional, not mandatory.

Fallback is not a special case — it is part of the design.


Injection Model

When embedding a plugin, the host injects its HostAPI:

audio_encode_ops.register(
    mode="plugin",
    host_api=AudioDeckHostAPI()
)

The plugin never creates the HostAPI itself.

The host always controls:

  • Whether a HostAPI is provided
  • Which implementation is used

Control remains centralized.


Design Discipline

HostAPI is:

  • A routing layer
  • Not a transformation layer
  • Not a validation framework
  • Not a business logic container

It should:

  • Map keys to storage
  • Draw the correct property
  • Preserve fallback behavior

Nothing more.


What HostAPI Is Not

HostAPI does not:

  • Know which plugin requested a key
  • Store plugin identity
  • Mutate shared keys
  • Enforce global state

It is intentionally minimal.


Where We Are

At this point, you understand:

  • Plugins declare shared keys.
  • Plugins never access storage directly.
  • Hosts inject HostAPI.
  • HostAPI routes shared keys to storage.

In the next chapter, we will implement a clean and scalable routing pattern inside audiodeck.

Property Ownership – A Clean Routing Pattern

In the previous chapter, we formalized the HostAPI contract.

We saw that:

  • Plugins speak in shared keys.
  • Hosts decide where those keys live.
  • Routing happens through get() and draw().

Earlier, we implemented routing using:

if key == "project.audio_path":
    ...

That works for one key.

It does not scale.

This chapter introduces a clean, declarative routing pattern.


The Problem with if Chains

Suppose audio_encode defines three shared keys:

shared={
    "audio_path": "project.audio_path",
    "bpm": "project.bpm",
    "output_dir": "project.output_dir",
}

Writing:

if key == ...
elif key == ...
elif key == ...

Becomes repetitive and fragile.

As the number of shared keys grows, branching logic becomes harder to reason about and maintain.

We want:

  • Explicit mapping
  • Easy extension
  • No branching logic explosion
  • Clear ownership declaration

Step 1 – Define a KEYMAP

Inside audiodeck/__init__.py:

KEYMAP = {
    "project.audio_path": ("audiodeck_props", "audio_path"),
    "project.bpm": ("audiodeck_props", "bpm"),
    "project.output_dir": ("audiodeck_props", "output_dir"),
}

Each entry maps:

shared_key → (property_group_name, attribute_name)

This makes ownership explicit and declarative.

The host now clearly states:

“These shared keys belong to these properties.”


Step 2 – Refactor HostAPI to Use KEYMAP

Replace the earlier implementation with:

class AudioDeckHostAPI:

    def get(self, context, key, fallback_prop):
        if key in KEYMAP:
            group_name, attr = KEYMAP[key]
            group = getattr(context.scene, group_name)
            return getattr(group, attr)

        return getattr(context.scene.qa_shared, fallback_prop)

    def draw(self, layout, context, key, fallback_prop, label=None):
        if key in KEYMAP:
            group_name, attr = KEYMAP[key]
            group = getattr(context.scene, group_name)
            layout.prop(group, attr, text=label)
        else:
            layout.prop(
                context.scene.qa_shared,
                fallback_prop,
                text=label
            )

Routing is now centralized.

No condition chains. No duplication. No hidden logic.


Adding a New Shared Key

To route a new key, modify only the KEYMAP:

KEYMAP["project.sample_rate"] = ("audiodeck_props", "sample_rate")

No other code changes are required.

Growth becomes mechanical.


Fallback Still Works

If a shared key is not listed in KEYMAP:

  • It automatically uses fallback storage.
  • The plugin continues to function.
  • Ownership transfer can be incremental.

This makes adoption safe and predictable.


Discipline Guidelines

To keep routing reliable:

  • Shared keys must match exactly.
  • Host property types must match plugin expectations.
  • Avoid transforming values inside HostAPI.
  • Keep KEYMAP static and explicit.

HostAPI is a routing layer. It should remain simple.


What You Now Have

At this point:

  • UI ownership belongs to audiodeck.
  • Data ownership belongs to audiodeck.
  • Routing is scalable.
  • Fallback behavior is preserved.
  • Plugins remain reusable.

In the next chapter, we extend this model to multiple embedded plugins.

Multi-Plugin Hosts and Generated Routing

At this point, you have built a proper host.

Inside audiodeck:

  • Shared keys are centralized in KEYMAP.
  • HostAPI routes through that map.
  • Fallback storage remains intact.
  • Plugins remain unaware of storage details.

The system is clean. It scales. It is generic.

You now have a host that can serve any embedded plugin.


Serving Multiple Plugins

Suppose you embed a second plugin audio_render:

audiodeck/
├── audio_encode/
└── audio_render/

Registration:

audio_encode_ops.register(
    mode="plugin",
    host_api=AudioDeckHostAPI()
)
audio_encode_ops.mount_instance("encode")

audio_render_ops.register(
    mode="plugin",
    host_api=AudioDeckHostAPI()
)
audio_render_ops.mount_instance("render")

Both plugins speak in shared keys.

Both are routed through the same KEYMAP. Update the KEYMAP to include audio_render's routing.

Routing is based on keys — not plugin identity.

Your host is now a system boundary.


The Routing Layer Is Generic

Notice what your HostAPI does not do:

  • It does not know which plugin requested a key.
  • It does not track plugin identity.
  • It does not contain business logic.

Everything flows through:

Plugin → HostAPI → KEYMAP → Storage

This is composability.

Plugins snap into the host. Shared keys connect through the map. Ownership remains centralized.


Look at What You Built

You wrote:

  • A declarative KEYMAP
  • A mechanical HostAPI
  • A predictable registration + mounting pattern

There is no dynamic behavior in this routing layer. No heuristics. No introspection.

It is entirely structural.


A Natural Question

If this structure is predictable…

If every host follows the same pattern…

If shared keys are already declared in decorators…

Why are we writing this by hand?

Let’s pause on the idea of generated keymap routing.

With this idea:

  • Shared keys declared in the host are collected.
  • Embedded plugin shared keys are collected.
  • A KEYMAP is generated.
  • A default HostAPI is emitted.
  • Fallback behavior is preserved.

The structure you just implemented manually could, in principle, be generated automatically.


Build Manually, Then Automate

You now understand:

  • The contract
  • The ownership model
  • The routing boundary

Automation without understanding creates dependency. Understanding before automation creates control.

That is why we built the routing layer manually first.

We will revisit automation much later in the book.

Before that, we need to cover a few more structural foundations.


Where We Stand

At this stage:

  • Hosts own UI.
  • Hosts own shared data.
  • Multiple plugins are supported.
  • Routing is centralized.
  • Plugin instances are explicit and named.
  • Generation removes boilerplate.

But how does this fit into the existing add-on ecosystem?

In Chapter 11, we will step outside the generated world and refactor a hand-written add-on into a host.

Next, we address an important issue for add-on developers — and how the generator proposes a disciplined solution.

Cooperative Long-Running Tasks

Now that we have built an add-on and executed our functions, we must address an important concern for serious add-on development.

Up to this point, every plugin function executes immediately.

That works for:

  • Small tasks
  • Quick transformations
  • Lightweight operations

But real tools often perform:

  • Frame-by-frame processing
  • Batch encoding
  • File iteration
  • Heavy analysis

If executed in a single operator call, Blender freezes.

Operators run on the main thread.

If a task takes 10 minutes, the UI blocks for 10 minutes.

That is not acceptable for serious tools.


The Cooperative Model

Instead of blocking execution, we introduce:

Cooperative long-running tasks.

The idea is simple:

  • A task performs a small unit of work.
  • It yields control back to Blender.
  • The host schedules the next step.
  • Progress is tracked.
  • The user can cancel.

This is similar to how rendering progresses frame by frame.

Blender stays responsive.


Declaring a Long Task

A plugin may declare:

@op(
    label="Process Frames",
    long_task=True,
)
def process_frames(...):
    ...

If long_task=True, the function must follow a contract.

That contract defines:

  • How incremental work is performed
  • How progress is reported
  • How cancellation is respected
  • How cleanup is handled

Plugins do not implement scheduling.

The host owns execution control.


Cancellation Model

Cancellation must be explicit and predictable.

QuickAddon long tasks must provide a visible cancellation mechanism similar to Blender’s “Render Animation” operator.

The host must provide:

  • A progress indicator
  • A visible cancel control (e.g., button or “X”)
  • Proper cleanup on cancellation

ESC behavior:

ESC may cancel the task only when the long-task modal operator actively owns execution focus, similar to Blender rendering.

ESC must not:

  • Cancel unrelated background work
  • Interfere with normal Blender interaction
  • Trigger cancellation unintentionally while the user performs other actions

The recommended model mirrors Blender’s native rendering workflow:

User starts task
→ Progress appears
→ Cancel button is visible
→ ESC cancels only while task is modal-owner

Cancellation is a host responsibility. Plugins must tolerate interruption.


Why This Matters

As tools grow:

  • Processing time increases
  • Users expect responsiveness
  • Cancellation becomes critical
  • Stability becomes non-negotiable

A disciplined long-task model allows:

  • Non-blocking execution
  • Progress feedback
  • Safe cancellation
  • Predictable lifecycle management

We will define the full long-task contract and implementation details in Chapter 13.

For now, understand the boundary:

Plugins may declare long tasks. Hosts own scheduling and execution control.

In the next chapter, we step outside the generated ecosystem and refactor an existing add-on into a host.

Refactoring an Existing Add-on into a Host

So far, we have stayed inside the generated ecosystem.

Now we step outside it.

Suppose you already have a working Blender add-on.

It was written manually. It grew over time. It works. But it feels heavier than it should.

We will refactor it into a proper host.


The Starting Point

Consider a simplified existing add-on:

class AUDIO_OT_encode(bpy.types.Operator):
    bl_idname = "audio.encode"
    bl_label = "Encode Audio"

    def execute(self, context):
        path = context.scene.audio_path
        bitrate = context.scene.bitrate
        print("Encoding:", path, bitrate)
        return {'FINISHED'}


class AUDIO_OT_render(bpy.types.Operator):
    bl_idname = "audio.render"
    bl_label = "Render Audio"

    def execute(self, context):
        path = context.scene.audio_path
        print("Rendering:", path)
        return {'FINISHED'}


class AUDIO_PT_panel(bpy.types.Panel):
    bl_label = "Audio Tools"
    bl_space_type = 'SEQUENCE_EDITOR'
    bl_region_type = 'UI'
    bl_category = "Audio"

    def draw(self, context):
        layout = self.layout
        layout.prop(context.scene, "audio_path")
        layout.prop(context.scene, "bitrate")
        layout.operator("audio.encode")
        layout.operator("audio.render")

This works.

But notice:

  • Shared data is accessed directly from context.scene.
  • Operators assume a specific storage location.
  • Logic and UI are tightly coupled.
  • Extending this structure requires repetition.

There is no clear ownership boundary.


Step 1 – Separate Ownership

First, formalize storage ownership.

Define a property group:

class AudioHostProps(bpy.types.PropertyGroup):
    audio_path: bpy.props.StringProperty(subtype="FILE_PATH")
    bitrate: bpy.props.IntProperty(default=192)

Register it:

bpy.types.Scene.audio_host = bpy.props.PointerProperty(
    type=AudioHostProps
)

Now storage is explicit.

Instead of scattering values across context.scene, we centralize them.


Step 2 – Extract Tool Logic

Instead of embedding logic directly inside operators, extract the functional core:

def encode_audio(audio_path, bitrate):
    print("Encoding:", audio_path, bitrate)


def render_audio(audio_path):
    print("Rendering:", audio_path)

Now logic is reusable and independent of Blender.

Operators become thin wrappers.


Step 3 – Convert Tools into Plugins

Now these functions can become QuickAddon plugins.

They declare shared keys:

@op(
    shared={
        "audio_path": "project.audio_path",
        "bitrate": "project.bitrate",
    }
)
def encode_audio(audio_path: Path, bitrate: int):
    ...

And:

@op(
    shared={
        "audio_path": "project.audio_path",
    }
)
def render_audio(audio_path: Path):
    ...

Important contract:

  • audio_path: Path means the generated wrapper passes a real Path object to plugin code
  • if a host/plugin author wants Blender filepath UI but needs a plain string, they should use audio_path: str plus param_subtypes={"audio_path": "FILE_PATH"}

Generate them as plugins.

These plugins:

  • Do not know where storage lives.
  • Do not assume context.scene.
  • Declare only their shared contracts.

Step 4 – Turn the Existing Add-on into a Host

Your original add-on becomes:

  • UI owner
  • Property owner
  • Orchestrator

It embeds the generated plugins.

It defines:

  • KEYMAP
  • HostAPI
  • Optional orchestration logic

The structure becomes:

audio_host/
├── __init__.py
├── encode_plugin/
└── render_plugin/

The host controls routing.

The plugins remain modular.


What Changed?

Before:

  • Operators directly accessed scene properties.
  • Storage was implicit.
  • Logic and UI were tightly coupled.

After:

  • Host owns storage.
  • Plugins declare shared keys.
  • Routing is centralized.
  • Logic is modular.
  • The system becomes composable.

What Did Not Change?

  • The core encode logic.
  • The core render logic.
  • The UI intent.
  • The user workflow.

We changed structure, not behavior.


The Result

Your existing add-on:

  • Becomes cleaner.
  • Gains composability.
  • Gains embeddable plugins.
  • Gains routing discipline.
  • Gains orchestration capability.

And it did not require rewriting everything from scratch.

In the next chapter, we go the other direction:

Refactoring an existing add-on into a reusable plugin.

Refactoring Existing Addon into Plugin

In the previous chapter, we refactored an existing add-on into a host.

Now we take the opposite direction.

Instead of turning a legacy add-on into a host, we turn it into a reusable plugin.

The goal is composability.


The Starting Point

Consider a typical legacy operator:

class AUDIO_OT_normalize(bpy.types.Operator):
    bl_idname = "audio.normalize"
    bl_label = "Normalize Audio"

    def execute(self, context):
        audio_path = context.scene.audio_path
        threshold = context.scene.threshold
        print("Normalizing:", audio_path, threshold)
        return {'FINISHED'}

This works.

But it assumes:

  • Properties live directly on context.scene.
  • Storage location is fixed.
  • UI and logic are tightly coupled.

It cannot be embedded cleanly into another host.


Step 1 – Extract the Core Logic

Separate execution from Blender context:

def normalize_audio(audio_path, threshold):
    print("Normalizing:", audio_path, threshold)

Now the function is pure.

It no longer depends on context.

The Blender-specific layer becomes a wrapper, not the core.


Step 2 – Declare Shared Keys

Instead of directly accessing scene properties, declare shared inputs explicitly:

@op(
    label="Normalize Audio",
    space="SEQUENCE_EDITOR",
    category="Audio",
    shared={
        "audio_path": "project.audio_path",
        "threshold": "project.threshold",
    },
)
def normalize_audio(
    audio_path: Path,
    threshold: float = -1.0,
):
    print("Normalizing:", audio_path, threshold)

This does three things:

  • Removes direct scene coupling.
  • Declares the input contract explicitly.
  • Makes the tool host-agnostic.

The function now speaks in shared keys, not storage paths.


Step 3 – Generate the Plugin

Run:

quickaddon build normalize.py

You now have:

normalize_plugin/
├── __init__.py
└── generated_ops.py

This plugin:

  • Declares shared keys.
  • Supports fallback storage.
  • Can run standalone.
  • Can be embedded into any host.

No assumptions about storage are embedded in it.


What Changed?

Before:

  • The operator assumed a storage location.
  • It could not be embedded cleanly.
  • Logic was tied to a specific add-on.

After:

  • Logic is pure.
  • Inputs are declarative.
  • Storage is routed by the host.
  • The plugin is reusable.

What Did Not Change?

  • The normalization logic.
  • The UI label.
  • The intended workflow.
  • The operator behavior.

Only the structure changed.


The Composability Test

If a tool can:

  • Declare shared keys.
  • Avoid direct context access.
  • Avoid owning storage.

Then it is a proper plugin.

Plugins should not:

  • Decide where data lives.
  • Perform orchestration.
  • Assume execution order.

Those responsibilities belong to the host.


The Architectural Symmetry

Chapter 11:

Existing add-on → Host

Chapter 12:

Existing add-on → Plugin

Together, these chapters define the system boundary.

Hosts own:

  • UI composition
  • Shared storage
  • Orchestration
  • Constraint validation

Plugins own:

  • Tool logic
  • Input declaration
  • Stateless execution

That separation is the foundation of composability.


Where You Are Now

You have seen:

  • Scripts turned into tools
  • Tools turned into plugins
  • Plugins embedded into hosts
  • Hosts managing routing
  • Hosts coordinating execution
  • Legacy add-ons refactored in both directions

You now understand the full architectural loop.

The rest is composition.

The Long Task Contract

In Chapter 10, we introduced cooperative long-running tasks.

In this chapter, we define the contract precisely.

This is not an implementation detail. It is an execution model.


Why the Contract Exists

Blender operators execute on the main thread.

If a function performs long-running work in a single call:

  • The UI freezes
  • The user cannot cancel
  • Progress is invisible
  • Blender appears unstable

The long task contract solves this by enforcing cooperative execution.


Declaring a Long Task

A plugin declares:

@op(
    label="Process Frames",
    long_task=True,
)
def process_frames(...):
    ...

If long_task=True, the function must follow the long task contract.


Strict Conformance

If long_task=True is declared:

  • The function must be a python generator.
  • The generator must yield at least once.

If the function:

  • Does not contain yield
  • Returns normally without yielding
  • Raises StopIteration immediately

The build must fail.

QuickAddon enforces this at build time.

Long tasks are not optional behavior. They are a contract.

Strict conformance guarantees:

  • Non-blocking execution
  • Predictable scheduling
  • Safe cancellation
  • Stable host control

The Core Rule

A long task must:

Perform incremental work and yield control back to the host after each step.

It must not block until completion.


Required Execution Model

A long task function must behave as a generator.

Conceptually:

def process_frames(...):
    total = compute_total()

    for i in range(total):
        do_one_unit_of_work(i)

        yield {
            "progress": i,
            "total": total,
        }

The function:

  • Executes a small unit of work
  • Yields a progress payload
  • Allows the host to resume later

It does not run to completion in a single call.


Yield Payload

Each yield must return a dictionary.

Minimum required fields:

progress
total

Optional fields:

message

Example:

yield {
    "progress": i,
    "total": total,
    "message": f"Processing frame {i}",
}

The host uses this data to:

  • Update the progress bar
  • Display status messages
  • Determine completion

Completion

When the generator finishes:

  • The task is considered complete.
  • The host performs cleanup.
  • Progress UI closes.
  • Execution returns FINISHED.

Cancellation

The host must provide an explicit cancellation mechanism.

Recommended UI model:

  • Status bar progress indicator
  • Visible cancel button (“X”)
  • ESC key support only while modal operator owns execution

When cancellation is triggered:

  • Host stops calling next()
  • Generator is discarded
  • Progress UI closes
  • Operator returns CANCELLED

Cancellation must be:

  • Explicit
  • Predictable
  • Scope-aware (v2)
  • Safe

Plugins must not rely on guaranteed completion.

They must tolerate interruption at any yield boundary.

Error Handling

If a long task generator raises an exception:

  • The host must stop execution.
  • Progress UI must close.
  • The error must be reported via Blender operator report.

If a yield payload:

  • Is not a dictionary
  • Omits required fields
  • Contains invalid values

The host must:

  • Cancel execution
  • Report a contract violation

The long task contract is enforced at runtime as well as at build time.


Host Responsibilities

The host must:

  • Detect long_task=True
  • Wrap execution in a modal operator
  • Schedule incremental next() calls
  • Update progress display
  • Provide cancellation
  • Ensure scope isolation (if scoped)

The host owns scheduling.

Plugins must not implement modal logic directly.


Interaction with Scoped Instances

If HostAPI v2 is used:

  • Each scope maintains its own long-task state.
  • Progress and cancellation apply only to that scope.
  • Multiple scoped instances may run independently.

Isolation must be enforced at the host level.


Forbidden Patterns

A long task must not:

  • Execute blocking loops without yielding
  • Spawn uncontrolled threads
  • Access scene storage directly
  • Assume uninterrupted execution
  • Manipulate modal handlers directly

The contract enforces discipline.


Short Tasks vs Long Tasks

Short task:

@op(...)
def simple_task(...):
    perform_all_work()

Long task:

@op(long_task=True)
def incremental_task(...):
    yield ...

The decorator determines the execution strategy.


Why This Matters

The long task contract provides:

  • Non-blocking UI
  • Visible progress
  • Safe cancellation
  • Predictable lifecycle
  • Stable host-controlled execution

It allows complex processing without destabilizing Blender. In chapter 15 we will expand further we examine how the generator implements this model internally.

In the next chapter, we will examine how the HostAPI paradigm can be used for more complex need.

Scoped Instances and Nested Composition

Up to this point, every embedded plugin instance has assumed a single storage domain.

That works for:

  • Simple add-ons
  • Single-instance tools
  • Linear workflows

But it limits composition.

What happens if:

  • You embed the same plugin twice?
  • You want two encoders with different settings?
  • A host embeds another host?
  • You build nested systems?

Static storage resolution breaks down.

We need instance isolation.

Earlier, in Chapter 9, we hinted at a more scalable routing model. We now extend that idea into scoped composition.


The Limitation of Static Storage

In the single-instance model:

shared_key → KEYMAP → Scene.some_pointer.some_attr

All plugin instances share the same storage location.

This prevents:

  • Multiple independent instances
  • Nested hosts
  • Scoped isolation

To unlock composition, storage must become scoped.


Introducing Scope

A scope represents one logical instance of a plugin inside a host.

Instead of resolving shared values globally:

key → value

We now resolve:

(scope, key) → value

Each mounted plugin instance receives its own scope.

Scope is allocated and owned by the host.


Binding a Scope for a Mounted Instance

HostAPI v2 introduces:

host_api.bind(context, instance_hint=None)

During hosted runtime execution for a mounted instance:

  1. The host/runtime allocates or selects a scope.
  2. A new HostAPI bound to that scope is returned.
  3. All shared resolution occurs within that scope.

Plugins do not manage scope.

Hosts do.


What Changes for Plugins?

Nothing in the tool source.

The generator ensures:

  • Shared keys remain declarative.
  • get() and draw() operate through the bound API.
  • Plugins remain unaware of storage paths.

The change is architectural, not behavioral.


Scoped KEYMAP

Routing must now consider scope.

Conceptually:

(scope, key) → host-owned property

If no host-owned mapping exists:

  • Scoped fallback storage is used.

Each plugin instance is isolated unless the host explicitly routes keys together.

HostAPI v2 enables the scalable routing model we anticipated earlier.


Nested Composition

Scopes make nested composition possible.

Example:

  • Host A embeds Host B.
  • Host A mounts a root instance.
  • Host-owned storage remains the ground.
  • Child systems may later mount child instances on top of the same scoped model.

Each layer isolates its instances.

No collisions. No implicit sharing. No accidental overwrites.

Composition becomes predictable.


Storage Backend

Scoped storage must not rely on dynamic RNA property creation.

Recommended fallback backend:

scene["qa_scope:<scope_id>:<fallback_prop>"]

Hosts may still route specific keys to typed PropertyGroups.

Fallback storage, however, must remain dynamic and scope-aware.


Multi-Instance Example

Imagine embedding the same plugin twice:

audiodeck/
├── encoder_A
└── encoder_B

Each registration calls bind().

Each receives a unique scope.

Each has independent values for:

  • project.audio_path
  • project.bitrate
  • project.output_dir

They do not interfere with one another.


Responsibility Boundaries

Plugins:

  • Declare shared keys
  • Remain stateless across scopes
  • Do not access scene storage directly

Hosts:

  • Allocate scopes
  • Own scoped storage
  • Maintain scope-aware routing
  • Decide when scopes intentionally share values

Isolation is a host responsibility.


When Should You Use Scoped Instances?

Scoped instances are necessary when:

  • A plugin may appear multiple times
  • Nested composition is required
  • Isolation is critical
  • Complex systems are being built

Simple add-ons may not need it.

QuickAddon supports both models.

Scoped instances transform QuickAddon from:

an add-on generator

into

a composable UI architecture framework.

At this point, you may want to review the formal specification: HostAPI V2 Spec.

Internal Execution Model

Up to this point, you have seen:

  • How plugins are declared.
  • How shared keys are routed.
  • How long tasks are enforced.
  • How HostAPI v1 and v2 separate storage ownership.

Now we step inside the system.

This chapter explains what actually happens when a user clicks a button.

The Three Phases of Execution

Every QuickAddon tool goes through three distinct phases:

  1. Build-time compilation
  2. Registration-time binding
  3. Runtime execution

Understanding these phases clarifies every design decision.

Phase 1 – Build-Time Compilation

When you run:


quickaddon build my_tool.py

QuickAddon performs:

  1. Module loading
  2. Decorator extraction
  3. Structural validation
  4. Contract validation (QA10)
  5. Render model construction
  6. Template emission

The source file is not executed inside Blender.

It is compiled into a structural add-on module.

At this stage:

  • Shared keys are registered.
  • Types are verified.
  • long_task contracts are enforced.
  • No Blender API calls occur.

Build-time is deterministic.

If it succeeds, the add-on structure is valid.

Phase 2 – Registration-Time Binding

When Blender enables the add-on:

  • Classes are registered.
  • PropertyGroups are created.
  • HostAPI is injected.

In v1:

  • A fallback HostAPI is used if none is injected.

In v2:

  • A named instance is mounted explicitly.
  • The runtime binds a scope for that mounted instance.
  • A bound HostAPI instance is used internally.
  • All shared routing resolves through the bound API.

Registration does not execute tool logic.

It prepares execution boundaries.

Phase 3 – Runtime Execution

When a user clicks a tool button:

Blender invokes the generated Operator.

The operator:

  1. Collects local parameters (if any)
  2. Resolves shared values via HostAPI
  3. Calls the user function

At this point execution diverges:

  • Short task
  • Long task

Short Task Execution Path

For short tasks:

fn(...)
return {'FINISHED'}

The function runs immediately.

If an exception occurs:

  • The operator reports the error.
  • Execution returns CANCELLED.

Short tasks are synchronous and simple.

Long Task Execution Path

If long_task=True:

Execution becomes cooperative.

The operator:

  1. Calls the user function.
  2. Verifies that a generator was returned (QA20).
  3. Starts a modal timer.
  4. Schedules incremental execution.

Instead of running to completion:

User Click
    ↓
Operator.invoke()
    ↓
Start generator
    ↓
Modal loop
    ↓
next(generator)
    ↓
Yield progress
    ↓
Repeat until StopIteration

Each yield returns control to Blender.

The UI remains responsive.

The long task operator owns:

  • Timer creation
  • Modal handler registration
  • Progress bar lifecycle
  • Generator advancement
  • Cleanup on completion or cancellation

The plugin never touches:

  • Timers
  • Modal handlers
  • Progress API

This separation guarantees stability.

Runtime Contract Enforcement (QA20)

During modal execution:

Each yield payload is validated:

  • Must be dict
  • Must contain progress
  • Must contain total

If validation fails:

  • Execution stops
  • Progress UI closes
  • Error is reported
  • Operator returns CANCELLED

Contracts are enforced at runtime to prevent silent corruption.

Cancellation Behavior

If the user cancels:

  • The modal loop stops.
  • The generator is discarded.
  • Progress UI closes.
  • Operator returns CANCELLED.

The generator must tolerate interruption.

Completion is not guaranteed.

Native Blender Parity

QuickAddon long tasks intentionally mirror Blender’s “Render Animation” execution model.

Characteristics:

  • Modal timer-driven execution
  • Progress displayed in status area
  • Visible cancel control
  • ESC cancels only while render is active
  • UI remains stable

This alignment ensures:

  • Native user expectations
  • Predictable cancellation
  • Familiar interaction patterns
  • Professional integration into Blender workflows

Scoped Execution (v2)

In v2:

Each plugin instance is bound to a scope.

All shared value resolution happens through:

(scope, key) → value

Long tasks remain isolated per scope.

Multiple instances may run independently.

Isolation is enforced at the HostAPI level.

No Threads, No Background Workers

QuickAddon intentionally does not use:

  • Threads
  • Background tasks
  • Async execution

Why?

Blender’s Python environment is single-threaded for UI safety.

Cooperative scheduling preserves:

  • Stability
  • Predictability
  • Debuggability

The generator model is sufficient.

Determinism Guarantees

The execution model guarantees:

  • Build-time structural correctness
  • Registration-time binding clarity
  • Runtime contract enforcement
  • No hidden shared state
  • No dynamic introspection

Execution is explicit at every layer.

Why This Model Exists

The internal execution model enforces:

  • Separation of structure and behavior
  • Host-controlled lifecycle
  • Plugin-declared intent
  • Deterministic routing
  • Safe incremental processing

Without this model:

  • Long tasks freeze Blender
  • Nested composition breaks
  • State leaks between instances
  • Bugs become nondeterministic

The model is strict because Blender is strict.

What You Now Understand

When you click a button:

You are not running a script.

You are entering a controlled execution pipeline:

  • Validated
  • Routed
  • Bound
  • Scheduled
  • Enforced

QuickAddon is not merely generating operators.

It is generating execution boundaries.

Composition Over Convenience

You began with a simple problem. How do I turn a Python function into a Blender add-on? Along the way, you built something larger. You learned:

  • How to declare tools instead of writing operators by hand.
  • How to separate UI from logic.
  • How to separate storage ownership from tool behavior.
  • How to route shared data through a contract.
  • How to compose multiple plugins into a host.
  • How to isolate instances with scopes.
  • How to execute long-running tasks without freezing Blender.
  • How to enforce architectural discipline at build time.

QuickAddon is not a macro generator. It is not a shortcut. It is a structure. The structure enforces:

  • Explicit ownership
  • Declarative inputs
  • Predictable routing
  • Controlled execution
  • Safe composition

You may never need multi-instance scoped hosts. You may never embed a host inside another host. You may never declare a long task. That is fine. The architecture scales down as cleanly as it scales up. The point is not complexity. The point is boundaries. When boundaries are clear:

  • Systems remain stable.
  • Growth remains controlled.
  • Refactoring becomes possible.
  • Composition becomes natural.

You now understand the full loop:

Script
→ Tool
→ Plugin
→ Host
→ System

Everything beyond this point is composition. Build what you need. Refactor when structure demands it. Generate when discipline permits it.

Own your boundaries.

Decorator Reference

This page documents the user-facing @op(...) contract.

It is a reference page, not a teaching chapter.

Use it when you need the current supported decorator surface in one place.


Basic Shape

@op(
    label="Setup From Audio",
    category="QuickAddon",
    space="SEQUENCE_EDITOR",
    region="UI",
    panel=True,
    long_task=False,
    shared={"audio_path": "project.audio_path"},
    param_labels={"audio_path": "Audio File"},
    param_order={"audio_path": 100, "bpm": 10},
    param_subtypes={"cache_dir": "DIR_PATH"},
    inject={"ctx": "ctx"},
)
def setup_from_audio(audio_path: Path, bpm: int = 120, cache_dir: str = "", ctx: Any = None):
    ...

Core Decorator Fields

FieldMeaning
labelUI label for the generated operator
idnameOptional Blender operator idname override
descriptionOptional operator description
categoryPanel tab/category
spaceBlender editor space
regionBlender region
panelWhether to include the op in generated panel UI
long_taskEnables the long-task generator contract
sharedDeclares shared key routing by parameter
param_labelsOverrides generated UI labels
param_orderControls display order; higher values render first
param_subtypesOverrides generated Blender property subtypes
injectExplicit runtime injection mapping

v1 and v2 both use the same decorator surface.

Track differences apply to generated runtime behavior, not to the decorator syntax itself.


Supported Parameter Types

QuickAddon currently supports:

  • str
  • int
  • float
  • bool
  • pathlib.Path
  • Literal["a", "b", ...]

These generate:

  • StringProperty
  • IntProperty
  • FloatProperty
  • BoolProperty
  • StringProperty(subtype="FILE_PATH")
  • EnumProperty

Runtime contract:

  • pathlib.Path parameters are passed to user code as Path objects
  • str parameters are passed to user code as plain strings
  • param_subtypes affects Blender UI only, not runtime conversion

Not supported:

  • *args
  • **kwargs

Unsupported parameter types fail build with QA10 diagnostics.


Shared Keys

Shared values are declared by parameter name:

@op(
    shared={
        "audio_path": "project.audio_path",
    }
)
def setup_from_audio(audio_path: Path):
    ...

Rules:

  • Shared keys are stable opaque identifiers.
  • The same shared key must keep one consistent type across all ops.
  • Shared parameters do not appear as local popup dialog properties.
  • Shared values are drawn in the shared inputs group instead.

If multiple ops use the same shared key, they reference the same logical shared value.


Panel

panel controls whether QuickAddon includes the operator in its auto-generated panel UI.

  • panel=True
    • Generate and register the operator
    • Include it in the generated panel tool list
  • panel=False
    • Generate and register the operator
    • Do not include it in the generated panel tool list

This is a UI composition flag only.

It does not:

  • remove the operator
  • disable the operator
  • remove parameters
  • prevent invocation from other UI or code

An operator with panel=False still exists as a normal Blender operator and can be invoked by bl_idname from menus, custom panels, keymaps, scripts, or host add-ons.

Why This Exists

Most QuickAddon examples start with the default generated panel because that is the lowest-friction path.

But QuickAddon supports a broader set of UI patterns:

  • standard panel-first tools
  • menu-driven operators
  • host-composed tools drawn by another add-on
  • hidden helper operators used for workflow plumbing

panel is what lets one generated operator model support all of those cases.

panel=False is a good fit for menu-only operators.

If the operator has local parameters:

  • invoking it from a menu still opens Blender’s floating properties dialog
  • the user can enter values before execution

If the operator has no local parameters:

  • it runs immediately when invoked

Shared parameters are different:

  • shared values do not appear in the local popup dialog
  • shared values are expected to be drawn by host/shared UI instead

Interaction With shared

panel=False can be used with shared, but only when some other UI is responsible for exposing those shared values.

Good fit:

  • a host add-on panel already draws the shared inputs
  • a custom UI calls generated draw helpers
  • shared values are owned by a host integration layer

Awkward fit:

  • the generated QuickAddon panel was the only place users could edit those shared values

Practical rule:

  • panel=False + shared is useful when another UI surface owns the shared state
  • panel=False + shared is confusing when no other UI exists for those shared inputs

Mental Model

panel answers one question:

Should QuickAddon auto-surface this operator in its default panel UI?


UI Labels

Use param_labels to override display names:

@op(
    param_labels={
        "audio_path": "Audio File",
        "start_frame": "Start Frame",
    }
)

This applies to:

  • Generated local operator properties
  • Shared input draw labels

If you do not supply param_labels, QuickAddon uses the parameter name.

Label Collision Rule

Resolved UI labels must be unique within each displayed group.

That means:

  • Local properties within one operator dialog must not collide
  • Shared inputs within the shared inputs group must not collide

Duplicate labels fail build with QA10 diagnostics.


UI Ordering

Use param_order to control property order:

@op(
    param_order={
        "audio_path": 100,
        "bpm": 10,
        "debug": 0,
    }
)

Rules:

  • Higher order values appear first
  • Parameters with the same order are sorted deterministically by UI label, then name
  • Applies to both local properties and shared inputs

If param_order is omitted, the default order value is 0.


param_subtypes

Use param_subtypes when you want filepath-style Blender UI without changing the runtime Python type:

@op(
    param_subtypes={
        "json_path": "FILE_PATH",
        "cache_dir": "DIR_PATH",
    }
)
def export_transforms(json_path: str = "", cache_dir: str = ""):
    ...

Rules:

  • Supported values are currently "FILE_PATH" and "DIR_PATH".
  • Valid only for str and Path parameters.
  • For Path, QuickAddon still passes a Path object at runtime.
  • For str, QuickAddon still passes a plain string at runtime.

Long Tasks

If long_task=True:

  • The function must be a generator
  • Async generators are not allowed
  • Runtime yield payloads must include progress and total

For the full contract, see Diagnostics and Contract Enforcement.


Injection

Injection is explicit and separate from shared routing.

Use:

@op(
    inject={
        "ctx": "ctx",
        "scene": "scene",
    }
)

Injected parameters:

  • Do not generate UI fields
  • Are supplied at execution time
  • Must be declared explicitly

For the full injection contract, see Injection Contract v1.


Strictness Notes

QuickAddon does not silently guess through decorator ambiguity.

Examples of build failures:

  • Unknown parameter referenced in shared, param_labels, param_order, or inject
  • Shared key type mismatch across ops
  • Duplicate resolved labels within one UI group
  • Invalid long_task=True contract

Reference pages for related contracts:

Injection Contract v1

The Injection Contract defines how runtime values (such as Blender context) are passed into plugin functions.

This system is separate from HostAPI and shared key routing.

HostAPI handles shared state. Injection handles runtime wiring.


Core Principle

Injection is explicit.

Nothing is auto-wired.

A parameter is injected only if it is declared in:

@op(inject={ ... })

Basic Usage

from typing import Any

@op(
    label="Example",
    inject={
        "ctx": "ctx",
        "scene": "scene",
    },
)
def example(ctx: Any, scene: Any, threshold: float = 0.5):
    ...

Parameters listed in inject:

  • Do not generate UI fields.
  • Are passed at operator execution time.
  • Must match function parameter names.

Injection Mapping

Injection is defined as:

inject={
    "parameter_name": "expression_or_alias",
}

The right-hand side may be:

1) Full Expression

Evaluated relative to execute(self, context) scope.

Example:

inject={
    "ctx": "context",
    "active_strip": "context.scene.sequence_editor.active_strip",
}

This generates static wiring:

ctx=context
active_strip=context.scene.sequence_editor.active_strip

No dynamic eval is used at runtime.


2) Built-in Aliases

These aliases expand to common context values:

AliasExpands To
ctxcontext
scenecontext.scene
wmcontext.window_manager
areacontext.area
regioncontext.region
spacecontext.space_data

Example:

inject={
    "ctx": "ctx",
}

Expands to:

ctx=context

Strictness Rules

1) Injection Must Be Explicit

There is no automatic context injection.

Even though operators receive context, it must be declared if used.


2) Any Requires Injection

If a parameter:

  • Is annotated as Any
  • Has no default value
  • Is not declared in inject

Build fails.

Example error:

Parameter 'ctx' is annotated as Any but not declared in @op(inject=...).
Injection must be explicit.

This prevents accidental UI generation or ambiguous wiring.


3) Unknown Alias Is an Error

If injection value is:

  • Not a built-in alias
  • Not a valid expression

Build fails.

No guessing. No silent fallback.


Runtime Behavior

If an injected expression evaluates to None, the function receives None.

Example:

  • context.area may be None
  • context.scene.sequence_editor may be None

Handling None is the responsibility of the plugin author.


Design Philosophy

Injection exists to support:

  • Context-aware operators
  • Refactoring existing addons
  • Future extension beyond Blender context

It does not:

  • Replace shared keys
  • Replace HostAPI
  • Imply ownership of runtime state

Relationship to HostAPI

HostAPI:

  • Routes shared keys.
  • Manages persistent state.

Injection:

  • Supplies runtime values.
  • Has no persistence semantics.

The two systems are orthogonal by design.

QuickAddon HostAPI v2 Spec

HostAPI v2 introduces scoped named instances and enables:

  • Multi-instance plugins
  • Nested composition (host-of-host)
  • Isolated storage per plugin instance
  • Hierarchical system construction

HostAPI v2 replaces the single-instance model of v1.

v1 and v2 are separate build tracks.


Core Concepts

Scope

A scope represents one logical instance of a plugin within a host.

Each mounted plugin instance receives a unique scope.

All shared key routing happens inside that scope.

Shared keys are no longer resolved globally. They are resolved as:


(scope, key)

Scope identity is:

  • Host-defined
  • Opaque to plugins
  • Stable across file save/load
  • Unique within a scene

Plugins must not assume scope format.


Bound HostAPI

HostAPI v2 introduces bind().

bind() allocates (or selects) a scope and returns a HostAPI instance bound to that scope.

After binding, all get() and draw() calls operate within that scope.

Scope allocation may be:

  • Eager (during bind)
  • Lazy (during first get/draw call)

Blender registration does not always provide reliable context, so hosts may defer scope materialization until runtime.

Scope identity remains host-defined and opaque.


Required Methods

class HostAPI:

    def bind(
        self,
        context=None,
        instance_hint: str | None = None,
    ) -> "HostAPI":
        """
        Allocate (or select) a scope for this plugin instance.
        - `instance_hint` may be used to request deterministic naming
          (e.g., "encode_A").
        - `context` may be None; hosts may allocate lazily.
        - Returns a HostAPI bound to that scope.
        """
        ...

    def get(self, context, key: str, fallback_prop: str):
        """
        Return the value for shared key within this scope.

        `fallback_prop` remains the plugin-generated property
        name for fallback storage.
        """
        ...

    def draw(
        self,
        layout,
        context,
        key: str,
        fallback_prop: str,
        *,
        label: str | None = None,
    ):
        """
        Draw UI control for shared key within this scope.

        Must draw either:
        - a host-owned property
        - or a scoped fallback property
        """
        ...

These methods define the routing contract for v2.

bind() is a host/runtime mechanic. Generated plugin users should think in terms of explicit named instances via mount_instance("name"), not direct bind() calls.


Public v2 Plugin Pattern

Generated v2 plugins expose a small explicit public pattern:

plugin.register(host_api=host_api)
plugin.mount_instance("main")
plugin.unmount_instance("main")
plugin.unregister()

Rules:

  • register() prepares the hosted runtime only.
  • register() does not create a hidden default instance.
  • Every usable instance is explicit and named.
  • Internal instance keys remain opaque runtime details.
  • Hosts should not call generated draw helpers directly.

Scope Allocation Rules

  • Each call to bind() allocates or selects one scope.
  • Scope identifiers must be unique within a scene.
  • Scope lifetime is tied to scene data.
  • Scope identity must remain stable across file save/load.

Hosts may choose:

  • Deterministic scope naming (recommended when the mounted instance has a stable name)
  • Opaque auto-generated identifiers

Plugins must treat scope identity as opaque.


Storage Model

HostAPI v2 does not mandate a specific storage backend.

Recommended fallback backend:

scene["qa_scope:<scope_id>:<fallback_prop>"]

IDProperties are recommended for:

  • Arbitrary shared keys
  • Multi-instance support
  • Nested composition
  • Dynamic routing

Hosts may route specific (scope, key) pairs to typed PropertyGroups via KEYMAP.


KEYMAP v2 Structure

KEYMAP must become scope-aware.

Hosts may implement either:

Global Routing

KEYMAP_GLOBAL = {
    "project.audio_path": ("audiodeck_props", "audio_path"),
}

Scoped Override

KEYMAP_SCOPED = {
    "<scope_id>": {
        "project.bpm": ("audiodeck_props", "bpm"),
    }
}

Resolution order for get():

  1. Scoped override (scope_id, key)
  2. Global routing key
  3. Scoped fallback storage

Isolation is enforced by design.


Nested Composition

HostAPI v2 enables nested systems.

Example:

  • Host A embeds Host B.
  • Host A mounts a root plugin instance.
  • Host-owned storage remains the ground for that tree.
  • Parent nodes may later mount child nodes on top of the same scoped storage model.

Scopes may be hierarchical internally, but hierarchy is an implementation detail of the host.

Plugins remain unaware of nesting.


Plugin Responsibilities

Plugins in v2:

  • Must not access context.scene directly for shared values
  • Must use HostAPI for all shared key resolution
  • May declare long_task=True (separate contract)
  • Must remain stateless across scopes

Plugins declare intent. Hosts enforce isolation.


Host Responsibilities

Hosts in v2:

  • Allocate scopes during registration or lazily at runtime
  • Own scoped storage lifecycle
  • Maintain scope-aware routing
  • Enforce isolation between instances
  • Optionally expose instance management UI

The host owns identity, routing, and lifecycle.


Multi-Instance Behavior

The same plugin module may expose multiple mounted named instances.

Each mounted instance receives a distinct scope.

Shared keys are isolated per scope unless explicitly routed by the host.

Isolation is the default.


Instance Identity

Host-facing identity is instance-oriented.

Use explicit instance names when you need deterministic labels or grouping in host UI.

Plugin identity remains internal to generated artifacts and is opaque to host APIs.


Upgrade Model

v1 and v2 are separate build tracks.

Upgrade path:

  • Regenerate the add-on with the v2 CLI option
  • Plugins remain unchanged at the source level
  • Decorator additions (if any) are minimal
  • No runtime compatibility layer is provided

The tracks do not mix.


Design Principles

HostAPI v2 is based on:

  • Explicit scoping
  • Explicit instance mounting
  • Centralized routing
  • Host-owned execution control
  • Composability over convenience
  • Stability over magic

HostAPI v2 enables QuickAddon to function as a composable UI architecture framework — not merely an add-on generator.

v1 to v2 Migration

QuickAddon does not provide a runtime compatibility layer between tracks. Migration is regeneration-based.

Core Rule

  • v1 and v2 are parallel tracks.
  • To move to v2, regenerate with --track v2.

Migration Steps

  1. Keep your tool source functions and @op declarations.
  2. Ensure filename stem follows canonical policy: ^[a-z_][a-z0-9_]*$.
  3. Rebuild with v2:
quickaddon build my_tool.py --out "$BLENDER_ADDON" --track v2 --force
  1. Host integration uses scoped API:
  • register plugin in plugin mode with unbound host API
  • mount explicit named instances with mount_instance("...")

What Changes Operationally

  • Shared resolution becomes scoped (scope + key).
  • Host controls scope lifecycle and routing.
  • Multiple instances of the same plugin become first-class.
  • register() prepares the runtime; it does not create an instance automatically.

What Stays Stable

  • Tool function source logic.
  • Shared key declarations.
  • Injection contract model (inject={...}).
  • Long-task contract (long_task=True generator semantics).

Common Migration Errors

  • Invalid source filename stem:
    • QA10-FILENAME-INVALID
  • Attempting --name override:
    • QA10-FILENAME-OVERRIDE-DISALLOWED
  • Shared key type mismatch across ops:
    • QA10-SHARED-KEY-TYPEMISMATCH

Diagnostics and Contract Enforcement

QuickAddon enforces architectural contracts.

Strictness is intentional.

When a rule is violated, the system does not guess. It fails early and clearly.

Each diagnostic includes:

  • A stable error code
  • File and function location (when applicable)
  • A concise explanation
  • A direct fix suggestion

Error codes follow this format:

QA<layer>-<area>-<rule>

Where:

  • QA10 → Build-time validation
  • QA20 → Runtime enforcement
  • areaLONGTASK, SHARED, HOSTAPI, etc.

Build-Time Validation

Build-time errors prevent invalid add-ons from being generated.

These diagnostics enforce structural correctness before Blender ever loads the add-on.

They are the most critical safeguards.


QA10-LONGTASK-NOTGEN

Triggered when:

  • long_task=True is declared
  • The function contains no yield or yield from

Example:

[QA10-LONGTASK-NOTGEN] my_tool.py:42 in process_frames
long_task=True requires a generator function (use 'yield' or 'yield from').

Fix:

  • Add incremental yield steps
  • Or remove long_task=True

QA10-LONGTASK-ASYNC

Triggered when:

  • async def is used with long_task=True

Async generators are not supported.

Fix:

  • Use a standard generator (def, not async def)

QA10-SHARED-KEY-TYPEMISMATCH

Triggered when:

  • The same shared key is declared with incompatible types across functions

Fix:

  • Ensure consistent type usage for the shared key

Shared keys must have stable type semantics across the system.


Runtime Contract Enforcement

Runtime errors occur inside Blender during execution.

They indicate a contract violation that passed build validation but failed during execution.


QA20-LONGTASK-RETURNED-NONGEN

Triggered when:

  • A long_task=True function returns a non-generator object at runtime

Example:

[QA20-LONGTASK-RETURNED-NONGEN] process_frames
long_task function did not return a generator.

Fix:

  • Ensure the function contains yield
  • Ensure execution does not return early before yielding

QA20-LONGTASK-YIELD-NONDICT

Triggered when:

  • The generator yields something other than a dictionary

Fix:

  • Yield a dictionary containing progress and total

QA20-LONGTASK-YIELD-MISSING-FIELDS

Triggered when:

  • The yield payload omits required fields

Minimum required fields:

progress
total

Optional:

message

Fix:

  • Include required fields in every yield

The host relies on these fields for scheduling and progress reporting.


QA20-LONGTASK-EXCEPTION

Triggered when:

  • The generator raises an exception

Behavior:

  • Execution stops
  • Progress UI closes
  • The error is reported through Blender’s operator report system

Fix:

  • Correct the underlying exception

Unhandled exceptions terminate the task immediately.


Design Philosophy

QuickAddon diagnostics are:

  • Deterministic
  • Searchable
  • Stable across versions

Strictness is not punishment.

It guarantees:

  • Non-blocking execution
  • Predictable routing
  • Isolation between instances
  • Stable nested composition

Architectural contracts are enforced so systems remain reliable at scale.

QA Scaffold CLI Reference

qa-scaffold is a convention-first CLI for generating and operating QuickAddon QA scaffold projects.

The workflow is Python-only and designed to hide shell orchestration behind stable subcommands.

Command Surface

qa-scaffold init <path>
qa-scaffold build [--config <path>]
qa-scaffold sync [--config <path>]

Standard Flow

  1. Initialize scaffold:
qa-scaffold init ./qa_scaffolding
  1. Enter scaffold project and sync its environment:
cd qa_scaffolding
uv sync
  1. Build generated addon:
uv run qa-scaffold build
  1. Build and copy to sync target:
uv run qa-scaffold sync

Config Contract

Default config file in scaffold root:

qa_scaffold.toml

Typical defaults:

plugin_script = "src/sample_plugin.py"
out_dir = "build/addons"
sync_dir = "build/synced"
track = "v2"
force = true
doctor = true
blender = "4.0"

Rules:

  • Config paths are resolved relative to the config file location.
  • track must be v1 or v2.
  • sync runs build first, then copies output to sync_dir.

Convention Policy

  • Users should not run shell scripts directly for scaffold operations.
  • Canonical execution path is uv run qa-scaffold <subcommand>.
  • Plugin identity still follows QuickAddon filename-stem policy.

Output Layout

After build/sync, expected structure is:

<scaffold>/build/addons/<plugin_stem>/...
<scaffold>/build/synced/<plugin_stem>/...