Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CxoTimeDescr descriptor and CxoTime.NOW sentinel #43

Merged
merged 4 commits into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

jeanconn marked this conversation as resolved.
Show resolved Hide resolved
Parameters
----------
default : CxoTimeLike, optional
Default value for the attribute which is provide to the ``CxoTime`` constructor.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: provided

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also say that it can be used in a class (not a dataclass) as long as the constructor initializes the attribute.

I checked that the following two worked as I expected.

class MyClass_1:
    time: CxoTime = CxoTimeDescriptor(default=CxoTime.NOW)
    def __init__(self, **kwargs):
        self.time = MyClass.time

class MyClass_2:
    time: CxoTime | None = CxoTimeDescriptor(default=None)
    def __init__(self, **kwargs):
        self.time = MyClass.time

Is there something else that one would need to do?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compatibility with DateTime
---------------------------

Expand Down