diff --git a/doc/lsst.verify/index.rst b/doc/lsst.verify/index.rst
index 59e52c2..dcc63cd 100644
--- a/doc/lsst.verify/index.rst
+++ b/doc/lsst.verify/index.rst
@@ -119,4 +119,8 @@ Python API reference
:no-main-docstr:
:no-inheritance-diagram:
+.. automodapi:: lsst.verify.timer
+ :no-main-docstr:
+ :no-inheritance-diagram:
+
.. _SQUASH: https://squash.lsst.codes
diff --git a/python/lsst/verify/__init__.py b/python/lsst/verify/__init__.py
index 455a3a7..4acaa31 100644
--- a/python/lsst/verify/__init__.py
+++ b/python/lsst/verify/__init__.py
@@ -41,4 +41,5 @@
from .jobmetadata import *
from .job import *
from .output import *
+from . import timer
from . import yamlpersistance
diff --git a/python/lsst/verify/jobmetadata.py b/python/lsst/verify/jobmetadata.py
index 104f0ef..9f6b7a7 100644
--- a/python/lsst/verify/jobmetadata.py
+++ b/python/lsst/verify/jobmetadata.py
@@ -196,7 +196,7 @@ def values(self):
"""Iterate over metadata values.
Returns
- ------
+ -------
items : `~collections.abc.ValuesView`
An iterable over all the values.
"""
diff --git a/python/lsst/verify/timer.py b/python/lsst/verify/timer.py
new file mode 100644
index 0000000..5f350f1
--- /dev/null
+++ b/python/lsst/verify/timer.py
@@ -0,0 +1,70 @@
+# This file is part of verify.
+#
+# Developed for the LSST Data Management System.
+# This product includes software developed by the LSST Project
+# (https://www.lsst.org).
+# See the COPYRIGHT file at the top-level directory of this distribution
+# for details of code ownership.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+__all__ = ["time_this_to_measurement"]
+
+from contextlib import contextmanager
+import time
+
+import astropy.units as u
+
+from .datum import Datum
+from .measurement import Measurement
+
+
+def _epoch_to_iso(seconds):
+ """Convert a time in seconds since Unix epoch to an ISO 8601 timestamp.
+
+ Parameters
+ ----------
+ seconds : `float`
+ The number of seconds since the Unix epoch.
+
+ Returns
+ -------
+ timestamp : `str`
+ The input time represented as a timestamp.
+ """
+ iso_format = "%Y-%m-%dT%H:%M:%SZ"
+ return time.strftime(iso_format, time.gmtime(seconds))
+
+
+@contextmanager
+def time_this_to_measurement(measurement: Measurement):
+ """Time the enclosed block and record it as an lsst.verify measurement.
+
+ Parameters
+ ----------
+ measurement : `lsst.verify.Measurement`
+ Measurement object to fill with the timing information. Its metric must
+ have time dimensions. Any properties other than ``metric`` and
+ ``metric_name`` may be overwritten.
+ """
+ start = time.time()
+ try:
+ yield
+ finally:
+ end = time.time()
+ measurement.quantity = (end - start) * u.second
+ # Same metadata as provided by TimingMetricTask
+ measurement.notes["estimator"] = "verify.timer.time_this_to_measurement"
+ measurement.extras["start"] = Datum(_epoch_to_iso(start))
+ measurement.extras["end"] = Datum(_epoch_to_iso(end))
diff --git a/tests/test_timer.py b/tests/test_timer.py
new file mode 100644
index 0000000..178d8af
--- /dev/null
+++ b/tests/test_timer.py
@@ -0,0 +1,95 @@
+# This file is part of verify.
+#
+# Developed for the LSST Data Management System.
+# This product includes software developed by the LSST Project
+# (https://www.lsst.org).
+# See the COPYRIGHT file at the top-level directory of this distribution
+# for details of code ownership.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+import time
+import unittest
+
+import astropy.units as u
+
+import lsst.utils.tests
+
+from lsst.verify import Measurement, Metric, Name
+from lsst.verify.timer import time_this_to_measurement
+
+
+class TimeThisTestSuite(unittest.TestCase):
+ def setUp(self):
+ super().setUp()
+ self.metric_name = Name("verify.DummyTime")
+
+ def test_basic(self):
+ duration = 0.2
+ meas = Measurement(self.metric_name)
+ with time_this_to_measurement(meas):
+ time.sleep(duration)
+
+ self.assertEqual(meas.metric_name, self.metric_name) # Should not have changed
+ self.assertIsNotNone(meas.quantity)
+ self.assertGreater(meas.quantity, duration * u.second)
+ self.assertLess(meas.quantity, 2 * duration * u.second)
+
+ def test_unit_checking_ok(self):
+ duration = 0.2
+ metric = Metric(self.metric_name, "Unconventional metric", u.nanosecond)
+ meas = Measurement(metric)
+ with time_this_to_measurement(meas):
+ time.sleep(duration)
+
+ self.assertEqual(meas.metric_name, self.metric_name) # Should not have changed
+ self.assertIsNotNone(meas.quantity)
+ self.assertGreater(meas.quantity, duration * u.second)
+ self.assertLess(meas.quantity, 2 * duration * u.second)
+
+ def test_unit_checking_bad(self):
+ duration = 0.2
+ metric = Metric(self.metric_name, "Non-temporal metric", u.meter / u.second)
+ meas = Measurement(metric)
+ with self.assertRaises(TypeError):
+ with time_this_to_measurement(meas):
+ time.sleep(duration)
+
+ def test_exception(self):
+ duration = 0.2
+ meas = Measurement(self.metric_name)
+ try:
+ with time_this_to_measurement(meas):
+ time.sleep(duration)
+ raise RuntimeError("Something went wrong!")
+ except RuntimeError:
+ pass
+
+ self.assertEqual(meas.metric_name, self.metric_name) # Should not have changed
+ self.assertIsNotNone(meas.quantity)
+ self.assertGreater(meas.quantity, duration * u.second)
+ self.assertLess(meas.quantity, 2 * duration * u.second)
+
+
+class MemoryTester(lsst.utils.tests.MemoryTestCase):
+ pass
+
+
+def setup_module(module):
+ lsst.utils.tests.init()
+
+
+if __name__ == "__main__":
+ lsst.utils.tests.init()
+ unittest.main()