Active-Object Slots (ActiveSlot)

This document describes the ActiveSlot descriptor system used in WolfMapViewer (wolfhece/PyDraw.py) to manage the active object of each type (active vector, active array, active LAZ data, …), and provides a step-by-step guide for adding a new slot.

Overview

WolfMapViewer keeps one active object of each domain type at any time. These slots are declared as class-level descriptors using ActiveSlot.

WolfMapViewer.active_vector   →  ActiveSlot(vector)
WolfMapViewer.active_array    →  ActiveSlot(WolfArray)
WolfMapViewer.active_laz      →  ActiveSlot(Wolf_LAZ_Data)
…

Reading or writing a slot is identical to a plain instance attribute:

vec = self.active_vector          # read  →  vector | None
self.active_vector = my_vector    # write →  type-checked at runtime

Files involved

File

Role

wolfhece/PyDraw.py

ActiveSlot class + all active_* declarations inside WolfMapViewer

tests/viewer/test_active_slot.py

Unit tests — descriptor protocol, type guards, WolfMapViewer inventory

ActiveSlot internals

ActiveSlot is a data descriptor (implements both __get__ and __set__).

class ActiveSlot(Generic[_T]):
    def __init__(self, expected_type: type[_T] | tuple[type, ...] | None = None) -> None: ...
    def __set_name__(self, owner, name): ...
    def __get__(self, obj, objtype=None): ...
    def __set__(self, obj, value): ...

Storage

The descriptor itself is a class-level singleton. Each per-instance value is stored in instance.__dict__ under the private key _slot_<name> (e.g. _slot_active_vector). This gives:

  • Zero overhead beyond a plain attribute for all existing callers.

  • Complete isolation between instances (no class-level state leakage).

  • The descriptor remains accessible at class level: WolfMapViewer.active_vector returns the ActiveSlot object itself (useful for registry introspection).

Auto-registration

When Python processes the class body it calls __set_name__(owner, name) automatically. This populates the class-level registry:

WolfMapViewer._active_slots  # dict[str, ActiveSlot]

This registry enables bulk operations — reset all slots to None, find which slot holds a given object — without enumerating slot names manually.

Runtime type guard

When a non-None value is assigned to a typed slot, ActiveSlot.__set__ calls isinstance(value, expected_type) and raises TypeError on mismatch:

self.active_array = "not an array"
# → TypeError: active_array expects WolfArray, got str

The error message always contains the slot name, the expected type(s), and the actual type.

None is always accepted — it represents no active object and bypasses the type check.

Union types

Pass a tuple of types to accept more than one class. Currently used by active_cloud which accepts both cloud_vertices and cloud_of_clouds:

active_cloud = ActiveSlot((cloud_vertices, cloud_of_clouds))

The runtime check behaves exactly like isinstance(value, (A, B)).

Note

Pylance does not infer a precise Union[A, B] return type from a tuple guard. Annotate the variable explicitly at call sites when static typing matters:

cloud: cloud_vertices | cloud_of_clouds | None = self.active_cloud

Generic type inference (Pylance / mypy)

ActiveSlot is declared as Generic[_T] and exposes two @overload signatures for __get__:

@overload
def __get__(self, obj: None,   objtype: type) -> ActiveSlot[_T]: ...
@overload
def __get__(self, obj: object, objtype: type) -> _T | None:      ...

Pylance therefore infers the return type of every typed slot directly from its declaration without inspecting all assignment sites:

active_array = ActiveSlot(WolfArray)  # Pylance sees: WolfArray | None

Declared slots

All active_* slots are declared near the top of the WolfMapViewer class body, grouped together for visibility. The current inventory is:

Slot name

Expected type

Domain object

active_vector

vector

Active polyline / polygon

active_zone

zone

Active zone (container of vectors)

active_zones

Zones

Active zone collection

active_array

WolfArray

Active raster array

active_bc

BcManager

Active boundary-condition manager

active_view

WolfViews

Active named view

active_vertex

wolfvertex

Active vertex (e.g. during editing)

active_cs

crosssections

Active cross-section set

active_tri

Triangulation

Active triangulation

active_tile

Tiles

Active tile layer

active_imagestiles

ImagesTiles

Active image-tile layer

active_particle_system

Particle_system

Active particle system

active_viewer3d

Wolf_Viewer3D

Active 3-D viewer

active_viewerlaz

viewerlaz

Active LAZ viewer

active_bridges

Bridges

Active bridge collection

active_bridge

Bridge

Active single bridge

active_weirs

Weirs

Active weir collection

active_weir

Weir

Active single weir

active_laz

Wolf_LAZ_Data

Active LAZ point-cloud data

active_injector

(untyped)

Active dike injector (optional wolfpydike package)

active_picturecollection

PictureCollection

Active picture collection

active_fig

MplFigViewer

Active Matplotlib figure viewer

active_cloud

(cloud_vertices, cloud_of_clouds)

Active point cloud (two accepted types)

active_profile

profile

Active cross-section profile

Note

active_injector has no type guard because wolfpydike is an optional dependency whose classes may not be available at import time. If the package is always present in your environment you may add the type later (see the step-by-step guide below).

The _active_slots registry

WolfMapViewer._active_slots is a dict[str, ActiveSlot] populated automatically by __set_name__. It can be used for:

Reset all slots

for name in self._active_slots:
    object.__setattr__(self, f'_slot_{name}', None)

Find which slot holds a given object

def find_active_slot(self, obj) -> str | None:
    for name, descriptor in self._active_slots.items():
        if descriptor.__get__(self, type(self)) is obj:
            return name
    return None

Iterate typed slots only

for name, descriptor in WolfMapViewer._active_slots.items():
    if descriptor._expected_type is not None:
        ...

Adding a new active slot: step-by-step guide

Step 1 — Identify the domain type

Determine the Python class (or tuple of classes) that the slot will accept. Import it at the top of PyDraw.py if it is not already imported.

Step 2 — Declare the slot

Add one line in the # --- Active-object slots --- block inside WolfMapViewer, keeping the block alphabetically ordered by concept:

# inside WolfMapViewer class body, in the Active-object slots block:
active_mytype = ActiveSlot(MyType)

For an optional dependency whose import may fail:

active_mytype = ActiveSlot()   # untyped — import is conditional

That single line does everything: registers the slot in _active_slots, stores values per-instance, enforces the type guard, and exposes the descriptor at class level.

Step 3 — Set the slot in _add_mytype

Every add_object handler should set active_mytype when it successfully loads an object:

def _add_mytype(self, which, filename, newobj, curfilter, ToCheck, id):
    if newobj is None:
        newobj = MyType(filename, mapviewer=self)
    self.mymytypes.append(newobj)
    self.active_mytype = newobj          # ← set the slot here
    return self.myitemsmytype, newobj, id

Step 4 — Use the slot in business logic

Guard against None using _check_active (see The _check_active guard helper) before doing any work:

if not self._check_active(
    active_mytype=_('No active MyType object -- please activate one first'),
):
    return
self.active_mytype.do_something()

For a single slot the pattern is equivalent to the old explicit if is None guard, but _check_active also supports multi-slot guards and always logs all missing slots before returning:

Step 5 — Reset the slot when appropriate

When the object is unloaded or the viewer is cleared, reset the slot to None:

self.active_mytype = None

The registry-based bulk reset (see above) handles all slots at once; no manual enumeration is needed.

Step 6 — Unit tests

Add a test to tests/viewer/test_active_slot.py in the TestWolfMapViewerActiveSlots class:

  1. Add 'active_mytype' to EXPECTED_SLOTS.

  2. Add 'active_mytype' to test_typed_slots_have_expected_type if the type guard is non-None.

Add a dedicated isolation test when the domain class is available without wx:

class TestActiveMyType(unittest.TestCase):

    def test_type_guard_accepts_correct_type(self):
        from wolfhece.mymodule import MyType

        class _Host:
            slot = ActiveSlot(MyType)

        h = _Host()
        obj = MyType(...)
        h.slot = obj
        self.assertIs(h.slot, obj)

    def test_type_guard_rejects_wrong_type(self):
        class _Host:
            slot = ActiveSlot(int)

        h = _Host()
        with self.assertRaises(TypeError):
            h.slot = "wrong"

Run the full suite with:

python -m pytest tests/viewer/test_active_slot.py -q

Interaction with add_object

The add_object pipeline and the ActiveSlot system are coupled at Step 4 of the pipeline (type dispatch):

add_object('mytype', ...)
    └─ _add_mytype()
          └─ self.active_mytype = newobj   ← ActiveSlot.__set__ type-checked

This means any newobj passed to add_object that is of the wrong type will raise TypeError before reaching the tree-registration phase. The error is intentional: it surfaces integration bugs early.

The _check_active guard helper

WolfMapViewer._check_active(**slot_messages) is a convenience method that tests one or more active_* slots in a single call.

Signature

def _check_active(self, **slot_messages: str | None) -> bool:
    """Check that every named slot is non-None.

    Logs a warning for every missing slot and returns False if any slot
    is absent.  Returns True only when **all** slots are present.
    """

Each keyword argument maps a slot name (string, must match an active_* attribute of the instance) to either an explicit warning message or None to use the built-in default message for that slot.

Default messages

WolfMapViewer defines a class-level dict _ACTIVE_DEFAULT_MSG that holds a ready-made warning text for the most common slots:

Slot

Default message

active_array

No active array – Please activate an array first

active_vector

No active vector – Please activate a vector first

active_cs

No active cross section – Please activate one first

active_cloud

No active cloud – Please activate a cloud first

active_tri

No active triangulation – Please activate one first

active_laz

No active LAZ data – Please activate one first

active_zones

No active zones – Please activate one first

active_res2d

No active 2D result – Please activate one first

active_bc

No active boundary condition manager – Please activate one first

Pass None as the message to use the default. All messages are wrapped with _() at call time, so they participate in the normal translation pipeline.

Usage — single slot

# Before
if self.active_array is None:
    logging.warning(_('No active array -- Please activate one first'))
    return

# After — explicit message
if not self._check_active(
    active_array=_('No active array -- Please activate one first'),
):
    return

# After — default message (equivalent, shorter)
if not self._check_active(active_array=None):
    return

Usage — multiple slots

The key advantage of _check_active over successive if is None checks is that all missing slots are logged in one pass, which gives the user a complete error picture:

# Explicit messages
if not self._check_active(
    active_cloud=_('No active cloud -- Please activate one first'),
    active_vector=_('No active vector -- Please activate one first'),
):
    return

# Default messages — identical behaviour, less boilerplate
if not self._check_active(active_cloud=None, active_vector=None):
    return

# Both self.active_cloud and self.active_vector are non-None here.
self.split_cloud_by_vector()

Note

Pylance does not narrow the type of a slot after _check_active because the narrowing happens inside a helper, not at the call site. Add an assert self.active_x is not None after the guard when you need Pylance to infer the non-optional type for a heavily-used local variable.

Behaviour table

active_cloud

active_vector

Warnings logged

Returns

None

None

2 (one per slot)

False

set

None

1 (vector message)

False

None

set

1 (cloud message)

False

set

set

0

True

Testing

The guard is exercised by tests/viewer/test_check_active_guard.py. The test module builds a _MinimalViewer stub that reuses the real _check_active and _on_split_cloud methods bound from WolfMapViewer, so production code is tested directly without any wx window or OpenGL context:

class _MinimalViewer:
    active_cloud  = ActiveSlot()   # un-typed: accepts MagicMock
    active_vector = ActiveSlot()

    _check_active   = WolfMapViewer._check_active
    _on_split_cloud = WolfMapViewer._on_split_cloud

    def __init__(self):
        self.split_cloud_by_vector = MagicMock()

Run the suite with:

python -m pytest tests/viewer/test_check_active_guard.py -v

Convention summary

Rule

Rationale

Always pass the domain class to ActiveSlot(...)

Enables the runtime guard and Pylance inference

Use ActiveSlot() (untyped) only for optional-dependency types

Avoids ImportError when the package is absent

Use a tuple for genuine Union types

isinstance supports tuples natively; no extra machinery needed

Never assign instance.__dict__['_slot_x'] directly

Always go through the descriptor (self.active_x = ...) to trigger the type guard

Use _check_active to guard one or more slots before use

Logs all missing slots in one pass and keeps call sites concise; prefer over successive if is None checks

Pass None as the message to use the built-in default text

Avoids repeating the same stock phrase; extend _ACTIVE_DEFAULT_MSG if you add a new slot

Checklist

☐ Domain class imported at the top of PyDraw.py
☐ active_mytype = ActiveSlot(MyType)  in the Active-object slots block
☐ self.active_mytype = newobj  set in _add_mytype (or equivalent entry point)
☐ _check_active guard added at every call site that reads active_mytype
☐ self.active_mytype = None  on unload / viewer reset
☐ 'active_mytype' added to EXPECTED_SLOTS in test_active_slot.py
☐ 'active_mytype' added to test_typed_slots_have_expected_type  (if typed)