Skip to content

Commit

Permalink
add MonthDay
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Nov 4, 2024
1 parent 48caa63 commit 8f65970
Show file tree
Hide file tree
Showing 17 changed files with 842 additions and 19 deletions.
6 changes: 3 additions & 3 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
-------------------
Expand Down
8 changes: 8 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down
2 changes: 1 addition & 1 deletion docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date-and-time-api>` for more details.

Testing
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions pysrc/whenever/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
_unpkl_ddelta,
_unpkl_dtdelta,
_unpkl_local,
_unpkl_md,
_unpkl_offset,
_unpkl_system,
_unpkl_tdelta,
Expand Down Expand Up @@ -36,6 +37,7 @@
_unpkl_ddelta,
_unpkl_dtdelta,
_unpkl_local,
_unpkl_md,
_unpkl_offset,
_unpkl_system,
_unpkl_tdelta,
Expand Down
23 changes: 23 additions & 0 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ __all__ = [
]

_EXTENSION_LOADED: bool
__version__: str

@final
class Date:
Expand All @@ -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: ...
Expand Down Expand Up @@ -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__(
Expand Down
204 changes: 203 additions & 1 deletion pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.10"
__version__ = "0.6.11"

import enum
import re
Expand Down Expand Up @@ -68,6 +68,7 @@
# Date and time
"Date",
"YearMonth",
"MonthDay",
"Time",
"Instant",
"OffsetDateTime",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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("<BB", self.month, self.day),)


# A separate unpickling function allows us to make backwards-compatible changes
# to the pickling format in the future
@no_type_check
def _unpkl_md(data: bytes) -> MonthDay:
return MonthDay(*unpack("<BB", data))


MonthDay.MIN = MonthDay._from_py_unchecked(
_date.min.replace(year=_DUMMY_LEAP_YEAR)
)
MonthDay.MAX = MonthDay._from_py_unchecked(
_date.max.replace(year=_DUMMY_LEAP_YEAR)
)


@final
class Time(_ImmutableBase):
"""Time of day without a date component
Expand Down Expand Up @@ -5102,6 +5302,7 @@ def _load_offset(offset: int | TimeDelta, /) -> _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:
Expand Down Expand Up @@ -5275,6 +5476,7 @@ def nanoseconds(i: int, /) -> TimeDelta:
for _unpkl in (
_unpkl_date,
_unpkl_ym,
_unpkl_md,
_unpkl_time,
_unpkl_tdelta,
_unpkl_dtdelta,
Expand Down
Loading

0 comments on commit 8f65970

Please sign in to comment.