Repository: cocotb/cocotb

Issue:
Customization of results.xml with "factory.add_option(...)"
Hello! I'd like to suggest a feature to the results.xml file that we get at the end of a simulation.

I'm running some tests permutations with a code that looks like this:
```python
for test in [test1, test2]:
        factory = TestFactory(test)
        factory.add_option("config1",            [True, False])
        factory.add_option("config2",            ["opt-a", "opt-b", "opt-c"])
        factory.add_option("config3",            [True, False])
        factory.generate_tests()
```

Then in my results.xml, I got the list of all the test permutations with this setting.
Right now it is only possible to get the name of the test, if it has passed/failed, its simulation time, its real time, and its ratio.

Could you guys add a way to add to each test in the results.xml the value of the custom options?


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb) Report
> Merging [#2566](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb) (08c2ddb) into [master](https://codecov.io/gh/cocotb/cocotb/commit/c065ccbcef913135a33cca29a8c65b72160425e4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb) (c065ccb) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/cocotb/cocotb/pull/2566/graphs/tree.svg?width=650&height=150&src=pr&token=Ab4Q7Vrtr5&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb)](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb)

```diff
@@           Coverage Diff           @@
##           master    #2566   +/-   ##
=======================================
  Coverage   74.46%   74.47%           
=======================================
  Files          38       38           
  Lines        6537     6538    +1     
  Branches      560      561    +1     
=======================================
+ Hits         4868     4869    +1     
  Misses       1485     1485           
  Partials      184      184           
```


| [Impacted Files](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb) | Coverage Δ | |
|---|---|---|
| [cocotb/regression.py](https://codecov.io/gh/cocotb/cocotb/pull/2566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb#diff-Y29jb3RiL3JlZ3Jlc3Npb24ucHk=) | `80.91% <100.00%> (+0.05%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb). Last update [c065ccb...08c2ddb](https://codecov.io/gh/cocotb/cocotb/pull/2566?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cocotb).


Patch:
diff --git a/cocotb/regression.py b/cocotb/regression.py
index 544cc296..fdd5af6a 100644
--- a/cocotb/regression.py
+++ b/cocotb/regression.py
@@ -637,6 +637,9 @@ class TestFactory:
 
     .. versionchanged:: 1.5
         Groups of options are now supported
+
+    .. versionchanged:: 2.0
+        Options are listed in test names instead of a counter
     """
 
     # Prevent warnings from collection of TestFactories by unit testing frameworks.
@@ -708,9 +711,6 @@ class TestFactory:
                 product(*d.values())
         ):
 
-            name = "%s%s%s_%03d" % (prefix, self.name, postfix, index + 1)
-            doc = "Automatically generated test\n\n"
-
             # preprocess testoptions to split tuples
             testoptions_split = {}
             for optname, optvalue in testoptions.items():
@@ -722,6 +722,8 @@ class TestFactory:
                     for n, v in zip(optname, optvalue):
                         testoptions_split[n] = v
 
+            doc = "Automatically generated test\n\n"
+
             for optname, optvalue in testoptions_split.items():
                 if callable(optvalue):
                     if not optvalue.__doc__:
@@ -732,15 +734,19 @@ class TestFactory:
                 else:
                     doc += "\t{}: {}\n".format(optname, repr(optvalue))
 
-            self.log.debug("Adding generated test \"%s\" to module \"%s\"" %
-                           (name, mod.__name__))
             kwargs = {}
             kwargs.update(self.kwargs_constant)
             kwargs.update(testoptions_split)
+
+            options_str = ",".join(f"{optname}={optvalue!r}" for optname, optvalue in kwargs.items())
+            name = f"{prefix}{self.name}{postfix}({options_str})"
+
             if hasattr(mod, name):
                 self.log.error("Overwriting %s in module %s. "
                                "This causes a previously defined testcase "
                                "not to be run. Consider setting/changing "
-                               "name_postfix" % (name, mod))
+                               "the postfix option" % (name, mod))
+
+            self.log.debug(f"Adding generated test {name!r} to module {mod.__name__!r}")
             setattr(mod, name, _create_test(self.test_function, name, doc, mod,
                                             *self.args, **kwargs))
diff --git a/cocotb/types/__init__.py b/cocotb/types/__init__.py
index 56e7bb97..426b0725 100644
--- a/cocotb/types/__init__.py
+++ b/cocotb/types/__init__.py
@@ -2,3 +2,46 @@
 # Licensed under the Revised BSD License, see LICENSE for details.
 # SPDX-License-Identifier: BSD-3-Clause
 from .logic import Logic, Bit  # noqa: F401
+from .range import Range  # noqa: F401
+from .array import Array  # noqa: F401
+
+
+def concat(a: Array, b: Array) -> Array:
+    """
+    Create a new array that is the concatenation of one array with another.
+
+    Uses the :meth:`__concat__` or :meth:`__rconcat__` special methods to dispatch to a particular implementation,
+    exactly like other binary operations in Python.
+
+    Raises:
+        TypeError: when the arguments do not support concatenation in the given order.
+    """
+    MISSING = object()
+    type_a = type(a)
+    type_b = type(b)
+    a_concat = getattr(type_a, "__concat__", MISSING)
+    a_rconcat = getattr(type_a, "__rconcat__", MISSING)
+    b_rconcat = getattr(type_b, "__rconcat__", MISSING)
+
+    if type_a is not type_b and issubclass(type_b, type_a) and a_rconcat != b_rconcat:
+        # 'b' is a subclass of 'a' with a more specific implementation of 'concat(a, b)'
+        call_order = ((b, b_rconcat, a), (a, a_concat, b))
+    elif type_a is not type_b:
+        # normal call order
+        call_order = ((a, a_concat, b), (b, b_rconcat, a))
+    else:
+        # types are the same, we expect implementation of 'concat(a, b)' to be in 'a.__concat__'
+        call_order = ((a, a_concat, b),)
+
+    for lhs, method, rhs in call_order:
+        if method is MISSING:
+            continue
+        res = method(lhs, rhs)
+        if res is not NotImplemented:
+            return res
+
+    raise TypeError(
+        "cannot concatenate {!r} with {!r}".format(
+            type_a.__qualname__, type_b.__qualname__
+        )
+    )
diff --git a/cocotb/types/array.py b/cocotb/types/array.py
new file mode 100644
index 00000000..9dd21c3d
--- /dev/null
+++ b/cocotb/types/array.py
@@ -0,0 +1,329 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+from typing import Optional, Any, Iterable, Iterator, overload
+from collections.abc import Sequence
+from .range import Range
+from sys import maxsize
+
+
+class Array(Sequence):
+    r"""
+    Fixed-size, arbitrarily-indexed, heterogeneous sequence type.
+
+    Arrays are similar to, but different from Python :class:`list`\ s.
+    An array can store values of any type or values of multiple types at a time, just like a :class:`list`.
+    Unlike :class:`list`\ s, an array's size cannot change.
+
+    The indexes of an array can start or end at any integer value, they are not limited to 0-based indexing.
+    Indexing schemes can be either ascending or descending in value.
+    An array's indexes are described using a :class:`~cocotb.types.Range` object.
+    Initial values are treated as iterables, which are copied into an internal buffer.
+
+    .. code-block:: python3
+
+        >>> Array("1234")  # the 0-based range `(0, len(value)-1)` is inferred
+        Array(['1', '2', '3', '4'], Range(0, 'to', 3))
+
+        >>> Array(range=Range(0, "downto", -3))  # the initial values are `None`
+        Array([None, None, None, None], Range(0, 'downto', -3))
+
+        >>> Array([1, True, None, "example"], Range(-2, 1))  # initial value and range lengths must be equal
+        Array([1, True, None, 'example'], Range(-2, 'to', 1))
+
+    Arrays also support "null" ranges; "null" arrays have zero length and cannot be indexed.
+
+    .. code-block:: python3
+
+        >>> Array(range=Range(1, "to", 0))
+        Array([], Range(1, 'to', 0))
+
+    Indexing and slicing is very similar to :class:`list`\ s, but it uses the indexing scheme specified.
+    Slicing, just like the :class:`~cocotb.types.Range` object uses an inclusive right bound,
+    which is commonly seen in HDLs.
+    Like :class:`list`\ s, if a start or stop index is not specified, it is inferred as the start or end of the array.
+    Slicing an array returns a new :class:`~cocotb.types.Array` object, whose bounds are the slice indexes.
+
+    .. code-block:: python3
+
+        >>> a = Array("1234abcd")
+        >>> a[7]
+        'd'
+        >>> a[2:5]
+        Array(['3', '4', 'a', 'b'], Range(2, 'to', 5))
+        >>> a[2:5] = reversed(a[2:5])
+        >>> "".join(a)
+        '12ba43cd'
+
+        >>> b = Array("1234", Range(0, -3))
+        >>> b[-2]
+        '3'
+        >>> b[-1:]
+        Array(['2', '3', '4'], Range(-1, 'downto', -3))
+        >>> b[:] = reversed(b)
+        >>> b
+        Array(['4', '3', '2', '1'], Range(0, 'downto', -3))
+
+    .. warning::
+        Arrays behave differently in certain situations than Python's builtin sequence types (:class:`list`, :class:`tuple`, etc.).
+
+        - Arrays are not necessarily 0-based and slices use inclusive right bounds,
+          so many functions that work on Python sequences by index (like :mod:`bisect`) may not work on arrays.
+        - Slice indexes must be specified in the same direction as the array and do not support specifying a "step".
+        - When setting a slice, the new value must be an iterable of the same size as the slice.
+        - Negative indexes are *not* treated as an offset from the end of the array, but are treated literally.
+
+    Arrays are equal to other arrays of the same length with the same values (structural equality).
+    Bounds do not matter for equality.
+
+    .. code-block:: python3
+
+        >>> a = Array([1, 1, 2, 3, 5], Range(4, "downto", 0))
+        >>> b = Array([1, 1, 2, 3, 5], Range(-2, "to", 2))
+        >>> a == b
+        True
+
+    You can change the bounds of an array by setting the :attr:`range` to a new value.
+    The new bounds must be the same length of the array.
+
+    .. code-block:: python3
+
+        >>> a = Array("1234")
+        >>> a.range
+        Range(0, 'to', 3)
+        >>> a.range = Range(3, 'downto', 0)
+        >>> a.range
+        Range(3, 'downto', 0)
+
+    Arrays support the methods and semantics defined by :class:`collections.abc.Sequence`.
+
+    .. code-block:: python
+
+        >>> a = Array("stuff", Range(2, "downto", -2))
+        >>> len(a)
+        5
+        >>> "t" in a
+        True
+        >>> a.index("u")
+        0
+        >>> for c in a:
+        ...     print(c)
+        s
+        t
+        u
+        f
+        f
+
+    Args:
+        value: Initial value for the array.
+        range: Indexing scheme of the array.
+
+    Raises:
+        ValueError: When argument values cannot be used to construct an array.
+        TypeError: When invalid argument types are used.
+    """
+
+    __slots__ = (
+        "_value",
+        "_range",
+    )
+
+    _value: Sequence
+    """
+    Private interface for subclasses to access the value as a :class:`~collections.abc.Sequence`
+
+    Subclasses that don't use a Sequence as their main representation should emulate this object,
+    or override *all* :class:`~cocotb.types.Array` methods *except*:
+        - :attr:`left`
+        - :attr:`direction`
+        - :attr:`right`
+        - :attr:`range`
+        - :attr:`__len__`
+        - :attr:`_translate_index`
+    """
+
+    def __init__(
+        self, value: Optional[Iterable[Any]] = None, range: Optional[Range] = None
+    ):
+        if value is not None and range is None:
+            self._value = list(value)
+            self._range = Range(0, "to", len(self._value) - 1)
+        elif value is not None and range is not None:
+            if not isinstance(range, Range):
+                raise TypeError("range argument must be of type 'Range'")
+            self._value = list(value)
+            self._range = range
+            if len(self._value) != len(self._range):
+                raise ValueError(
+                    "init value of length {!r} does not fit in {!r}".format(
+                        len(self._value), self._range
+                    )
+                )
+        elif value is None and range is not None:
+            if not isinstance(range, Range):
+                raise TypeError("range argument must be of type 'Range'")
+            self._value = [None] * len(range)
+            self._range = range
+        else:
+            raise TypeError("must pass a value, range, or both")
+
+    @property
+    def left(self) -> int:
+        """Leftmost index of the array."""
+        return self.range.left
+
+    @property
+    def direction(self) -> str:
+        """``"to"`` if indexes are ascending, ``"downto"`` otherwise."""
+        return self.range.direction
+
+    @property
+    def right(self) -> int:
+        """Rightmost index of the array."""
+        return self.range.right
+
+    @property
+    def range(self) -> Range:
+        """:class:`Range` of the indexes of the array."""
+        return self._range
+
+    @range.setter
+    def range(self, new_range: Range) -> None:
+        """Sets a new indexing scheme on the array, must be the same size"""
+        if not isinstance(new_range, Range):
+            raise TypeError("range argument must be of type 'Range'")
+        if len(new_range) != len(self):
+            raise ValueError(
+                f"{new_range!r} not the same length as old range ({self._range!r})."
+            )
+        self._range = new_range
+
+    def __len__(self) -> int:
+        return len(self.range)
+
+    def __iter__(self) -> Iterator[Any]:
+        return iter(self._value)
+
+    def __reversed__(self) -> Iterator[Any]:
+        return reversed(self._value)
+
+    def __contains__(self, item: Any) -> bool:
+        return item in self._value
+
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self._value == other._value
+
+    @overload
+    def __getitem__(self, item: int) -> Any:
+        pass  # pragma: no cover
+
+    @overload
+    def __getitem__(self, item: slice) -> "Array":
+        pass  # pragma: no cover
+
+    def __getitem__(self, item):
+        if isinstance(item, int):
+            idx = self._translate_index(item)
+            return self._value[idx]
+        elif isinstance(item, slice):
+            start = item.start if item.start is not None else self.left
+            stop = item.stop if item.stop is not None else self.right
+            if item.step is not None:
+                raise IndexError("do not specify step")
+            start_i = self._translate_index(start)
+            stop_i = self._translate_index(stop)
+            if start_i > stop_i:
+                raise IndexError(
+                    "slice [{}:{}] direction does not match array direction [{}:{}]".format(
+                        start, stop, self.left, self.right
+                    )
+                )
+            value = self._value[start_i : stop_i + 1]
+            range = Range(start, self.direction, stop)
+            return type(self)(value=value, range=range)
+        raise TypeError(
+            "indexes must be ints or slices, not {}".format(type(item).__name__)
+        )
+
+    @overload
+    def __setitem__(self, item: int, value: Any) -> None:
+        pass  # pragma: no cover
+
+    @overload
+    def __setitem__(self, item: slice, value: Iterable[Any]) -> None:
+        pass  # pragma: no cover
+
+    def __setitem__(self, item, value):
+        if isinstance(item, int):
+            idx = self._translate_index(item)
+            self._value[idx] = value
+        elif isinstance(item, slice):
+            start = item.start if item.start is not None else self.left
+            stop = item.stop if item.stop is not None else self.right
+            if item.step is not None:
+                raise IndexError("do not specify step")
+            start_i = self._translate_index(start)
+            stop_i = self._translate_index(stop)
+            if start_i > stop_i:
+                raise IndexError(
+                    "slice [{}:{}] direction does not match array direction [{}:{}]".format(
+                        start, stop, self.left, self.right
+                    )
+                )
+            value = list(value)
+            if len(value) != (stop_i - start_i + 1):
+                raise ValueError(
+                    "value of length {!r} will not fit in slice [{}:{}]".format(
+                        len(value), start, stop
+                    )
+                )
+            self._value[start_i : stop_i + 1] = value
+        else:
+            raise TypeError(
+                "indexes must be ints or slices, not {}".format(type(item).__name__)
+            )
+
+    def __repr__(self) -> str:
+        return "{}({!r}, {!r})".format(type(self).__name__, self._value, self._range)
+
+    def __concat__(self, other: "Array") -> "Array":
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return type(self)(self._value + other._value)
+
+    def __rconcat__(self, other: "Array") -> "Array":
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return type(self)(other._value + self._value)
+
+    def index(
+        self, value: Any, start: Optional[int] = None, stop: Optional[int] = None
+    ) -> int:
+        """
+        Return index of first occurrence of *value*.
+
+        Raises :exc:`ValueError` if the value is not found.
+        Search only within *start* and *stop* if given.
+        """
+        if start is not None:
+            start = self._translate_index(start)
+        else:
+            start = 0
+        if stop is not None:
+            stop = self._translate_index(stop)
+        else:
+            stop = maxsize  # same default value used by Python lists
+        idx = self._value.index(value, start, stop)
+        return self._range[idx]
+
+    def count(self, value: Any) -> int:
+        """Return number of occurrences of *value*."""
+        return self._value.count(value)
+
+    def _translate_index(self, item: int) -> int:
+        try:
+            return self._range.index(item)
+        except ValueError:
+            raise IndexError(f"index {item} out of range") from None
diff --git a/cocotb/types/range.py b/cocotb/types/range.py
new file mode 100644
index 00000000..420212cc
--- /dev/null
+++ b/cocotb/types/range.py
@@ -0,0 +1,174 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+from typing import Any, Iterator, overload, Optional
+from collections.abc import Sequence
+
+
+class Range(Sequence):
+    r"""
+    Variant of :class:`range` with inclusive right bound.
+
+    In Python, :class:`range` and :class:`slice` have a non-inclusive right bound.
+    In both Verilog and VHDL, ranges and arrays have an inclusive right bound.
+    This type mimics Python's :class:`range` type, but implements HDL-like inclusive right bounds,
+    using the names :attr:`left` and :attr:`right` as replacements for ``start`` and ``stop`` to
+    match VHDL.
+    Range directionality can be specified using ``'to'`` or ``'downto'`` between the
+    left and right bounds.
+    Not specifying directionality will cause the directionality to be inferred.
+
+    .. code-block:: python3
+
+        >>> r = Range(-2, 3)
+        >>> r.left, r.right, len(r)
+        (-2, 3, 6)
+
+        >>> s = Range(8, 'downto', 1)
+        >>> s.left, s.right, len(s)
+        (8, 1, 8)
+
+    :meth:`from_range` and :meth:`to_range` can be used to convert from and to :class:`range`.
+
+    .. code-block:: python3
+
+        >>> r = Range(-2, 3)
+        >>> r.to_range()
+        range(-2, 4)
+
+    :class:`Range` supports "null" ranges as seen in VHDL.
+    "null" ranges occur when a left bound cannot reach a right bound with the given direction.
+    They have a length of 0, but the :attr:`left`, :attr:`right`, and :attr:`direction` values remain as given.
+
+    .. code-block:: python3
+
+        >>> r = Range(1, 'to', 0)  # no way to count from 1 'to' 0
+        >>> r.left, r.direction, r.right
+        (1, 'to', 0)
+        >>> len(r)
+        0
+
+    .. note::
+        This is only possible when specifying the direction.
+
+    Ranges also support all the features of :class:`range` including, but not limited to:
+
+    - ``value in range`` to see if a value is in the range,
+    - ``range.index(value)`` to see what position in the range the value is,
+
+    The typical use case of this type is in conjunction with :class:`~cocotb.types.Array`.
+
+    Args:
+        left: leftmost bound of range
+        direction: ``'to'`` if values are ascending, ``'downto'`` if descending
+        right: rightmost bound of range (inclusive)
+    """
+
+    __slots__ = ("_range",)
+
+    @overload
+    def __init__(self, left: int, right: int):
+        pass  # pragma: no cover
+
+    @overload
+    def __init__(self, left: int, direction: str, right: int):
+        pass  # pragma: no cover
+
+    def __init__(self, left, direction=None, right=None):
+        if direction is not None and right is None:
+            right, direction = direction, None
+        if direction is None:
+            # direction is 'to' if left == right
+            step = 1 if left <= right else -1
+        else:
+            direction = direction.lower()
+            if direction == "to":
+                step = 1
+            elif direction == "downto":
+                step = -1
+            else:
+                raise ValueError("direction must be 'to' or 'downto'")
+        self._range = range(left, right + step, step)
+
+    @classmethod
+    def from_range(cls, rng: range) -> "Range":
+        """Convert :class:`range` to :class:`Range`."""
+        if rng.step not in (1, -1):
+            raise ValueError("step must be '1' or '-1'")
+        obj = cls.__new__(cls)
+        obj._range = rng
+        return obj
+
+    def to_range(self) -> range:
+        """Convert :class:`Range` to :class:`range`."""
+        return self._range
+
+    @property
+    def left(self) -> int:
+        """Leftmost value in a range."""
+        return self._range.start
+
+    @property
+    def direction(self) -> str:
+        """``'to'`` if values are meant to be ascending, ``'downto'`` otherwise."""
+        return "to" if self._range.step == 1 else "downto"
+
+    @property
+    def right(self) -> int:
+        """Rightmost value in a range."""
+        return self._range.stop - self._range.step
+
+    def __len__(self) -> int:
+        return len(self._range)
+
+    @overload
+    def __getitem__(self, item: int) -> int:
+        pass  # pragma: no cover
+
+    @overload
+    def __getitem__(self, item: slice) -> "Range":
+        pass  # pragma: no cover
+
+    def __getitem__(self, item):
+        if isinstance(item, int):
+            return self._range[item]
+        elif isinstance(item, slice):
+            return type(self).from_range(self._range[item])
+        raise TypeError(
+            "indices must be integers or slices, not {}".format(type(item).__name__)
+        )
+
+    def __contains__(self, item: Any) -> bool:
+        return item in self._range
+
+    def __iter__(self) -> Iterator[int]:
+        return iter(self._range)
+
+    def __reversed__(self) -> Iterator[int]:
+        return reversed(self._range)
+
+    def __eq__(self, other: Any) -> bool:
+        if type(self) is not type(other):
+            return NotImplemented
+        return self._range == other._range
+
+    def __hash__(self) -> int:
+        return hash(self._range)
+
+    def index(
+        self, value: Any, start: Optional[int] = None, stop: Optional[int] = None
+    ) -> int:
+        if start is not None or stop is not None:
+            # bpo-43836
+            raise RuntimeError(
+                "'range.index' does not currently support the 'start' or 'stop' arguments"
+            )
+        return self._range.index(value)
+
+    def count(self, item: Any) -> int:
+        return self._range.count(item)
+
+    def __repr__(self) -> str:
+        return "{}({!r}, {!r}, {!r})".format(
+            type(self).__qualname__, self.left, self.direction, self.right
+        )
diff --git a/documentation/source/library_reference.rst b/documentation/source/library_reference.rst
index 551d48e3..8fcd71a6 100644
--- a/documentation/source/library_reference.rst
+++ b/documentation/source/library_reference.rst
@@ -75,6 +75,15 @@ as the types used by cocotb's `simulator handles <#simulation-object-handles>`_.
 
 .. autoclass:: cocotb.types.Bit
 
+.. autoclass:: cocotb.types.Range
+    :members:
+    :exclude-members: count, index
+
+.. autofunction:: cocotb.types.concat
+
+.. autoclass:: cocotb.types.Array
+    :members:
+    :exclude-members: count, index
 
 Triggers
 --------
diff --git a/documentation/source/newsfragments/2316.change.1.rst b/documentation/source/newsfragments/2316.change.1.rst
new file mode 100644
index 00000000..dfba61d5
--- /dev/null
+++ b/documentation/source/newsfragments/2316.change.1.rst
@@ -0,0 +1,1 @@
+Assigning out-of-range Python integers to signals will now raise an :exc:`OverflowError`.
diff --git a/documentation/source/newsfragments/2316.change.rst b/documentation/source/newsfragments/2316.change.rst
index 23a32634..3c3cb6bc 100644
--- a/documentation/source/newsfragments/2316.change.rst
+++ b/documentation/source/newsfragments/2316.change.rst
@@ -1,2 +1,1 @@
 Assigning out-of-range Python integers to signals would previously truncate the value silently for signal widths <= 32 bits and truncate the value with a warning for signal widths > 32 bits.
-Assigning out-of-range Python integers to signals will now raise an :exc:`OverflowError`.
diff --git a/documentation/source/newsfragments/2438.doc.rst b/documentation/source/newsfragments/2438.doc.rst
index 926b03f5..63f7d5bc 100644
--- a/documentation/source/newsfragments/2438.doc.rst
+++ b/documentation/source/newsfragments/2438.doc.rst
@@ -1,2 +1,1 @@
-The :ref:`analog_model` example has been added,
-showing how to use Python models for analog circuits together with a digital part.
+The :ref:`analog_model` example has been added, showing how to use Python models for analog circuits together with a digital part.
diff --git a/documentation/source/newsfragments/2490.removal.1.rst b/documentation/source/newsfragments/2490.removal.1.rst
index 2aaa582f..bfb70bab 100644
--- a/documentation/source/newsfragments/2490.removal.1.rst
+++ b/documentation/source/newsfragments/2490.removal.1.rst
@@ -1,2 +1,1 @@
-Setting values on indexed handles using the ``handle[i] = value`` syntax is deprecated.
-Instead use the ``handle[i].value = value`` or ``handle[i] <= value`` syntax.
+Setting values on indexed handles using the ``handle[i] = value`` syntax is deprecated. Instead use the ``handle[i].value = value`` or ``handle[i] <= value`` syntax.
diff --git a/documentation/source/newsfragments/2490.removal.rst b/documentation/source/newsfragments/2490.removal.rst
index b1cf2b82..03022424 100644
--- a/documentation/source/newsfragments/2490.removal.rst
+++ b/documentation/source/newsfragments/2490.removal.rst
@@ -1,2 +1,1 @@
-Setting values on handles using the ``dut.handle = value`` syntax is deprecated.
-Instead use the ``handle.value = value`` or ``handle <= value`` syntax.
+Setting values on handles using the ``dut.handle = value`` syntax is deprecated. Instead use the ``handle.value = value`` or ``handle <= value`` syntax.
diff --git a/documentation/source/newsfragments/2502.change.rst b/documentation/source/newsfragments/2502.change.rst
index 232d4a6f..60926a7c 100644
--- a/documentation/source/newsfragments/2502.change.rst
+++ b/documentation/source/newsfragments/2502.change.rst
@@ -1,2 +1,1 @@
-The environment variable :envvar:`COCOTB_LOG_LEVEL` now supports ``TRACE`` value,
-which is used for verbose low-level logging that was previously in ``DEBUG`` logs.
+The environment variable :envvar:`COCOTB_LOG_LEVEL` now supports ``TRACE`` value, which is used for verbose low-level logging that was previously in ``DEBUG`` logs.
diff --git a/documentation/source/newsfragments/2548.change.rst b/documentation/source/newsfragments/2548.change.rst
new file mode 100644
index 00000000..db6a6f14
--- /dev/null
+++ b/documentation/source/newsfragments/2548.change.rst
@@ -0,0 +1,1 @@
+Change the generated test name of :class:`TestFactory`\ -generated tests to include options used for that test.
diff --git a/documentation/source/newsfragments/README.rst b/documentation/source/newsfragments/README.rst
index 6b38d9e5..2989e3a6 100644
--- a/documentation/source/newsfragments/README.rst
+++ b/documentation/source/newsfragments/README.rst
@@ -23,6 +23,10 @@ and ``<TYPE>`` is one of:
 
 In that file, make sure to use full sentences with correct case and punctuation,
 and do not use a bullet point at the beginning of the file.
+Each fragment file should be a single piece of news on a single line;
+multi-line files will not render correctly.
+In cases where there is more than one piece of news for a pull request,
+split the news into 2 fragments (see below for details on how to do that).
 Use Sphinx references (see https://sphinx-tutorial.readthedocs.io/cheatsheet/)
 if you refer to added classes, methods etc.
 


Test patch:
diff --git a/tests/pytest/test_array.py b/tests/pytest/test_array.py
new file mode 100644
index 00000000..0092fa5e
--- /dev/null
+++ b/tests/pytest/test_array.py
@@ -0,0 +1,200 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+from cocotb.types import Array, Range, concat
+import pytest
+
+
+def test_value_only_construction():
+    a = Array("1234")
+    assert a.left == 0
+    assert a.direction == 'to'
+    assert a.right == 3
+
+
+def test_range_only_construction():
+    a = Array(range=Range(1, -2))
+    assert a.left == 1
+    assert a.direction == 'downto'
+    assert a.right == -2
+    assert all(v is None for v in a)
+
+
+def test_both_construction():
+    a = Array("1234", Range(-2, 1))
+    assert a.left == -2
+    assert a.direction == 'to'
+    assert a.right == 1
+
+    with pytest.raises(ValueError):
+        Array("1234", Range(0, 1))
+
+
+def test_bad_construction():
+    with pytest.raises(TypeError):
+        Array()
+    with pytest.raises(TypeError):
+        Array(value=1)
+    with pytest.raises(TypeError):
+        Array(range=tuple())
+    with pytest.raises(TypeError):
+        Array(value="1234", range=tuple())
+
+
+def test_length():
+    a = Array(range=Range(1, 6))
+    assert len(a) == 6
+
+
+def test_range():
+    r = Range(-2, 8)
+    a = Array(range=r)
+    assert a.range == r
+
+
+def test_equality():
+    assert Array("1234", Range(1, 4)) == Array("1234", Range(1, 4))
+    assert Array("1234", Range(1, 4)) == Array("1234", Range(0, -3))
+    assert Array("1234", Range(1, 4)) != Array("4321", Range(1, 4))
+    assert Array("1234") != "1234"
+    assert Array("1234") != 8
+
+
+def test_repr_eval():
+    r = Array("1234")
+    assert eval(repr(r)) == r
+
+
+def test_iter():
+    val = [7, True, object(), 'example']
+    a = Array(val)
+    assert list(a) == val
+
+
+def test_reversed():
+    val = [7, True, object(), 'example']
+    a = Array(val)
+    assert list(reversed(a)) == list(reversed(val))
+
+
+def test_contains():
+    a = Array([7, True, object(), 'example'])
+    assert True in a
+    assert 89 not in a
+
+
+def test_index():
+    r = Array((i for j in range(10) for i in range(10)))  # 0-9 repeated 10 times
+    assert r.index(5) == 5
+    assert r.index(5, 10, 20) == 15
+
+
+def test_count():
+    r = Array("111111")
+    assert r.count("1") == 6
+
+
+def test_indexing():
+    a = Array("1234", Range(8, 'to', 11))
+    assert a[8] == "1"
+    with pytest.raises(IndexError):
+        a[0]
+    a[11] = False
+    assert a[11] is False
+
+    b = Array("1234", Range(10, 'downto', 7))
+    assert b[8] == "3"
+    with pytest.raises(IndexError):
+        b[-2]
+    b[8] = 9
+    assert b[8] == 9
+
+
+def test_bad_indexing():
+    with pytest.raises(TypeError):
+        Array("1234")[list()]
+    with pytest.raises(TypeError):
+        Array("1234")[object()] = 9
+
+
+def test_slicing():
+    a = Array("testingstuff")
+    b = a[2:6]
+    assert b.left == 2
+    assert b.right == 6
+    assert b == Array("sting")
+    a[0:3] = "hack"
+    assert a == Array("hackingstuff")
+
+
+def test_slicing_infered_start_stop():
+    a = Array([1, 2, 3, 4])
+    assert a[:] == a
+    a[:] = "1234"
+    assert a == Array("1234")
+
+
+def test_dont_specify_step():
+    with pytest.raises(IndexError):
+        Array("1234")[::1]
+    with pytest.raises(IndexError):
+        Array("7896")[1:2:1] = [1, 2]
+
+
+def test_slice_direction_mismatch():
+    a = Array([1, 2, 3, 4], Range(10, 'downto', 7))
+    with pytest.raises(IndexError):
+        a[7:9]
+    with pytest.raises(IndexError):
+        a[9:10] = ['a', 'b']
+
+
+def test_set_slice_wrong_length():
+    a = Array("example")
+    with pytest.raises(ValueError):
+        a[2:4] = "real bad"
+
+
+def test_slice_correct_infered():
+    a = Array("1234")
+    b = a[:0]
+    assert b.right == 0
+
+
+def test_array_concat():
+    l = Array("01ZX", Range(0, 'to', 3))
+    p = Array("1101")
+    r = concat(l, p)
+    assert r == Array("01ZX1101")
+
+    rconcat_called = None
+
+    class SpecialArray(Array):
+
+        def __rconcat__(self, other):
+            nonlocal rconcat_called
+            rconcat_called = 3
+            return super().__rconcat__(other)
+
+    q = SpecialArray("ABC")
+
+    r2 = concat(q, l)
+    assert r2 == Array("ABC01ZX")
+    r3 = concat(l, q)
+    assert r3 == Array("01ZXABC")
+    assert rconcat_called == 3
+
+    with pytest.raises(TypeError):
+        concat(l, "nope")
+    with pytest.raises(TypeError):
+        concat("nope", l)
+
+
+def test_changing_range():
+    a = Array("1234")
+    a.range = Range(3, 'downto', 0)
+    assert a.range == Range(3, 'downto', 0)
+    with pytest.raises(TypeError):
+        a.range = range(10)
+    with pytest.raises(ValueError):
+        a.range = Range(7, 'downto', 0)
diff --git a/tests/pytest/test_range.py b/tests/pytest/test_range.py
new file mode 100644
index 00000000..8e35131f
--- /dev/null
+++ b/tests/pytest/test_range.py
@@ -0,0 +1,128 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+from cocotb.types import Range
+import pytest
+
+
+def test_to_range():
+    r = Range(1, 'to', 8)
+    assert r.left == 1
+    assert r.direction == 'to'
+    assert r.right == 8
+    assert len(r) == 8
+    assert list(r) == [1, 2, 3, 4, 5, 6, 7, 8]
+    assert list(reversed(r)) == [8, 7, 6, 5, 4, 3, 2, 1]
+    assert r[0] == 1
+    assert r[7] == 8
+    with pytest.raises(IndexError):
+        r[8]
+    assert r[3:7] == Range(4, 'to', 7)
+    assert 8 in r
+    assert 10 not in r
+    assert r.index(7) == 6
+    with pytest.raises(ValueError):
+        r.index(9)
+    assert r.count(4) == 1
+    assert r.count(10) == 0
+
+
+def test_downto_range():
+    r = Range(4, 'downto', -3)
+    assert r.left == 4
+    assert r.direction == 'downto'
+    assert r.right == -3
+    assert len(r) == 8
+    assert list(r) == [4, 3, 2, 1, 0, -1, -2, -3]
+    assert list(reversed(r)) == [-3, -2, -1, 0, 1, 2, 3, 4]
+    assert r[0] == 4
+    assert r[7] == -3
+    with pytest.raises(IndexError):
+        r[8]
+    assert r[3:7] == Range(1, 'downto', -2)
+    assert 0 in r
+    assert 10 not in r
+    assert r.index(2) == 2
+    with pytest.raises(ValueError):
+        r.index(9)
+    assert r.count(4) == 1
+    assert r.count(10) == 0
+
+
+def test_null_range():
+    r = Range(1, 'downto', 4)
+    assert r.left == 1
+    assert r.direction == 'downto'
+    assert r.right == 4
+    assert len(r) == 0
+    assert list(r) == []
+    assert list(reversed(r)) == []
+    with pytest.raises(IndexError):
+        r[0]
+    assert 2 not in r
+    with pytest.raises(ValueError):
+        r.index(4)
+    assert r.count(4) == 0
+
+
+def test_equality():
+    assert Range(7, 'downto', -7) == Range(7, 'downto', -7)
+    assert Range(7, 'downto', -7) != Range(0, 'to', 8)
+    assert Range(1, 'to', 0) == Range(8, 'to', -8)  # null ranges are all equal?
+    assert Range(1, 'to', 4) != 789
+
+
+def test_other_constructors():
+    assert Range(1, 8) == Range(1, 'to', 8)
+    assert Range(3, -4) == Range(3, 'downto', -4)
+    assert Range(left=1, right=8) == Range(1, 'to', 8)
+    assert Range(left=3, right=-4) == Range(3, 'downto', -4)
+
+
+def test_use_in_set():
+    assert len({Range(1, 'to', 8), Range(1, 'to', 8)}) == 1
+    assert len({Range(1, 'to', 8), Range(8, 'downto', 1)}) == 2
+
+
+def test_conversions():
+    t = range(10, 1, -1)
+    r = Range.from_range(t)
+    assert r.left == 10
+    assert r.right == 2
+    assert r.direction == 'downto'
+    assert r.to_range() == t
+
+
+def test_repr():
+    r = Range(5, 'to', 9)
+    assert eval(repr(r)) == r
+
+
+def test_uppercase_in_direction():
+    r = Range(1, 'TO', 8)
+    assert r.direction == 'to'
+
+
+def test_bad_direction():
+    with pytest.raises(ValueError):
+        Range(1, 'nope', 8)
+
+
+def test_bad_bound():
+    with pytest.raises(TypeError):
+        Range(object(), 'to', 8)
+
+
+def test_bad_step():
+    with pytest.raises(ValueError):
+        Range.from_range(range(10, 5, -2))
+
+
+def test_bad_getitem():
+    with pytest.raises(TypeError):
+        Range(10, 'downto', 4)["8"]
+
+
+def test_range_index_bpo43836():
+    with pytest.raises(RuntimeError):
+        Range(10, 0).index(7, start=4, stop=10)