Skip to content

Commit

Permalink
Merge pull request #43 from sot/cxotime-descr
Browse files Browse the repository at this point in the history
Add CxoTimeDescr descriptor and CxoTime.NOW sentinel
  • Loading branch information
taldcroft authored Jan 9, 2024
2 parents cfcc413 + f0f3d24 commit 2bbbaf2
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 10 deletions.
4 changes: 2 additions & 2 deletions cxotime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
58 changes: 53 additions & 5 deletions cxotime/cxotime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -80,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")
Expand All @@ -104,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 = ()
Expand Down Expand Up @@ -498,3 +507,42 @@ 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, use ``CxoTime.NOW``, either as the default
or when setting the attribute.
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 = CxoTimeDescriptor(default=CxoTime.NOW)
...
>>> obj = MyClass("2023:100") # Example run at 2024:006:12:02:35
>>> obj.start
<CxoTime object: scale='utc' format='date' value=2023:100:00:00:00.000>
>>> obj.stop
<CxoTime object: scale='utc' format='date' value=2024:006:12:02:35.000>
"""

cls = CxoTime
94 changes: 91 additions & 3 deletions cxotime/tests/test_cxotime.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
import io
import time
from dataclasses import dataclass

import astropy.units as u
import numpy as np
Expand All @@ -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,
Expand Down Expand Up @@ -81,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():
Expand Down Expand Up @@ -454,3 +457,88 @@ 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="attribute 'time' is required and cannot be set 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)


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 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
42 changes: 42 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://docs.python.org/3/library/dataclasses.html>`_ 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
---------------------------

Expand Down

0 comments on commit 2bbbaf2

Please sign in to comment.