Clean up properly during resource acquisition

When acquiring resources in the __enter__ method of a context manager, it is important to ensure that the resources are properly cleaned up if an exception occurs.

In the following example, if acquire_resource2() raises an exception in the __enter__ method, the __exit__ method will not be called, and self._resource1 will not be closed. A similar issue occurs if resource2.close() raises an exception, in which case resource1.close() will not be called.

class MyDevice(Device):

    def __enter__(self) -> Self:
        self._resource1 = acquire_resource1()
        self._resource2 = acquire_resource2()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self._resource2.close()
        self._resource1.close()

To avoid this issue, you can use a contextlib.ExitStack like in the following example.

class MyDevice(Device):

    def __enter__(self) -> Self:
        self._stack = contextlib.ExitStack()

        try:
            self._resource1 = acquire_resource1()
            self._stack.callback(resource1.close)
            self._resource2 = acquire_resource2()
            self._stack.callback(resource2.close)
        except:
            self._stack.close()
            raise

    def __exit__(self, exc_type, exc_val, exc_tb):
        self._stack.close()