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``. .. code-block:: text 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: .. code-block:: python vec = self.active_vector # read → vector | None self.active_vector = my_vector # write → type-checked at runtime Files involved ~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 40 60 * - 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__``). .. code-block:: python 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_`` (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: .. code-block:: python 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: .. code-block:: python 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``: .. code-block:: python 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: .. code-block:: python 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__``: .. code-block:: python @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: .. code-block:: python 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: .. list-table:: :header-rows: 1 :widths: 30 35 35 * - 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 ~~~~~~~~~~~~~~~ .. code-block:: python for name in self._active_slots: object.__setattr__(self, f'_slot_{name}', None) Find which slot holds a given object ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python 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 ~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python 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: .. code-block:: python # inside WolfMapViewer class body, in the Active-object slots block: active_mytype = ActiveSlot(MyType) For an optional dependency whose import may fail: .. code-block:: python 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: .. code-block:: python 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 :ref:`check_active_guard`) before doing any work: .. code-block:: python 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``: .. code-block:: python 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``: .. code-block:: python 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: .. code-block:: bash 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): .. code-block:: text 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. .. _check_active_guard: 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 ~~~~~~~~~ .. code-block:: python 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: .. list-table:: :header-rows: 1 :widths: 30 70 * - 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 ~~~~~~~~~~~~~~~~~~~~ .. code-block:: python # 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: .. code-block:: python # 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 ~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 20 20 30 30 * - ``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: .. code-block:: python 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: .. code-block:: bash python -m pytest tests/viewer/test_check_active_guard.py -v Convention summary ------------------- .. list-table:: :header-rows: 1 :widths: 30 70 * - 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 --------- .. code-block:: text ☐ 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)