v0.166.0
  1"""
  2The shape every `capture_*` helper hands back.
  3
  4A capture is a read-only sequence of what happened inside its block, in the
  5order it happened. It is complete when the block ends, so that is when it can
  6be read.
  7
  8`Captured` is what a test reads. `CaptureSource` is for writing a
  9`capture_*` helper whose captures all read from one place, such as the span
 10exporter or a connection's query log.
 11"""
 12
 13from collections.abc import Callable, Generator, Iterable, Iterator, Sequence
 14from contextlib import contextmanager
 15from typing import overload
 16
 17__all__ = ["CaptureSource", "Captured"]
 18
 19
 20class Captured[T](Sequence[T]):
 21    """
 22    What a `capture_*` block recorded, in the order it happened.
 23
 24        with capture_spans() as spans:
 25            Client().get("/")
 26
 27        assert len(spans) == 1
 28        assert spans[0].name == "GET /"
 29
 30    It's a sequence, so `len()`, indexing, slicing, iteration, `in` and
 31    truthiness all work, and nothing can be added to it or removed from it.
 32    It compares equal to a list or tuple holding the same items.
 33
 34    Read it after the block. Inside the block the capture is still going — a
 35    span that hasn't ended or a metric that hasn't been collected isn't there
 36    yet — so reading it raises instead of answering with part of the story.
 37    """
 38
 39    def __init__(self, *, helper: str) -> None:
 40        # The helper's name, for the messages: "capture_spans".
 41        self._helper = helper
 42        self._items: tuple[T, ...] | None = None
 43
 44    def finish(self, items: Iterable[T]) -> None:
 45        """
 46        Say what was captured. The helper that made this capture calls it
 47        when its block ends, and from then on the capture can be read.
 48        """
 49        self._items = tuple(items)
 50
 51    @property
 52    def finished(self) -> bool:
 53        """Whether the block has ended, so the capture can be read."""
 54        return self._items is not None
 55
 56    def _finished_items(self) -> tuple[T, ...]:
 57        if self._items is None:
 58            raise RuntimeError(
 59                f"{self._helper}() is still capturing — read what it captured"
 60                f" after the `with {self._helper}()` block ends, not inside it."
 61            )
 62        return self._items
 63
 64    def __len__(self) -> int:
 65        return len(self._finished_items())
 66
 67    @overload
 68    def __getitem__(self, index: int) -> T: ...
 69
 70    @overload
 71    def __getitem__(self, index: slice) -> Sequence[T]: ...
 72
 73    def __getitem__(self, index: int | slice) -> T | Sequence[T]:
 74        return self._finished_items()[index]
 75
 76    def __iter__(self) -> Iterator[T]:
 77        return iter(self._finished_items())
 78
 79    def __eq__(self, other: object) -> bool:
 80        # Equal to a list or tuple of the same items, so `captured == []`
 81        # says what it looks like it says instead of always being False.
 82        if isinstance(other, Captured):
 83            return self._finished_items() == other._finished_items()
 84        if isinstance(other, list | tuple):
 85            return list(self._finished_items()) == list(other)
 86        return NotImplemented
 87
 88    def __repr__(self) -> str:
 89        if self._items is None:
 90            return f"<{type(self).__name__}: still capturing>"
 91        return f"<{type(self).__name__} {list(self._items)!r}>"
 92
 93
 94class CaptureSource[T]:
 95    """
 96    The one place every capture of a kind reads from: the span exporter, a
 97    connection's query log. It is a list that grows as things happen, given
 98    here as a function that reads it and a function that empties it.
 99
100        _span_source = CaptureSource(
101            read=exporter.get_finished_spans, clear=exporter.clear
102        )
103
104        @contextmanager
105        def capture_spans():
106            captured = CapturedSpans()
107            with _span_source.capturing_into(captured):
108                yield captured
109
110    Captures nest: a project lifecycle can capture around every test, with
111    the test's own capture inside it. So a capture doesn't empty the list
112    when it starts. It remembers how long the list was, and gets what was
113    added after that. The list is emptied when no capture is open: before
114    the outermost one starts, and after it ends, so that nothing captured is
115    kept for the rest of the run.
116    """
117
118    def __init__(
119        self, *, read: Callable[[], Sequence[T]], clear: Callable[[], None]
120    ) -> None:
121        self._read = read
122        self._clear = clear
123        self._open_captures = 0
124
125    @property
126    def capturing(self) -> bool:
127        """Whether any capture that reads from here is open."""
128        return self._open_captures > 0
129
130    @contextmanager
131    def capturing_into(self, captured: Captured[T]) -> Generator[None]:
132        """
133        Capture for the duration of the block, and finish `captured` with
134        what was added during it. A block that raises still finishes its
135        capture.
136        """
137        if not self.capturing:
138            self._clear()
139        start = len(self._read())
140
141        self._open_captures += 1
142        try:
143            yield
144        finally:
145            self._open_captures -= 1
146            captured.finish(self._read()[start:])
147            if not self.capturing:
148                self._clear()