Skip to content

Commit

Permalink
(wip) add YearMonth
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Nov 1, 2024
1 parent 3f24fa7 commit 9a600ba
Show file tree
Hide file tree
Showing 10 changed files with 762 additions and 7 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
🚀 Changelog
============

0.6.11 (2024-11-??)
-------------------

**Added**

- Added ``YearMonth`` class

**Fixed**

- ``__version__`` is accessible, whether the Rust or Python version is used

0.6.10 (2024-10-30)
-------------------

Expand Down
6 changes: 4 additions & 2 deletions pysrc/whenever/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
_unpkl_tdelta,
_unpkl_time,
_unpkl_utc,
_unpkl_ym,
_unpkl_zoned,
)

Expand All @@ -24,7 +25,6 @@
from ._pywhenever import *
from ._pywhenever import ( # for the docs
__all__,
__version__,
_BasicConversions,
_KnowsInstant,
_KnowsInstantAndLocal,
Expand All @@ -41,16 +41,18 @@
_unpkl_tdelta,
_unpkl_time,
_unpkl_utc,
_unpkl_ym,
_unpkl_zoned,
)

_EXTENSION_LOADED = False


from contextlib import contextmanager as _contextmanager
from dataclasses import dataclass as _dataclass
from typing import Iterator as _Iterator

from ._pywhenever import __version__


@_dataclass
class _TimePatch:
Expand Down
21 changes: 21 additions & 0 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Date:
def month(self) -> int: ...
@property
def day(self) -> int: ...
def year_month(self) -> YearMonth: ...
def day_of_week(self) -> Weekday: ...
def at(self, t: Time, /) -> LocalDateTime: ...
def py_date(self) -> _date: ...
Expand Down Expand Up @@ -81,6 +82,26 @@ class Date:
def __ge__(self, other: Date) -> bool: ...
def __hash__(self) -> int: ...

@final
class YearMonth:
def __init__(self, year: int, month: int) -> None: ...
MIN: ClassVar[YearMonth]
MAX: ClassVar[YearMonth]
@property
def year(self) -> int: ...
@property
def month(self) -> int: ...
def format_common_iso(self) -> str: ...
@classmethod
def parse_common_iso(cls, s: str, /) -> YearMonth: ...
def replace(self, *, year: int = ..., month: int = ...) -> YearMonth: ...
def on_day(self, day: int, /) -> Date: ...
def __lt__(self, other: YearMonth) -> bool: ...
def __le__(self, other: YearMonth) -> bool: ...
def __gt__(self, other: YearMonth) -> bool: ...
def __ge__(self, other: YearMonth) -> bool: ...
def __hash__(self) -> int: ...

@final
class Time:
def __init__(
Expand Down
162 changes: 162 additions & 0 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
__all__ = [
# Date and time
"Date",
"YearMonth",
"Time",
"Instant",
"OffsetDateTime",
Expand Down Expand Up @@ -191,6 +192,16 @@ def month(self) -> int:
def day(self) -> int:
return self._py_date.day

def year_month(self) -> YearMonth:
"""The year and month (without a day component)
Example
-------
>>> Date(2021, 1, 2).year_month()
YearMonth(2021-01)
"""
return YearMonth._from_py_unchecked(self._py_date.replace(day=1))

def day_of_week(self) -> Weekday:
"""The day of the week
Expand Down Expand Up @@ -482,6 +493,155 @@ def _unpkl_date(data: bytes) -> Date:
Date.MAX = Date._from_py_unchecked(_date.max)


@final
class YearMonth(_ImmutableBase):
"""A year and month without a day component
Example
-------
>>> ym = YearMonth(2021, 1)
YearMonth(2021-01)
"""

# 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[YearMonth]
"""The minimum possible year-month"""
MAX: ClassVar[YearMonth]
"""The maximum possible year-month"""

def __init__(self, year: int, month: int) -> None:
self._py_date = _date(year, month, 1)

@property
def year(self) -> int:
return self._py_date.year

@property
def month(self) -> int:
return self._py_date.month

def format_common_iso(self) -> str:
"""Format as the common ISO 8601 year-month format.
Inverse of :meth:`parse_common_iso`.
Example
-------
>>> YearMonth(2021, 1).format_common_iso()
'2021-01'
"""
return self._py_date.isoformat()[:7]

@classmethod
def parse_common_iso(cls, s: str, /) -> YearMonth:
"""Create from the common ISO 8601 format ``YYYY-MM``.
Does not accept more "exotic" ISO 8601 formats.
Inverse of :meth:`format_common_iso`
Example
-------
>>> YearMonth.parse_common_iso("2021-01-02")
YearMonth(2021-01-02)
"""
if not _match_yearmonth(s):
raise ValueError(f"Invalid format: {s!r}")
return cls._from_py_unchecked(_date.fromisoformat(s + "-01"))

def replace(self, **kwargs: Any) -> YearMonth:
"""Create a new instance with the given fields replaced
Example
-------
>>> d = YearMonth(2021, 12)
>>> d.replace(month=3)
YearMonth(2021-03)
"""
if "day" in kwargs:
raise TypeError(
"replace() got an unexpected keyword argument 'day'"
)
return YearMonth._from_py_unchecked(self._py_date.replace(**kwargs))

def on_day(self, day: int, /) -> Date:
"""Create a date from this year-month with a given day
Example
-------
>>> YearMonth(2021, 1).on_day(2)
Date(2021-01-02)
"""
return Date._from_py_unchecked(self._py_date.replace(day=day))

__str__ = format_common_iso

def __repr__(self) -> str:
return f"YearMonth({self})"

def __eq__(self, other: object) -> bool:
"""Compare for equality
Example
-------
>>> ym = YearMonth(2021, 1)
>>> ym == YearMonth(2021, 1)
True
>>> ym == YearMonth(2021, 2)
False
"""
if not isinstance(other, YearMonth):
return NotImplemented
return self._py_date == other._py_date

def __lt__(self, other: YearMonth) -> bool:
if not isinstance(other, YearMonth):
return NotImplemented
return self._py_date < other._py_date

def __le__(self, other: YearMonth) -> bool:
if not isinstance(other, YearMonth):
return NotImplemented
return self._py_date <= other._py_date

def __gt__(self, other: YearMonth) -> bool:
if not isinstance(other, YearMonth):
return NotImplemented
return self._py_date > other._py_date

def __ge__(self, other: YearMonth) -> bool:
if not isinstance(other, YearMonth):
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, /) -> YearMonth:
self = _object_new(cls)
self._py_date = d
return self

@no_type_check
def __reduce__(self):
return _unpkl_ym, (pack("<HB", self.year, self.month),)


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


YearMonth.MIN = YearMonth._from_py_unchecked(_date.min)
YearMonth.MAX = YearMonth._from_py_unchecked(_date.max.replace(day=1))


@final
class Time(_ImmutableBase):
"""Time of day without a date component
Expand Down Expand Up @@ -4941,6 +5101,7 @@ def _load_offset(offset: int | TimeDelta, /) -> _timezone:
_match_next_datedelta_component = re.compile(
r"^(\d{1,8})([YMWD])", re.ASCII
).match
_match_yearmonth = re.compile(r"\d{4}-\d{2}", re.ASCII).fullmatch


def _check_utc_bounds(dt: _datetime) -> _datetime:
Expand Down Expand Up @@ -5113,6 +5274,7 @@ def nanoseconds(i: int, /) -> TimeDelta:

for _unpkl in (
_unpkl_date,
_unpkl_ym,
_unpkl_time,
_unpkl_tdelta,
_unpkl_dtdelta,
Expand Down
12 changes: 10 additions & 2 deletions src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use pyo3_ffi::*;
use std::fmt::{self, Display, Formatter};

use crate::common::*;
use crate::{date_delta::DateDelta, local_datetime::DateTime, time::Time, State};
use crate::{
date_delta::DateDelta, local_datetime::DateTime, time::Time, yearmonth::YearMonth, State,
};

#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
pub struct Date {
Expand Down Expand Up @@ -206,7 +208,7 @@ impl Display for Date {
}

pub(crate) const MAX_YEAR: c_long = 9999;
const MIN_YEAR: c_long = 1;
pub(crate) const MIN_YEAR: c_long = 1;
const DAYS_IN_MONTH: [u8; 13] = [
0, // 1-indexed
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
Expand Down Expand Up @@ -413,6 +415,11 @@ unsafe fn from_py_date(cls: *mut PyObject, date: *mut PyObject) -> PyReturn {
}
}

unsafe fn year_month(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
let Date { year, month, .. } = Date::extract(slf);
YearMonth::new_unchecked(year, month).to_obj(State::for_obj(slf).yearmonth_type)
}

unsafe fn __str__(slf: *mut PyObject) -> PyReturn {
format!("{}", Date::extract(slf)).to_py()
}
Expand Down Expand Up @@ -702,6 +709,7 @@ static mut METHODS: &[PyMethodDef] = &[
method!(identity2 named "__deepcopy__", "", METH_O),
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!(__reduce__, ""),
method_kwargs!(
add,
Expand Down
15 changes: 14 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod offset_datetime;
mod system_datetime;
mod time;
mod time_delta;
mod yearmonth;
mod zoned_datetime;

use date::unpickle as _unpkl_date;
Expand All @@ -29,12 +30,13 @@ use system_datetime::unpickle as _unpkl_system;
use time::unpickle as _unpkl_time;
use time_delta::unpickle as _unpkl_tdelta;
use time_delta::{hours, microseconds, milliseconds, minutes, nanoseconds, seconds};
use yearmonth::unpickle as _unpkl_ym;
use zoned_datetime::unpickle as _unpkl_zoned;

static mut MODULE_DEF: PyModuleDef = PyModuleDef {
m_base: PyModuleDef_HEAD_INIT,
m_name: c"whenever".as_ptr(),
m_doc: c"A better datetime API for Python, written in Rust".as_ptr(),
m_doc: c"Modern datetime library for Python".as_ptr(),
m_size: mem::size_of::<State>() as _,
m_methods: unsafe { METHODS.as_ptr() as *mut _ },
m_slots: unsafe { MODULE_SLOTS.as_ptr() as *mut _ },
Expand All @@ -48,6 +50,7 @@ static mut MODULE_DEF: PyModuleDef = PyModuleDef {

static mut METHODS: &[PyMethodDef] = &[
method!(_unpkl_date, "", METH_O),
method!(_unpkl_ym, "", METH_O),
method!(_unpkl_time, "", METH_O),
method_vararg!(_unpkl_ddelta, ""),
method!(_unpkl_tdelta, "", METH_O),
Expand Down Expand Up @@ -269,6 +272,14 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int {
date::SINGLETONS,
ptr::addr_of_mut!(state.date_type),
ptr::addr_of_mut!(state.unpickle_date),
) || !new_type(
module,
module_name,
ptr::addr_of_mut!(yearmonth::SPEC),
c"_unpkl_ym",
yearmonth::SINGLETONS,
ptr::addr_of_mut!(state.yearmonth_type),
ptr::addr_of_mut!(state.unpickle_yearmonth),
) || !new_type(
module,
module_name,
Expand Down Expand Up @@ -622,6 +633,7 @@ unsafe extern "C" fn module_clear(module: *mut PyObject) -> c_int {
struct State {
// types
date_type: *mut PyTypeObject,
yearmonth_type: *mut PyTypeObject,
time_type: *mut PyTypeObject,
date_delta_type: *mut PyTypeObject,
time_delta_type: *mut PyTypeObject,
Expand All @@ -643,6 +655,7 @@ struct State {

// unpickling functions
unpickle_date: *mut PyObject,
unpickle_yearmonth: *mut PyObject,
unpickle_time: *mut PyObject,
unpickle_date_delta: *mut PyObject,
unpickle_time_delta: *mut PyObject,
Expand Down
Loading

0 comments on commit 9a600ba

Please sign in to comment.