Repository: cocotb/cocotb

Issue:
[Tracking] cleanup handle.py
- [x] ConstantObject does not reveal with the underlying value type is (#2720)
- [x] NonConstantObject type has no purpose (#2720)
- [x] ModifiableObject is both the class for logic arrays and every other value type causing unnecessary functionality to be inherited (#2720)
- [x] NonHierarchyArrayObject is both the class for non-hierarchy arrays and a base class for ModifiableObject and all more specific typed objects causing unnecessary functionality to be inherited (#2720)
- [x] Unclear which types are abstract and which aren't (#2720)
- [x] string casts on handles is the path unless it's a value handle, then it's the string cast of the value (#2720)
- [x] equality is handle equality unless it's a value handle, then it's the equality of the value of the handle and this breaks the hashability, whoops (#2720)
- [x] unnecessary specification of `__ne__` (#2720)
- [x] unnecessary access of handle attributes on handle creation (#2720)
- [ ] unclear what is public method and what isn't
- [x] `__getattr__` syntax conflicts with attribute/method access (introduces `__getitem__` #3655)
- [x] `__iter__` on HierarchyObject and HierarchyArrayObject return child handles instead of keys, like you might expect from a pseudo-dictionary/list type (introduces `_keys`, `_values`, and `_items` #3655)
- [x] `__len__` isn't defined on HierarchyObject (#3655)
- [x] `_id` takes an "extended" argument instead of just having the user add backslashes themselves like they might expect, and extended=True by default (deprecated and replaced with `__getitem__` #3655)
- [x] `_id` is a terrible name (deprecated and replaced with `__getitem__` #3655)
- [x] `_range` isn't available on HierarchyArrayObject (#3655)
- [ ] `_range` is a crappy interface, prefer `left` and `right`
- [ ] `.value` property cannot be properly typed since type systems assume getter and setter of property get and set using the same type
- [x] clean up dead code (#2720)
- [x] clean up unnecessarily inherited attributes/methods (#2720)
- [x] tons of unnecessary logs (#2720)
- [ ] fix/improve documentation
Change `ModifiableObject.drivers` and `ModifiableObject.loads` to be Python iterables
Currently they just return `cocotb.simulator.gpi_hdl_iterator` which is not a standard Python iterator, but has it's own interface and returns raw handles instead of `SimHandleBase`s. 


Discussion:

Resolving patch:
diff --git a/src/cocotb/handle.py b/src/cocotb/handle.py
--- a/src/cocotb/handle.py
+++ b/src/cocotb/handle.py
@@ -33,13 +33,18 @@
 from logging import Logger
 from typing import (
     Any,
+    Callable,
     Dict,
     Generic,
     Iterable,
     List,
     Optional,
+    Sequence,
     Tuple,
+    Type,
     TypeVar,
+    Union,
+    cast,
 )
 
 import cocotb
@@ -57,7 +62,7 @@ class _Limits(enum.IntEnum):
 
 
 @lru_cache(maxsize=None)
-def _value_limits(n_bits, limits):
+def _value_limits(n_bits: int, limits: _Limits) -> Tuple[int, int]:
     """Calculate min/max for given number of bits and limits class"""
     if limits == _Limits.SIGNED_NBIT:
         min_val = -(2 ** (n_bits - 1))
@@ -78,6 +83,7 @@ class SimHandleBase(ABC):
     All simulation objects are hashable and equatable by identity.
 
     .. code-block:: python3
+
         a = dut.clk
         b = dut.clk
         assert a == b
@@ -167,7 +173,7 @@ def __repr__(self) -> str:
 
 
 class HierarchyObjectBase(SimHandleBase, Generic[KeyType]):
-    """Base class for hierarchical objects.
+    """Base class for hierarchical simulation objects.
 
     Hierarchical objects don't have values, they are just scopes/namespaces of other objects.
     This includes array-like hierarchical structures like "generate loops"
@@ -308,14 +314,14 @@ def __len__(self) -> int:
         self._discover_all()
         return len(self._sub_handles)
 
-    def __dir__(self) -> List[str]:
+    def __dir__(self) -> Iterable[str]:
         """Permits IPython tab completion and debuggers to work."""
         self._discover_all()
-        return super().__dir__() + [str(k) for k in self._keys()]
+        return set(super().__dir__()) | {str(k) for k in self._keys()}
 
 
 class HierarchyObject(HierarchyObjectBase[str]):
-    r"""Named hierarchical scope objects.
+    r"""A simulation object that is a name-indexed collection of hierarchical simulation objects.
 
     This class is used for named hierarchical structures, such as "generate blocks" or "module"/"entity" instantiations.
 
@@ -337,6 +343,9 @@ class HierarchyObject(HierarchyObjectBase[str]):
     Any possible name of an object is supported with the index syntax,
     but it can be more verbose.
 
+    Accessing a non-existent child with attribute syntax results in an :class:`AttributeError`,
+    and accessing a non-existent child with index syntax results in a :class:`KeyError`.
+
     .. note::
         If you need to access signals/nets that start with ``_``,
         or use escaped identifier (Verilog) or extended identifier (VHDL) characters,
@@ -346,7 +355,7 @@ class HierarchyObject(HierarchyObjectBase[str]):
 
         .. code-block:: python3
 
-            dut["_underscore_signal]
+            dut["_underscore_signal"]
             dut["\\%extended !ID\\"]
 
     Iteration yields all child objects in no particular order.
@@ -376,7 +385,7 @@ def __setattr__(self, name: str, value: Any) -> None:
 
     def __getattr__(self, name: str) -> SimHandleBase:
         if name.startswith("_"):
-            return object.__getattribute__(self, name)
+            return object.__getattribute__(self, name)  # type: ignore
 
         try:
             return self[name]
@@ -392,6 +401,15 @@ def _id(self, name: str, extended: bool = True) -> SimHandleBase:
         If *extended* is ``True``, run the query only for VHDL extended identifiers.
         For Verilog, only ``extended=False`` is supported.
 
+        Args:
+            name: The child object by name.
+            extended: If ``True``, treat the *name* as an extended identifier.
+
+        Returns: The child object.
+
+        Raises:
+            AttributeError: If the child object is not found.
+
         .. deprecated:: 2.0
             Use ``handle[child_name]`` syntax instead.
             If extended identifiers are needed simply add a ``\\`` character before and after the name.
@@ -418,7 +436,7 @@ def _get_handle_by_key(self, key: str) -> Optional[simulator.gpi_sim_hdl]:
 
 
 class HierarchyArrayObject(HierarchyObjectBase[int]):
-    """Arrays of hierarchy objects.
+    """A simulation object that is an array of hierarchical simulation objects.
 
     This class is used for array-like hierarchical structures like "generate loops".
 
@@ -430,6 +448,8 @@ class HierarchyArrayObject(HierarchyObjectBase[int]):
         block_0 = dut.gen_pipe_stages[0]
         block_7 = dut.gen_pipe_stages[7]
 
+    Accessing a non-existent child results in an :class:`IndexError`.
+
     Iteration yields all child objects in order.
 
     .. code-block:: python3
@@ -494,7 +514,7 @@ def range(self) -> Range:
         # guess direction based on length until we can get that from the GPI
         length = self._handle.get_num_elems()
         if length == 0:
-            direction = "to" if left < right else "downto"
+            direction = "downto" if left <= right else "to"
         else:
             direction = "to" if left <= right else "downto"
 
@@ -520,69 +540,226 @@ def __iter__(self) -> Iterable[SimHandleBase]:
             yield self[i]
 
 
-class NonHierarchyObject(SimHandleBase):
-    """Common base class for all non-hierarchy objects."""
+class _GPISetAction(enum.IntEnum):
+    DEPOSIT = 0
+    FORCE = 1
+    RELEASE = 2
+
+
+#: The type of the value a :class:`Deposit` or :class:`Force` action contains.
+ValueT = TypeVar("ValueT")
+
+
+class Deposit(Generic[ValueT]):
+    """Action used for placing a value into a given handle. This is the default action.
+
+    If another deposit comes after this deposit, the newer deposit overwrites the old value.
+    If an HDL process is driving the signal/net/register where a deposit from cocotb is made,
+    the deposited value will be overwritten at the end of the next delta cycle,
+    essentially causing a single delta cycle "glitch" in the waveform.
+    """
+
+    def __init__(self, value: ValueT) -> None:
+        self.value = value
+
+
+class Force(Generic[ValueT]):
+    """Action used to force a handle to a given value until a :class:`Release` is applied.
+
+    :class:`Deposit` writes from cocotb or drives from HDL processes
+    do not cause the value to change until the handle is :class:`Release`\ d.
+    Further :class:`Force`\ s will overwrite the value and leave the value forced.
+    :class:`Freeze`\ s will act as a no-op.
+    """
+
+    def __init__(self, value: ValueT) -> None:
+        self.value = value
+
+
+class Freeze:
+    """Action used to make a handle keep its current value until a :class:`Release` is applied.
+
+    :class:`Deposit` writes from cocotb or drives from HDL processes
+    do not cause the value to change until the handle is :class:`Release`\ d.
+    :class:`Force`\ s will overwrite the value and leave the value forced.
+    Further :class:`Freeze`\ s will act as a no-op.
+    """
+
+
+class Release:
+    """Action used to stop the effects of a previously applied :class:`Force`/:class:`Freeze` action."""
+
+
+def _map_action_obj_to_value_action_enum_pair(
+    handle: "ValueObjectBase[Any, Any]",
+    value: Union[ValueT, Deposit[ValueT], Force[ValueT], Freeze, Release],
+) -> Tuple[ValueT, _GPISetAction]:
+    if isinstance(value, Deposit):
+        return value.value, _GPISetAction.DEPOSIT
+    elif isinstance(value, Force):
+        return value.value, _GPISetAction.FORCE
+    elif isinstance(value, Freeze):
+        return handle.value, _GPISetAction.FORCE
+    elif isinstance(value, Release):
+        return handle.value, _GPISetAction.RELEASE
+    else:
+        return value, _GPISetAction.DEPOSIT
+
+
+#: Type accepted and returned by the :attr:`~ValueObjectBase.value` property.
+ValuePropertyT = TypeVar("ValuePropertyT")
+
+
+#: Type accepted by :meth:`~ValueObjectBase.set` and :meth:`~ValueObjectBase.setimmediatevalue`.
+ValueSetT = TypeVar("ValueSetT")
+
+
+class ValueObjectBase(SimHandleBase, Generic[ValuePropertyT, ValueSetT]):
+    """Base class for all simulation objects that have a value."""
 
     @property
-    def value(self):
-        """The value of this simulation object.
+    @abstractmethod
+    def value(self) -> ValuePropertyT:
+        """Get or set the value of the simulation object.
+
+        :getter: Returns the current value of the simulation object.
+
+        :setter:
+            Assigns the value at end of the current simulator delta cycle.
+            Takes whatever values that :meth:`set` takes,
+            including :class:`Deposit`, :class:`Force`, :class:`Freeze`, and :class:`Release` actions.
 
         .. note::
-            When setting this property, the value is stored by the :class:`~cocotb.scheduler.Scheduler`
-            and all stored values are written at the same time at the end of the current simulator time step.
 
-            Use :meth:`setimmediatevalue` to set the value immediately.
+            Use :meth:`setimmediatevalue` if you need to set the value of the simulation object immediately.
         """
-        raise TypeError(
-            f"Not permissible to get values of object {self._name} of type {type(self)}"
-        )
 
     @value.setter
-    def value(self, value):
+    @abstractmethod
+    def value(self, value: ValuePropertyT) -> None:
+        ...
+
+    def set(
+        self,
+        value: Union[ValueSetT, Deposit[ValueSetT], Force[ValueSetT], Freeze, Release],
+    ) -> None:
+        """Assign the value to this simulation object at the end of the current delta cycle.
+
+        This is known in Verilog as a "non-blocking assignment" and in VHDL as a "signal assignment".
+
+        See :class:`Deposit`, :class:`Force`, :class:`Freeze`, and :class:`Release` for additional actions that can be taken when setting a value.
+        The default behavior is to :class:`Deposit` the value.
+        Use these actions like so:
+
+        .. code-block:: python3
+
+            dut.handle.set(1)  # default Deposit action
+            dut.handle.set(Deposit(2))
+            dut.handle.set(Force(3))
+            dut.handle.set(Freeze())
+            dut.handle.set(Release())
+        """
         if self.is_const:
             raise TypeError(f"{self._path} is constant")
-        self._set_value(value, cocotb.scheduler._schedule_write)
 
-    def setimmediatevalue(self, value):
-        """Assign a value to this simulation object immediately."""
+        value_, action = _map_action_obj_to_value_action_enum_pair(self, value)
+
+        self._set_value(value_, action, cocotb.scheduler._schedule_write)
+
+    def setimmediatevalue(
+        self,
+        value: Union[ValueSetT, Deposit[ValueSetT], Force[ValueSetT], Freeze, Release],
+    ) -> None:
+        """Assign a value to this simulation object immediately.
+
+        This is known in Verilog as a "blocking" assignment and in VHDL as a variable assignment.
+
+        See :class:`Deposit`, :class:`Force`, :class:`Freeze`, and :class:`Release` for additional actions that can be taken when setting a value.
+        The default behavior is to :class:`Deposit` the value.
+        See :meth:`set` for an example on how to use these action types.
+        """
         if self.is_const:
             raise TypeError(f"{self._path} is constant")
 
-        def _call_now(handle, f, *args):
+        def _call_now(
+            handle: "ValueObjectBase[Any, Any]", f: Callable[..., None], args: Any
+        ) -> None:
             f(*args)
 
-        self._set_value(value, _call_now)
+        value_, action = _map_action_obj_to_value_action_enum_pair(self, value)
+
+        self._set_value(value_, action, _call_now)
 
     @cached_property
     def is_const(self) -> bool:
         """``True`` if the simulator object is immutable, e.g. a Verilog parameter or VHDL constant or generic."""
         return self._handle.get_const()
 
-    def _set_value(self, value, call_sim):
-        """This should be overriden in subclasses.
-
-        This is used to implement both the setter for :attr:`value`, and the
-        :meth:`setimmediatevalue` method.
+    @abstractmethod
+    def _set_value(
+        self,
+        value: ValueSetT,
+        action: _GPISetAction,
+        schedule_write: Callable[
+            ["ValueObjectBase[Any, Any]", Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
+        """Schedule a write of the given value to a simulator object.
+
+        Conversion from multiple Python types into a type understood by the simulator is expected.
+        This is used to implement the :attr:`value` property setter, :meth:`setimmediatevalue`, and :meth:`set`.
 
-        ``call_sim(handle, f, *args)`` should be used to schedule simulator writes,
-        rather than performing them directly as ``f(*args)``.
+        Args:
+            value: A value used to set the handle.
+            action: Whether to deposit, force, or release the value on the handle.
+            schedule_write: A function which takes ``(handle, callback, args)`` to schedule the writes.
         """
-        raise TypeError(
-            f"Not permissible to set values on object {self._name} of type {type(self)}"
-        )
 
 
-class NonHierarchyIndexableObjectBase(NonHierarchyObject):
+#: Subtype of :class:`ValueObjectBase` returned when iterating or indexing a :class:`IndexableValueObjectBase`.
+ChildObjectT = TypeVar("ChildObjectT", bound=ValueObjectBase[Any, Any])
+
+
+class IndexableValueObjectBase(
+    ValueObjectBase[ValuePropertyT, ValueSetT],
+    Generic[ValuePropertyT, ValueSetT, ChildObjectT],
+):
+    """Base class for all simulation object types that have a range and can be indexed.
+
+    These objects can be iterated over to yield child objects:
+
+    .. code-block:: python3
+
+        for child in dut.array_object:
+            print(child._path)
+
+    A particular child can be retrieved using its index:
+
+    .. code-block:: python3
+
+        child = dut.array_object[0]
+
+        # reversed iteration over children
+        for child_idx in reversed(dut.array_object.range):
+            dut.array_object[child_idx]
+
+    .. note::
+
+        While seemingly all objects that inherit from this class should be able to be indexed, this is not the case.
+        For example, a single logic object cannot be indexed, while an array of logics may be able to be indexed.
+        If this object cannot be indexed, trying to index will raise an :exc:`IndexError` and iteration will yield nothing.
+    """
+
     @abstractmethod
-    def __init__(self, handle, path):
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
         super().__init__(handle, path)
-        self._sub_handles: Dict[int, SimHandleBase] = {}
+        self._sub_handles: Dict[int, ChildObjectT] = {}
 
     @cached_property
     def _range(self) -> Tuple[int, int]:
         return self._handle.get_range()
 
-    def __getitem__(self, index):
+    def __getitem__(self, index: int) -> ChildObjectT:
         if isinstance(index, slice):
             raise IndexError("Slice indexing is not supported")
         if self._range is None:
@@ -593,10 +770,10 @@ def __getitem__(self, index):
         if not new_handle:
             raise IndexError(f"{self._path} contains no object at index {index}")
         path = self._path + "[" + str(index) + "]"
-        self._sub_handles[index] = SimHandle(new_handle, path)
+        self._sub_handles[index] = cast(ChildObjectT, SimHandle(new_handle, path))
         return self._sub_handles[index]
 
-    def __iter__(self):
+    def __iter__(self) -> Iterable[ChildObjectT]:
         if self._range is None:
             return
 
@@ -607,7 +784,7 @@ def __iter__(self):
             except IndexError:
                 continue
 
-    def _range_iter(self, left, right):
+    def _range_iter(self, left: int, right: int) -> Iterable[int]:
         if left > right:
             while left >= right:
                 yield left
@@ -622,46 +799,77 @@ def __len__(self) -> int:
         return self._handle.get_num_elems()
 
 
-class NonHierarchyIndexableObject(NonHierarchyIndexableObjectBase):
-    """A non-hierarchy indexable object.
+#: Type of value of each element in an :class:`ArrayObject`.
+ElemValueT = TypeVar("ElemValueT")
 
-    Getting and setting the current value of an array is done
-    by iterating through sub-handles in left-to-right order.
 
-    Given an HDL array ``arr``:
+class ArrayObject(
+    IndexableValueObjectBase[List[ElemValueT], List[ElemValueT], ChildObjectT],
+    Generic[ElemValueT, ChildObjectT],
+):
+    """A simulation object that is an array of value-having simulation objects.
 
-    +--------------+---------------------+--------------------------------------------------------------+
-    | Verilog      | VHDL                | ``arr.value`` is equivalent to                               |
-    +==============+=====================+==============================================================+
-    | ``arr[4:7]`` | ``arr(4 to 7)``     | ``[arr[4].value, arr[5].value, arr[6].value, arr[7].value]`` |
-    +--------------+---------------------+--------------------------------------------------------------+
-    | ``arr[7:4]`` | ``arr(7 downto 4)`` | ``[arr[7].value, arr[6].value, arr[5].value, arr[4].value]`` |
-    +--------------+---------------------+--------------------------------------------------------------+
+    This object is used whenever an array is seen that isn't either a logic array or string.
+    In Verilog, only unpacked vectors use this type.
+    Packed vectors are typically mapped to :class:`LogicObject`.
+    """
 
-    When setting the signal as in ``arr.value = ...``, the same index equivalence as noted in the table holds.
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
+        super().__init__(handle, path)
 
-    .. warning::
-        Assigning a value to a sub-handle:
+    @property
+    def value(self) -> List[ElemValueT]:
+        """The current value of the simulation object.
 
-        - **Wrong**: ``dut.some_array.value[0] = 1`` (gets value as a list then updates index 0)
-        - **Correct**: ``dut.some_array[0].value = 1``
-    """
+        :getter:
+            Returns the current values of each element of the array object as a :class:`list` of element values.
+            The elements of the array appear in the list in left-to-right order.
 
-    def __init__(self, handle, path):
-        super().__init__(handle, path)
+        :setter:
+            Assigns a :class:`list` of values to each element of the array at the end of the current delta cycle.
+            The element values are assigned in left-to-right order.
 
-    @NonHierarchyObject.value.getter
-    def value(self) -> list:
-        # Don't use self.__iter__, because it has an unwanted `except IndexError`
-        return [self[i].value for i in self._range_iter(self._range[0], self._range[1])]
+        Given an HDL array ``arr``, when getting the value:
+
+        +--------------+---------------------+--------------------------------------------------------------+
+        | Verilog      | VHDL                | ``arr.value`` is equivalent to                               |
+        +==============+=====================+==============================================================+
+        | ``arr[4:7]`` | ``arr(4 to 7)``     | ``[arr[4].value, arr[5].value, arr[6].value, arr[7].value]`` |
+        +--------------+---------------------+--------------------------------------------------------------+
+        | ``arr[7:4]`` | ``arr(7 downto 4)`` | ``[arr[7].value, arr[6].value, arr[5].value, arr[4].value]`` |
+        +--------------+---------------------+--------------------------------------------------------------+
+
+        When setting the signal as in ``arr.value = ...``, the same index equivalence as noted in the table holds.
 
-    def _set_value(self, value, call_sim):
-        """Assign value from a list of same length to an array in left-to-right order.
-        Index 0 of the list maps to the left-most index in the array.
+        .. warning::
+            Assigning a value to a sub-handle:
+
+            - **Wrong**: ``dut.some_array.value[0] = 1`` (gets value as a list then updates index 0)
+            - **Correct**: ``dut.some_array[0].value = 1``
+
+        Raises:
+            TypeError:
+                If assigning a type other than :class:`list`.
 
-        See the docstring for this class.
+            ValueError:
+                If assigning a :class:`list` of different length than the simulation object.
         """
-        if type(value) is not list:  # noqa: E721
+        # Don't use self.__iter__, because it has an unwanted `except IndexError`
+        return [self[i].value for i in self._range_iter(self._range[0], self._range[1])]
+
+    @value.setter
+    def value(self, value: List[ElemValueT]) -> None:
+        self.set(value)
+
+    def _set_value(
+        self,
+        value: List[ElemValueT],
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
+        if not isinstance(value, list):
             raise TypeError(
                 f"Assigning non-list value to object {self._name} of type {type(self)}"
             )
@@ -673,127 +881,60 @@ def _set_value(self, value, call_sim):
         for val_idx, self_idx in enumerate(
             self._range_iter(self._range[0], self._range[1])
         ):
-            self[self_idx]._set_value(value[val_idx], call_sim)
-
-
-class _SetAction:
-    """Base class representing the type of action used while write-accessing a handle."""
-
-    pass
-
-
-class _SetValueAction(_SetAction):
-    __slots__ = ("value",)
-    """Base class representing the type of action used while write-accessing a handle with a value."""
-
-    def __init__(self, value):
-        self.value = value
-
-
-class Deposit(_SetValueAction):
-    """Action used for placing a value into a given handle."""
-
-    def _as_gpi_args_for(self, hdl):
-        return self.value, 0  # GPI_DEPOSIT
-
-
-class Force(_SetValueAction):
-    """Action used to force a handle to a given value until a release is applied."""
-
-    def _as_gpi_args_for(self, hdl):
-        return self.value, 1  # GPI_FORCE
-
-
-class Freeze(_SetAction):
-    """Action used to make a handle keep its current value until a release is used."""
-
-    def _as_gpi_args_for(self, hdl):
-        return hdl.value, 1  # GPI_FORCE
-
-
-class Release(_SetAction):
-    """Action used to stop the effects of a previously applied force/freeze action."""
-
-    def _as_gpi_args_for(self, hdl):
-        return 0, 2  # GPI_RELEASE
-
-
-class ModifiableObject(NonHierarchyObject):
-    """Base class for simulator objects whose values can be modified."""
-
-    def drivers(self):
-        """An iterator for gathering all drivers for a signal.
-
-        This is currently only available for VPI.
-        Also, only a few simulators implement this.
-        """
-        return self._handle.iterate(simulator.DRIVERS)
-
-    def loads(self):
-        """An iterator for gathering all loads on a signal.
-
-        This is currently only available for VPI.
-        Also, only a few simulators implement this.
-        """
-        return self._handle.iterate(simulator.LOADS)
-
-    def _check_for_set_action(self, value):
-        if not isinstance(value, _SetAction):
-            return value, 0  # GPI_DEPOSIT
-        return value._as_gpi_args_for(self)
-
-
-class LogicObject(ModifiableObject, NonHierarchyIndexableObjectBase):
-    """Specific object handle for Verilog nets and regs and VHDL std_logic and std_logic_vectors"""
+            self[self_idx]._set_value(value[val_idx], action, schedule_write)
+
+
+class LogicObject(
+    IndexableValueObjectBase[LogicArray, Union[LogicArray, Logic, int], "LogicObject"],
+    ValueObjectBase[LogicArray, Union[LogicArray, Logic, int]],
+):
+    """A logic or logic array simulation object.
+
+    Verilog types that map to this object:
+        * ``logic``
+        * ``reg``
+        * ``bit``
+        * packed any-dimensional vectors of ``logic``, ``reg``, or ``bit``
+        * packed any-dimensional vectors of packed structures
+
+    VHDL types that map to this object:
+        * ``std_logic`` and ``std_ulogic``
+        * ``std_logic_vector`` and ``std_ulogic_vector``
+        * ``unsigned``
+        * ``signed``
+        * ``ufixed``
+        * ``sfixed``
+        * ``float``
+    """
 
-    def __init__(self, handle, path):
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
         super().__init__(handle, path)
 
-    def _set_value(self, value, call_sim):
-        """Set the value of the underlying simulation object to *value*.
-
-        This operation will fail unless the handle refers to a modifiable
-        object, e.g. net, signal or variable.
-
-        We determine the library call to make based on the type of the value
-        because assigning integers less than 32 bits is faster.
-
-        Args:
-            value (cocotb.types.LogicArray, int):
-                The value to drive onto the simulator object.
-
-        Raises:
-            TypeError: If target has an unsupported type for value assignment.
-
-            OverflowError: If int value is out of the range that can be represented
-                by the target. -2**(Nbits - 1) <= value <= 2**Nbits - 1
-
-        .. versionchanged:: 2.0
-            Using :class:`ctypes.Structure` objects to set values was removed.
-            Convert the struct object to a :class:`~cocotb.types.LogicArray` before assignment using
-            ``LogicArray("".join(format(int(byte), "08b") for byte in bytes(struct_obj)))`` instead.
-
-        .. versionchanged:: 2.0
-            Using :class:`dict` objects to set values was removed.
-            Convert the dictionary to an integer before assignment using
-            ``sum(v << (d['bits'] * i) for i, v in enumerate(d['values']))``.
-        """
-        value, set_action = self._check_for_set_action(value)
-
+    def _set_value(
+        self,
+        value: Union[LogicArray, Logic, int],
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
+        value_: LogicArray
         if isinstance(value, int):
             min_val, max_val = _value_limits(len(self), _Limits.VECTOR_NBIT)
             if min_val <= value <= max_val:
                 if len(self) <= 32:
-                    call_sim(self, self._handle.set_signal_val_int, set_action, value)
+                    schedule_write(
+                        self, self._handle.set_signal_val_int, (action, value)
+                    )
                     return
 
                 if value < 0:
-                    value = LogicArray(
+                    value_ = LogicArray(
                         value,
                         Range(len(self) - 1, "downto", 0),
                     )
                 else:
-                    value = LogicArray(
+                    value_ = LogicArray(
                         value,
                         Range(len(self) - 1, "downto", 0),
                     )
@@ -809,83 +950,123 @@ def _set_value(self, value, call_sim):
                 raise ValueError(
                     f"cannot assign value of length {len(value)} to handle of length {len(self)}"
                 )
+            value_ = value
 
         elif isinstance(value, Logic):
             if len(self) != 1:
                 raise ValueError(
                     f"cannot assign value of length 1 to handle of length {len(self)}"
                 )
-            value = LogicArray([value])
+            value_ = LogicArray([value])
 
         else:
             raise TypeError(
                 f"Unsupported type for value assignment: {type(value)} ({value!r})"
             )
 
-        call_sim(self, self._handle.set_signal_val_binstr, set_action, value.binstr)
+        schedule_write(
+            self, self._handle.set_signal_val_binstr, (action, value_.binstr)
+        )
 
-    @ModifiableObject.value.getter
+    @property
     def value(self) -> LogicArray:
-        binstr = self._handle.get_signal_val_binstr()
-        return LogicArray(binstr)
+        """The value of the simulation object.
 
+        :getter:
+            Returns the current value of the simulation object as a :class:`~cocotb.types.LogicArray`,
+            even when the object is a single logic object and not an array.
 
-class RealObject(ModifiableObject):
-    """Specific object handle for Real signals and variables."""
+        :setter:
+            Assigns a value at the end of the current delta cycle.
+            A :class:`~cocotb.types.LogicArray`, :class:`str`, or :class:`int` can be used to set the value.
+            When a :class:`str` or :class:`int` is given, it is as if it is first converted a :class:`~cocotb.types.LogicArray`.
 
-    def __init__(self, handle, path):
-        super().__init__(handle, path)
-
-    def _set_value(self, value, call_sim):
-        """Set the value of the underlying simulation object to value.
+        Raises:
+            TypeError: If assignment is given a type other than :class:`~cocotb.types.LogicArray`, :class:`int`, or :class:`str`.
 
-        This operation will fail unless the handle refers to a modifiable
-        object, e.g. net, signal or variable.
+            OverflowError:
+                If int value is out of the range that can be represented by the target:
+                ``-2**(len(handle) - 1) <= value <= 2**len(handle) - 1``
 
-        Args:
-            value (float): The value to drive onto the simulator object.
+        .. versionchanged:: 2.0
+            Using :class:`ctypes.Structure` objects to set values was removed.
+            Convert the struct object to a :class:`~cocotb.types.LogicArray` before assignment using
+            ``LogicArray("".join(format(int(byte), "08b") for byte in bytes(struct_obj)))`` instead.
 
-        Raises:
-            TypeError: If target has an unsupported type for
-                real value assignment.
+        .. versionchanged:: 2.0
+            Using :class:`dict` objects to set values was removed.
+            Convert the dictionary to an integer before assignment using
+            ``sum(v << (d['bits'] * i) for i, v in enumerate(d['values']))`` instead.
         """
-        value, set_action = self._check_for_set_action(value)
+        binstr = self._handle.get_signal_val_binstr()
+        return LogicArray(binstr)
 
-        try:
-            value = float(value)
-        except ValueError:
+    @value.setter
+    def value(self, value: LogicArray) -> None:
+        self.set(value)
+
+
+class RealObject(ValueObjectBase[float, float]):
+    """A real/float simulation object.
+
+    This type is used when a ``real`` object in VHDL or ``float`` object in Verilog is seen.
+    """
+
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
+        super().__init__(handle, path)
+
+    def _set_value(
+        self,
+        value: float,
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
+        if not isinstance(value, (float, int)):
             raise TypeError(
                 f"Unsupported type for real value assignment: {type(value)} ({value!r})"
             )
 
-        call_sim(self, self._handle.set_signal_val_real, set_action, value)
+        schedule_write(self, self._handle.set_signal_val_real, (action, value))
 
-    @ModifiableObject.value.getter
+    @property
     def value(self) -> float:
-        return self._handle.get_signal_val_real()
+        """The value of the simulation object.
 
+        :getter:
+            Returns the current value of the simulation object as a :class:`float`.
 
-class EnumObject(ModifiableObject):
-    """Specific object handle for enumeration signals and variables."""
+        :setter:
+            Assigns a :class:`float` value at the end of the current delta cycle.
 
-    def __init__(self, handle, path):
-        super().__init__(handle, path)
+        Raises:
+            TypeError: If assignment is given a type other than :class:`float`.
+        """
+        return self._handle.get_signal_val_real()
 
-    def _set_value(self, value, call_sim):
-        """Set the value of the underlying simulation object to *value*.
+    @value.setter
+    def value(self, value: float) -> None:
+        self.set(value)
 
-        This operation will fail unless the handle refers to a modifiable
-        object, e.g. net, signal or variable.
 
-        Args:
-            value (int): The value to drive onto the simulator object.
+class EnumObject(ValueObjectBase[int, int]):
+    """An enumeration simulation object.
 
-        Raises:
-            TypeError: If target has an unsupported type for
-                 integer value assignment.
-        """
-        value, set_action = self._check_for_set_action(value)
+    This type is used when an enumerated-type simulation object is seen that isn't a "logic" or similar type.
+    """
 
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
+        super().__init__(handle, path)
+
+    def _set_value(
+        self,
+        value: int,
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
         if not isinstance(value, int):
             raise TypeError(
                 f"Unsupported type for enum value assignment: {type(value)} ({value!r})"
@@ -893,7 +1074,7 @@ def _set_value(self, value, call_sim):
 
         min_val, max_val = _value_limits(32, _Limits.UNSIGNED_NBIT)
         if min_val <= value <= max_val:
-            call_sim(self, self._handle.set_signal_val_int, set_action, value)
+            schedule_write(self, self._handle.set_signal_val_int, (action, value))
         else:
             raise OverflowError(
                 "Int value ({!r}) out of range for assignment of enum signal ({!r})".format(
@@ -901,35 +1082,60 @@ def _set_value(self, value, call_sim):
                 )
             )
 
-    @ModifiableObject.value.getter
+    @property
     def value(self) -> int:
+        """The value of the simulation object.
+
+        :getter:
+            Returns the current enumeration value of the simulation object as an :class:`int`.
+            The value is the integer mapping of the enumeration value.
+
+        :setter:
+            Assigns a new enumeration value at the end of the current delta cycle using an :class:`int`.
+            The :class:`int` value is the integer mapping of the enumeration value.
+
+        Raises:
+            TypeError: If assignment is given a type other than :class:`int`.
+
+            OverflowError: If the value used in assignment is out of range of a 32-bit signed integer.
+        """
         return self._handle.get_signal_val_long()
 
+    @value.setter
+    def value(self, value: int) -> None:
+        self.set(value)
 
-class IntegerObject(ModifiableObject):
-    """Specific object handle for integer and enumeration signals and variables."""
 
-    def __init__(self, handle, path):
-        super().__init__(handle, path)
+class IntegerObject(ValueObjectBase[int, int]):
+    """An integer simulation object.
 
-    def _set_value(self, value, call_sim):
-        """Set the value of the underlying simulation object to *value*.
+    Verilog types that map to this object:
+        * ``byte``
+        * ``shortint``
+        * ``int``
+        * ``longint``
 
-        This operation will fail unless the handle refers to a modifiable
-        object, e.g. net, signal or variable.
+    This type should not be used for the 4-state integer types ``integer`` and ``time``.
 
-        Args:
-            value (int): The value to drive onto the simulator object.
+    VHDL types that map to this object:
+        * ``integer``
+        * ``natural``
+        * ``positive``
 
-        Raises:
-            TypeError: If target has an unsupported type for
-                 integer value assignment.
+    Objects that use this type are assumed to be two's complement 32-bit integers with 2-state (``0`` and ``1``) bits.
+    """
 
-            OverflowError: If value is out of range for assignment
-                 of 32-bit IntegerObject.
-        """
-        value, set_action = self._check_for_set_action(value)
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
+        super().__init__(handle, path)
 
+    def _set_value(
+        self,
+        value: int,
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
         if not isinstance(value, int):
             raise TypeError(
                 "Unsupported type for integer value assignment: {} ({!r})".format(
@@ -939,7 +1145,7 @@ def _set_value(self, value, call_sim):
 
         min_val, max_val = _value_limits(32, _Limits.SIGNED_NBIT)
         if min_val <= value <= max_val:
-            call_sim(self, self._handle.set_signal_val_int, set_action, value)
+            schedule_write(self, self._handle.set_signal_val_int, (action, value))
         else:
             raise OverflowError(
                 "Int value ({!r}) out of range for assignment of integer signal ({!r})".format(
@@ -947,37 +1153,48 @@ def _set_value(self, value, call_sim):
                 )
             )
 
-    @ModifiableObject.value.getter
+    @property
     def value(self) -> int:
-        return self._handle.get_signal_val_long()
+        """The value of the simulation object.
 
+        :getter:
+            Returns the current value of the simulation object as a :class:`int`.
 
-class StringObject(ModifiableObject, NonHierarchyIndexableObjectBase):
-    """Specific object handle for String variables."""
+        :setter:
+            Assigns a :class:`int` value at the end of the current delta cycle.
 
-    def __init__(self, handle, path):
-        super().__init__(handle, path)
+        Raises:
+            TypeError: If assignment is given a type other than :class:`int`.
 
-    def _set_value(self, value, call_sim):
-        """Set the value of the underlying simulation object to *value*.
+            OverflowError: If the value used in assignment is out of range of a 32-bit signed integer.
+        """
+        return self._handle.get_signal_val_long()
 
-        This operation will fail unless the handle refers to a modifiable
-        object, e.g. net, signal or variable.
+    @value.setter
+    def value(self, value: int) -> None:
+        self.set(value)
 
-        Args:
-            value (bytes): The value to drive onto the simulator object.
 
-        Raises:
-            TypeError: If target has an unsupported type for
-                 string value assignment.
+class StringObject(
+    IndexableValueObjectBase[bytes, bytes, IntegerObject],
+    ValueObjectBase[bytes, bytes],
+):
+    """A string simulation object.
 
-        .. versionchanged:: 1.4
-            Takes :class:`bytes` instead of :class:`str`.
-            Users are now expected to choose an encoding when using these objects.
+    This type is used when a ``string`` (VHDL or Verilog) simulation object is seen.
+    """
 
-        """
-        value, set_action = self._check_for_set_action(value)
+    def __init__(self, handle: simulator.gpi_sim_hdl, path: Optional[str]) -> None:
+        super().__init__(handle, path)
 
+    def _set_value(
+        self,
+        value: bytes,
+        action: _GPISetAction,
+        schedule_write: Callable[
+            [ValueObjectBase[Any, Any], Callable[..., None], Sequence[Any]], None
+        ],
+    ) -> None:
         if not isinstance(value, bytes):
             raise TypeError(
                 "Unsupported type for string value assignment: {} ({!r})".format(
@@ -985,21 +1202,74 @@ def _set_value(self, value, call_sim):
                 )
             )
 
-        call_sim(self, self._handle.set_signal_val_str, set_action, value)
+        schedule_write(self, self._handle.set_signal_val_str, (action, value))
 
-    @ModifiableObject.value.getter
+    @property
     def value(self) -> bytes:
+        """The value of the simulation object.
+
+        :getter:
+            Returns the current value of the simulation object as a :class:`bytes`.
+
+        :setter:
+            Assigns a :class:`bytes` value at the end of the current delta cycle.
+            When the value's length is less than the simulation object's,
+            the value is padded with NUL (``'\0'``) characters up to the appropriate length.
+            When the value's length is greater than the simulation object's,
+            the value is truncated without a NUL terminator to the appropriate length,
+            without warning.
+
+        Strings in both Verilog and VHDL are byte arrays without any particular encoding.
+        Encoding must be done to turn Python strings into byte arrays.
+        Because :ref:`there are many encodings <https://docs.python.org/3/library/codecs.html#standard-encodings>`,
+        this step is left up to the user.
+
+        An example of how encoding and decoding could be accomplished using an ASCII string.
+
+        .. code-block:: python3
+
+            # lowercase a string
+            value = dut.string_handle.value.decode("ascii")
+            value = value.lower()
+            dut.string_handle.value = value.encode("ascii")
+
+        Raises:
+            TypeError: If assignment is given a type other than :class:`bytes`.
+
+        .. versionchanged:: 1.4
+            Takes :class:`bytes` instead of :class:`str`.
+            Users are now expected to choose an encoding when using these objects.
+        """
         return self._handle.get_signal_val_str()
 
+    @value.setter
+    def value(self, value: bytes) -> None:
+        self.set(value)
+
+
+_ConcreteHandleTypes = Union[
+    HierarchyObject,
+    HierarchyArrayObject,
+    LogicObject,
+    ArrayObject[Any, ValueObjectBase[Any, Any]],
+    RealObject,
+    IntegerObject,
+    EnumObject,
+    StringObject,
+]
+
 
-_handle2obj = {}
+_handle2obj: Dict[
+    simulator.gpi_sim_hdl,
+    _ConcreteHandleTypes,
+] = {}
 
-_type2cls = {
+_type2cls: Dict[int, Type[_ConcreteHandleTypes]] = {
     simulator.MODULE: HierarchyObject,
     simulator.STRUCTURE: HierarchyObject,
     simulator.REG: LogicObject,
     simulator.NET: LogicObject,
-    simulator.NETARRAY: NonHierarchyIndexableObject,
+    simulator.NETARRAY: ArrayObject[Any, ValueObjectBase[Any, Any]],
     simulator.REAL: RealObject,
     simulator.INTEGER: IntegerObject,
     simulator.ENUM: EnumObject,
@@ -1009,15 +1279,16 @@ def value(self) -> bytes:
 }
 
 
-def SimHandle(handle, path=None):
+def SimHandle(
+    handle: simulator.gpi_sim_hdl, path: Optional[str] = None
+) -> SimHandleBase:
     """Factory function to create the correct type of `SimHandle` object.
 
     Args:
-        handle (int): The GPI handle to the simulator object.
-        path (str): Path to this handle, ``None`` if root.
+        handle: The GPI handle to the simulator object.
+        path: Path to this handle.
 
-    Returns:
-        The `SimHandle` object.
+    Returns: An appropriate :class:`SimHandleBase` object.
 
     Raises:
         NotImplementedError: If no matching object for GPI type could be found.
@@ -1033,8 +1304,7 @@ def SimHandle(handle, path=None):
     t = handle.get_type()
     if t not in _type2cls:
         raise NotImplementedError(
-            "Couldn't find a matching object for GPI type %s(%d) (path=%s)"
-            % (handle.get_type_string(), t, path)
+            f"Couldn't find a matching object for GPI type {handle.get_type_string()}({t}) (path={path})"
         )
     obj = _type2cls[t](handle, path)
     _handle2obj[handle] = obj
diff --git a/src/cocotb/scheduler.py b/src/cocotb/scheduler.py
--- a/src/cocotb/scheduler.py
+++ b/src/cocotb/scheduler.py
@@ -543,7 +543,7 @@ def _unschedule(self, task):
                 )
                 self._abort_test(e)
 
-    def _schedule_write(self, handle, write_func, *args):
+    def _schedule_write(self, handle, write_func, args):
         """Queue `write_func` to be called on the next ReadWrite trigger."""
         if self._mode == Scheduler._MODE_READONLY:
             raise Exception(
diff --git a/src/cocotb/triggers.py b/src/cocotb/triggers.py
--- a/src/cocotb/triggers.py
+++ b/src/cocotb/triggers.py
@@ -40,7 +40,7 @@
 import cocotb.task
 from cocotb import _outcomes, simulator
 from cocotb._py_compat import cached_property
-from cocotb.handle import LogicObject, ModifiableObject
+from cocotb.handle import LogicObject, ValueObjectBase
 from cocotb.utils import (
     ParametrizedSingleton,
     get_sim_steps,
@@ -418,7 +418,7 @@ class Edge(_EdgeBase):
 
     @classmethod
     def __singleton_key__(cls, signal):
-        if not isinstance(signal, ModifiableObject):
+        if not isinstance(signal, ValueObjectBase):
             raise TypeError("")
         return signal
 


Test patch:
diff --git a/tests/test_cases/test_array/test_array.py b/tests/test_cases/test_array/test_array.py
--- a/tests/test_cases/test_array/test_array.py
+++ b/tests/test_cases/test_array/test_array.py
@@ -8,10 +8,10 @@
 import cocotb
 from cocotb.clock import Clock
 from cocotb.handle import (
+    ArrayObject,
     HierarchyArrayObject,
     HierarchyObject,
     LogicObject,
-    NonHierarchyIndexableObject,
 )
 from cocotb.triggers import Timer
 
@@ -400,7 +400,7 @@ def _discover(obj):
             obj,
             (
                 cocotb.handle.HierarchyObjectBase,
-                cocotb.handle.NonHierarchyIndexableObjectBase,
+                cocotb.handle.IndexableValueObjectBase,
             ),
         ):
             return 0
@@ -432,24 +432,24 @@ async def test_direct_constant_indexing(dut):
     tlog.info("Checking Types of complex array structures in constants/parameters.")
     _check_type(tlog, dut.param_rec, HierarchyObject)
     _check_type(tlog, dut.param_rec.a, LogicObject)
-    _check_type(tlog, dut.param_rec.b, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.param_rec.b, ArrayObject)
     _check_type(tlog, dut.param_rec.b[1], LogicObject)
 
-    _check_type(tlog, dut.param_cmplx, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.param_cmplx, ArrayObject)
     _check_type(tlog, dut.param_cmplx[0], HierarchyObject)
     _check_type(tlog, dut.param_cmplx[0].a, LogicObject)
-    _check_type(tlog, dut.param_cmplx[0].b, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.param_cmplx[0].b, ArrayObject)
     _check_type(tlog, dut.param_cmplx[0].b[1], LogicObject)
 
     _check_type(tlog, dut.const_rec, HierarchyObject)
     _check_type(tlog, dut.const_rec.a, LogicObject)
-    _check_type(tlog, dut.const_rec.b, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.const_rec.b, ArrayObject)
     _check_type(tlog, dut.const_rec.b[1], LogicObject)
 
-    _check_type(tlog, dut.const_cmplx, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.const_cmplx, ArrayObject)
     _check_type(tlog, dut.const_cmplx[1], HierarchyObject)
     _check_type(tlog, dut.const_cmplx[1].a, LogicObject)
-    _check_type(tlog, dut.const_cmplx[1].b, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.const_cmplx[1].b, ArrayObject)
     _check_type(tlog, dut.const_cmplx[1].b[1], LogicObject)
 
 
@@ -494,14 +494,14 @@ async def test_direct_signal_indexing(dut):
     _check_type(tlog, dut.sig_desc[20], LogicObject)
     _check_type(tlog, dut.sig_asc[17], LogicObject)
     _check_type(tlog, dut.sig_t1, LogicObject)
-    _check_type(tlog, dut.sig_t2, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_t2, ArrayObject)
     _check_type(tlog, dut.sig_t2[5], LogicObject)
     _check_type(tlog, dut.sig_t2[5][3], LogicObject)
     _check_type(tlog, dut.sig_t3a[2][3], LogicObject)
     _check_type(tlog, dut.sig_t3b[3], LogicObject)
-    _check_type(tlog, dut.sig_t3a, NonHierarchyIndexableObject)
-    _check_type(tlog, dut.sig_t4, NonHierarchyIndexableObject)
-    _check_type(tlog, dut.sig_t4[3], NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_t3a, ArrayObject)
+    _check_type(tlog, dut.sig_t4, ArrayObject)
+    _check_type(tlog, dut.sig_t4[3], ArrayObject)
     # the following version cannot index into those arrays and will error out
     if not (
         cocotb.LANGUAGE in ["verilog"]
@@ -510,12 +510,12 @@ async def test_direct_signal_indexing(dut):
     ):
         _check_type(tlog, dut.sig_t4[3][4], LogicObject)
         _check_type(tlog, dut.sig_t4[3][4][1], LogicObject)
-    _check_type(tlog, dut.sig_t5, NonHierarchyIndexableObject)
-    _check_type(tlog, dut.sig_t5[1], NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_t5, ArrayObject)
+    _check_type(tlog, dut.sig_t5[1], ArrayObject)
     _check_type(tlog, dut.sig_t5[1][0], LogicObject)
     _check_type(tlog, dut.sig_t5[1][0][6], LogicObject)
-    _check_type(tlog, dut.sig_t6, NonHierarchyIndexableObject)
-    _check_type(tlog, dut.sig_t6[1], NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_t6, ArrayObject)
+    _check_type(tlog, dut.sig_t6[1], ArrayObject)
     # the following version cannot index into those arrays and will error out
     if not (
         cocotb.LANGUAGE in ["verilog"]
@@ -524,10 +524,10 @@ async def test_direct_signal_indexing(dut):
     ):
         _check_type(tlog, dut.sig_t6[0][3], LogicObject)
         _check_type(tlog, dut.sig_t6[0][3][7], LogicObject)
-    _check_type(tlog, dut.sig_cmplx, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_cmplx, ArrayObject)
 
     if cocotb.LANGUAGE in ["verilog"]:
-        _check_type(tlog, dut.sig_t7[1], NonHierarchyIndexableObject)
+        _check_type(tlog, dut.sig_t7[1], ArrayObject)
         _check_type(tlog, dut.sig_t7[0][3], LogicObject)
         _check_type(
             tlog, dut.sig_t8[1], LogicObject
@@ -543,13 +543,13 @@ async def test_direct_signal_indexing(dut):
     ):
         _check_type(tlog, dut.sig_cmplx[1], HierarchyObject)
         _check_type(tlog, dut.sig_cmplx[1].a, LogicObject)
-        _check_type(tlog, dut.sig_cmplx[1].b, NonHierarchyIndexableObject)
+        _check_type(tlog, dut.sig_cmplx[1].b, ArrayObject)
         _check_type(tlog, dut.sig_cmplx[1].b[1], LogicObject)
         _check_type(tlog, dut.sig_cmplx[1].b[1][2], LogicObject)
 
     _check_type(tlog, dut.sig_rec, HierarchyObject)
     _check_type(tlog, dut.sig_rec.a, LogicObject)
-    _check_type(tlog, dut.sig_rec.b, NonHierarchyIndexableObject)
+    _check_type(tlog, dut.sig_rec.b, ArrayObject)
 
     # Riviera has a bug and finds dut.sig_rec.b[1], but the type returned is 0 which is unknown
     # only true for version 2016.02
diff --git a/tests/test_cases/test_array_simple/test_array_simple.py b/tests/test_cases/test_array_simple/test_array_simple.py
--- a/tests/test_cases/test_array_simple/test_array_simple.py
+++ b/tests/test_cases/test_array_simple/test_array_simple.py
@@ -91,7 +91,7 @@ async def test_1dim_array_indexes(dut):
     _check_value(tlog, dut.array_0_to_3[3], 0x00)
     _check_value(tlog, dut.array_0_to_3[1], 0x20)
 
-    # Get sub-handles through NonHierarchyIndexableObject.__getitem__
+    # Get sub-handles through ArrayObject.__getitem__
     dut.array_7_downto_4[7].value = 0xDE
     dut.array_4_to_7[4].value = 0xFC
     dut.array_3_downto_0[0].value = 0xAB
@@ -132,7 +132,7 @@ async def test_ndim_array_indexes(dut):
     _check_value(tlog, dut.array_2d[1][29], 0x90)
     _check_value(tlog, dut.array_2d[1][28], 0x80)
 
-    # Get sub-handles through NonHierarchyIndexableObject.__getitem__
+    # Get sub-handles through ArrayObject.__getitem__
     dut.array_2d[1].value = [0xDE, 0xAD, 0xBE, 0xEF]
     dut.array_2d[0][31].value = 0x0F
 
diff --git a/tests/test_cases/test_cocotb/test_handle.py b/tests/test_cases/test_cocotb/test_handle.py
--- a/tests/test_cases/test_cocotb/test_handle.py
+++ b/tests/test_cases/test_cocotb/test_handle.py
@@ -283,7 +283,7 @@ async def test_integer(dut):
     ):
         limits = (
             _Limits.VECTOR_NBIT
-        )  # stream_in_int is ModifiableObject in Riviera and GHDL, not IntegerObject
+        )  # stream_in_int is LogicObject in Riviera and GHDL, not IntegerObject
     else:
         limits = _Limits.SIGNED_NBIT
 
@@ -301,7 +301,7 @@ async def test_integer_overflow(dut):
     ):
         limits = (
             _Limits.VECTOR_NBIT
-        )  # stream_in_int is ModifiableObject in Riviera and GHDL, not IntegerObject
+        )  # stream_in_int is LogicObject in Riviera and GHDL, not IntegerObject
     else:
         limits = _Limits.SIGNED_NBIT
 
@@ -318,7 +318,7 @@ async def test_integer_underflow(dut):
     ):
         limits = (
             _Limits.VECTOR_NBIT
-        )  # stream_in_int is ModifiableObject in Riviera and GHDL, not IntegerObject
+        )  # stream_in_int is LogicObject in Riviera and GHDL, not IntegerObject
     else:
         limits = _Limits.SIGNED_NBIT
 
diff --git a/tests/test_cases/test_compare/test_compare.py b/tests/test_cases/test_compare/test_compare.py
--- a/tests/test_cases/test_compare/test_compare.py
+++ b/tests/test_cases/test_compare/test_compare.py
@@ -7,7 +7,7 @@
 
 import cocotb
 from cocotb.clock import Clock
-from cocotb.handle import NonHierarchyObject, SimHandleBase
+from cocotb.handle import SimHandleBase, ValueObjectBase
 from cocotb.triggers import FallingEdge, RisingEdge
 
 
@@ -36,11 +36,11 @@ async def test_compare_simhandlebase(dut):
         await tb.clkedge
 
     # Want to check the __eq__ comparator in SimHandleBase
-    # (overridden in NonHierarchyObject)
+    # (overridden in ValueObjectBase)
     assert isinstance(dut.i_module_a, SimHandleBase)
-    assert not isinstance(dut.i_module_a, NonHierarchyObject)
+    assert not isinstance(dut.i_module_a, ValueObjectBase)
     assert isinstance(dut.i_module_b, SimHandleBase)
-    assert not isinstance(dut.i_module_b, NonHierarchyObject)
+    assert not isinstance(dut.i_module_b, ValueObjectBase)
 
     # Same handle
     assert dut.i_module_a == dut.i_module_a
@@ -54,18 +54,18 @@ async def test_compare_simhandlebase(dut):
 
 
 @cocotb.test()
-async def test_compare_nonhierarchy(dut):
-    """Test for NonHierarchyObject comparisons"""
+async def test_compare_valueobject(dut):
+    """Test for ValueObjectBase comparisons."""
     tb = Testbench(dut)
     await tb.initialise()
     for _ in range(3):
         await tb.clkedge
 
-    # Check that all these signals are NonHierarchyObject children
-    assert isinstance(dut.counter_plus_two, NonHierarchyObject)
-    assert isinstance(dut.counter_plus_five, NonHierarchyObject)
-    assert isinstance(dut.clk, NonHierarchyObject)
-    assert isinstance(dut.i_module_a.clk, NonHierarchyObject)
+    # Check that all these signals are ValueObjectBase children
+    assert isinstance(dut.counter_plus_two, ValueObjectBase)
+    assert isinstance(dut.counter_plus_five, ValueObjectBase)
+    assert isinstance(dut.clk, ValueObjectBase)
+    assert isinstance(dut.i_module_a.clk, ValueObjectBase)
 
     # Two different handles
     assert not dut.counter_plus_two == dut.counter_plus_five
diff --git a/tests/test_cases/test_discovery/test_discovery.py b/tests/test_cases/test_discovery/test_discovery.py
--- a/tests/test_cases/test_discovery/test_discovery.py
+++ b/tests/test_cases/test_discovery/test_discovery.py
@@ -31,11 +31,11 @@
 import pytest
 from cocotb._sim_versions import IcarusVersion
 from cocotb.handle import (
+    ArrayObject,
     HierarchyObject,
     HierarchyObjectBase,
+    IndexableValueObjectBase,
     IntegerObject,
-    NonHierarchyIndexableObject,
-    NonHierarchyIndexableObjectBase,
     StringObject,
 )
 from cocotb.triggers import Timer
@@ -68,7 +68,7 @@ async def recursive_discover(dut):
     """Discover absolutely everything in the DUT"""
 
     def _discover(obj):
-        if not isinstance(obj, (HierarchyObject, NonHierarchyIndexableObject)):
+        if not isinstance(obj, (HierarchyObject, ArrayObject)):
             return
         for thing in obj:
             dut._log.debug("Found %s (%s)", thing._name, type(thing))
@@ -412,7 +412,7 @@ async def custom_type(dut):
     count = 0
 
     def _discover(obj):
-        if not isinstance(obj, (HierarchyObjectBase, NonHierarchyIndexableObjectBase)):
+        if not isinstance(obj, (HierarchyObjectBase, IndexableValueObjectBase)):
             return 0
         iter_count = 0
         for elem in obj:
@@ -501,7 +501,7 @@ def _discover(obj):
         if questa_vhpi and isinstance(obj, StringObject):
             # Iterating over the elements of a string with Questa's VHPI causes a stacktrace
             return 0
-        if not isinstance(obj, (HierarchyObjectBase, NonHierarchyIndexableObjectBase)):
+        if not isinstance(obj, (HierarchyObjectBase, IndexableValueObjectBase)):
             return 0
         count = 0
         for thing in obj:
diff --git a/tests/test_cases/test_iteration_mixedlang/test_iteration.py b/tests/test_cases/test_iteration_mixedlang/test_iteration.py
--- a/tests/test_cases/test_iteration_mixedlang/test_iteration.py
+++ b/tests/test_cases/test_iteration_mixedlang/test_iteration.py
@@ -38,7 +38,7 @@ def recursive_dump(parent, log):
         parent,
         (
             cocotb.handle.HierarchyObjectBase,
-            cocotb.handle.NonHierarchyIndexableObjectBase,
+            cocotb.handle.IndexableValueObjectBase,
         ),
     ):
         return 0
@@ -50,32 +50,6 @@ def recursive_dump(parent, log):
     return count
 
 
-@cocotb.test(expect_fail=True)
-async def test_drivers(dut):
-    """Try iterating over drivers of a signal.
-
-    Seems that few simulators implement ``vpiDriver``.
-    """
-    tlog = logging.getLogger("cocotb.test")
-    drivers = list(dut.i_verilog.uart1.uart_rx_1.rx_data.drivers())
-    assert drivers, "No drivers found for dut.i_verilog.uart1.uart_rx_1.rx_data"
-    for driver in drivers:
-        tlog.info("Found %s" % repr(driver))
-
-
-@cocotb.test(expect_fail=True)
-async def test_loads(dut):
-    """Try iterating over loads of a signal.
-
-    Seems that few simulators implement ``vpiLoad``.
-    """
-    tlog = logging.getLogger("cocotb.test")
-    loads = list(dut.i_verilog.uart1.ser_in.loads())
-    assert loads, "No loads found for dut.i_verilog.uart1.ser_in"
-    for load in loads:
-        tlog.info("Found %s" % repr(load))
-
-
 @cocotb.test
 async def recursive_discovery(dut):
     """Recursively discover every single object in the design."""
diff --git a/tests/test_cases/test_iteration_verilog/test_iteration_es.py b/tests/test_cases/test_iteration_verilog/test_iteration_es.py
--- a/tests/test_cases/test_iteration_verilog/test_iteration_es.py
+++ b/tests/test_cases/test_iteration_verilog/test_iteration_es.py
@@ -59,7 +59,7 @@ def dump_all_the_things(parent):
             parent,
             (
                 cocotb.handle.HierarchyObjectBase,
-                cocotb.handle.NonHierarchyIndexableObjectBase,
+                cocotb.handle.IndexableValueObjectBase,
             ),
         ):
             return 0
diff --git a/tests/test_cases/test_iteration_vhdl/test_iteration.py b/tests/test_cases/test_iteration_vhdl/test_iteration.py
--- a/tests/test_cases/test_iteration_vhdl/test_iteration.py
+++ b/tests/test_cases/test_iteration_vhdl/test_iteration.py
@@ -43,10 +43,10 @@ def total_object_count():
 
     # Questa 2023.1 onwards (FLI) do not discover the following objects, which
     # are instantiated four times:
-    # - inst_generic_sp_ram.clk (<class 'cocotb.handle.ModifiableObject'>)
-    # - inst_generic_sp_ram.rst (<class 'cocotb.handle.ModifiableObject'>)
-    # - inst_generic_sp_ram.wen (<class 'cocotb.handle.ModifiableObject'>)
-    # - inst_generic_sp_ram.en (<class 'cocotb.handle.ModifiableObject'>)
+    # - inst_generic_sp_ram.clk (<class 'cocotb.handle.LogicObject'>)
+    # - inst_generic_sp_ram.rst (<class 'cocotb.handle.LogicObject'>)
+    # - inst_generic_sp_ram.wen (<class 'cocotb.handle.LogicObject'>)
+    # - inst_generic_sp_ram.en (<class 'cocotb.handle.LogicObject'>)
     if (
         SIM_NAME.startswith("modelsim")
         and QuestaVersion(SIM_VERSION) >= QuestaVersion("2023.1")
@@ -90,7 +90,7 @@ def dump_all_the_things(parent):
             parent,
             (
                 cocotb.handle.HierarchyObjectBase,
-                cocotb.handle.NonHierarchyIndexableObjectBase,
+                cocotb.handle.IndexableValueObjectBase,
             ),
         ):
             return 0
diff --git a/tests/test_cases/test_multi_dimension_array/test_cocotb_array.py b/tests/test_cases/test_multi_dimension_array/test_cocotb_array.py
--- a/tests/test_cases/test_multi_dimension_array/test_cocotb_array.py
+++ b/tests/test_cases/test_multi_dimension_array/test_cocotb_array.py
@@ -1,6 +1,5 @@
 import cocotb
-from cocotb.handle import LogicObject
-from cocotb.handle import NonHierarchyIndexableObject as ArrayObject
+from cocotb.handle import ArrayObject, LogicObject
 from cocotb.triggers import Timer
 
 SIM_NAME = cocotb.SIM_NAME.lower()