import collections
import contextlib
import typing
from typing import (
Any,
Callable,
ContextManager,
DefaultDict,
Dict,
Generic,
Iterator,
List,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
import tbot
import tbot.error
import tbot.role
from tbot import machine
M = TypeVar("M", bound=machine.Machine)
class InstanceManager(Generic[M]):
def __init__(self) -> None:
self._cx = contextlib.ExitStack()
self._current_users = 0
self._instance: Optional[M] = None
self._available = False
def init(
self,
*,
context: Optional[ContextManager[M]] = None,
instance: Optional[M] = None,
) -> None:
if self._instance is not None:
raise tbot.error.ContextError("trying to re-initialize a live instance")
self._cx = contextlib.ExitStack()
self._available = True
if instance is not None and context is not None:
raise ValueError("cannot have both `context` and `instance` arguments")
elif instance is not None:
self._instance = self._cx.enter_context(instance) # type: ignore
elif context is not None:
self._instance = self._cx.enter_context(context)
else:
raise ValueError("needs either `context` or `instance` argument")
def teardown(self) -> None:
if self._instance is None:
raise tbot.error.ContextError("trying to de-init a closed instance")
# Necessary to ensure any open contexts for this machine do not
# prevent it from running its deinitialization code:
self._instance._rc = 1
self._cx.close()
self._instance = None
@contextlib.contextmanager
def request(self, exclusive: bool = False, keep_alive: bool = False) -> Iterator[M]:
if self._instance is None:
raise tbot.error.ContextError("trying to access a closed instance")
if not self._available:
raise tbot.error.ContextError(
"trying to access instance which is not available"
)
try:
self._current_users += 1
if exclusive:
# Mark the instance as exclusively used so no future request()
# will succeed.
self._available = False
with self._instance as m:
yield m
finally:
self._current_users -= 1
if exclusive or (not keep_alive and self._current_users == 0):
# If we were the last user or the request() was an exclusive
# one, tear down this instance now. Future requests will then
# need to re-initilize it.
if self.is_alive():
self.teardown()
def is_alive(self) -> bool:
return self._instance is not None
def has_users(self) -> bool:
return self._current_users != 0
[docs]class Context(typing.ContextManager):
"""
A context which machines can be registered in and where instances can be retrieved from.
You will usually access the global context :py:data:`tbot.ctx` which is an
instance of this class instead of instantiating :py:class:`tbot.Context`
yourself. See the :ref:`context` guide for a detailed introduction.
In case you do need to construct a context yourself, there are a few
customization possibilities:
:param bool keep_alive: Whether machines should be immediately
de-initialized once their context-manager is exited or whether they
should be "kept alive" for a future request to immediately re-access
them.
.. warning::
Keeping instances alive can have unintended side-effects: If, for
example, a test brings a machine into an unusable state and then
fails, a followup testcase could gain access to the same broken
instance without reinitialization.
To avoid such problems, always write testcase to leave the instance
in a clean state. If a testcase can't guarantee this, it should
request the instance with ``reset_on_error=True`` or even
``exclusive=True``.
:param bool add_defaults: Add default machines for some roles from
:py:mod:`tbot.role`, for example for :py:class:`tbot.role.LocalHost`.
Defaults to ``False``.
:param bool reset_on_error_by_default: Set ``reset_on_error=True`` for all
``request()`` s which do not explicitly overwrite it. It is a good idea
to set this in conjunction with ``keep_alive=True``.
"""
def __init__(
self,
*,
keep_alive: bool = False,
add_defaults: bool = False,
reset_on_error_by_default: bool = False,
) -> None:
self._roles: Dict[Type[tbot.role.Role], Type[machine.Machine]] = {}
self._weak_roles: Set[Type[tbot.role.Role]] = set()
self._open_contexts = 0
self._keep_alive = keep_alive
self._reset_on_error_default = reset_on_error_by_default
self._teardown_order: List[Type] = []
self._instances: DefaultDict[Type[machine.Machine], InstanceManager] = (
collections.defaultdict(InstanceManager)
)
if add_defaults:
tbot.role._register_default_machines(self)
[docs] def register(
self, machine: Type[M], roles: Union[Any, List[Any]], *, weak: bool = False
) -> None:
"""
Register a machine in this context for certain roles.
Registers the machine-class ``machine`` for the role ``roles`` or, if
``roles`` is a list, for all roles it contains. If for any role
a machine is already registered, an exception is thrown.
This function is usually called in the ``register_machines()`` function
of a lab- or board-config:
.. code-block:: python
class SomeLabHostClass(...):
...
def register_machines(ctx: tbot.Context) -> None:
ctx.register(SomeLabHostClass, tbot.role.LabHost)
# or, to register for multiple roles
ctx.register(SomeLabHostClass, [tbot.role.LabHost, tbot.role.BuildHost])
:param machine: A concrete machine-class to be registered. This
machine-class will later be instantiated on request via its
:py:meth:`Connector.from_context()
<tbot.machine.connector.Connector.from_context()>` classmethod.
:param roles: Either a single role or a list of roles for which
``machine`` should be registered.
:param bool weak: Changes the way registration works: The machine is
only registered for those roles which do not already have a machine
registered. It will be registered as a weak default which means
a later register will overwrite it without erroring. This is
usually not necessary and should be used with care.
"""
if not isinstance(roles, list):
roles = [roles]
for role in roles:
if not issubclass(role, tbot.role.Role):
tbot.error.TbotException(f"{role!r} is not a role")
if role in self._roles:
if weak:
continue
elif role in self._weak_roles:
# Overwrite the weak default
self._weak_roles.discard(role)
else:
raise KeyError(
f"a machine is already registered for role {tbot.role.rolename(role)}"
)
if weak:
self._weak_roles.add(role)
self._roles[role] = machine
def _get_class_and_instance(
self, type: Callable[..., M]
) -> Tuple[Type[M], InstanceManager]:
type = typing.cast(Type[M], type)
if type in self._roles:
role = typing.cast(Type[tbot.role.Role], type)
machine_class = typing.cast(Type[M], self._roles[role])
elif type in self._instances:
machine_class = type
else:
raise tbot.error.MachineNotFoundError(f"no machine found for {type!r}")
instance = self._instances[machine_class]
return (machine_class, instance)
[docs] @contextlib.contextmanager
def request(
self,
type: Callable[..., M],
*,
reset: bool = False,
exclusive: bool = False,
reset_on_error: Optional[bool] = None,
) -> Iterator[M]:
"""
Request a machine instance from this context.
Requests an instance of the :ref:`role <tbot_role>` ``type`` from the
context. If no instance exists, one will be created. If a previous
testcase has already requested such an instance, the same instance is
returned (this behavior can be controlled with the ``reset`` and
``exclusive`` keyword arguments).
This function must be used as a context manager:
.. code-block:: python
@tbot.testcase
def foo_bar():
with tbot.ctx.request(tbot.role.LabHost) as lh:
lh.exec0("uname", "-a")
Alternatively, if you need multiple machines, a pattern similar to
:py:class:`contextlib.ExitStack` can be used:
.. code-block:: python
@tbot.testcase
def foo_bar():
with tbot.ctx() as cx:
lh = cx.request(tbot.role.LabHost)
bh = cx.request(tbot.role.BuildHost)
lh.exec0("cat", "/etc/os-release")
bh.exec0("free", "-h")
The semantics of a ``request()`` can be controlled further by the
``reset``, ``exclusive``, and ``reset_on_error`` keyword arguments.
See their documentation for the details.
:param tbot.role.Role type: The :ref:`role <tbot_role>` for which a machine instance
is requested.
:param bool reset: Controls what happens if an instance already exists:
- ``False`` (default): If an instance already exists due to a
previous **request()**, it will be returned (both requests
*share* it).
- ``True``: If an instance already exists due to a previous
**request()**, it will be torn down and re-initialized (the
previous request thus looses access to it).
``reset=True`` can, for example, be used to write a testcase where
the DUT is powercycled:
.. code-block:: python
@tbot.testcase
def test_with_reboot():
with tbot.ctx.request(tbot.role.BoardUBoot) as ub:
ub.exec0("version")
# Device will be powercycled here, even though if some "outer"
# context for U-Boot is still active. Note that such an outer
# context will loose access to the instance after this point.
with tbot.ctx.request(tbot.role.BoardUBoot, reset=True) as ub:
ub.exec0("version")
:param bool exclusive: Controls whether other requests can get
access to the same instance while this request is active:
- ``False`` (default): A **request()** after this one will get
*shared* access to the same instance.
- ``True``: Any future **request()** while this one is active is
forbidden and will fail. Once this **request()** ends, the
instance will be torn down so future requests will need to
re-initialize it.
This mode should be used when you are going to do changes to the
instance which could potentially bring it into a state that other
testcases won't expect.
:param bool reset_on_error: Controls behavior when the context-manager
returned by this ``request()`` is exited abnormally via an exception:
- ``False`` (default): The exception is ignored (and just
propagates further up), no special behavior.
- ``True``: Instructs the ``Context`` to forcefully de-initialize
this instance if the context-manager returned from this
``request()`` was exited with an exception. The exception is
then of course propagated further up.
This can be useful to ensure a follow-up request will always get a
clean instance, even when something went wrong here. This is
especially relevant for a :py:class:`Context` which has
``keep_alive=True``.
If ``exclusive=True``, ``reset_on_error=True`` is essentially a no-op.
The default might be ``True`` if the :py:class:`~tbot.Context` was
instantiated with ``reset_on_error_by_default=True``.
"""
if reset_on_error is None:
reset_on_error = self._reset_on_error_default
if self._keep_alive and self._open_contexts == 0:
raise tbot.error.ContextError(
"When a context is marked with `keep_alive` you **must** enter "
+ "its own context-manager to ensure proper cleanup."
)
machine_class, instance = self._get_class_and_instance(type)
if instance.is_alive() and reset:
# Requester wants the machine to be re-initialized if it is already alive.
instance.teardown()
if not instance.is_alive():
instance.init(context=machine_class.from_context(self))
with instance.request(exclusive, self._keep_alive) as m:
assert isinstance(m, machine_class), f"machine type mismatch"
if machine_class not in self._teardown_order:
self._teardown_order.append(machine_class)
try:
yield m
except BaseException as e:
if reset_on_error:
if (
e.__class__.__name__ == "Skipped"
and e.__class__.mro()[1].__module__ == "_pytest.outcomes"
):
tbot.log.warning(
"Ignoring `reset_on_error` because exception was from pytest.skip()"
)
else:
if instance.is_alive():
instance.teardown()
raise e from None
[docs] def get_machine_class(self, type: Callable[..., M]) -> Type[M]:
"""
Return the registered machine class for a :py:class:`~tbot.role.Role`.
"""
role = typing.cast(Type[tbot.role.Role], type)
return typing.cast(Type[M], self._roles[role])
[docs] def teardown_if_alive(self, type: Callable[..., M]) -> bool:
"""
Tear down any existing machine instances for a certain role.
This is useful, for example, when there might be a ``BoardLinux``
instance active and you need to get into ``BoardUBoot``.
:returns: Boolean whether an instance was alive and torn down
(``True``) or whether no instance was alive (``False``).
.. versionadded:: 0.9.3
"""
_, instance = self._get_class_and_instance(type)
if instance.is_alive():
instance.teardown()
return True
else:
return False
[docs] @contextlib.contextmanager
def reconfigure(
self,
*,
keep_alive: Optional[bool] = None,
reset_on_error_by_default: Optional[bool] = None,
) -> "Iterator[Context]":
"""
Temporarily reconfigure this context (e.g. ``keep_alive`` flag).
This method allows you to temporarily change flags for this context and
have them restored afterwards. For example, this can be useful for
running a test-suite with the ``keep_alive`` and ``reset_on_error``
flags enabled.
**Example**:
.. code-block:: python
with ctx.reconfigure(keep_alive=True):
...
Once the reconfiguration context-manager exits, the old state will be
restored. This especially means that any machines which were kept
alive due to the reconfiguration (but have no active outside users)
will be torn down before returning to the old state.
.. versionadded:: 0.9.1
"""
keep_alive_orig = self._keep_alive
reset_on_error_orig = self._reset_on_error_default
try:
if keep_alive is not None:
self._keep_alive = keep_alive
if reset_on_error_by_default is not None:
self._reset_on_error_default = reset_on_error_by_default
yield self
finally:
self._keep_alive = keep_alive_orig
self._reset_on_error_default = reset_on_error_orig
# If the previous configuration did not enable keep_alive, we need
# to tear down all the machines which are still alive but have no
# users.
if keep_alive_orig is False and keep_alive is True:
for cls in reversed(self._teardown_order):
inst = self._instances[cls]
if inst.is_alive() and not inst.has_users():
inst.teardown()
[docs] def is_active(self) -> bool:
"""
Check whether this context was already "activated" by entering it.
For the :py:class:`tbot.Context` to work properly, it should be entered
as a context-manager at least once (but it is okay to do it multiple
times):
.. code-block:: python
with tbot.ctx:
...
``is_active()`` can be used to check if this has already happened.
This can be used as an indication whether the context was already
initialized or not. If it wasn't, you probably need to register
machines for this context first (for example by loading configuration
modules).
.. versionadded:: 0.10.1
"""
return self._open_contexts != 0
@contextlib.contextmanager
def __call__(self) -> "Iterator[ContextHandle]":
with contextlib.ExitStack() as exitstack:
handle = ContextHandle(self, exitstack)
yield handle
def __enter__(self) -> "Context":
self._open_contexts += 1
return self
def __exit__(self, *args: Any) -> None:
try:
if self._open_contexts == 1:
for cls in reversed(self._teardown_order):
inst = self._instances[cls]
if inst.is_alive():
if self._keep_alive:
# If we kept instances alive, now is a good time to
# finally tear them down; there won't be any users
# after this point...
inst.teardown()
else:
tbot.log.warning(
f"Found dangling {cls!r} instance in this context"
)
finally:
if self._open_contexts == 1:
for cls, inst in self._instances.items():
if inst.is_alive():
tbot.log.warning(
f"Teardown went wrong! A {cls!r} instance is still alive.\n"
+ "Please report this to https://github.com/rahix/tbot/issues!"
)
self._open_contexts -= 1
T = TypeVar("T")
class ContextHandle:
def __init__(self, ctx: Context, exitstack: contextlib.ExitStack) -> None:
self.ctx = ctx
self._exitstack = exitstack
def request(
self,
type: Callable[..., M],
*,
reset: bool = False,
exclusive: bool = False,
reset_on_error: Optional[bool] = None,
) -> M:
return self.enter_context(
self.ctx.request(
type, reset=reset, exclusive=exclusive, reset_on_error=reset_on_error
)
)
def get_machine_class(self, type: Callable[..., M]) -> Type[M]:
return self.ctx.get_machine_class(type)
def enter_context(self, context: ContextManager[T]) -> T:
return self._exitstack.enter_context(context)