Skip to content

Commit 2bbbaf2

Browse files
authored
Merge pull request #43 from sot/cxotime-descr
Add CxoTimeDescr descriptor and CxoTime.NOW sentinel
2 parents cfcc413 + f0f3d24 commit 2bbbaf2

File tree

4 files changed

+188
-10
lines changed

4 files changed

+188
-10
lines changed

cxotime/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from astropy import units
44
from astropy.time import TimeDelta
55

6-
from .convert import *
7-
from .cxotime import CxoTime, CxoTimeLike
6+
from .convert import * # noqa: F401, F403
7+
from .cxotime import * # noqa: F401, F403
88

99
__version__ = ska_helpers.get_version(__package__)
1010

cxotime/cxotime.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import numpy.typing as npt
1010
from astropy.time import Time, TimeCxcSec, TimeDecimalYear, TimeJD, TimeYearDayTime
1111
from astropy.utils import iers
12+
from ska_helpers.utils import TypedDescriptor
13+
14+
__all__ = ["CxoTime", "CxoTimeLike", "CxoTimeDescriptor"]
15+
1216

1317
# TODO: use npt.NDArray with numpy 1.21
1418
CxoTimeLike = Union["CxoTime", str, float, int, np.ndarray, npt.ArrayLike, None]
@@ -80,13 +84,18 @@ class CxoTime(Time):
8084
8185
"""
8286

87+
# Sentinel object for CxoTime(CxoTime.NOW) to return the current time. See e.g.
88+
# https://python-patterns.guide/python/sentinel-object/.
89+
NOW = object()
90+
8391
def __new__(cls, *args, **kwargs):
84-
# Handle the case of `CxoTime()` which returns the current time. This is
85-
# for compatibility with DateTime.
86-
if not args or (len(args) == 1 and args[0] is None):
92+
# Handle the case of `CxoTime()`, `CxoTime(None)`, or `CxoTime(CxoTime.NOW)`,
93+
# all of which return the current time. This is for compatibility with DateTime.
94+
if not args or (len(args) == 1 and (args[0] is None or args[0] is CxoTime.NOW)):
8795
if not kwargs:
8896
# Stub in a value for `val` so super()__new__ can run since `val`
89-
# is a required positional arg.
97+
# is a required positional arg. NOTE that this change to args here does
98+
# not affect the args in the call to __init__() below.
9099
args = (None,)
91100
else:
92101
raise ValueError("cannot supply keyword arguments with no time value")
@@ -104,7 +113,7 @@ def __init__(self, *args, **kwargs):
104113
# implies copy=False) then no other initialization is needed.
105114
return
106115

107-
if len(args) == 1 and args[0] is None:
116+
if len(args) == 1 and (args[0] is None or args[0] is CxoTime.NOW):
108117
# Compatibility with DateTime and allows kwarg default of None with
109118
# input casting like `date = CxoTime(date)`.
110119
args = ()
@@ -498,3 +507,42 @@ def to_value(self, parent=None, **kwargs):
498507
return out
499508

500509
value = property(to_value)
510+
511+
512+
class CxoTimeDescriptor(TypedDescriptor):
513+
"""Descriptor for an attribute that is CxoTime (in date format) or None if not set.
514+
515+
This allows setting the attribute with any ``CxoTimeLike`` value.
516+
517+
Note that setting this descriptor to ``None`` will set the attribute to ``None``,
518+
which is different than ``CxoTime(None)`` which returns the current time.
519+
520+
To set an attribute to the current time, use ``CxoTime.NOW``, either as the default
521+
or when setting the attribute.
522+
523+
Parameters
524+
----------
525+
default : CxoTimeLike, optional
526+
Default value for the attribute which is provide to the ``CxoTime`` constructor.
527+
If not specified or ``None``, the default for the attribute is ``None``.
528+
required : bool, optional
529+
If ``True``, the attribute is required to be set explicitly when the object is
530+
created. If ``False`` the default value is used if the attribute is not set.
531+
532+
Examples
533+
--------
534+
>>> from dataclasses import dataclass
535+
>>> from cxotime import CxoTime, CxoTimeDescriptor
536+
>>> @dataclass
537+
... class MyClass:
538+
... start: CxoTime | None = CxoTimeDescriptor()
539+
... stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW)
540+
...
541+
>>> obj = MyClass("2023:100") # Example run at 2024:006:12:02:35
542+
>>> obj.start
543+
<CxoTime object: scale='utc' format='date' value=2023:100:00:00:00.000>
544+
>>> obj.stop
545+
<CxoTime object: scale='utc' format='date' value=2024:006:12:02:35.000>
546+
"""
547+
548+
cls = CxoTime

cxotime/tests/test_cxotime.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
import io
77
import time
8+
from dataclasses import dataclass
89

910
import astropy.units as u
1011
import numpy as np
@@ -17,6 +18,7 @@
1718
# Test that cxotime.__init__ imports the CxoTime class and all converters like date2secs
1819
from cxotime import ( # noqa: F401
1920
CxoTime,
21+
CxoTimeDescriptor,
2022
convert_time_format,
2123
date2greta,
2224
date2jd,
@@ -81,15 +83,16 @@ def test_cxotime_now(now_method):
8183
CxoTime(scale="utc")
8284

8385

84-
def test_cxotime_now_by_none():
85-
ct_now = CxoTime(None)
86+
@pytest.mark.parametrize("arg0", [None, CxoTime.NOW])
87+
def test_cxotime_now_by_arg(arg0):
88+
ct_now = CxoTime(arg0)
8689
t_now = Time.now()
8790
assert abs((ct_now - t_now).to_value(u.s)) < 0.1
8891

8992
with pytest.raises(
9093
ValueError, match="cannot supply keyword arguments with no time value"
9194
):
92-
CxoTime(None, scale="utc")
95+
CxoTime(arg0, scale="utc")
9396

9497

9598
def test_cxotime_from_datetime():
@@ -454,3 +457,88 @@ def test_convert_time_format_obj():
454457
"""Explicit test of convert_time_format for CxoTime object"""
455458
tm = CxoTime(100.0)
456459
assert tm.date == convert_time_format(tm, "date")
460+
461+
462+
def test_cxotime_descriptor_not_required_no_default():
463+
@dataclass
464+
class MyClass:
465+
time: CxoTime | None = CxoTimeDescriptor()
466+
467+
obj = MyClass()
468+
assert obj.time is None
469+
470+
obj = MyClass(time="2020:001")
471+
assert isinstance(obj.time, CxoTime)
472+
assert obj.time.value == "2020:001:00:00:00.000"
473+
assert obj.time.format == "date"
474+
475+
tm = CxoTime(100.0)
476+
assert tm.format == "secs"
477+
478+
# Initialize with CxoTime object
479+
obj = MyClass(time=tm)
480+
assert isinstance(obj.time, CxoTime)
481+
assert obj.time.value == 100.0
482+
483+
# CxoTime does not copy an existing CxoTime object for speed
484+
assert obj.time is tm
485+
486+
487+
def test_cxotime_descriptor_is_required():
488+
@dataclass
489+
class MyClass:
490+
time: CxoTime = CxoTimeDescriptor(required=True)
491+
492+
obj = MyClass(time="2020-01-01")
493+
assert obj.time.date == "2020:001:00:00:00.000"
494+
495+
with pytest.raises(
496+
ValueError,
497+
match="attribute 'time' is required and cannot be set to None",
498+
):
499+
MyClass()
500+
501+
502+
def test_cxotime_descriptor_has_default():
503+
@dataclass
504+
class MyClass:
505+
time: CxoTime = CxoTimeDescriptor(default="2020-01-01")
506+
507+
obj = MyClass()
508+
assert obj.time.value == "2020-01-01 00:00:00.000"
509+
510+
obj = MyClass(time="2023:100")
511+
assert obj.time.value == "2023:100:00:00:00.000"
512+
513+
514+
def test_cxotime_descriptor_is_required_has_default_exception():
515+
with pytest.raises(
516+
ValueError, match="cannot set both 'required' and 'default' arguments"
517+
):
518+
519+
@dataclass
520+
class MyClass1:
521+
time: CxoTime = CxoTimeDescriptor(default=100.0, required=True)
522+
523+
524+
def test_cxotime_descriptor_with_NOW():
525+
@dataclass
526+
class MyData:
527+
stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW)
528+
529+
# Make a new object and check that the stop time is approximately the current time.
530+
obj1 = MyData()
531+
assert (CxoTime.now() - obj1.stop).sec < 0.1
532+
533+
# Wait for 0.5 second and make a new object and check that the stop time is 0.5
534+
# second later. This proves the NOW sentinel is evaluated at object creation time
535+
# not class definition time.
536+
time.sleep(0.5)
537+
obj2 = MyData()
538+
dt = obj2.stop - obj1.stop
539+
assert round(dt.sec, 1) == 0.5
540+
541+
time.sleep(0.5)
542+
obj2.stop = CxoTime.NOW
543+
dt = obj2.stop - obj1.stop
544+
assert round(dt.sec, 1) == 1.0

docs/index.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,48 @@ or in python::
202202
iso 2022-01-02 12:00:00.000
203203
unix 1641124800.000
204204

205+
CxoTime.NOW sentinel
206+
--------------------
207+
208+
The |CxoTime| class has a special sentinel value ``CxoTime.NOW`` which can be used
209+
to specify the current time. This is useful for example when defining a function that
210+
has accepts a CxoTime-like argument that defaults to the current time.
211+
212+
.. note:: Prior to introduction of ``CxoTime.NOW``, the standard idiom was to specify
213+
``None`` as the argument default to indicate the current time. This is still
214+
supported but is strongly discouraged for new code.
215+
216+
For example::
217+
218+
>>> from cxotime import CxoTime
219+
>>> def my_func(stop=CxoTime.NOW):
220+
... stop = CxoTime(stop)
221+
... print(stop)
222+
...
223+
>>> my_func()
224+
2024:006:11:37:41.930
225+
226+
This can also be used in a `dataclass
227+
<https://docs.python.org/3/library/dataclasses.html>`_ to specify an attribute that is
228+
optional and defaults to the current time when the object is created::
229+
230+
>>> import time
231+
>>> from dataclasses import dataclass
232+
>>> from cxotime import CxoTime, CxoTimeDescriptor
233+
>>> @dataclass
234+
... class MyData:
235+
... start: CxoTime = CxoTimeDescriptor(required=True)
236+
... stop: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW)
237+
...
238+
>>> obj1 = MyData("2022:001")
239+
>>> print(obj1.start)
240+
2022:001:00:00:00.000
241+
>>> time.sleep(2)
242+
>>> obj2 = MyData("2022:001")
243+
>>> dt = obj2.stop - obj1.stop
244+
>>> round(dt.sec, 2)
245+
2.0
246+
205247
Compatibility with DateTime
206248
---------------------------
207249

0 commit comments

Comments
 (0)