When-Exactly

An expressive and intuitive library for working with dates.

Rationale

People think and communicate about time in terms of years, months, weeks, days, hours, minutes, etc.

People do not think about time in terms of datetimes...

When-Exactly is a library that aims to bring more human-friendly date-time types into the hands of developers, so they can write more expressive code when working with dates.

Overview

>>> import when_exactly as wnx

>>> year = wnx.Year(2025) # the year 2025
>>> year
Year(2025)

>>> month = year.month(1) # month 1 (January) of the year
>>> month
Month(2025, 1)

>>> day = wnx.Day(2025, 12, 25) # December 25, 2025
>>> day
Day(2025, 12, 25)

>>> day.month # the month that the day is a part of
Month(2025, 12)

>>> day.week # the week that the day is a part of
Week(2025, 52)

>>> day.week.days[0:5] # all weekday (Mon thru Fri) of the week
Days([Day(2025, 12, 22), Day(2025, 12, 23), Day(2025, 12, 24), Day(2025, 12, 25), Day(2025, 12, 26)])

when_exactly package

A Python package for working with time intervals.

CustomInterval dataclass

Bases: when_exactly.core.interval.Interval

A custom intervval.

This class serves as a base class from which custom intervals can be derived. It provides the necessary interface and methods to be implemented by subclasses.

Source code in src/when_exactly/core/custom_interval.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class CustomInterval(Interval):
    """A custom intervval.

    This class serves as a base class from which custom intervals can be derived.
    It provides the necessary interface and methods to be implemented by subclasses.

    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        Interval.__init__(self, *args, **kwargs)

    @classmethod
    def from_moment(cls, moment: Moment) -> CustomInterval:
        raise NotImplementedError("CustomInterval from_moment not implemented")

    @property
    def next(self) -> CustomInterval:
        raise NotImplementedError("CustomInterval next not implemented")

    @property
    def previous(self) -> CustomInterval:
        raise NotImplementedError("CustomInterval previous not implemented")

    def __repr__(self) -> str:
        raise NotImplementedError("CustomInterval repr not implemented")

    def __str__(self) -> str:
        raise NotImplementedError("CustomInterval str not implemented")

    def __add__(self, value: int) -> CustomInterval:
        next_value = deepcopy(self)
        for _ in range(value):
            next_value = next_value.next
        return next_value

    def __sub__(self, value: int) -> CustomInterval:
        prev_value = deepcopy(self)
        for _ in range(value):
            prev_value = prev_value.previous
        return prev_value

Day dataclass

Bases: when_exactly.core.custom_interval.CustomInterval

Represents a single day, from midnight to midnight.

Source code in src/when_exactly/_api.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class Day(CustomInterval):
    """Represents a single day, from midnight to midnight."""

    def __init__(self, year: int, month: int, day: int) -> None:
        start = Moment(year, month, day, 0, 0, 0)
        stop = start + Delta(days=1)
        CustomInterval.__init__(self, start=start, stop=stop)

    @classmethod
    def from_moment(cls, moment: Moment) -> Day:
        return Day(
            moment.year,
            moment.month,
            moment.day,
        )

    @property
    def previous(self) -> Day:
        return Day.from_moment(self.start - Delta(days=1))

    def __repr__(self) -> str:
        return f"Day({self.start.year}, {self.start.month}, {self.start.day})"

    def __str__(self) -> str:
        return f"{self.start.year:04}-{self.start.month:02}-{self.start.day:02}"

    def hour(self, hour: int) -> Hour:
        return Hour(
            self.start.year,
            self.start.month,
            self.start.day,
            hour,
        )

    @cached_property
    def month(self) -> Month:
        return Month(
            self.start.year,
            self.start.month,
        )

    @cached_property
    def week(self) -> Week:
        return Week.from_moment(self.start)

    @cached_property
    def ordinal_day(self) -> OrdinalDay:
        return OrdinalDay(
            self.start.year,
            self.start.ordinal_day,
        )

    @cached_property
    def weekday(self) -> Weekday:
        return Weekday(
            self.start.year,
            self.start.week,
            self.start.week_day,
        )

    @property
    def next(self) -> Day:
        return Day.from_moment(self.stop)

InvalidMomentError

Bases: RuntimeError

Raised when a moment is invalid.

Source code in src/when_exactly/core/errors.py
1
2
3
4
5
6
7
8
class InvalidMomentError(RuntimeError):
    """Raised when a moment is invalid."""

    message: str

    def __init__(self, message: str):
        self.message = message
        super().__init__(f"Invalid Moment: {self.message}")

Moment dataclass

A Moment represents a specific point in time with year, month, day, hour, minute, and second.

The Moment is analogous to the builtin datetime.datetime class, but it is immutable and provides additional functionality for date arithmetic.

Source code in src/when_exactly/core/moment.py
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclasses.dataclass(frozen=True)
class Moment:
    """A Moment represents a specific point in time with year, month, day, hour, minute, and second.

    The `Moment` is analogous to the builtin [`datetime.datetime`](https://docs.python.org/3/library/datetime.html#datetime.datetime) class,
    but it is immutable and provides additional functionality for date arithmetic."""

    year: int
    month: int
    day: int
    hour: int
    minute: int
    second: int

    def to_datetime(self) -> datetime.datetime:
        return datetime.datetime(
            self.year, self.month, self.day, self.hour, self.minute, self.second
        )

    @classmethod
    def from_datetime(cls, dt: datetime.datetime) -> Moment:
        return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)

    def __post_init__(self) -> None:
        try:
            self.to_datetime()
        except ValueError as e:
            raise InvalidMomentError(str(e)) from e

    def __lt__(self, other: Moment) -> bool:
        return self.to_datetime() < other.to_datetime()

    def __le__(self, other: Moment) -> bool:
        return self.to_datetime() <= other.to_datetime()

    def __add__(self, delta: Delta) -> Moment:
        new_moment_kwargs = {
            "year": self.year,
            "month": self.month,
            "day": self.day,
            "hour": self.hour,
            "minute": self.minute,
            "second": self.second,
        }
        if delta.years != 0:
            new_moment_kwargs["year"] += delta.years

        if delta.months != 0:
            new_moment_kwargs["month"] += delta.months
            while new_moment_kwargs["month"] > 12:
                new_moment_kwargs["month"] -= 12
                new_moment_kwargs["year"] += 1

            while new_moment_kwargs["month"] < 1:
                new_moment_kwargs["month"] += 12
                new_moment_kwargs["year"] -= 1

        while (
            new_moment_kwargs["day"] > 28
        ):  # if the day is too large for the month, wnx need to decrement it until it is valid
            try:
                Moment(**new_moment_kwargs)  # type: ignore
                break
            except InvalidMomentError:
                new_moment_kwargs["day"] -= 1

        dt = Moment(**new_moment_kwargs).to_datetime()  # type: ignore

        dt += datetime.timedelta(weeks=delta.weeks)
        dt += datetime.timedelta(days=delta.days)
        dt += datetime.timedelta(hours=delta.hours)
        dt += datetime.timedelta(minutes=delta.minutes)
        dt += datetime.timedelta(seconds=delta.seconds)

        return Moment.from_datetime(dt)

    def __sub__(self, delta: Delta) -> Moment:
        return self + Delta(
            years=-delta.years,
            months=-delta.months,
            weeks=-delta.weeks,
            days=-delta.days,
            hours=-delta.hours,
            minutes=-delta.minutes,
            seconds=-delta.seconds,
        )

    def __str__(self) -> str:
        return self.to_datetime().isoformat()

    @property
    def week_year(self) -> int:
        return self.to_datetime().isocalendar()[0]

    @property
    def week(self) -> int:
        return self.to_datetime().isocalendar()[1]

    @property
    def week_day(self) -> int:
        return self.to_datetime().isocalendar()[2]

    @property
    def ordinal_day(self) -> int:
        return (
            self.to_datetime().toordinal()
            - datetime.date(self.year, 1, 1).toordinal()
            + 1
        )

OrdinalDay dataclass

Bases: when_exactly.core.custom_interval.CustomInterval

An ordinal day interval.

Source code in src/when_exactly/_api.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class OrdinalDay(CustomInterval):
    """An ordinal day interval."""

    def __init__(self, year: int, ordinal_day: int) -> None:
        start = Moment.from_datetime(
            datetime.datetime.fromordinal(
                datetime.date(year, 1, 1).toordinal() + ordinal_day - 1
            )
        )
        stop = start + Delta(days=1)
        Interval.__init__(self, start=start, stop=stop)

    def __repr__(self) -> str:
        return f"OrdinalDay({self.start.year}, {self.start.ordinal_day})"

    def __str__(self) -> str:
        return f"{self.start.year:04}-{self.start.ordinal_day:03}"

    @classmethod
    def from_moment(cls, moment: Moment) -> OrdinalDay:
        return OrdinalDay(moment.year, moment.ordinal_day)

    @property
    def next(self) -> OrdinalDay:
        return OrdinalDay.from_moment(self.stop)

    @property
    def previous(self) -> OrdinalDay:
        return OrdinalDay.from_moment(self.start - Delta(days=1))

Second dataclass

Bases: when_exactly.core.custom_interval.CustomInterval

A second interval.

Source code in src/when_exactly/_api.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class Second(CustomInterval):
    """A second interval."""

    def __init__(
        self, year: int, month: int, day: int, hour: int, minute: int, second: int
    ) -> None:
        start = Moment(year, month, day, hour, minute, second)
        stop = start + Delta(seconds=1)
        Interval.__init__(self, start=start, stop=stop)

    def __repr__(self) -> str:
        return f"Second({self.start.year}, {self.start.month}, {self.start.day}, {self.start.hour}, {self.start.minute}, {self.start.second})"

    def __str__(self) -> str:
        start = self.start
        return f"{start.year:04}-{start.month:02}-{start.day:02}T{start.hour:02}:{start.minute:02}:{start.second:02}"

    def minute(self) -> Minute:
        return Minute.from_moment(self.start)

    @property
    def next(self) -> Second:
        return Second.from_moment(self.stop)

    @property
    def previous(self) -> Second:
        return Second.from_moment(self.start - Delta(seconds=1))

    @classmethod
    def from_moment(cls, moment: Moment) -> Second:
        return Second(
            moment.year,
            moment.month,
            moment.day,
            moment.hour,
            moment.minute,
            moment.second,
        )

Weekday dataclass

Bases: when_exactly.core.custom_interval.CustomInterval

A weekday interval.

Source code in src/when_exactly/_api.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class Weekday(CustomInterval):
    """A weekday interval."""

    def __init__(self, year: int, week: int, week_day: int):
        start = Moment.from_datetime(
            datetime.datetime.fromisocalendar(
                year=year,
                week=week,
                day=week_day,
            )
        )
        stop = start + Delta(days=1)
        Interval.__init__(self, start=start, stop=stop)

    def __repr__(self) -> str:
        return (
            f"Weekday({self.start.week_year}, {self.start.week}, {self.start.week_day})"
        )

    def __str__(self) -> str:
        return f"{self.start.week_year}-W{self.start.week:02}-{self.start.week_day}"

    @classmethod
    def from_moment(cls, moment: Moment) -> Weekday:
        return Weekday(
            year=moment.week_year,
            week=moment.week,
            week_day=moment.week_day,
        )

    @property
    def next(self) -> Weekday:
        return Weekday.from_moment(moment=self.stop)

    @property
    def previous(self) -> Weekday:
        return Weekday.from_moment(moment=self.start - Delta(days=1))

    @cached_property
    def week(self) -> Week:
        return Week.from_moment(self.start)

    def to_day(self) -> Day:
        return Day.from_moment(self.start)

Year dataclass

Bases: when_exactly.core.custom_interval.CustomInterval

The Year represents an entire year, starting from January 1 to December 31.

Creating a Year

>>> import when_exactly as wnx

>>> year = wnx.Year(2025)
>>> year
Year(2025)

>>> str(year)
'2025'

The Months of a Year

Get the Months of a year.

>>> months = year.months
>>> len(months)
12

>>> months[0]
Month(2025, 1)

>>> months[-2:]
Months([Month(2025, 11), Month(2025, 12)])

The Weeks of a Year

Get the Weeks of a year.

>>> weeks = year.weeks
>>> len(weeks)
52

>>> weeks[0]
Week(2025, 1)

Source code in src/when_exactly/_api.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@dataclasses.dataclass(frozen=True, init=False, repr=False)
class Year(CustomInterval):
    """The `Year` represents an entire year, starting from _January 1_ to _December 31_.

    ## Creating a Year

    ```python
    >>> import when_exactly as wnx

    >>> year = wnx.Year(2025)
    >>> year
    Year(2025)

    >>> str(year)
    '2025'

    ```

    ## The Months of a Year

    Get the [`Months`](months.md) of a year.

    ```python
    >>> months = year.months
    >>> len(months)
    12

    >>> months[0]
    Month(2025, 1)

    >>> months[-2:]
    Months([Month(2025, 11), Month(2025, 12)])

    ```

    ## The Weeks of a Year

    Get the [`Weeks`](weeks.md) of a year.

    ```python
    >>> weeks = year.weeks
    >>> len(weeks)
    52

    >>> weeks[0]
    Week(2025, 1)

    ```
    """

    def __init__(self, year: int) -> None:
        """# Create a Year.

        Parameters:
            year: The year to represent.

        Examples:
            ```python
            >>> import when_exactly as wnx

            >>> year = wnx.Year(2025)
            >>> year
            Year(2025)

            >>> str(year)
            '2025'

            ```
        """

        Interval.__init__(
            self,
            start=Moment(year, 1, 1, 0, 0, 0),
            stop=Moment(year + 1, 1, 1, 0, 0, 0),
        )

    def __repr__(self) -> str:
        return f"Year({self.start.year})"

    def __str__(self) -> str:
        return f"{self.start.year:04}"

    @classmethod
    def from_moment(cls, moment: Moment) -> Year:
        """Create a `Year` from a `Moment`."""
        return Year(moment.year)

    @cached_property
    def months(self) -> Months:
        return Months([Month(self.start.year, self.start.month + i) for i in range(12)])

    @cached_property
    def weeks(self) -> Weeks:
        return Weeks(
            gen_until(
                Week(self.start.year, 1),
                Week(self.start.year + 1, 1),
            )
        )

    def month(self, month: int) -> Month:
        """Get a specific month of the year.
        Args:
            month (int): The month number (1-12).
        """
        return Month(
            self.start.year,
            month,
        )

    @property
    def next(self) -> Year:
        return Year.from_moment(self.stop)

    @property
    def previous(self) -> Year:
        return Year(self.start.year - 1)

    def week(self, week: int) -> Week:
        return Week(
            self.start.year,
            week,
        )

    def ordinal_day(self, ordinal_day: int) -> OrdinalDay:
        return OrdinalDay(
            self.start.year,
            ordinal_day,
        )

__init__(year)

Create a Year.

Parameters:

Name Type Description Default
year int

The year to represent.

required

Examples:

>>> import when_exactly as wnx

>>> year = wnx.Year(2025)
>>> year
Year(2025)

>>> str(year)
'2025'

Source code in src/when_exactly/_api.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def __init__(self, year: int) -> None:
    """# Create a Year.

    Parameters:
        year: The year to represent.

    Examples:
        ```python
        >>> import when_exactly as wnx

        >>> year = wnx.Year(2025)
        >>> year
        Year(2025)

        >>> str(year)
        '2025'

        ```
    """

    Interval.__init__(
        self,
        start=Moment(year, 1, 1, 0, 0, 0),
        stop=Moment(year + 1, 1, 1, 0, 0, 0),
    )

from_moment(moment) classmethod

Create a Year from a Moment.

Source code in src/when_exactly/_api.py
107
108
109
110
@classmethod
def from_moment(cls, moment: Moment) -> Year:
    """Create a `Year` from a `Moment`."""
    return Year(moment.year)

month(month)

Get a specific month of the year. Args: month (int): The month number (1-12).

Source code in src/when_exactly/_api.py
125
126
127
128
129
130
131
132
133
def month(self, month: int) -> Month:
    """Get a specific month of the year.
    Args:
        month (int): The month number (1-12).
    """
    return Month(
        self.start.year,
        month,
    )

options: members: no