Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next small release #199

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
🚀 Changelog
============

0.6.17 (2025-01-29)
-------------------

- Added ``hours_in_day()`` and ``start_of_day()`` methods to ``ZonedDateTime``
to make it easier to work with edge cases around DST transitions.
- Fix cases in type stubs where positional-only arguments weren't marked as such

0.6.16 (2024-12-22)
-------------------

Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ There’s no way to be sure...
✨ Until now! ✨

*Whenever* helps you write **correct** and **type checked** datetime code,
using **well-established concepts** from modern libraries in other languages.
using **well-established concepts** from [modern libraries](#acknowledgements) in other languages.
It's also **way faster** than other third-party libraries—and usually the standard library as well.
If performance isn't your top priority, a **pure Python** version is available as well.

Expand Down Expand Up @@ -214,10 +214,10 @@ For more details, see the licenses included in the distribution.

## Acknowledgements

This project is inspired by—and borrows concepts from—the following projects. Check them out!
This project is inspired by—and borrows most concepts from—the following projects. Check them out!

- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/)
- [Temporal](https://tc39.es/proposal-temporal/docs/)
- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/)

The benchmark comparison graph is based on the one from the [Ruff](https://github.com/astral-sh/ruff) project.
For timezone data, **Whenever** uses Python's own `zoneinfo` module.
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Concrete classes
:members:
tz,
is_ambiguous,
start_of_day,
hours_in_day,
:member-order: bysource
:show-inheritance:

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 = "[email protected]"},
]
readme = "README.md"
version = "0.6.16"
version = "0.6.17"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
32 changes: 17 additions & 15 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class Time:
def second(self) -> int: ...
@property
def nanosecond(self) -> int: ...
def on(self, d: Date) -> LocalDateTime: ...
def on(self, d: Date, /) -> LocalDateTime: ...
def py_time(self) -> _time: ...
@classmethod
def from_py_time(cls, t: _time, /) -> Time: ...
Expand Down Expand Up @@ -659,6 +659,8 @@ class ZonedDateTime(_KnowsInstantAndLocal):
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> ZonedDateTime: ...
def hours_in_day(self) -> float: ...
def start_of_day(self) -> ZonedDateTime: ...
# FUTURE: disable date components in strict stubs version
def __add__(self, delta: Delta) -> ZonedDateTime: ...
@overload
Expand Down Expand Up @@ -828,12 +830,12 @@ class LocalDateTime(_KnowsLocal):
tz: str,
/,
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"],
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> ZonedDateTime: ...
def assume_system_tz(
self,
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"],
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> SystemDateTime: ...
@classmethod
def from_py_datetime(cls, d: _datetime, /) -> LocalDateTime: ...
Expand All @@ -854,8 +856,8 @@ class LocalDateTime(_KnowsLocal):
second: int = ...,
nanosecond: int = ...,
) -> LocalDateTime: ...
def replace_date(self, d: Date) -> LocalDateTime: ...
def replace_time(self, t: Time) -> LocalDateTime: ...
def replace_date(self, d: Date, /) -> LocalDateTime: ...
def replace_time(self, t: Time, /) -> LocalDateTime: ...
@overload
def add(
self,
Expand Down Expand Up @@ -947,16 +949,16 @@ FRIDAY = Weekday.FRIDAY
SATURDAY = Weekday.SATURDAY
SUNDAY = Weekday.SUNDAY

def years(i: int) -> DateDelta: ...
def months(i: int) -> DateDelta: ...
def weeks(i: int) -> DateDelta: ...
def days(i: int) -> DateDelta: ...
def hours(i: float) -> TimeDelta: ...
def minutes(i: float) -> TimeDelta: ...
def seconds(i: float) -> TimeDelta: ...
def milliseconds(i: float) -> TimeDelta: ...
def microseconds(i: float) -> TimeDelta: ...
def nanoseconds(i: int) -> TimeDelta: ...
def years(i: int, /) -> DateDelta: ...
def months(i: int, /) -> DateDelta: ...
def weeks(i: int, /) -> DateDelta: ...
def days(i: int, /) -> DateDelta: ...
def hours(i: float, /) -> TimeDelta: ...
def minutes(i: float, /) -> TimeDelta: ...
def seconds(i: float, /) -> TimeDelta: ...
def milliseconds(i: float, /) -> TimeDelta: ...
def microseconds(i: float, /) -> TimeDelta: ...
def nanoseconds(i: int, /) -> TimeDelta: ...

class _TimePatch:
def shift(self, *args: Any, **kwargs: Any) -> None: ...
Expand Down
38 changes: 35 additions & 3 deletions 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.16"
__version__ = "0.6.17"

import enum
import re
Expand Down Expand Up @@ -612,8 +612,8 @@ def parse_common_iso(cls, s: str, /) -> YearMonth:

Example
-------
>>> YearMonth.parse_common_iso("2021-01-02")
YearMonth(2021-01-02)
>>> YearMonth.parse_common_iso("2021-01")
YearMonth(2021-01)
"""
if not _match_yearmonth(s):
raise ValueError(f"Invalid format: {s!r}")
Expand Down Expand Up @@ -4310,6 +4310,38 @@ def is_ambiguous(self) -> bool:
# ambiguous datetimes are never equal across timezones
return self._py_dt.astimezone(_UTC) != self._py_dt

def hours_in_day(self) -> float:
"""The number of hours in the day, accounting for timezone transitions,
e.g. during a DST transition.

Example
-------
>>> ZonedDateTime(2020, 8, 15, tz="Europe/London").hours_in_day()
24
>>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").hours_in_day()
25
"""
midnight = _datetime.combine(
self._py_dt.date(), _time(), self._py_dt.tzinfo
)
next_midnight = midnight + _timedelta(days=1)
return (
next_midnight.astimezone(_UTC) - midnight.astimezone(_UTC)
) / _timedelta(hours=1)

def start_of_day(self) -> ZonedDateTime:
"""The start of the current calendar day.

This is almost always at midnight the same day, but may be different
for timezones which transition at—and thus skip over—midnight.
"""
midnight = _datetime.combine(
self._py_dt.date(), _time(), self._py_dt.tzinfo
)
return ZonedDateTime._from_py_unchecked(
midnight.astimezone(_UTC).astimezone(self._py_dt.tzinfo), 0
)

def __repr__(self) -> str:
return f"ZonedDateTime({str(self).replace('T', ' ', 1)})"

Expand Down
2 changes: 1 addition & 1 deletion src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl Date {
// Since the data already fits within an i32
// we don't need to do any extra hashing. It may be counterintuitive,
// but this is also what `int` does: `hash(6) == 6`.
mem::transmute::<_, i32>(self)
mem::transmute(self)
}

pub(crate) const fn increment(mut self) -> Self {
Expand Down
27 changes: 25 additions & 2 deletions src/docstrings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1603,8 +1603,8 @@ Inverse of :meth:`format_common_iso`

Example
-------
>>> YearMonth.parse_common_iso(\"2021-01-02\")
YearMonth(2021-01-02)
>>> YearMonth.parse_common_iso(\"2021-01\")
YearMonth(2021-01)
";
pub(crate) const YEARMONTH_REPLACE: &CStr = c"\
replace($self, /, *, year=None, month=None)
Expand Down Expand Up @@ -1691,6 +1691,20 @@ Create an instance from a UNIX timestamp (in nanoseconds).

The inverse of the ``timestamp_nanos()`` method.
";
pub(crate) const ZONEDDATETIME_HOURS_IN_DAY: &CStr = c"\
hours_in_day($self)
--

The number of hours in the day, accounting for timezone transitions,
e.g. during a DST transition.

Example
-------
>>> ZonedDateTime(2020, 8, 15, tz=\"Europe/London\").hours_in_day()
24
>>> ZonedDateTime(2023, 10, 29, tz=\"Europe/Amsterdam\").hours_in_day()
25
";
pub(crate) const ZONEDDATETIME_IS_AMBIGUOUS: &CStr = c"\
is_ambiguous($self)
--
Expand Down Expand Up @@ -1761,6 +1775,15 @@ Construct a new instance with the time replaced.

See the ``replace()`` method for more information.
";
pub(crate) const ZONEDDATETIME_START_OF_DAY: &CStr = c"\
start_of_day($self)
--

The start of the current calendar day.

This is almost always at midnight the same day, but may be different
for timezones which transition at—and thus skip over—midnight.
";
pub(crate) const ZONEDDATETIME_SUBTRACT: &CStr = c"\
subtract($self, delta=None, /, *, years=0, months=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, disambiguate=None)
--
Expand Down
17 changes: 8 additions & 9 deletions src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,16 +186,15 @@ impl Display for Time {
}
}

pub(crate) const MIDNIGHT: Time = Time {
hour: 0,
minute: 0,
second: 0,
nanos: 0,
};

pub(crate) const SINGLETONS: &[(&CStr, Time); 3] = &[
(
c"MIDNIGHT",
Time {
hour: 0,
minute: 0,
second: 0,
nanos: 0,
},
),
(c"MIDNIGHT", MIDNIGHT),
(
c"NOON",
Time {
Expand Down
56 changes: 55 additions & 1 deletion src/zoned_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
instant::{Instant, MAX_INSTANT, MIN_INSTANT},
local_datetime::DateTime,
offset_datetime::{self, OffsetDateTime},
time::Time,
time::{Time, MIDNIGHT},
time_delta::{self, TimeDelta},
State,
};
Expand Down Expand Up @@ -1328,6 +1328,58 @@ unsafe fn difference(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn {
.to_obj(state.time_delta_type)
}

unsafe fn start_of_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf);
let &State {
py_api,
exc_repeated,
exc_skipped,
..
} = State::for_obj(slf);
ZonedDateTime::resolve_using_disambiguate(
py_api,
date,
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.to_obj(Py_TYPE(slf))
}

unsafe fn hours_in_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf);
let &State {
py_api,
exc_repeated,
exc_skipped,
..
} = State::for_obj(slf);
let start_of_day = ZonedDateTime::resolve_using_disambiguate(
py_api,
date,
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.instant();
let start_of_next_day = ZonedDateTime::resolve_using_disambiguate(
py_api,
date.increment(),
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.instant();
((start_of_next_day.total_nanos() - start_of_day.total_nanos()) as f64 / 3_600_000_000_000.0)
.to_py()
}

static mut METHODS: &[PyMethodDef] = &[
method!(identity2 named "__copy__", c""),
method!(identity2 named "__deepcopy__", c"", METH_O),
Expand Down Expand Up @@ -1378,6 +1430,8 @@ static mut METHODS: &[PyMethodDef] = &[
method_kwargs!(add, doc::ZONEDDATETIME_ADD),
method_kwargs!(subtract, doc::ZONEDDATETIME_SUBTRACT),
method!(difference, doc::KNOWSINSTANT_DIFFERENCE, METH_O),
method!(start_of_day, doc::ZONEDDATETIME_START_OF_DAY),
method!(hours_in_day, doc::ZONEDDATETIME_HOURS_IN_DAY),
PyMethodDef::zeroed(),
];

Expand Down
6 changes: 4 additions & 2 deletions tests/test_instant.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,8 +826,10 @@ def test_to_system_tz():
assert d.to_system_tz().exact_eq(
SystemDateTime(2022, 11, 6, 1, disambiguate="earlier")
)
assert Instant.from_utc(2022, 11, 6, 6).to_system_tz() == SystemDateTime(
2022, 11, 6, 1, disambiguate="later"
assert (
Instant.from_utc(2022, 11, 6, 6)
.to_system_tz()
.exact_eq(SystemDateTime(2022, 11, 6, 1, disambiguate="later"))
)

with pytest.raises((ValueError, OverflowError)):
Expand Down
Loading