From 29bb5b0f5ee86350bb390bc1764250335cab00b0 Mon Sep 17 00:00:00 2001 From: Arie Bovenberg Date: Sun, 10 Nov 2024 20:15:43 +0100 Subject: [PATCH] Make from_py_datetime() methods more permissive --- CHANGELOG.rst | 6 ++ README.md | 8 ++- docs/overview.rst | 2 +- pysrc/whenever/_pywhenever.py | 56 +++++++++--------- src/common.rs | 26 +++++++-- src/instant.rs | 28 +++++++-- src/lib.rs | 2 +- src/local_datetime.rs | 4 +- src/offset_datetime.rs | 42 +++++++------- src/time.rs | 6 +- tests/test_instant.py | 103 +++++++++++++++++++++++++++++++--- tests/test_offset_datetime.py | 103 ++++++++++++++++++++++++++-------- tests/test_system_datetime.py | 55 ++++++++++++++++-- 13 files changed, 337 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0d344bae..818c25f0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ 🚀 Changelog ============ +0.6.13 (2024-11-14) +------------------- + +- Make ``from_py_datetime()`` on ``Instant``/``OffsetDateTime`` less pedantic. + It now accepts any aware datetime + 0.6.12 (2024-11-08) ------------------- diff --git a/README.md b/README.md index 2cd0082d..441d08eb 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,13 @@ or [API reference](https://whenever.readthedocs.io/en/latest/api.html). - ✅ Deltas - ✅ Date and time of day (separate from datetime) - ✅ Implement Rust extension for performance - - 🚧 Parsing leap seconds +- 🔒 **1.0**: API stability and backwards compatibility - 🚧 Improved parsing and formatting - - 🚧 More helpful error messages - 🚧 Intervals -- 🔒 **1.0**: API stability and backwards compatibility + - 🚧 Ranges and recurring times + - 🚧 Parsing leap seconds + - 🚧 More helpful error messages + ## Limitations diff --git a/docs/overview.rst b/docs/overview.rst index 3994985a..eb0d2a92 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -762,7 +762,7 @@ To and from the standard library Each **whenever** datetime class can be converted to a standard library :class:`~datetime.datetime` with the :meth:`~whenever._BasicConversions.py_datetime` method. -Conversely, you can create a type from a standard library datetime with the +Conversely, you can create instances from a standard library datetime with the :meth:`~whenever._BasicConversions.from_py_datetime` classmethod. >>> from datetime import datetime, UTC diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 7b7be7ca..14f0998a 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -2978,24 +2978,24 @@ def from_timestamp_nanos(cls, i: int, /) -> Instant: @classmethod def from_py_datetime(cls, d: _datetime, /) -> Instant: - """Create an Instant from a standard library ``datetime`` object - with ``tzinfo=datetime.timezone.utc``. + """Create an Instant from a standard library ``datetime`` object. + The datetime must be aware. The inverse of the ``py_datetime()`` method. - - Important - --------- - If the datetime tzinfo is *not* UTC, a ``ValueError`` is raised. - If you want to convert a datetime with a different timezone to UTC, - use ``ZonedDateTime`` or ``OffsetDateTime``. """ - if d.tzinfo is not _UTC: + if d.tzinfo is None: raise ValueError( - "Can only create Instant from UTC datetime, " - f"got datetime with tzinfo={d.tzinfo!r}" + "Cannot create Instant from a naive datetime. " + "Use LocalDateTime.from_py_datetime() for this." + ) + if d.utcoffset() is None: + raise ValueError( + "Cannot create from datetime with utcoffset() None" ) + as_utc = d.astimezone(_UTC) return cls._from_py_unchecked( - _strip_subclasses(d.replace(microsecond=0)), d.microsecond * 1_000 + _strip_subclasses(as_utc.replace(microsecond=0)), + as_utc.microsecond * 1_000, ) def format_common_iso(self) -> str: @@ -3475,22 +3475,28 @@ def from_timestamp_nanos( @classmethod def from_py_datetime(cls, d: _datetime, /) -> OffsetDateTime: """Create an instance from a standard library ``datetime`` object. + The datetime must be aware. The inverse of the ``py_datetime()`` method. - Important - --------- - If the datetime tzinfo is *not* a fixed offset, - a ``ValueError`` is raised. If you want to convert a datetime - with a ``ZoneInfo`` tzinfo, use ``ZonedDateTime.from_py_datetime()`` instead. """ - if not isinstance(d.tzinfo, _timezone): + if d.tzinfo is None: raise ValueError( - "Datetime's tzinfo is not a datetime.timezone, " - f"got tzinfo={d.tzinfo!r}" + "Cannot create from a naive datetime. " + "Use LocalDateTime.from_py_datetime() for this." ) + if (offset := d.utcoffset()) is None: + raise ValueError( + "Cannot create from datetime with utcoffset() None" + ) + elif offset.microseconds: + raise ValueError("Sub-second offsets are not supported") return cls._from_py_unchecked( - _check_utc_bounds(_strip_subclasses(d.replace(microsecond=0))), + _check_utc_bounds( + _strip_subclasses( + d.replace(microsecond=0, tzinfo=_timezone(offset)) + ) + ), d.microsecond * 1_000, ) @@ -4442,12 +4448,12 @@ def from_timestamp_nanos(cls, i: int, /) -> SystemDateTime: _fromtimestamp(secs, _UTC).astimezone(), nanos ) - # TODO py_datetime docstring - @classmethod def from_py_datetime(cls, d: _datetime, /) -> SystemDateTime: - """Create an instance from a standard library ``datetime`` object - with fixed-offset tzinfo. + """Create an instance from a standard library ``datetime`` object. + The datetime must be aware. + + The inverse of the ``py_datetime()`` method. """ odt = OffsetDateTime.from_py_datetime(d) return cls._from_py_unchecked(odt._py_dt, odt._nanos) diff --git a/src/common.rs b/src/common.rs index cb7a00a9..0d52931c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -684,11 +684,23 @@ pub(crate) unsafe fn newref<'a>(obj: *mut PyObject) -> &'a mut PyObject { obj.as_mut().unwrap() } +// FUTURE: replace with Py_IsNone when dropping Py 3.9 support +pub(crate) unsafe fn is_none(x: *mut PyObject) -> bool { + x == Py_None() +} + +// NOTE: assumes it's an "aware" datetime object pub(crate) unsafe fn offset_from_py_dt(dt: *mut PyObject) -> PyResult { - // OPTIMIZE: is calling ZoneInfo.utcoffset() faster? let delta = methcall0(dt, "utcoffset")?; defer_decref!(delta); - Ok(PyDateTime_DELTA_GET_DAYS(delta) * 86_400 + PyDateTime_DELTA_GET_SECONDS(delta)) + if is_none(delta) { + // This case is rare, but possible even with aware datetimes + Err(value_err!("utcoffset() returned None")) + } else if PyDateTime_DELTA_GET_MICROSECONDS(delta) != 0 { + Err(value_err!("Sub-second offsets are not supported",)) + } else { + Ok(PyDateTime_DELTA_GET_DAYS(delta) * 86_400 + PyDateTime_DELTA_GET_SECONDS(delta)) + } } pub(crate) fn offset_fmt(secs: i32) -> String { @@ -721,6 +733,10 @@ pub(crate) enum OffsetResult { Fold(i32, i32), } +pub(crate) unsafe fn is_naive(py_dt: *mut PyObject) -> bool { + is_none(get_dt_tzinfo(py_dt)) +} + unsafe fn system_offset( date: Date, time: Time, @@ -1051,7 +1067,7 @@ where #[inline] #[allow(dead_code)] -unsafe fn getattr_tzinfo(dt: *mut PyObject) -> *mut PyObject { +unsafe fn getattr_tzinfo_unchecked(dt: *mut PyObject) -> *mut PyObject { let tzinfo = PyObject_GetAttrString(dt, c"tzinfo".as_ptr()); // To keep things consistent with the Py3.10 version, // we need to decref it, turning it into a borrowed reference. @@ -1068,7 +1084,7 @@ pub(crate) unsafe fn get_dt_tzinfo(dt: *mut PyObject) -> *mut PyObject { } #[cfg(not(Py_3_10))] { - getattr_tzinfo(dt) + getattr_tzinfo_unchecked(dt) } } @@ -1080,7 +1096,7 @@ pub(crate) unsafe fn get_time_tzinfo(dt: *mut PyObject) -> *mut PyObject { } #[cfg(not(Py_3_10))] { - getattr_tzinfo(dt) + getattr_tzinfo_unchecked(dt) } } diff --git a/src/instant.rs b/src/instant.rs index aebdda5a..d20ddff4 100644 --- a/src/instant.rs +++ b/src/instant.rs @@ -230,9 +230,12 @@ impl Instant { .as_result() } - pub(crate) unsafe fn from_py(dt: *mut PyObject, state: &State) -> Option { + unsafe fn from_py(dt: *mut PyObject, state: &State) -> PyResult> { let tzinfo = get_dt_tzinfo(dt); - (tzinfo == state.py_api.TimeZone_UTC).then_some(Instant::from_datetime( + if is_none(tzinfo) { + Err(value_err!("datetime cannot be naive"))?; + }; + let inst = Instant::from_datetime( Date { year: PyDateTime_GET_YEAR(dt) as u16, month: PyDateTime_GET_MONTH(dt) as u8, @@ -244,7 +247,20 @@ impl Instant { second: PyDateTime_DATE_GET_SECOND(dt) as u8, nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000, }, - )) + ); + Ok(if tzinfo == state.py_api.TimeZone_UTC { + // Fast path for the common case + Some(inst) + } else { + let delta = methcall1(tzinfo, "utcoffset", dt)?; + if is_none(delta) { + Err(value_err!("datetime utcoffset() is None"))?; + } + let nanos = i128::from(PyDateTime_DELTA_GET_DAYS(delta)) * NS_PER_DAY + + i128::from(PyDateTime_DELTA_GET_SECONDS(delta)) * 1_000_000_000 + + i128::from(PyDateTime_DELTA_GET_MICROSECONDS(delta)) * 1_000; + inst.shift(-nanos) + }) } #[cfg(target_pointer_width = "64")] @@ -533,8 +549,8 @@ unsafe fn from_py_datetime(cls: *mut PyObject, dt: *mut PyObject) -> PyReturn { if PyDateTime_Check(dt) == 0 { Err(type_err!("Expected a datetime object"))?; } - Instant::from_py(dt, State::for_type(cls.cast())) - .ok_or_else(|| value_err!("datetime must have tzinfo set to UTC, got {}", dt.repr()))? + Instant::from_py(dt, State::for_type(cls.cast()))? + .ok_or_else(|| value_err!("datetime out of range: {}", dt.repr()))? .to_obj(cls.cast()) } @@ -776,7 +792,7 @@ unsafe fn parse_rfc2822(cls: *mut PyObject, s_obj: *mut PyObject) -> PyReturn { defer_decref!(dt); let tzinfo = get_dt_tzinfo(dt); if tzinfo == state.py_api.TimeZone_UTC - || (tzinfo == Py_None() && s_obj.to_str()?.unwrap().contains("-0000")) + || (is_none(tzinfo) && s_obj.to_str()?.unwrap().contains("-0000")) { Instant::from_datetime( Date { diff --git a/src/lib.rs b/src/lib.rs index 7c979104..b99d1eb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -473,7 +473,7 @@ unsafe fn time_machine_installed() -> PyResult { defer_decref!(find_spec); let spec = call1(find_spec, steal!("time_machine".to_py()?))?; defer_decref!(spec); - Ok((spec as *mut PyObject) != Py_None()) + Ok(!is_none(spec)) } unsafe fn traverse(target: *mut PyObject, visit: visitproc, arg: *mut c_void) { diff --git a/src/local_datetime.rs b/src/local_datetime.rs index a82586f4..c4ad2f59 100644 --- a/src/local_datetime.rs +++ b/src/local_datetime.rs @@ -569,7 +569,7 @@ unsafe fn from_py_datetime(type_: *mut PyObject, dt: *mut PyObject) -> PyReturn Err(type_err!("argument must be datetime.datetime"))? } let tzinfo = get_dt_tzinfo(dt); - if tzinfo != Py_None() { + if !is_none(tzinfo) { Err(value_err!( "datetime must be naive, but got tzinfo={}", tzinfo.repr() @@ -670,7 +670,7 @@ unsafe fn strptime(cls: *mut PyObject, args: &[*mut PyObject]) -> PyReturn { .as_result()?; defer_decref!(parsed); let tzinfo = get_dt_tzinfo(parsed); - if tzinfo != Py_None() { + if !is_none(tzinfo) { Err(value_err!( "datetime must be naive, but got tzinfo={}", tzinfo.repr() diff --git a/src/offset_datetime.rs b/src/offset_datetime.rs index 6d0097a3..3c58ce0e 100644 --- a/src/offset_datetime.rs +++ b/src/offset_datetime.rs @@ -129,25 +129,23 @@ impl OffsetDateTime { // Returns None if the tzinfo is incorrect, or the UTC time is out of bounds pub(crate) unsafe fn from_py(dt: *mut PyObject, state: &State) -> PyResult> { debug_assert!(PyObject_IsInstance(dt, state.py_api.DateTimeType.cast()).is_positive()); - let tzinfo = get_dt_tzinfo(dt); - Ok(match PyObject_IsInstance(tzinfo, state.timezone_type) { - 1 => OffsetDateTime::new( - Date { - year: PyDateTime_GET_YEAR(dt) as u16, - month: PyDateTime_GET_MONTH(dt) as u8, - day: PyDateTime_GET_DAY(dt) as u8, - }, - Time { - hour: PyDateTime_DATE_GET_HOUR(dt) as u8, - minute: PyDateTime_DATE_GET_MINUTE(dt) as u8, - second: PyDateTime_DATE_GET_SECOND(dt) as u8, - nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000, - }, - offset_from_py_dt(dt)?, - ), - 0 => None, - _ => Err(py_err!())?, - }) + if is_none(get_dt_tzinfo(dt)) { + Err(value_err!("Datetime cannot be naive"))? + } + Ok(OffsetDateTime::new( + Date { + year: PyDateTime_GET_YEAR(dt) as u16, + month: PyDateTime_GET_MONTH(dt) as u8, + day: PyDateTime_GET_DAY(dt) as u8, + }, + Time { + hour: PyDateTime_DATE_GET_HOUR(dt) as u8, + minute: PyDateTime_DATE_GET_MINUTE(dt) as u8, + second: PyDateTime_DATE_GET_SECOND(dt) as u8, + nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000, + }, + offset_from_py_dt(dt)?, + )) } pub(crate) unsafe fn from_py_and_nanos_unchecked( @@ -1088,6 +1086,12 @@ unsafe fn parse_rfc2822(cls: *mut PyObject, s_obj: *mut PyObject) -> PyReturn { let state = State::for_type(cls.cast()); let py_dt = call1(state.parse_rfc2822, s_obj)?; defer_decref!(py_dt); + if is_naive(py_dt) { + Err(value_err!( + "parsed datetime must have a timezone, got {}", + s_obj.repr() + ))? + }; OffsetDateTime::from_py(py_dt, state)? .ok_or_else(|| { value_err!( diff --git a/src/time.rs b/src/time.rs index fc665019..9c6c35d7 100644 --- a/src/time.rs +++ b/src/time.rs @@ -324,10 +324,10 @@ unsafe fn py_time(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { unsafe fn from_py_time(type_: *mut PyObject, time: *mut PyObject) -> PyReturn { if PyTime_Check(time) == 0 { - Err(type_err!("argument must be a whenever.Time"))? + Err(type_err!("argument must be a datetime.time"))? } - if get_time_tzinfo(time) != Py_None() { - Err(value_err!("time with timezone is not supported"))? + if !is_none(get_time_tzinfo(time)) { + Err(value_err!("time with tzinfo is not supported"))? } // FUTURE: check `fold=0`? Time { diff --git a/tests/test_instant.py b/tests/test_instant.py index 08bd539b..1cb7288a 100644 --- a/tests/test_instant.py +++ b/tests/test_instant.py @@ -1,7 +1,8 @@ import pickle import re from copy import copy, deepcopy -from datetime import datetime as py_datetime, timedelta, timezone +from datetime import datetime as py_datetime, timedelta, timezone, tzinfo +from zoneinfo import ZoneInfo import pytest from hypothesis import given @@ -474,17 +475,100 @@ def test_py_datetime(): ) -def test_from_py_datetime(): - d = py_datetime(2020, 8, 15, 23, 12, 9, 987_654, tzinfo=timezone.utc) - assert Instant.from_py_datetime(d) == Instant.from_utc( - 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000 - ) +class TestFromPyDatetime: + + def test_utc(self): + d = py_datetime(2020, 8, 15, 23, 12, 9, 987_654, tzinfo=timezone.utc) + assert Instant.from_py_datetime(d) == Instant.from_utc( + 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000 + ) + + def test_offset(self): + + assert Instant.from_py_datetime( + py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(-timedelta(hours=4)), + ) + ).exact_eq( + Instant.from_utc(2020, 8, 16, 3, 12, 9, nanosecond=987_654_000) + ) + + def test_subsecond_offset(self): + assert Instant.from_py_datetime( + py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(timedelta(hours=4, microseconds=30)), + ) + ).exact_eq( + Instant.from_utc(2020, 8, 15, 19, 12, 9, nanosecond=987_624_000) + ) - with pytest.raises(ValueError, match="UTC.*timedelta"): - Instant.from_py_datetime( - d.replace(tzinfo=timezone(-timedelta(hours=4))) + def test_zoneinfo(self): + + assert Instant.from_py_datetime( + py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=ZoneInfo("America/New_York"), + ) + ).exact_eq( + Instant.from_utc(2020, 8, 16, 3, 12, 9, nanosecond=987_654_000) ) + def test_subclass(self): + + class MyDateTime(py_datetime): + pass + + assert Instant.from_py_datetime( + MyDateTime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(-timedelta(hours=4)), + ) + ) == Instant.from_utc(2020, 8, 16, 3, 12, 9, nanosecond=987_654_000) + + def test_out_of_range(self): + d = py_datetime(1, 1, 1, tzinfo=timezone(timedelta(hours=5))) + with pytest.raises((ValueError, OverflowError), match="range"): + Instant.from_py_datetime(d) + + def test_naive(self): + with pytest.raises(ValueError, match="naive"): + Instant.from_py_datetime(py_datetime(2020, 8, 15, 12)) + + def test_utcoffset_none(self): + + class MyTz(tzinfo): + def utcoffset(self, _): + return None + + with pytest.raises(ValueError, match="utcoffset.*"): + Instant.from_py_datetime(py_datetime(2020, 8, 15, tzinfo=MyTz())) + def test_now(): now = Instant.now() @@ -811,6 +895,7 @@ def test_format_rfc3339(): ).format_rfc3339() == "2020-08-15 23:12:09.00000045Z" ) + Instant.now().format_rfc3339 class TestParseRFC3339: diff --git a/tests/test_offset_datetime.py b/tests/test_offset_datetime.py index 1d563290..3af38eb0 100644 --- a/tests/test_offset_datetime.py +++ b/tests/test_offset_datetime.py @@ -1,6 +1,7 @@ import pickle import re from datetime import datetime as py_datetime, timedelta, timezone, tzinfo +from zoneinfo import ZoneInfo import pytest from hypothesis import given @@ -636,35 +637,89 @@ def test_py_datetime(): ) -def test_from_py_datetime(): - d = py_datetime( - 2020, - 8, - 15, - 23, - 12, - 9, - 987_654, - tzinfo=timezone(timedelta(hours=2)), - ) - assert OffsetDateTime.from_py_datetime(d).exact_eq( - OffsetDateTime( - 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000, offset=2 +class TestFromPyDatetime: + + def test_offset(self): + + d = py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(timedelta(hours=2)), + ) + assert OffsetDateTime.from_py_datetime(d).exact_eq( + OffsetDateTime( + 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000, offset=2 + ) + ) + + def test_zoneinfo(self): + + d = py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=ZoneInfo("Europe/Amsterdam"), + ) + assert OffsetDateTime.from_py_datetime(d).exact_eq( + OffsetDateTime( + 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000, offset=2 + ) ) - ) - class SomeTzinfo(tzinfo): - pass + def test_naive(self): + with pytest.raises(ValueError, match="naive"): + OffsetDateTime.from_py_datetime(py_datetime(12, 3, 4, 12)) - d2 = d.replace(tzinfo=SomeTzinfo()) # type: ignore[abstract] - with pytest.raises(ValueError, match="SomeTzinfo"): - OffsetDateTime.from_py_datetime(d2) + def test_out_of_range(self): + d = py_datetime(1, 1, 1, tzinfo=timezone(timedelta(hours=5))) + with pytest.raises(ValueError, match="range"): + OffsetDateTime.from_py_datetime(d) - # UTC out of range - d = py_datetime(1, 1, 1, tzinfo=timezone(timedelta(hours=1))) + def test_utcoffset_none(self): - with pytest.raises(ValueError, match="range"): - OffsetDateTime.from_py_datetime(d) + class MyTz(tzinfo): + def utcoffset(self, _): + return None + + with pytest.raises(ValueError, match="utcoffset.*"): + OffsetDateTime.from_py_datetime( + py_datetime(2020, 8, 15, tzinfo=MyTz()) + ) + + def test_subsecond_offset(self): + with pytest.raises(ValueError, match="Sub-second"): + OffsetDateTime.from_py_datetime( + py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(timedelta(hours=2, microseconds=30)), + ) + ) + + def test_subclass(self): + class MyDatetime(py_datetime): + pass + + d = MyDatetime(2020, 8, 15, 23, 12, 9, 987_654, tzinfo=timezone.utc) + assert OffsetDateTime.from_py_datetime(d).exact_eq( + OffsetDateTime( + 2020, 8, 15, 23, 12, 9, nanosecond=987_654_000, offset=0 + ) + ) def test_replace_date(): diff --git a/tests/test_system_datetime.py b/tests/test_system_datetime.py index 3187c1bf..af146a89 100644 --- a/tests/test_system_datetime.py +++ b/tests/test_system_datetime.py @@ -1,7 +1,7 @@ import pickle import re from copy import copy, deepcopy -from datetime import datetime as py_datetime, timedelta, timezone +from datetime import datetime as py_datetime, timedelta, timezone, tzinfo from typing import Any import pytest @@ -845,11 +845,11 @@ def test_disambiguated(self): SystemDateTime(2023, 10, 29, 2, 15, 30, disambiguate="later") ) - def test_wrong_tzinfo(self): - with pytest.raises(ValueError, match="Paris"): - SystemDateTime.from_py_datetime( - py_datetime(2020, 8, 15, 23, tzinfo=ZoneInfo("Europe/Paris")) - ) + @system_tz_ams() + def test_zoneinfo(self): + assert SystemDateTime.from_py_datetime( + py_datetime(2020, 8, 15, 23, tzinfo=ZoneInfo("Europe/Paris")) + ).exact_eq(SystemDateTime(2020, 8, 15, 23)) def test_bounds(self): with pytest.raises((ValueError, OverflowError), match="range|year"): @@ -864,6 +864,49 @@ def test_bounds(self): ) ) + def test_subsecond_offset(self): + with pytest.raises(ValueError, match="Sub-second"): + SystemDateTime.from_py_datetime( + py_datetime( + 2020, + 8, + 15, + 23, + 12, + 9, + 987_654, + tzinfo=timezone(timedelta(hours=2, microseconds=30)), + ) + ) + + def test_utcoffset_none(self): + + class MyTz(tzinfo): + def utcoffset(self, _): + return None + + with pytest.raises(ValueError, match="utcoffset.*"): + SystemDateTime.from_py_datetime( + py_datetime(2020, 8, 15, tzinfo=MyTz()) + ) + + @system_tz_ams() + def test_naive(self): + with pytest.raises(ValueError, match="naive"): + SystemDateTime.from_py_datetime(py_datetime(2020, 8, 15, 12)) + + @system_tz_ams() + def test_subclass(self): + class MyDatetime(py_datetime): + pass + + d = MyDatetime( + 2020, 8, 15, 23, 12, 9, 987_654, tzinfo=ZoneInfo("Europe/Paris") + ) + assert SystemDateTime.from_py_datetime(d).exact_eq( + SystemDateTime(2020, 8, 15, 23, 12, 9, nanosecond=987_654_000) + ) + @system_tz_nyc() def test_now():