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 |
|---|---|
|
|
|
Unit tests — descriptor protocol, type guards, |
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_vectorreturns theActiveSlotobject 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 polyline / polygon |
|
|
Active zone (container of vectors) |
|
|
Active zone collection |
|
|
Active raster array |
|
|
Active boundary-condition manager |
|
|
Active named view |
|
|
Active vertex (e.g. during editing) |
|
|
Active cross-section set |
|
|
Active triangulation |
|
|
Active tile layer |
|
|
Active image-tile layer |
|
|
Active particle system |
|
|
Active 3-D viewer |
|
|
Active LAZ viewer |
|
|
Active bridge collection |
|
|
Active single bridge |
|
|
Active weir collection |
|
|
Active single weir |
|
|
Active LAZ point-cloud data |
|
(untyped) |
Active dike injector (optional |
|
|
Active picture collection |
|
|
Active Matplotlib figure viewer |
|
|
Active point cloud (two accepted types) |
|
|
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:
Add
'active_mytype'toEXPECTED_SLOTS.Add
'active_mytype'totest_typed_slots_have_expected_typeif 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 |
|---|---|
|
No active array – Please activate an array first |
|
No active vector – Please activate a vector first |
|
No active cross section – Please activate one first |
|
No active cloud – Please activate a cloud first |
|
No active triangulation – Please activate one first |
|
No active LAZ data – Please activate one first |
|
No active zones – Please activate one first |
|
No active 2D result – Please activate one first |
|
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
|
|
Warnings logged |
Returns |
|---|---|---|---|
|
|
2 (one per slot) |
|
set |
|
1 (vector message) |
|
|
set |
1 (cloud message) |
|
set |
set |
0 |
|
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 |
Enables the runtime guard and Pylance inference |
Use |
Avoids |
Use a |
|
Never assign |
Always go through the descriptor ( |
Use |
Logs all missing slots in one pass and keeps call sites concise;
prefer over successive |
Pass |
Avoids repeating the same stock phrase; extend |
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)