From 8f659703079d30cb47df95c798e88b6e2af228fd Mon Sep 17 00:00:00 2001 From: Arie Bovenberg Date: Sat, 2 Nov 2024 15:46:25 +0100 Subject: [PATCH] add MonthDay --- CHANGELOG.rst | 6 +- docs/api.rst | 8 + docs/faq.rst | 2 +- docs/overview.rst | 4 + pyproject.toml | 2 +- pysrc/whenever/__init__.py | 2 + pysrc/whenever/__init__.pyi | 23 +++ pysrc/whenever/_pywhenever.py | 204 ++++++++++++++++++++++- src/date.rs | 19 ++- src/lib.rs | 22 +++ src/monthday.rs | 297 ++++++++++++++++++++++++++++++++++ src/system_datetime.rs | 2 - src/yearmonth.rs | 6 +- tests/test_date.py | 6 + tests/test_month_day.py | 244 ++++++++++++++++++++++++++++ tests/test_system_datetime.py | 2 +- tests/test_year_month.py | 12 +- 17 files changed, 842 insertions(+), 19 deletions(-) create mode 100644 src/monthday.rs create mode 100644 tests/test_month_day.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 14137bb1..d3550089 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,16 +1,16 @@ ๐Ÿš€ Changelog ============ -0.6.11 (2024-11-??) +0.6.11 (2024-11-04) ------------------- **Added** -- Added ``YearMonth`` class +- Added ``YearMonth`` and ``MonthDay`` classes for working with year-month and month-day pairs **Fixed** -- ``__version__`` is accessible, whether the Rust or Python version is used +- ``whenever.__version__`` is now also accessible when Rust extension is used 0.6.10 (2024-10-30) ------------------- diff --git a/docs/api.rst b/docs/api.rst index 007cd6ed..6fb9298e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -131,6 +131,14 @@ Date and time components :members: :special-members: __eq__, __lt__, __le__, __gt__, __ge__, __sub__, __add__ +.. autoclass:: whenever.YearMonth + :members: + :special-members: __eq__, __lt__, __le__, __gt__, __ge__ + +.. autoclass:: whenever.MonthDay + :members: + :special-members: __eq__, __lt__, __le__, __gt__, __ge__ + .. autoclass:: whenever.Time :members: :special-members: __eq__, __lt__, __le__, __gt__, __ge__ diff --git a/docs/faq.rst b/docs/faq.rst index 83dac2c2..afef4bac 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -189,7 +189,7 @@ There's no plan to change this due to the following reasons: 1. The benefits of subclassing are limited. If you want to extend the classes, composition is a better way to do it. Alternatively, you can use Python's dynamic features to create - something that "quacks" like a subclass. + something that behaves like a subclass. 2. For a class to support subclassing properly, a lot of extra work is needed. It also adds many subtle ways to misuse the API, that are hard to control. 3. Enabling subclassing would undo some performance optimizations. diff --git a/docs/overview.rst b/docs/overview.rst index ca461ef1..62ca5195 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -803,6 +803,10 @@ Date(2023-02-28) >>> d - Date(2022, 10, 15) DateDelta(P3M16D) +There's also :class:`~whenever.YearMonth` and :class:`~whenever.MonthDay` for representing +year-month and month-day combinations, respectively. +These are useful for representing recurring events or birthdays. + See the :ref:`API reference ` for more details. Testing diff --git a/pyproject.toml b/pyproject.toml index 43f99c6b..97baaf44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ maintainers = [ {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, ] readme = "README.md" -version = "0.6.10" +version = "0.6.11" description = "Modern datetime library for Python" requires-python = ">=3.9" classifiers = [ diff --git a/pysrc/whenever/__init__.py b/pysrc/whenever/__init__.py index 416362d1..9a6c035c 100644 --- a/pysrc/whenever/__init__.py +++ b/pysrc/whenever/__init__.py @@ -8,6 +8,7 @@ _unpkl_ddelta, _unpkl_dtdelta, _unpkl_local, + _unpkl_md, _unpkl_offset, _unpkl_system, _unpkl_tdelta, @@ -36,6 +37,7 @@ _unpkl_ddelta, _unpkl_dtdelta, _unpkl_local, + _unpkl_md, _unpkl_offset, _unpkl_system, _unpkl_tdelta, diff --git a/pysrc/whenever/__init__.pyi b/pysrc/whenever/__init__.pyi index a4c5f445..aecdad8a 100644 --- a/pysrc/whenever/__init__.pyi +++ b/pysrc/whenever/__init__.pyi @@ -41,6 +41,7 @@ __all__ = [ ] _EXTENSION_LOADED: bool +__version__: str @final class Date: @@ -54,6 +55,7 @@ class Date: @property def day(self) -> int: ... def year_month(self) -> YearMonth: ... + def month_day(self) -> MonthDay: ... def day_of_week(self) -> Weekday: ... def at(self, t: Time, /) -> LocalDateTime: ... def py_date(self) -> _date: ... @@ -102,6 +104,27 @@ class YearMonth: def __ge__(self, other: YearMonth) -> bool: ... def __hash__(self) -> int: ... +@final +class MonthDay: + def __init__(self, month: int, day: int) -> None: ... + MIN: ClassVar[MonthDay] + MAX: ClassVar[MonthDay] + @property + def month(self) -> int: ... + @property + def day(self) -> int: ... + def format_common_iso(self) -> str: ... + @classmethod + def parse_common_iso(cls, s: str, /) -> MonthDay: ... + def replace(self, *, month: int = ..., day: int = ...) -> MonthDay: ... + def in_year(self, year: int, /) -> Date: ... + def is_leap(self) -> bool: ... + def __lt__(self, other: MonthDay) -> bool: ... + def __le__(self, other: MonthDay) -> bool: ... + def __gt__(self, other: MonthDay) -> bool: ... + def __ge__(self, other: MonthDay) -> bool: ... + def __hash__(self) -> int: ... + @final class Time: def __init__( diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 56556d74..14dc3b1e 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -32,7 +32,7 @@ # - It saves some overhead from __future__ import annotations -__version__ = "0.6.10" +__version__ = "0.6.11" import enum import re @@ -68,6 +68,7 @@ # Date and time "Date", "YearMonth", + "MonthDay", "Time", "Instant", "OffsetDateTime", @@ -202,6 +203,18 @@ def year_month(self) -> YearMonth: """ return YearMonth._from_py_unchecked(self._py_date.replace(day=1)) + def month_day(self) -> MonthDay: + """The month and day (without a year component) + + Example + ------- + >>> Date(2021, 1, 2).month_day() + MonthDay(--01-02) + """ + return MonthDay._from_py_unchecked( + self._py_date.replace(year=_DUMMY_LEAP_YEAR) + ) + def day_of_week(self) -> Weekday: """The day of the week @@ -497,6 +510,8 @@ def _unpkl_date(data: bytes) -> Date: class YearMonth(_ImmutableBase): """A year and month without a day component + Useful for representing recurring events or billing periods. + Example ------- >>> ym = YearMonth(2021, 1) @@ -622,6 +637,7 @@ def __hash__(self) -> int: @classmethod def _from_py_unchecked(cls, d: _date, /) -> YearMonth: + assert d.day == 1 self = _object_new(cls) self._py_date = d return self @@ -642,6 +658,190 @@ def _unpkl_ym(data: bytes) -> YearMonth: YearMonth.MAX = YearMonth._from_py_unchecked(_date.max.replace(day=1)) +_DUMMY_LEAP_YEAR = 4 + + +@final +class MonthDay(_ImmutableBase): + """A month and day without a year component. + + Useful for representing recurring events or birthdays. + + Example + ------- + >>> MonthDay(11, 23) + MonthDay(--11-23) + """ + + # We store the underlying data in a datetime.date object, + # which allows us to benefit from its functionality and performance. + # It isn't exposed to the user, so it's not a problem. + __slots__ = ("_py_date",) + + MIN: ClassVar[MonthDay] + """The minimum possible month-day""" + MAX: ClassVar[MonthDay] + """The maximum possible month-day""" + + def __init__(self, month: int, day: int) -> None: + self._py_date = _date(_DUMMY_LEAP_YEAR, month, day) + + @property + def month(self) -> int: + return self._py_date.month + + @property + def day(self) -> int: + return self._py_date.day + + def format_common_iso(self) -> str: + """Format as the common ISO 8601 month-day format. + + Inverse of ``parse_common_iso``. + + Example + ------- + >>> MonthDay(10, 8).format_common_iso() + '--10-08' + + Note + ---- + This format is officially only part of the 2000 edition of the + ISO 8601 standard. There is no alternative for month-day + in the newer editions. However, it is still widely used in other libraries. + """ + return f"-{self._py_date.isoformat()[4:]}" + + @classmethod + def parse_common_iso(cls, s: str, /) -> MonthDay: + """Create from the common ISO 8601 format ``--MM-DD``. + Does not accept more "exotic" ISO 8601 formats. + + Inverse of :meth:`format_common_iso` + + Example + ------- + >>> MonthDay.parse_common_iso("--11-23") + MonthDay(--11-23) + """ + if not _match_monthday(s): + raise ValueError(f"Invalid format: {s!r}") + return cls._from_py_unchecked( + _date.fromisoformat(f"{_DUMMY_LEAP_YEAR:0>4}" + s[1:]) + ) + + def replace(self, **kwargs: Any) -> MonthDay: + """Create a new instance with the given fields replaced + + Example + ------- + >>> d = MonthDay(11, 23) + >>> d.replace(month=3) + MonthDay(--03-23) + """ + if "year" in kwargs: + raise TypeError( + "replace() got an unexpected keyword argument 'year'" + ) + return MonthDay._from_py_unchecked(self._py_date.replace(**kwargs)) + + def in_year(self, year: int, /) -> Date: + """Create a date from this month-day with a given day + + Example + ------- + >>> MonthDay(8, 1).in_year(2025) + Date(2025-08-01) + + Note + ---- + This method will raise a ``ValueError`` if the month-day is a leap day + and the year is not a leap year. + """ + return Date._from_py_unchecked(self._py_date.replace(year=year)) + + def is_leap(self) -> bool: + """Check if the month-day is February 29th + + Example + ------- + >>> MonthDay(2, 29).is_leap() + True + >>> MonthDay(3, 1).is_leap() + False + """ + return self._py_date.month == 2 and self._py_date.day == 29 + + __str__ = format_common_iso + + def __repr__(self) -> str: + return f"MonthDay({self})" + + def __eq__(self, other: object) -> bool: + """Compare for equality + + Example + ------- + >>> md = MonthDay(10, 1) + >>> md == MonthDay(10, 1) + True + >>> md == MonthDay(10, 2) + False + """ + if not isinstance(other, MonthDay): + return NotImplemented + return self._py_date == other._py_date + + def __lt__(self, other: MonthDay) -> bool: + if not isinstance(other, MonthDay): + return NotImplemented + return self._py_date < other._py_date + + def __le__(self, other: MonthDay) -> bool: + if not isinstance(other, MonthDay): + return NotImplemented + return self._py_date <= other._py_date + + def __gt__(self, other: MonthDay) -> bool: + if not isinstance(other, MonthDay): + return NotImplemented + return self._py_date > other._py_date + + def __ge__(self, other: MonthDay) -> bool: + if not isinstance(other, MonthDay): + return NotImplemented + return self._py_date >= other._py_date + + def __hash__(self) -> int: + return hash(self._py_date) + + @classmethod + def _from_py_unchecked(cls, d: _date, /) -> MonthDay: + assert d.year == _DUMMY_LEAP_YEAR + self = _object_new(cls) + self._py_date = d + return self + + @no_type_check + def __reduce__(self): + return _unpkl_md, (pack(" MonthDay: + return MonthDay(*unpack(" _timezone: r"^(\d{1,8})([YMWD])", re.ASCII ).match _match_yearmonth = re.compile(r"\d{4}-\d{2}", re.ASCII).fullmatch +_match_monthday = re.compile(r"--\d{2}-\d{2}", re.ASCII).fullmatch def _check_utc_bounds(dt: _datetime) -> _datetime: @@ -5275,6 +5476,7 @@ def nanoseconds(i: int, /) -> TimeDelta: for _unpkl in ( _unpkl_date, _unpkl_ym, + _unpkl_md, _unpkl_time, _unpkl_tdelta, _unpkl_dtdelta, diff --git a/src/date.rs b/src/date.rs index 2b7d209b..3e7646c9 100644 --- a/src/date.rs +++ b/src/date.rs @@ -5,7 +5,8 @@ use std::fmt::{self, Display, Formatter}; use crate::common::*; use crate::{ - date_delta::DateDelta, local_datetime::DateTime, time::Time, yearmonth::YearMonth, State, + date_delta::DateDelta, local_datetime::DateTime, monthday::MonthDay, time::Time, + yearmonth::YearMonth, State, }; #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] @@ -209,9 +210,9 @@ impl Display for Date { pub(crate) const MAX_YEAR: c_long = 9999; pub(crate) const MIN_YEAR: c_long = 1; -const DAYS_IN_MONTH: [u8; 13] = [ +pub(crate) const MAX_MONTH_DAYS_IN_LEAP_YEAR: [u8; 13] = [ 0, // 1-indexed - 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, ]; const MIN_ORD: i32 = 1; const MAX_ORD: i32 = 3_652_059; @@ -229,10 +230,10 @@ const fn is_leap(year: u16) -> bool { const fn days_in_month(year: u16, month: u8) -> u8 { debug_assert!(month >= 1 && month <= 12); - if month == 2 && is_leap(year) { - 29 + if month == 2 && !is_leap(year) { + 28 } else { - DAYS_IN_MONTH[month as usize] + MAX_MONTH_DAYS_IN_LEAP_YEAR[month as usize] } } @@ -420,6 +421,11 @@ unsafe fn year_month(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { YearMonth::new_unchecked(year, month).to_obj(State::for_obj(slf).yearmonth_type) } +unsafe fn month_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { + let Date { month, day, .. } = Date::extract(slf); + MonthDay::new_unchecked(month, day).to_obj(State::for_obj(slf).monthday_type) +} + unsafe fn __str__(slf: *mut PyObject) -> PyReturn { format!("{}", Date::extract(slf)).to_py() } @@ -710,6 +716,7 @@ static mut METHODS: &[PyMethodDef] = &[ method!(day_of_week, "Return the day of the week"), method!(at, "Combine with a time to create a datetime", METH_O), method!(year_month, "Return the year and month"), + method!(month_day, "Return the month and day"), method!(__reduce__, ""), method_kwargs!( add, diff --git a/src/lib.rs b/src/lib.rs index a65d639a..7c979104 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ mod date_delta; mod datetime_delta; mod instant; pub mod local_datetime; +mod monthday; mod offset_datetime; mod system_datetime; mod time; @@ -25,6 +26,7 @@ use date_delta::{days, months, weeks, years}; use datetime_delta::unpickle as _unpkl_dtdelta; use instant::{unpickle as _unpkl_utc, UNIX_EPOCH_INSTANT}; use local_datetime::unpickle as _unpkl_local; +use monthday::unpickle as _unpkl_md; use offset_datetime::unpickle as _unpkl_offset; use system_datetime::unpickle as _unpkl_system; use time::unpickle as _unpkl_time; @@ -51,6 +53,7 @@ static mut MODULE_DEF: PyModuleDef = PyModuleDef { static mut METHODS: &[PyMethodDef] = &[ method!(_unpkl_date, "", METH_O), method!(_unpkl_ym, "", METH_O), + method!(_unpkl_md, "", METH_O), method!(_unpkl_time, "", METH_O), method_vararg!(_unpkl_ddelta, ""), method!(_unpkl_tdelta, "", METH_O), @@ -280,6 +283,14 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int { yearmonth::SINGLETONS, ptr::addr_of_mut!(state.yearmonth_type), ptr::addr_of_mut!(state.unpickle_yearmonth), + ) || !new_type( + module, + module_name, + ptr::addr_of_mut!(monthday::SPEC), + c"_unpkl_md", + monthday::SINGLETONS, + ptr::addr_of_mut!(state.monthday_type), + ptr::addr_of_mut!(state.unpickle_monthday), ) || !new_type( module, module_name, @@ -497,6 +508,13 @@ unsafe extern "C" fn module_traverse( let state = State::for_mod(module); // types traverse_type(state.date_type, visit, arg, date::SINGLETONS.len()); + traverse_type( + state.yearmonth_type, + visit, + arg, + yearmonth::SINGLETONS.len(), + ); + traverse_type(state.monthday_type, visit, arg, monthday::SINGLETONS.len()); traverse_type(state.time_type, visit, arg, time::SINGLETONS.len()); traverse_type( state.date_delta_type, @@ -569,6 +587,8 @@ unsafe extern "C" fn module_clear(module: *mut PyObject) -> c_int { let state = PyModule_GetState(module).cast::().as_mut().unwrap(); // types Py_CLEAR(ptr::addr_of_mut!(state.date_type).cast()); + Py_CLEAR(ptr::addr_of_mut!(state.yearmonth_type).cast()); + Py_CLEAR(ptr::addr_of_mut!(state.monthday_type).cast()); Py_CLEAR(ptr::addr_of_mut!(state.time_type).cast()); Py_CLEAR(ptr::addr_of_mut!(state.date_delta_type).cast()); Py_CLEAR(ptr::addr_of_mut!(state.time_delta_type).cast()); @@ -634,6 +654,7 @@ struct State { // types date_type: *mut PyTypeObject, yearmonth_type: *mut PyTypeObject, + monthday_type: *mut PyTypeObject, time_type: *mut PyTypeObject, date_delta_type: *mut PyTypeObject, time_delta_type: *mut PyTypeObject, @@ -656,6 +677,7 @@ struct State { // unpickling functions unpickle_date: *mut PyObject, unpickle_yearmonth: *mut PyObject, + unpickle_monthday: *mut PyObject, unpickle_time: *mut PyObject, unpickle_date_delta: *mut PyObject, unpickle_time_delta: *mut PyObject, diff --git a/src/monthday.rs b/src/monthday.rs new file mode 100644 index 00000000..dc597a1d --- /dev/null +++ b/src/monthday.rs @@ -0,0 +1,297 @@ +use core::ffi::{c_int, c_long, c_void, CStr}; +use core::{mem, ptr::null_mut as NULL}; +use pyo3_ffi::*; +use std::fmt::{self, Display, Formatter}; + +use crate::common::*; +use crate::date::{Date, MAX_MONTH_DAYS_IN_LEAP_YEAR}; +use crate::State; + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] +pub struct MonthDay { + pub(crate) month: u8, + pub(crate) day: u8, +} + +pub(crate) const SINGLETONS: &[(&CStr, MonthDay); 2] = &[ + (c"MIN", MonthDay::new_unchecked(1, 1)), + (c"MAX", MonthDay::new_unchecked(12, 31)), +]; + +impl MonthDay { + pub(crate) const unsafe fn hash(self) -> i32 { + (self.month as i32) << 8 | self.day as i32 + } + + pub(crate) const fn from_longs(month: c_long, day: c_long) -> Option { + if month < 1 || month > 12 { + return None; + } + if day >= 1 && day <= MAX_MONTH_DAYS_IN_LEAP_YEAR[month as usize] as _ { + Some(MonthDay { + month: month as u8, + day: day as u8, + }) + } else { + None + } + } + + pub(crate) const fn new(month: u8, day: u8) -> Option { + if month == 0 || month > 12 || day == 0 || day > MAX_MONTH_DAYS_IN_LEAP_YEAR[month as usize] + { + None + } else { + Some(MonthDay { month, day }) + } + } + + pub(crate) const fn new_unchecked(month: u8, day: u8) -> Self { + debug_assert!(month > 0); + debug_assert!(month <= 12); + debug_assert!(day > 0 && day <= 31); + MonthDay { month, day } + } + + pub(crate) fn parse_all(s: &[u8]) -> Option { + if s.len() == 7 && s[0] == b'-' && s[1] == b'-' && s[4] == b'-' { + MonthDay::new( + parse_digit(s, 2)? * 10 + parse_digit(s, 3)?, + parse_digit(s, 5)? * 10 + parse_digit(s, 6)?, + ) + } else { + None + } + } +} + +impl PyWrapped for MonthDay {} + +impl Display for MonthDay { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "--{:02}-{:02}", self.month, self.day) + } +} + +unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyObject) -> PyReturn { + let mut month: c_long = 0; + let mut day: c_long = 0; + + // FUTURE: parse them manually, which is more efficient + if PyArg_ParseTupleAndKeywords( + args, + kwargs, + c"ll:MonthDay".as_ptr(), + arg_vec(&[c"month", c"day"]).as_mut_ptr(), + &mut month, + &mut day, + ) == 0 + { + Err(py_err!())? + } + + MonthDay::from_longs(month, day) + .ok_or_value_err("Invalid month/day component value")? + .to_obj(cls) +} + +unsafe fn __repr__(slf: *mut PyObject) -> PyReturn { + format!("MonthDay({})", MonthDay::extract(slf)).to_py() +} + +unsafe extern "C" fn __hash__(slf: *mut PyObject) -> Py_hash_t { + MonthDay::extract(slf).hash() as Py_hash_t +} + +unsafe fn __richcmp__(a_obj: *mut PyObject, b_obj: *mut PyObject, op: c_int) -> PyReturn { + Ok(if Py_TYPE(b_obj) == Py_TYPE(a_obj) { + let a = MonthDay::extract(a_obj); + let b = MonthDay::extract(b_obj); + match op { + pyo3_ffi::Py_LT => a < b, + pyo3_ffi::Py_LE => a <= b, + pyo3_ffi::Py_EQ => a == b, + pyo3_ffi::Py_NE => a != b, + pyo3_ffi::Py_GT => a > b, + pyo3_ffi::Py_GE => a >= b, + _ => unreachable!(), + } + .to_py()? + } else { + newref(Py_NotImplemented()) + }) +} + +static mut SLOTS: &[PyType_Slot] = &[ + slotmethod!(Py_tp_new, __new__), + slotmethod!(Py_tp_str, __str__, 1), + slotmethod!(Py_tp_repr, __repr__, 1), + slotmethod!(Py_tp_richcompare, __richcmp__), + PyType_Slot { + slot: Py_tp_doc, + pfunc: c"A month and day type, i.e. a date without a specific year".as_ptr() as *mut c_void, + }, + PyType_Slot { + slot: Py_tp_methods, + pfunc: unsafe { METHODS.as_ptr() as *mut c_void }, + }, + PyType_Slot { + slot: Py_tp_getset, + pfunc: unsafe { GETSETTERS.as_ptr() as *mut c_void }, + }, + PyType_Slot { + slot: Py_tp_hash, + pfunc: __hash__ as *mut c_void, + }, + PyType_Slot { + slot: Py_tp_dealloc, + pfunc: generic_dealloc as *mut c_void, + }, + PyType_Slot { + slot: 0, + pfunc: NULL(), + }, +]; + +unsafe fn __str__(slf: *mut PyObject) -> PyReturn { + format!("{}", MonthDay::extract(slf)).to_py() +} + +unsafe fn format_common_iso(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { + __str__(slf) +} + +unsafe fn parse_common_iso(cls: *mut PyObject, s: *mut PyObject) -> PyReturn { + MonthDay::parse_all(s.to_utf8()?.ok_or_type_err("argument must be str")?) + .ok_or_else(|| value_err!("Invalid format: {}", s.repr()))? + .to_obj(cls.cast()) +} + +unsafe fn __reduce__(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { + let MonthDay { month, day } = MonthDay::extract(slf); + ( + State::for_obj(slf).unpickle_monthday, + steal!((steal!(pack![month, day].to_py()?),).to_py()?), + ) + .to_py() +} + +unsafe fn replace( + slf: *mut PyObject, + cls: *mut PyTypeObject, + args: &[*mut PyObject], + kwargs: &mut KwargIter, +) -> PyReturn { + let &State { + str_month, str_day, .. + } = State::for_type(cls); + if !args.is_empty() { + Err(type_err!("replace() takes no positional arguments")) + } else { + let md = MonthDay::extract(slf); + let mut month = md.month.into(); + let mut day = md.day.into(); + handle_kwargs("replace", kwargs, |key, value, eq| { + if eq(key, str_month) { + month = value + .to_long()? + .ok_or_type_err("month must be an integer")?; + } else if eq(key, str_day) { + day = value.to_long()?.ok_or_type_err("day must be an integer")?; + } else { + return Ok(false); + } + Ok(true) + })?; + MonthDay::from_longs(month, day) + .ok_or_value_err("Invalid month/day components")? + .to_obj(cls) + } +} + +unsafe fn in_year(slf: *mut PyObject, year_obj: *mut PyObject) -> PyReturn { + let &State { date_type, .. } = State::for_obj(slf); + let MonthDay { month, day } = MonthDay::extract(slf); + let year = year_obj + .to_long()? + .ok_or_type_err("year must be an integer")? + .try_into() + .ok() + .ok_or_value_err("year out of range")?; + // OPTIMIZE: we don't need to check the validity of the month again + Date::new(year, month, day) + .ok_or_value_err("Invalid date components")? + .to_obj(date_type) +} + +unsafe fn is_leap(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { + let MonthDay { month, day } = MonthDay::extract(slf); + (day == 29 && month == 2).to_py() +} + +static mut METHODS: &[PyMethodDef] = &[ + method!( + format_common_iso, + "Return the date in the common ISO 8601 format" + ), + method!( + parse_common_iso, + "Create a date from the common ISO 8601 format", + METH_O | METH_CLASS + ), + method!(identity2 named "__copy__", ""), + method!(identity2 named "__deepcopy__", "", METH_O), + method!( + in_year, + "Create a date from this month and day in the specified year", + METH_O + ), + method!(is_leap, "Return whether this month and day is February 29"), + method!(__reduce__, ""), + method_kwargs!( + replace, + "replace($self, *, month=None, day=None)\n--\n\n\ + Return a new instance with the specified components replaced" + ), + PyMethodDef::zeroed(), +]; + +pub(crate) unsafe fn unpickle(module: *mut PyObject, arg: *mut PyObject) -> PyReturn { + let mut packed = arg.to_bytes()?.ok_or_type_err("Invalid pickle data")?; + if packed.len() != 2 { + Err(value_err!("Invalid pickle data"))? + } + MonthDay { + month: unpack_one!(packed, u8), + day: unpack_one!(packed, u8), + } + .to_obj(State::for_mod(module).monthday_type) +} + +unsafe fn get_month(slf: *mut PyObject) -> PyReturn { + MonthDay::extract(slf).month.to_py() +} + +unsafe fn get_day(slf: *mut PyObject) -> PyReturn { + MonthDay::extract(slf).day.to_py() +} + +static mut GETSETTERS: &[PyGetSetDef] = &[ + getter!( + get_month named "month", + "The month component" + ), + getter!( + get_day named "day", + "The day component" + ), + PyGetSetDef { + name: NULL(), + get: None, + set: None, + doc: NULL(), + closure: NULL(), + }, +]; + +type_spec!(MonthDay, SLOTS); diff --git a/src/system_datetime.rs b/src/system_datetime.rs index 42c6215f..cd6b21eb 100644 --- a/src/system_datetime.rs +++ b/src/system_datetime.rs @@ -528,8 +528,6 @@ unsafe fn replace( unsafe fn now(cls: *mut PyObject, _: *mut PyObject) -> PyReturn { let state = State::for_type(cls.cast()); let (timestamp, nanos) = state.time_ns()?; - // Technically conversion to i128 can overflow, but only if system - // time is set to a very very very distant future let utc_dt = Instant::from_timestamp(timestamp) .ok_or_value_err("timestamp is out of range")? .to_py_ignore_nanos(state.py_api)?; diff --git a/src/yearmonth.rs b/src/yearmonth.rs index 06899a0e..3c6f1a73 100644 --- a/src/yearmonth.rs +++ b/src/yearmonth.rs @@ -82,7 +82,7 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb if PyArg_ParseTupleAndKeywords( args, kwargs, - c"|ll:YearMonth".as_ptr(), + c"ll:YearMonth".as_ptr(), arg_vec(&[c"year", c"month"]).as_mut_ptr(), &mut year, &mut month, @@ -247,8 +247,8 @@ static mut METHODS: &[PyMethodDef] = &[ method!(__reduce__, ""), method_kwargs!( replace, - "replace($self, *, year=None, month=None, day=None)\n--\n\n\ - Return a new date with the specified components replaced" + "replace($self, *, month=None, day=None)\n--\n\n\ + Return a new instance with the specified components replaced" ), PyMethodDef::zeroed(), ]; diff --git a/tests/test_date.py b/tests/test_date.py index c1dcd9f3..08d3fc2a 100644 --- a/tests/test_date.py +++ b/tests/test_date.py @@ -10,6 +10,7 @@ Date, DateDelta, LocalDateTime, + MonthDay, Time, Weekday, YearMonth, @@ -134,6 +135,11 @@ def test_year_month(): assert d.year_month() == YearMonth(2021, 1) +def test_month_day(): + d = Date(2021, 1, 2) + assert d.month_day() == MonthDay(1, 2) + + def test_py_date(): d = Date(2021, 1, 2) assert d.py_date() == py_date(2021, 1, 2) diff --git a/tests/test_month_day.py b/tests/test_month_day.py new file mode 100644 index 00000000..10cb7e00 --- /dev/null +++ b/tests/test_month_day.py @@ -0,0 +1,244 @@ +import pickle +import re +from copy import copy, deepcopy + +import pytest + +from whenever import Date, MonthDay + +from .common import AlwaysEqual, AlwaysLarger, AlwaysSmaller, NeverEqual + + +class TestInit: + + def test_valid(self): + assert MonthDay(12, 3) is not None + assert MonthDay(1, 1) is not None + assert MonthDay(12, 31) is not None + assert MonthDay(2, 29) is not None + + @pytest.mark.parametrize( + "month, day", + [ + (13, 1), + (2, 30), + (8, 32), + (0, 3), + (10_000, 3), + ], + ) + def test_invalid_combinations(self, month, day): + with pytest.raises(ValueError): + MonthDay(month, day) + + def test_invalid(self): + with pytest.raises(TypeError): + MonthDay(2) # type: ignore[call-arg] + + with pytest.raises(TypeError): + MonthDay("20", "SEP") # type: ignore[arg-type] + + with pytest.raises(TypeError): + MonthDay() # type: ignore[call-arg] + + +def test_properties(): + md = MonthDay(12, 14) + assert md.month == 12 + assert md.day == 14 + + +def test_is_leap(): + assert MonthDay(2, 29).is_leap() + assert not MonthDay(2, 28).is_leap() + assert not MonthDay(3, 1).is_leap() + assert not MonthDay(1, 1).is_leap() + assert not MonthDay(12, 31).is_leap() + + +def test_eq(): + md = MonthDay(10, 12) + same = MonthDay(10, 12) + different = MonthDay(10, 11) + + assert md == same + assert not md == different + assert not md == NeverEqual() + assert md == AlwaysEqual() + + assert not md != same + assert md != different + assert md != NeverEqual() + assert not md != AlwaysEqual() + assert md != None # noqa: E711 + assert None != md # noqa: E711 + assert not md == None # noqa: E711 + assert not None == md # noqa: E711 + + assert hash(md) == hash(same) + + +def test_comparison(): + md = MonthDay(7, 5) + same = MonthDay(7, 5) + bigger = MonthDay(8, 2) + smaller = MonthDay(6, 12) + + assert md <= same + assert md <= bigger + assert not md <= smaller + assert md <= AlwaysLarger() + assert not md <= AlwaysSmaller() + + assert not md < same + assert md < bigger + assert not md < smaller + assert md < AlwaysLarger() + assert not md < AlwaysSmaller() + + assert md >= same + assert not md >= bigger + assert md >= smaller + assert not md >= AlwaysLarger() + assert md >= AlwaysSmaller() + + assert not md > same + assert not md > bigger + assert md > smaller + assert not md > AlwaysLarger() + assert md > AlwaysSmaller() + + +def test_format_common_iso(): + assert MonthDay(11, 12).format_common_iso() == "--11-12" + assert MonthDay(2, 1).format_common_iso() == "--02-01" + + +def test_str(): + assert str(MonthDay(10, 31)) == "--10-31" + assert str(MonthDay(2, 1)) == "--02-01" + + +def test_repr(): + assert repr(MonthDay(11, 12)) == "MonthDay(--11-12)" + assert repr(MonthDay(2, 1)) == "MonthDay(--02-01)" + + +class TestParseCommonIso: + + @pytest.mark.parametrize( + "s, expected", + [ + ("--08-21", MonthDay(8, 21)), + ("--10-02", MonthDay(10, 2)), + ], + ) + def test_valid(self, s, expected): + assert MonthDay.parse_common_iso(s) == expected + + @pytest.mark.parametrize( + "s", + [ + "--2A-01", # non-digit + "--11-01T03:04:05", # with a time + "2021-01-02", # with a year + "--11-1", # no padding + "--1-13", # no padding + "W12-04", # week date + "03-12", # no dashes + "-10-12", # not enough dashes + "---12-03", # negative month + "--1๐Ÿงจ-12", # non-ASCII + "--1๐Ÿ™-11", # non-ascii + ], + ) + def test_invalid(self, s): + with pytest.raises( + ValueError, + match=r"Invalid format.*" + re.escape(repr(s)), + ): + MonthDay.parse_common_iso(s) + + def test_no_string(self): + with pytest.raises(TypeError, match="(int|str)"): + MonthDay.parse_common_iso(20210102) # type: ignore[arg-type] + + +def test_replace(): + md = MonthDay(12, 31) + assert md.replace(month=8) == MonthDay(8, 31) + assert md.replace(day=8) == MonthDay(12, 8) + assert md == MonthDay(12, 31) # original is unchanged + + with pytest.raises(ValueError, match="(day|month|date)"): + md.replace(month=2) + + with pytest.raises(ValueError, match="(date|day)"): + md.replace(day=32) + + with pytest.raises(TypeError): + md.replace(3) # type: ignore[misc] + + with pytest.raises(TypeError, match="foo"): + md.replace(foo=3) # type: ignore[call-arg] + + with pytest.raises(TypeError, match="year"): + md.replace(year=2000) # type: ignore[call-arg] + + with pytest.raises(TypeError, match="foo"): + md.replace(foo="blabla") # type: ignore[call-arg] + + with pytest.raises(ValueError, match="(date|month)"): + md.replace(month=13) + + +def test_in_year(): + md = MonthDay(12, 28) + assert md.in_year(2000) == Date(2000, 12, 28) + assert md.in_year(4) == Date(4, 12, 28) + + with pytest.raises(ValueError): + md.in_year(0) + + with pytest.raises(ValueError): + md.in_year(10_000) + + with pytest.raises(ValueError): + md.in_year(-1) + + leap_day = MonthDay(2, 29) + assert leap_day.in_year(2000) == Date(2000, 2, 29) + with pytest.raises(ValueError): + leap_day.in_year(2001) + + +def test_copy(): + md = MonthDay(5, 1) + assert copy(md) is md + assert deepcopy(md) is md + + +def test_singletons(): + assert MonthDay.MIN == MonthDay(1, 1) + assert MonthDay.MAX == MonthDay(12, 31) + + +def test_pickling(): + d = MonthDay(11, 1) + dumped = pickle.dumps(d) + assert pickle.loads(dumped) == d + + +def test_unpickle_compatibility(): + dumped = ( + b"\x80\x04\x95#\x00\x00\x00\x00\x00\x00\x00\x8c\x08whenever\x94\x8c\t_unpkl_m" + b"d\x94\x93\x94C\x02\x0b\x01\x94\x85\x94R\x94." + ) + assert pickle.loads(dumped) == MonthDay(11, 1) + + +def test_cannot_subclass(): + with pytest.raises(TypeError): + + class SubclassDate(MonthDay): # type: ignore[misc] + pass diff --git a/tests/test_system_datetime.py b/tests/test_system_datetime.py index a57cf35b..3187c1bf 100644 --- a/tests/test_system_datetime.py +++ b/tests/test_system_datetime.py @@ -868,7 +868,7 @@ def test_bounds(self): @system_tz_nyc() def test_now(): now = SystemDateTime.now() - assert now.offset == hours(-4) + assert now.offset in (hours(-4), hours(-5)) py_now = py_datetime.now(ZoneInfo("America/New_York")) assert py_now - now.py_datetime() < timedelta(seconds=1) diff --git a/tests/test_year_month.py b/tests/test_year_month.py index 714930f2..8301aea5 100644 --- a/tests/test_year_month.py +++ b/tests/test_year_month.py @@ -27,10 +27,20 @@ def test_valid(self): (10_000, 3), ], ) - def test_invalid(self, year, month): + def test_invalid_combinations(self, year, month): with pytest.raises(ValueError): YearMonth(year, month) + def test_invalid(self): + with pytest.raises(TypeError): + YearMonth(2000) # type: ignore[call-arg] + + with pytest.raises(TypeError): + YearMonth("2001", "SEP") # type: ignore[arg-type] + + with pytest.raises(TypeError): + YearMonth() # type: ignore[call-arg] + def test_properties(): ym = YearMonth(2021, 12)