diff --git a/fluent.runtime/fluent/runtime/types.py b/fluent.runtime/fluent/runtime/types.py index c6717194..932ae814 100644 --- a/fluent.runtime/fluent/runtime/types.py +++ b/fluent.runtime/fluent/runtime/types.py @@ -1,5 +1,5 @@ import warnings -from datetime import date, datetime +from datetime import date, datetime, time from decimal import Decimal from typing import Any, Literal, TypeVar, Union, cast @@ -344,13 +344,12 @@ class FluentDateType(FluentType): # some Python implementation (e.g. PyPy) implement some methods. # So we leave those alone, and implement another `_init_options` # which is called from other constructors. - def _init_options( - self, dt_obj: Union[date, datetime], kwargs: dict[str, Any] - ) -> None: - if "timeStyle" in kwargs and not isinstance(self, datetime): - raise TypeError( - "timeStyle option can only be specified for datetime instances, not date instance" - ) + def _init_options(self, dt_obj: Union[date, datetime, time], kwargs: dict[str, Any]) -> None: + if 'timeStyle' in kwargs and not isinstance(self, (datetime, time)): + raise TypeError("timeStyle option can only be specified for datetime or time instances, not date instance") + + if 'dateStyle' in kwargs and not isinstance(self, (datetime, date)): + raise TypeError("dateStyle option can only be specified for datetime or date instances, not time instance") self.options = merge_options( DateFormatOptions, getattr(dt_obj, "options", None), kwargs @@ -360,7 +359,7 @@ def _init_options( warnings.warn(f"FluentDateType option {k} is not yet supported") def format(self, locale: Locale) -> str: - if isinstance(self, datetime): + if isinstance(self, (datetime, time)): selftz = _ensure_datetime_tzinfo(self, tzinfo=self.options.timeZone) else: selftz = cast(datetime, self) @@ -368,11 +367,13 @@ def format(self, locale: Locale) -> str: ds = self.options.dateStyle ts = self.options.timeStyle if ds is None: - if ts is None: + if ts is None and not isinstance(selftz, time): return format_date(selftz, format="medium", locale=locale) else: - return format_time(selftz, format=ts, locale=locale) - elif ts is None: + return format_time(selftz, format=ts or "short", locale=locale) + assert not isinstance(selftz, time) + + if ts is None: return format_date(selftz, format=ds, locale=locale) # Both date and time. Logic copied from babel.dates.format_datetime, @@ -387,15 +388,26 @@ def format(self, locale: Locale) -> str: ) -def _ensure_datetime_tzinfo(dt: datetime, tzinfo: Union[str, None] = None) -> datetime: +def _ensure_datetime_tzinfo(dt: Union[datetime, time], tzinfo: Union[str, None] = None) -> Union[datetime, time]: """ - Ensure the datetime passed has an attached tzinfo. + Ensure the datetime or time passed has an attached tzinfo. """ - # Adapted from babel's function. + if isinstance(dt, datetime): + # Adapted from babel's function. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=pytz.UTC) + if tzinfo is not None: + dt = dt.astimezone(get_timezone(tzinfo)) + return dt if dt.tzinfo is None: - dt = dt.replace(tzinfo=pytz.UTC) - if tzinfo is not None: - dt = dt.astimezone(get_timezone(tzinfo)) + tz = get_timezone(tzinfo) if tzinfo is not None else pytz.UTC + return dt.replace(tzinfo=tz) + elif tzinfo is not None: + tz = get_timezone(tzinfo) + if tz != dt.tzinfo: + print(dt.tzinfo, tz) + raise TypeError("timezone conversion not supported for time values") + return dt @@ -407,6 +419,20 @@ def from_date(cls, dt_obj: date, **kwargs: Any) -> "FluentDate": return obj +class FluentTime(FluentDateType, time): + @classmethod + def from_time(cls, dt_obj: time, **kwargs: Any) -> "FluentTime": + obj = cls( + dt_obj.hour, + dt_obj.minute, + dt_obj.second, + dt_obj.microsecond, + tzinfo=dt_obj.tzinfo + ) + obj._init_options(dt_obj, kwargs) + return obj + + class FluentDateTime(FluentDateType, datetime): @classmethod def from_date_time(cls, dt_obj: datetime, **kwargs: Any) -> "FluentDateTime": @@ -425,12 +451,14 @@ def from_date_time(cls, dt_obj: datetime, **kwargs: Any) -> "FluentDateTime": def fluent_date( - dt: Union[date, datetime, FluentDateType, FluentNone], **kwargs: Any + dt: Union[date, datetime, time, FluentDateType, FluentNone], **kwargs: Any ) -> Union[FluentDateType, FluentNone]: if isinstance(dt, FluentDateType) and not kwargs: return dt if isinstance(dt, datetime): return FluentDateTime.from_date_time(dt, **kwargs) + elif isinstance(dt, time): + return FluentTime.from_time(dt, **kwargs) elif isinstance(dt, date): return FluentDate.from_date(dt, **kwargs) elif isinstance(dt, FluentNone): diff --git a/fluent.runtime/tests/test_types.py b/fluent.runtime/tests/test_types.py index df4ca825..2bb7c425 100644 --- a/fluent.runtime/tests/test_types.py +++ b/fluent.runtime/tests/test_types.py @@ -1,6 +1,6 @@ import re import warnings -from datetime import date, datetime +from datetime import date, datetime, time from decimal import Decimal import pytest @@ -151,6 +151,7 @@ def test_copy_attributes(self): a_date = date(2018, 2, 1) a_datetime = datetime(2018, 2, 1, 14, 15, 16, 123456, tzinfo=pytz.UTC) +a_time = time(10, 31, 00, 333, tzinfo=pytz.UTC) class TestFluentDate: @@ -162,6 +163,16 @@ def test_date(self): assert fd.month == a_date.month assert fd.day == a_date.day + def test_time(self): + fd = fluent_date(a_time) + assert isinstance(fd, time) + assert isinstance(fd, FluentDateType) + assert fd.hour == a_time.hour + assert fd.minute == a_time.minute + assert fd.second == a_time.second + assert fd.microsecond == a_time.microsecond + assert fd.tzinfo == a_time.tzinfo + def test_datetime(self): fd = fluent_date(a_datetime) assert isinstance(fd, datetime) @@ -175,13 +186,27 @@ def test_datetime(self): assert fd.microsecond == a_datetime.microsecond assert fd.tzinfo == a_datetime.tzinfo - def test_format_defaults(self): + def test_date_format_defaults(self): fd = fluent_date(a_date) en_US = Locale.parse("en_US") en_GB = Locale.parse("en_GB") assert fd.format(en_GB) == "1 Feb 2018" assert fd.format(en_US) == "Feb 1, 2018" + def test_time_format_defaults(self): + fd = fluent_date(a_time) + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + assert fd.format(en_GB) == '10:31' + assert re.search('^10:31\\sAM$', fd.format(en_US)) + + def test_datetime_format_defaults(self): + fd = fluent_date(a_datetime) + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + assert fd.format(en_GB) == '1 Feb 2018' + assert re.search('Feb 1, 2018', fd.format(en_US)) + def test_dateStyle_date(self): fd = fluent_date(a_date, dateStyle="long") en_US = Locale.parse("en_US") @@ -203,6 +228,13 @@ def test_timeStyle_datetime(self): assert re.search("^2:15\\sPM$", fd.format(en_US)) assert fd.format(en_GB) == "14:15" + def test_timeStyle_time(self): + fd = fluent_date(a_datetime.time(), timeStyle='short') + en_US = Locale.parse('en_US') + en_GB = Locale.parse('en_GB') + assert re.search('^2:15\\sPM$', fd.format(en_US)) + assert fd.format(en_GB) == '14:15' + def test_dateStyle_and_timeStyle_datetime(self): fd = fluent_date(a_datetime, timeStyle="short", dateStyle="short") en_US = Locale.parse("en_US") @@ -256,6 +288,14 @@ def test_timeZone(self): fd2d = fluent_date(dt1, timeStyle="short", timeZone="Europe/London") assert fd2d.format(en_GB) == "00:30" + ft = fluent_date(a_time, timeZone="UTC") + assert ft.format(en_GB) == "10:31" + ft = fluent_date(a_time, timeZone="Europe/London") + with pytest.raises(TypeError): + ft.format(en_GB) + ft = fluent_date(time(10, 31, 00, 333), timeZone="Europe/London") + assert ft.format(en_GB) == "10:31" + def test_allow_unsupported_options(self): # We are just checking that these don't raise exceptions with warnings.catch_warnings():