1# curio/time.py
2#
3# Functionality related to time handling including timeouts and sleeping
4
5__all__ = [
6    'clock', 'sleep', 'timeout_after', 'ignore_after',
7    ]
8
9# -- Standard library
10
11import logging
12log = logging.getLogger(__name__)
13
14# --- Curio
15
16from .task import current_task
17from .traps import *
18from .errors import *
19from . import meta
20
21async def clock():
22    '''
23    Immediately return the current value of the kernel clock. There
24    are no side-effects such as task preemption or cancellation.
25    '''
26    return await _clock()
27
28async def sleep(seconds):
29    '''
30    Sleep for a specified number of seconds.  Sleeping for 0 seconds
31    makes a task immediately switch to the next ready task (if any).
32    Returns the value of the kernel clock when awakened.
33    '''
34    return await _sleep(seconds)
35
36class _TimeoutAfter(object):
37    '''
38    Helper class used by timeout_after() and ignore_after() functions
39    when used as a context manager.  For example:
40
41        async with timeout_after(delay):
42            statements
43            ...
44    '''
45
46    def __init__(self, clock, ignore=False, timeout_result=None):
47        self._clock = clock
48        self._ignore = ignore
49        self._timeout_result = timeout_result
50        self.expired = False
51        self.result = True
52
53    async def __aenter__(self):
54        task = await current_task()
55        # Clock adjusted to absolute time
56        if self._clock is not None:
57            self._clock += await _clock()
58        self._deadlines = task._deadlines
59        self._deadlines.append(self._clock)
60        self._prior = await _set_timeout(self._clock)
61        return self
62
63    async def __aexit__(self, ty, val, tb):
64        current_clock = await _unset_timeout(self._prior)
65
66        # Discussion.  If a timeout has occurred, it will either
67        # present itself here as a TaskTimeout or TimeoutCancellationError
68        # exception.  The value of this exception is set to the current
69        # kernel clock which can be compared against our own deadline.
70        # What happens next is driven by these rules:
71        #
72        # 1.  If we are the outer-most context where the timeout
73        #     period has expired, then a TaskTimeout is raised.
74        #
75        # 2.  If the deadline has expired for at least one outer
76        #     context, (but not us), a TimeoutCancellationError is
77        #     raised.  This means that time has expired elsewhere.
78        #     We're being cancelled because of that, but the reason
79        #     for the cancellation wasn't due to a timeout on our
80        #     part.
81        #
82        # 3.  If the timeout period has not expired on ANY remaining
83        #     timeout context, it means that a timeout has escaped
84        #     some inner timeout context where it should have been
85        #     caught. This is an operational error.  We raise
86        #     UncaughtTimeoutError.
87
88        try:
89            if ty in (TaskTimeout, TimeoutCancellationError):
90                timeout_clock = val.args[0]
91                # Find the outer most deadline that has expired
92                for n, deadline in enumerate(self._deadlines):
93                    if deadline <= timeout_clock:
94                        break
95                else:
96                    # No remaining context has expired. An operational error
97                    raise UncaughtTimeoutError('Uncaught timeout received')
98
99                if n < len(self._deadlines) - 1:
100                    if ty is TaskTimeout:
101                        raise TimeoutCancellationError(val.args[0]).with_traceback(tb) from None
102                    else:
103                        return False
104                else:
105                    # The timeout is us.  Make sure it's a TaskTimeout (unless ignored)
106                    self.result = self._timeout_result
107                    self.expired = True
108                    if self._ignore:
109                        return True
110                    else:
111                        if ty is TimeoutCancellationError:
112                            raise TaskTimeout(val.args[0]).with_traceback(tb) from None
113                        else:
114                            return False
115            elif ty is None:
116                if current_clock > self._deadlines[-1]:
117                    # Further discussion.  In the presence of threads and blocking
118                    # operations, it's possible that a timeout has expired, but
119                    # there was simply no opportunity to catch it because there was
120                    # no suspension point.
121                    badness = current_clock - self._deadlines[-1]
122                    log.warning('%r. Operation completed successfully, '
123                                'but it took longer than an enclosing timeout. Badness delta=%r.',
124                                await current_task(), badness)
125
126        finally:
127            self._deadlines.pop()
128
129    def __enter__(self):
130        return thread.AWAIT(self.__aenter__())
131
132    def __exit__(self, *args):
133        return thread.AWAIT(self.__aexit__(*args))
134
135async def _timeout_after_func(clock, coro, args,
136                              ignore=False, timeout_result=None):
137    coro = meta.instantiate_coroutine(coro, *args)
138    async with _TimeoutAfter(clock, ignore=ignore, timeout_result=timeout_result):
139        return await coro
140
141def timeout_after(seconds, coro=None, *args):
142    '''
143    Raise a TaskTimeout exception in the calling task after seconds
144    have elapsed.  This function may be used in two ways. You can
145    apply it to the execution of a single coroutine:
146
147         await timeout_after(seconds, coro(args))
148
149    or you can use it as an asynchronous context manager to apply
150    a timeout to a block of statements:
151
152         async with timeout_after(seconds):
153             await coro1(args)
154             await coro2(args)
155             ...
156    '''
157    if coro is None:
158        return _TimeoutAfter(seconds)
159    else:
160        return _timeout_after_func(seconds, coro, args)
161
162def ignore_after(seconds, coro=None, *args, timeout_result=None):
163    '''
164    Stop the enclosed task or block of code after seconds have
165    elapsed.  No exception is raised when time expires. Instead, None
166    is returned.  This is often more convenient that catching an
167    exception.  You can apply the function to a single coroutine:
168
169        if await ignore_after(5, coro(args)) is None:
170            # A timeout occurred
171            ...
172
173    Alternatively, you can use this function as an async context
174    manager on a block of statements like this:
175
176        async with ignore_after(5) as r:
177            await coro1(args)
178            await coro2(args)
179            ...
180        if r.result is None:
181            # A timeout occurred
182
183    When used as a context manager, the return manager object has
184    a result attribute that will be set to None if the time
185    period expires (or True otherwise).
186
187    You can change the return result to a different value using
188    the timeout_result keyword argument.
189    '''
190    if coro is None:
191        return _TimeoutAfter(seconds, ignore=True, timeout_result=timeout_result)
192    else:
193        return _timeout_after_func(seconds, coro, args, ignore=True, timeout_result=timeout_result)
194
195from . import thread
196