From a35f3e2f41b55a90f21b57e677da0225cb1dff6d Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Sun, 17 Dec 2023 09:52:54 -0500 Subject: [PATCH 1/4] Add CxoTimeDescriptor descriptor and minor tidy --- cxotime/__init__.py | 4 +-- cxotime/cxotime.py | 41 ++++++++++++++++++++++ cxotime/tests/test_cxotime.py | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/cxotime/__init__.py b/cxotime/__init__.py index 966a80a..adf2056 100644 --- a/cxotime/__init__.py +++ b/cxotime/__init__.py @@ -3,8 +3,8 @@ from astropy import units from astropy.time import TimeDelta -from .convert import * -from .cxotime import CxoTime, CxoTimeLike +from .convert import * # noqa: F401, F403 +from .cxotime import * # noqa: F401, F403 __version__ = ska_helpers.get_version(__package__) diff --git a/cxotime/cxotime.py b/cxotime/cxotime.py index 372e663..99377c5 100644 --- a/cxotime/cxotime.py +++ b/cxotime/cxotime.py @@ -9,6 +9,10 @@ import numpy.typing as npt from astropy.time import Time, TimeCxcSec, TimeDecimalYear, TimeJD, TimeYearDayTime from astropy.utils import iers +from ska_helpers.utils import TypedDescriptor + +__all__ = ["CxoTime", "CxoTimeLike", "CxoTimeDescriptor"] + # TODO: use npt.NDArray with numpy 1.21 CxoTimeLike = Union["CxoTime", str, float, int, np.ndarray, npt.ArrayLike, None] @@ -498,3 +502,40 @@ def to_value(self, parent=None, **kwargs): return out value = property(to_value) + + +class CxoTimeDescriptor(TypedDescriptor): + """Descriptor for an attribute that is CxoTime (in date format) or None if not set. + + This allows setting the attribute with any ``CxoTimeLike`` value. + + Note that setting this descriptor to ``None`` will set the attribute to ``None``, + which is different than ``CxoTime(None)`` which returns the current time. To set + an attribute to the current time, set it with ``CxoTime.now()``. + + Parameters + ---------- + default : CxoTimeLike, optional + Default value for the attribute which is provide to the ``CxoTime`` constructor. + If not specified or ``None``, the default for the attribute is ``None``. + required : bool, optional + If ``True``, the attribute is required to be set explicitly when the object + is created. If ``False`` the default value is used if the attribute is not set. + + Examples + -------- + >>> from dataclasses import dataclass + >>> from cxotime import CxoTime, CxoTimeDescriptor + >>> @dataclass + ... class MyClass: + ... start: CxoTime | None = CxoTimeDescriptor() + ... stop: CxoTime | None = CxoTimeDescriptor() + ... + >>> obj = MyClass("2023:100") + >>> obj.start + + >>> obj.stop is None + True + """ + + cls = CxoTime diff --git a/cxotime/tests/test_cxotime.py b/cxotime/tests/test_cxotime.py index e4e3860..6f48e90 100644 --- a/cxotime/tests/test_cxotime.py +++ b/cxotime/tests/test_cxotime.py @@ -5,6 +5,7 @@ """ import io import time +from dataclasses import dataclass import astropy.units as u import numpy as np @@ -17,6 +18,7 @@ # Test that cxotime.__init__ imports the CxoTime class and all converters like date2secs from cxotime import ( # noqa: F401 CxoTime, + CxoTimeDescriptor, convert_time_format, date2greta, date2jd, @@ -454,3 +456,65 @@ def test_convert_time_format_obj(): """Explicit test of convert_time_format for CxoTime object""" tm = CxoTime(100.0) assert tm.date == convert_time_format(tm, "date") + + +def test_cxotime_descriptor_not_required_no_default(): + @dataclass + class MyClass: + time: CxoTime | None = CxoTimeDescriptor() + + obj = MyClass() + assert obj.time is None + + obj = MyClass(time="2020:001") + assert isinstance(obj.time, CxoTime) + assert obj.time.value == "2020:001:00:00:00.000" + assert obj.time.format == "date" + + tm = CxoTime(100.0) + assert tm.format == "secs" + + # Initialize with CxoTime object + obj = MyClass(time=tm) + assert isinstance(obj.time, CxoTime) + assert obj.time.value == 100.0 + + # CxoTime does not copy an existing CxoTime object for speed + assert obj.time is tm + + +def test_cxotime_descriptor_is_required(): + @dataclass + class MyClass: + time: CxoTime = CxoTimeDescriptor(required=True) + + obj = MyClass(time="2020-01-01") + assert obj.time.date == "2020:001:00:00:00.000" + + with pytest.raises( + ValueError, + match="cannot set required attribute 'time' to None", + ): + MyClass() + + +def test_cxotime_descriptor_has_default(): + @dataclass + class MyClass: + time: CxoTime = CxoTimeDescriptor(default="2020-01-01") + + obj = MyClass() + assert obj.time.value == "2020-01-01 00:00:00.000" + + obj = MyClass(time="2023:100") + assert obj.time.value == "2023:100:00:00:00.000" + + +def test_cxotime_descriptor_is_required_has_default_exception(): + with pytest.raises( + ValueError, match="cannot set both 'required' and 'default' arguments" + ): + + @dataclass + class MyClass1: + time: CxoTime = CxoTimeDescriptor(default=100.0, required=True) From 3839e4b3b536260d02152f745b705a4fa1c3d0a0 Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Sat, 6 Jan 2024 06:18:57 -0500 Subject: [PATCH 2/4] Update test for typed-descriptor-lazy-default branch --- cxotime/tests/test_cxotime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cxotime/tests/test_cxotime.py b/cxotime/tests/test_cxotime.py index 6f48e90..2f5ee9b 100644 --- a/cxotime/tests/test_cxotime.py +++ b/cxotime/tests/test_cxotime.py @@ -493,7 +493,7 @@ class MyClass: with pytest.raises( ValueError, - match="cannot set required attribute 'time' to None", + match="attribute 'time' is required and cannot be set to None", ): MyClass() From 76b7db828a727bca78c7bc422668702556823b7b Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Sat, 6 Jan 2024 07:00:36 -0500 Subject: [PATCH 3/4] Add a cxotime.NOW sentinel for initializing to now --- cxotime/cxotime.py | 15 ++++++++----- cxotime/tests/test_cxotime.py | 25 ++++++++++++++++++--- docs/index.rst | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/cxotime/cxotime.py b/cxotime/cxotime.py index 99377c5..4fbec89 100644 --- a/cxotime/cxotime.py +++ b/cxotime/cxotime.py @@ -84,13 +84,18 @@ class CxoTime(Time): """ + # Sentinel object for CxoTime(CxoTime.NOW) to return the current time. See e.g. + # https://python-patterns.guide/python/sentinel-object/. + NOW = object() + def __new__(cls, *args, **kwargs): - # Handle the case of `CxoTime()` which returns the current time. This is - # for compatibility with DateTime. - if not args or (len(args) == 1 and args[0] is None): + # Handle the case of `CxoTime()`, `CxoTime(None)`, or `CxoTime(CxoTime.NOW)`, + # all of which return the current time. This is for compatibility with DateTime. + if not args or (len(args) == 1 and (args[0] is None or args[0] is CxoTime.NOW)): if not kwargs: # Stub in a value for `val` so super()__new__ can run since `val` - # is a required positional arg. + # is a required positional arg. NOTE that this change to args here does + # not affect the args in the call to __init__() below. args = (None,) else: raise ValueError("cannot supply keyword arguments with no time value") @@ -108,7 +113,7 @@ def __init__(self, *args, **kwargs): # implies copy=False) then no other initialization is needed. return - if len(args) == 1 and args[0] is None: + if len(args) == 1 and (args[0] is None or args[0] is CxoTime.NOW): # Compatibility with DateTime and allows kwarg default of None with # input casting like `date = CxoTime(date)`. args = () diff --git a/cxotime/tests/test_cxotime.py b/cxotime/tests/test_cxotime.py index 2f5ee9b..f5e3390 100644 --- a/cxotime/tests/test_cxotime.py +++ b/cxotime/tests/test_cxotime.py @@ -83,15 +83,16 @@ def test_cxotime_now(now_method): CxoTime(scale="utc") -def test_cxotime_now_by_none(): - ct_now = CxoTime(None) +@pytest.mark.parametrize("arg0", [None, CxoTime.NOW]) +def test_cxotime_now_by_arg(arg0): + ct_now = CxoTime(arg0) t_now = Time.now() assert abs((ct_now - t_now).to_value(u.s)) < 0.1 with pytest.raises( ValueError, match="cannot supply keyword arguments with no time value" ): - CxoTime(None, scale="utc") + CxoTime(arg0, scale="utc") def test_cxotime_from_datetime(): @@ -518,3 +519,21 @@ def test_cxotime_descriptor_is_required_has_default_exception(): @dataclass class MyClass1: time: CxoTime = CxoTimeDescriptor(default=100.0, required=True) + + +def test_cxotime_descriptor_with_NOW(): + @dataclass + class MyData: + stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW) + + # Make a new object and check that the stop time is approximately the current time. + obj1 = MyData() + assert (CxoTime.now() - obj1.stop).sec < 0.1 + + # Wait for a second and make a new object and check that the stop time is 1 second + # later. This proves the NOW sentinel is evaluated at object creation time not class + # definition time. + time.sleep(1.0) + obj2 = MyData() + dt = obj2.stop - obj1.stop + assert round(dt.sec, 1) == 1.0 diff --git a/docs/index.rst b/docs/index.rst index 67e9900..502bb52 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -202,6 +202,48 @@ or in python:: iso 2022-01-02 12:00:00.000 unix 1641124800.000 +CxoTime.NOW sentinel +-------------------- + +The |CxoTime| class has a special sentinel value ``CxoTime.NOW`` which can be used +to specify the current time. This is useful for example when defining a function that +has accepts a CxoTime-like argument that defaults to the current time. + +.. note:: Prior to introduction of ``CxoTime.NOW``, the standard idiom was to specify + ``None`` as the argument default to indicate the current time. This is still + supported but is strongly discouraged for new code. + +For example:: + + >>> from cxotime import CxoTime + >>> def my_func(stop=CxoTime.NOW): + ... stop = CxoTime(stop) + ... print(stop) + ... + >>> my_func() + 2024:006:11:37:41.930 + +This can also be used in a `dataclass +`_ to specify an attribute that is +optional and defaults to the current time when the object is created:: + + >>> import time + >>> from dataclasses import dataclass + >>> from cxotime import CxoTime, CxoTimeDescriptor + >>> @dataclass + ... class MyData: + ... start: CxoTime = CxoTimeDescriptor(required=True) + ... stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW) + ... + >>> obj1 = MyData("2022:001") + >>> print(obj1.start) + 2022:001:00:00:00.000 + >>> time.sleep(2) + >>> obj2 = MyData("2022:001") + >>> dt = obj2.stop - obj1.stop + >>> round(dt.sec, 2) + 2.0 + Compatibility with DateTime --------------------------- From f0f3d24d17f318c54b483e644ead80142b7439e1 Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Sat, 6 Jan 2024 07:06:41 -0500 Subject: [PATCH 4/4] More updates for CxoTime.NOW --- cxotime/cxotime.py | 18 ++++++++++-------- cxotime/tests/test_cxotime.py | 13 +++++++++---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/cxotime/cxotime.py b/cxotime/cxotime.py index 4fbec89..0d21e34 100644 --- a/cxotime/cxotime.py +++ b/cxotime/cxotime.py @@ -515,8 +515,10 @@ class CxoTimeDescriptor(TypedDescriptor): This allows setting the attribute with any ``CxoTimeLike`` value. Note that setting this descriptor to ``None`` will set the attribute to ``None``, - which is different than ``CxoTime(None)`` which returns the current time. To set - an attribute to the current time, set it with ``CxoTime.now()``. + which is different than ``CxoTime(None)`` which returns the current time. + + To set an attribute to the current time, use ``CxoTime.NOW``, either as the default + or when setting the attribute. Parameters ---------- @@ -524,8 +526,8 @@ class CxoTimeDescriptor(TypedDescriptor): Default value for the attribute which is provide to the ``CxoTime`` constructor. If not specified or ``None``, the default for the attribute is ``None``. required : bool, optional - If ``True``, the attribute is required to be set explicitly when the object - is created. If ``False`` the default value is used if the attribute is not set. + If ``True``, the attribute is required to be set explicitly when the object is + created. If ``False`` the default value is used if the attribute is not set. Examples -------- @@ -534,13 +536,13 @@ class CxoTimeDescriptor(TypedDescriptor): >>> @dataclass ... class MyClass: ... start: CxoTime | None = CxoTimeDescriptor() - ... stop: CxoTime | None = CxoTimeDescriptor() + ... stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW) ... - >>> obj = MyClass("2023:100") + >>> obj = MyClass("2023:100") # Example run at 2024:006:12:02:35 >>> obj.start - >>> obj.stop is None - True + >>> obj.stop + """ cls = CxoTime diff --git a/cxotime/tests/test_cxotime.py b/cxotime/tests/test_cxotime.py index f5e3390..fcb0af7 100644 --- a/cxotime/tests/test_cxotime.py +++ b/cxotime/tests/test_cxotime.py @@ -530,10 +530,15 @@ class MyData: obj1 = MyData() assert (CxoTime.now() - obj1.stop).sec < 0.1 - # Wait for a second and make a new object and check that the stop time is 1 second - # later. This proves the NOW sentinel is evaluated at object creation time not class - # definition time. - time.sleep(1.0) + # Wait for 0.5 second and make a new object and check that the stop time is 0.5 + # second later. This proves the NOW sentinel is evaluated at object creation time + # not class definition time. + time.sleep(0.5) obj2 = MyData() dt = obj2.stop - obj1.stop + assert round(dt.sec, 1) == 0.5 + + time.sleep(0.5) + obj2.stop = CxoTime.NOW + dt = obj2.stop - obj1.stop assert round(dt.sec, 1) == 1.0