Skip to content

Commit f50a8db

Browse files
authored
Merge pull request #53 from NatLabRockies/feature/time-utils
feature/time-utils
2 parents 11a56b0 + 60e88a4 commit f50a8db

10 files changed

Lines changed: 419 additions & 62 deletions

File tree

.github/linters/.codespellrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[codespell]
2-
skip = build,docs,images,reports,*.c,*.so,*.pyd
2+
skip = build,docs,images,reports,inprogress,*.c,*.so,*.pyd

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## [Unreleased](https://github.com/NatLabRockies/scikit-sundae)
44

55
### New Features
6+
- Add `Timer` and `Timeout` utils to profile and limit execution times ([#53](https://github.com/NatLabRockies/scikit-sundae/pull/53))
67
- Move to newest SUNDIALS v7.7 for CI builds/tests ([#52](https://github.com/NatLabRockies/scikit-sundae/pull/52))
78
- Move to newest SUNDIALS v7.6 for CI builds/tests ([#44](https://github.com/NatLabRockies/scikit-sundae/pull/44))
89
- Custom `__reduce__` methods, allowing solvers to be serialized ([#38](https://github.com/NatLabRockies/scikit-sundae/pull/38))

docs/source/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@
9191
'header_links_before_dropdown': 5,
9292
'footer_start': ['copyright'],
9393
'footer_end': ['sphinx-version'],
94-
'navbar_persistent': ['search-button-field'],
94+
'navbar_persistent': ['search-button'],
95+
'navbar_end': ['navbar-icon-links', 'theme-switcher'],
9596
'primary_sidebar_end': ['sidebar-ethical-ads'],
9697
'secondary_sidebar_items': ['page-toc'],
9798
'search_bar_text': 'Search...',

images/tests.svg

Lines changed: 4 additions & 4 deletions
Loading

src/sksundae/utils/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""General-purpose module for shared utilities across the package."""
2+
3+
from ._timer import Timer
4+
from ._timeout import Timeout
5+
from ._rich_result import RichResult
6+
7+
__all__ = ['Timer', 'Timeout', 'RichResult']
Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
"""General-purpose module for shared utilities across the package."""
2-
31
import numpy as np
42

5-
__all__ = ['RichResult',]
6-
73

84
# RichResult and its formatters are modified copies from scipy._lib._util
95
class RichResult:
@@ -40,9 +36,7 @@ def __init__(self, **kwargs):
4036
4137
.. code-block:: python
4238
43-
import sksundae as sun
44-
45-
class CustomResult(sun.utils.RichResult):
39+
class CustomResult(RichResult):
4640
_order_keys = ['first', 'second', 'third',]
4741
4842
result = CustomResult(second=None, last=None, first=None)
@@ -56,8 +50,6 @@ class CustomResult(sun.utils.RichResult):
5650
5751
import numpy as np
5852
59-
from sksundae.utils import RichResult
60-
6153
t = np.linspace(0, 1, 1000)
6254
y = np.random.rand(1000, 5)
6355
@@ -72,8 +64,6 @@ class CustomResult(sun.utils.RichResult):
7264
7365
.. code-block:: python
7466
75-
from sksundae.utils import RichResult
76-
7767
result = RichResult(a=10, b=20, c=30)
7868
7969
print(result.a*(result.b + result.c))

src/sksundae/utils/_timeout.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
from __future__ import annotations
2+
3+
import _thread
4+
import threading
5+
6+
7+
class TimeoutError(BaseException):
8+
pass
9+
10+
11+
class Timeout:
12+
"""Timeout context manager."""
13+
14+
__slots__ = (
15+
'_seconds', '_name', '_raise_exc', '_timer', '_expired', '_exception',
16+
)
17+
18+
def __init__(
19+
self,
20+
seconds: float,
21+
name: str = 'Timeout',
22+
raise_exc: bool = True,
23+
) -> None:
24+
"""
25+
Uses a thread to track the time spent in a context block and forces an
26+
exit (and optionally raises a `TimeoutError`) if the execution time
27+
exceeds a given number of seconds. See the notes for important details
28+
on edge cases where this may not work as expected.
29+
30+
Parameters
31+
----------
32+
seconds : float
33+
Number of seconds before timing out.
34+
name : str, optional
35+
Name to identify the block in exit messages, by default 'Timeout'.
36+
raise_exc : bool, optional
37+
If True, raise a `TimeoutError` on exit when the given time limit is
38+
exceeded. Use False to handle the timeout manually.
39+
40+
Notes
41+
-----
42+
The `KeyboardInterrupt` that is used to force the exit of the main
43+
thread may not be immediate, depending on the state of the main thread.
44+
For example, if you are running `time.sleep`, the interruption will not
45+
be raised until the sleep is complete.
46+
47+
If you are using extensions that are not written in pure Python, the
48+
interruption may not be raised at all, depending on how that extension
49+
was written to catch and handle exceptions.
50+
51+
Examples
52+
--------
53+
The `Timeout` class is used as a context manager, as shown below. Here,
54+
we force a timeout by setting the time limit to 1/1000 of the execution
55+
time to complete the `slow_fibonacci` function.
56+
57+
.. code-block:: python
58+
59+
def slow_fibonacci(n):
60+
if n < 2:
61+
return n
62+
return slow_fibonacci(n-1) + slow_fibonacci(n-2)
63+
64+
with Timer() as timer:
65+
result = slow_fibonacci(30)
66+
67+
exec_time = timer.elapsed_time
68+
time_limit = exec_time / 1000
69+
70+
with Timeout(time_limit) as timeout:
71+
result = slow_fibonacci(30)
72+
73+
By default, the context manager will raise an exception if the context
74+
block exceeds the set time limit. If you want to suppress this behavior,
75+
and handle it manually you can do so using `raise_exc=False`.
76+
77+
.. code-block:: python
78+
79+
with Timeout(time_limit, raise_exc=False) as timeout:
80+
result = slow_fibonacci(30)
81+
82+
if timeout.expired:
83+
raise timeout.exception
84+
85+
Since we use a `KeyboardInterrupt` to stop the main thread, you may also
86+
want to differentiate between user-raised interrupts and timeouts. This
87+
also requires setting `raise_exc=False` to prevent an exception on exit.
88+
89+
.. code-block:: python
90+
91+
with Timeout(time_limit, raise_exc=False) as timeout:
92+
93+
try:
94+
result = slow_fibonacci(30)
95+
except KeyboardInterrupt:
96+
if timeout.expired:
97+
print('Timeout expired.')
98+
else:
99+
print('User interrupt.') # or raise to stop execution
100+
101+
While demonstrated with a standin function for the Fibonacci sequence,
102+
this context manager can be used with any block and can help catch and
103+
cancel long-running operations. Note that the `Timer` and `Timeout`
104+
utilities can also be used in the same context block, as demonstrated
105+
below.
106+
107+
.. code-block:: python
108+
109+
with Timer() as timer, Timeout(1000*exec_time) as timeout:
110+
result = slow_fibonacci(30)
111+
112+
print(f"{timeout.expired=}") # should be False since limit is high
113+
timer.print_elapsed()
114+
115+
"""
116+
self._name = name
117+
self._seconds = seconds
118+
self._raise_exc = raise_exc
119+
120+
self._timer = None
121+
self._expired = False
122+
self._exception = TimeoutError(
123+
f"{self._name} exceeded {self._seconds:.2f} seconds."
124+
)
125+
126+
def __enter__(self) -> Timeout:
127+
"""Create and start the timer when entering the context block."""
128+
self._timer = threading.Timer(self._seconds, self._interrupt)
129+
self._timer.start()
130+
131+
self._expired = False
132+
133+
return self
134+
135+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
136+
"""If the timer is still running, cancel, reset, and handle errors."""
137+
if self._timer:
138+
self._timer.cancel()
139+
self._timer = None
140+
141+
if self.expired and self._raise_exc:
142+
raise self.exception from None
143+
elif self.expired and not self._raise_exc:
144+
return True # suppress if expired but not raising
145+
146+
return False # don't suppress exceptions
147+
148+
@property
149+
def expired(self) -> bool:
150+
"""Whether the timer has expired."""
151+
return self._expired
152+
153+
@property
154+
def exception(self) -> TimeoutError:
155+
"""The exception to raise if the timer expired."""
156+
return self._exception
157+
158+
def _interrupt(self) -> None:
159+
"""
160+
Define the interruption to use when the timer runs out. Here the main
161+
thread is interrupted with KeyboardInterrupt and a flag is set to True
162+
to provide a way to differentiate from user-raised interrupts.
163+
164+
"""
165+
self._expired = True
166+
_thread.interrupt_main() # raises KeyboardInterrupt

src/sksundae/utils/_timer.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import time
4+
import textwrap
5+
6+
from typing import Literal
7+
8+
9+
class Timer:
10+
"""Timer context manager."""
11+
12+
__slots__ = ('name', '_units', '_converter', '_start', '_stop', '_display')
13+
14+
def __init__(
15+
self,
16+
name: str = 'Elapsed time',
17+
units: Literal['s', 'min', 'h'] = 's',
18+
display: bool = True,
19+
) -> None:
20+
"""
21+
Records and prints the elapsed time between entering and exiting a
22+
context block.
23+
24+
Parameters
25+
----------
26+
name : str, optional
27+
Context block name used in print. The default is 'Elapsed time'.
28+
units : Literal['s', 'min', 'h'], optional
29+
Units to use when printing the elapsed time. The default is 's'.
30+
display : bool, optional
31+
If True, print on exit. Otherwise, only stores the elapsed time.
32+
33+
Notes
34+
-----
35+
If you want to print in additional units, you can convert and print the
36+
elapsed time using the `elapsed_time` property.
37+
38+
A timer instance can be reused across multiple context blocks; however,
39+
the `elapsed_time` property will only store values from the last block.
40+
41+
Examples
42+
--------
43+
The `Timer` works as a context manager:
44+
45+
.. code-block:: python
46+
47+
import time
48+
49+
def function(sleep_time: float) -> None:
50+
time.sleep(sleep_time)
51+
52+
with Timer():
53+
function(2.)
54+
55+
If you want to silence printing, set `display=False`. You can then call
56+
`print_elapsed` at a later time, or access the `elapsed_time` property
57+
for custom printing:
58+
59+
.. code-block:: python
60+
61+
with Timer(display=False) as timer:
62+
function(2.)
63+
64+
# print in all allowed units
65+
for units in ['s', 'min', 'h']:
66+
timer.print_elapsed(units)
67+
68+
# custom printing, with higher precision
69+
print(f"Elapsed time: {timer.elapsed_time:.10f} s")
70+
71+
"""
72+
if units not in {'s', 'min', 'h'}:
73+
raise ValueError(
74+
"'units' input is invalid. Expected one of {'s', 'min', 'h'},"
75+
f" but got {units!r}."
76+
)
77+
78+
self.name = name
79+
self._units = units
80+
self._converter = {
81+
's': lambda t: t,
82+
'min': lambda t: t / 60.,
83+
'h': lambda t: t / 3600.,
84+
}
85+
86+
self._start = 0.
87+
self._stop = 0.
88+
self._display = display
89+
90+
def __repr__(self) -> str: # pragma: no cover
91+
"""Quick representation of the timer showing name, time, and units."""
92+
elapsed = self._converter[self._units](self.elapsed_time)
93+
elapsed_string = f"{elapsed} {self._units}"
94+
95+
data = {
96+
'name': self.name,
97+
'elapsed': elapsed_string,
98+
}
99+
100+
summary = "\n".join([f"{k}={v!r}," for k, v in data.items()])
101+
summary = textwrap.indent(summary, " " * 4)
102+
103+
return f"{type(self).__name__}(\n{summary}\n)"
104+
105+
def __enter__(self) -> Timer:
106+
"""Store start time when entering a context block."""
107+
self._start = time.perf_counter()
108+
return self
109+
110+
def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]:
111+
"""Store stop time on exit, and optionally print."""
112+
self._stop = time.perf_counter()
113+
if self._display:
114+
self.print_elapsed(self._units)
115+
116+
return False
117+
118+
@property
119+
def elapsed_time(self) -> float:
120+
"""The elapsed time in seconds."""
121+
return self._stop - self._start
122+
123+
def print_elapsed(self, units: Literal['s', 'min', 'h'] = 's') -> None:
124+
"""
125+
Print the elapsed time.
126+
127+
Parameters
128+
----------
129+
units : Literal['s', 'min', 'h'], optional
130+
Units to use when printing the elapsed time. The default is 's'.
131+
132+
"""
133+
elapsed = self._converter[units](self.elapsed_time)
134+
print(f"{self.name}: {elapsed:.5f} {units}")

0 commit comments

Comments
 (0)