Skip to content
Merged
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
33 changes: 33 additions & 0 deletions docs/user-guide/api-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,29 @@ than advancing the existing one:
print(next(iw), iw)
```

!!! warning "Arithmetic is bounded by ISO years 0001 and 9999"

Values are backed by `datetime.date`, so stepping past either end raises the standard library's
own error rather than a domain one: `OverflowError` from the `timedelta` paths (`next`,
`previous`, `+`, `-`, `weeksout`, `daysout`) and `ValueError` from the `strptime` ones (`days`,
`nth`, `to_date`, `to_datetime`). Guard with `except (OverflowError, ValueError)` if you work near
the bounds. This is deliberate: a bounds check on every arithmetic call would cost every caller
something to protect a range essentially nobody reaches.

`9999-W52` is the last representable week and a partial one, since it runs into year 10000 from
its sixth day on:

```python exec="true" source="material-block" result="python"
from iso_week_date import IsoWeek

print(IsoWeek("9999-W52").nth(5))

try:
IsoWeek("9999-W52").nth(6)
except (OverflowError, ValueError) as e:
print(f"day 6: {type(e).__name__}")
```

## Replacing components

`replace` returns a new instance with some components swapped out, leaving the others untouched. Its arguments are
Expand Down Expand Up @@ -299,6 +322,16 @@ print(tuple(_range))

`as_str=False` yields class instances instead of strings, and `inclusive` accepts the same four values as `is_between`.

The generated values always sit on a grid anchored at `start`, and `inclusive` removes the endpoints
from that grid rather than shifting it. With a `step` greater than 1 the grid may skip over `end`
entirely, in which case there is no `end` for `inclusive` to keep or drop:

```python exec="true" source="material-block" session="tour" result="python"
for inclusive in ("both", "left", "right", "neither"):
weeks = IsoWeek.range(start="2023-W01", end="2023-W05", step=2, inclusive=inclusive)
print(f"{inclusive:8} {tuple(weeks)}")
```

## `IsoWeek` specific

### `days` property and `nth` method
Expand Down
33 changes: 28 additions & 5 deletions docs/user-guide/dataframe-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,34 @@ string column, and an all-null column needs no explicit string dtype to be recog
A non-null value that is not a string is malformed data, not missing data, so a column holding
lists, dicts or numbers is `False` rather than an error.

!!! warning "The checks validate the format, not the calendar"
`is_isoweek_series` and `is_isoweekdate_series` test the string pattern, so weeks `01` through
`53` all pass. They do not check the week number against the year: `2023-W53` passes even though
2023 has only 52 weeks and [`IsoWeek`](../api/isoweek.md)`("2023-W53")` rejects it. Construct an
`IsoWeek` when you need that guarantee.
### What the checks accept

`is_isoweek_series` and `is_isoweekdate_series` accept exactly the strings
[`IsoWeek`](../api/isoweek.md) and [`IsoWeekDate`](../api/isoweekdate.md) accept: the format _and_
the calendar. Weeks `01` through `53` are all well-formed, but only long ISO years have a week 53, so
`2020-W53` passes and `2023-W53` does not.

That makes a check a usable precondition for a conversion, since the two agree on every input:

```python exec="true" source="material-block" result="python"
import pandas as pd

from iso_week_date.pandas_utils import is_isoweek_series, isoweek_to_datetime

weeks = pd.Series(["2023-W53"]) # 2023 has only 52 ISO weeks

print(is_isoweek_series(weeks))
if is_isoweek_series(weeks):
print(isoweek_to_datetime(weeks).to_list())
else:
print("rejected before the conversion could go wrong")
```

!!! warning "Older pandas does not complain about these values"
`isoweek_to_datetime` raises on `2023-W53` only from pandas 3.0 onwards. Earlier versions convert
it without complaint and return `2024-01-01`, silently rolling a week that does not exist into the
next ISO year. polars refuses on every supported version. Checking first is what protects you on
the versions that do not.

## Functions

Expand Down
69 changes: 58 additions & 11 deletions src/iso_week_date/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from itertools import pairwise
from typing import TYPE_CHECKING, ClassVar, Literal, overload

from iso_week_date._utils import classproperty, format_err_msg, match_isoweek, weeks_of_year
from iso_week_date._utils import classproperty, format_err_msg, is_int, match_isoweek, weeks_of_year

if TYPE_CHECKING:
from collections.abc import Generator, Iterable
Expand All @@ -33,6 +33,14 @@ class BaseIsoWeek(ABC):

It defines the common interface for both classes and implements the common methods between them.

Note:
Values are backed by `datetime.date`, so the representable range is ISO years `0001` to
`9999`. Arithmetic that steps outside it surfaces the standard library's own error rather than
a domain one: `OverflowError` from `timedelta` (`next`, `previous`, `+`, `-`, `weeksout`,
`daysout`) and `ValueError` from `strptime` (`days`, `nth`, `to_date`, `to_datetime`). This is
left as is on purpose: a bounds check on every arithmetic call would cost every caller
something to protect a range essentially nobody reaches.

Attributes:
value_: stores the string value representing the iso-week date in the `_format` format.
offset_: class variable, stores the offset to be used when converting to and from `datetime` and `date` objects.
Expand Down Expand Up @@ -147,8 +155,12 @@ def __ge__(self: Self, other: Self | object) -> bool:

@classproperty
def _compact_pattern(cls: type[Self]) -> re.Pattern[str]: # type: ignore[misc] # noqa: N805
"""Returns compiled compact pattern."""
return re.compile(cls._pattern.pattern.replace(")-(", ")(")) # pragma: no cover
"""Returns compiled compact pattern.

Derived from `_pattern` by dropping the dashes between its groups, so the two cannot drift
apart. `re.compile` caches internally, so repeated access returns the same object.
"""
return re.compile(cls._pattern.pattern.replace(")-(", ")("))

@classproperty
def _compact_format(cls: type[Self]) -> str: # type: ignore[misc] # noqa: N805
Expand Down Expand Up @@ -189,17 +201,22 @@ def from_compact(cls: type[Self], _str: str, /) -> Self:
Since values are validated in the initialization method, our goal in this method is to "add" the dashes in the
appropriate places. To achieve this we:

* First check that the length of the string is correct (either 7 or 8).
* First check that the string matches `_compact_pattern`.
* Split the string in 3 parts.
* Remove (filter) empty values.
* Finally join them with a dash in between.

Matching `_compact_pattern` rather than only checking the length reports a malformed value in
terms of the compact format the caller actually passed. Left to the dashed `_validate`, a
value such as `"2025W0x"` was rejected against the `YYYY-WNN` pattern instead. The week
number is still checked against the year's week count by `__init__`.
"""
if not isinstance(_str, str):
msg = f"Expected `str` type, found {type(_str)}"
raise TypeError(msg)

compact_format = cls._compact_format # type: ignore[arg-type]
if len(_str) != len(compact_format):
compact_format = cls._compact_format
if match_isoweek(cls._compact_pattern, _str) is None:
msg = format_err_msg(compact_format, _str)
raise ValueError(msg)

Expand Down Expand Up @@ -295,6 +312,11 @@ def _to_datetime(self: Self, value: str) -> datetime:

In general this is not always the case and we need to manipulate `value_` attribute before passing it to
`datetime.strptime` method.

A `ValueError` here means the date is out of range rather than the format is wrong: `value` is
built from an already validated `value_` and an already validated weekday. `9999-W52-7` is a
valid ISO week date whose Sunday falls in year 10000, and a non-zero `offset_` can push a
boundary value out the same way. See the note on year bounds in `BaseIsoWeek`.
"""
return datetime.strptime(value, "%G-W%V-%u") + self.offset_

Expand Down Expand Up @@ -473,7 +495,25 @@ def range(
* `start > end`.
* `inclusive` not one of "both", "left", "right" or "neither".
* `step` is not strictly positive.
TypeError: If `step` is not an int.
TypeError: If `step` is not an int (`bool` is not accepted).

Examples:
The generated values sit on a grid anchored at `start`, and `inclusive` removes the two
endpoints from it. With `step` greater than 1 the grid may not land on `end` at all, in
which case there is no `end` for `inclusive` to keep or drop:

>>> from iso_week_date import IsoWeek
>>>
>>> tuple(IsoWeek.range("2025-W01", "2025-W05", step=2, inclusive="both"))
('2025-W01', '2025-W03', '2025-W05')
>>> tuple(IsoWeek.range("2025-W01", "2025-W05", step=2, inclusive="left"))
('2025-W01', '2025-W03')
>>> tuple(IsoWeek.range("2025-W01", "2025-W05", step=2, inclusive="right"))
('2025-W03', '2025-W05')
>>> tuple(IsoWeek.range("2025-W01", "2025-W05", step=2, inclusive="neither"))
('2025-W03',)
>>> tuple(IsoWeek.range("2025-W01", "2025-W06", step=2, inclusive="right"))
('2025-W03', '2025-W05')
"""
_start = cls._cast(start)
_end = cls._cast(end)
Expand All @@ -482,7 +522,7 @@ def range(
msg = f"`start` must be before `end` value, found: {_start} > {_end}"
raise ValueError(msg)

if not isinstance(step, int):
if not is_int(step):
msg = f"`step` must be integer, found {type(step)}"
raise TypeError(msg)

Expand All @@ -495,11 +535,18 @@ def range(
raise ValueError(msg)

_delta = _end - _start
range_start = 0 if inclusive in {"both", "left"} else 1
range_end = _delta + 1 if inclusive in {"both", "right"} else _delta

# The grid is anchored at `start` and stepped from there, and `inclusive` then filters the
# two endpoints out of it. Moving the anchor instead (`range(1, ...)` for a start-exclusive
# call) shifted every generated value off the grid, so `inclusive="right"` dropped `end`
# along with `start` for any `step > 1` and never honoured the endpoint it names.
skip_start = inclusive in {"right", "neither"}
skip_end = inclusive in {"left", "neither"}

weeks_range: Generator[str | Self, None, None] = (
(_start + i).to_string() if as_str else _start + i for i in range(range_start, range_end, step)
(_start + i).to_string() if as_str else _start + i
for i in range(0, _delta + 1, step)
if not (skip_start and i == 0) and not (skip_end and i == _delta)
)

return weeks_range
85 changes: 75 additions & 10 deletions src/iso_week_date/_utils.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Generic, TypeVar
from typing import TYPE_CHECKING, Any, Final, Generic, Protocol, TypeVar

if TYPE_CHECKING:
import re
from collections.abc import Callable

from typing_extensions import Self
from typing_extensions import Self, TypeIs


T = TypeVar("T")
R = TypeVar("R")


class SupportsYearArithmetic(Protocol): # noqa: PLW1641
"""Whatever `is_long_year` needs from a year: integer arithmetic and boolean combination.

Operands and results are `Any` because neither can be pinned down. `Series == 4` is a *boolean*
Series rather than a `Series[int]`, so the intermediate types do not follow the input type; and
pandas-stubs declares each of these as a large overload set that no single exact signature can
satisfy. Narrowing any of them silently drops a backend from the bound instead of checking it
more strictly: `polars.Expr.__eq__` accepts only what polars can compare against, so demanding
the `object` that `object.__eq__` declares excludes `Expr` outright.
"""

# Positional-only, as every dunder is: `int.__add__` takes no keyword, so a protocol that allowed
# one would not be satisfied by `int` at all.
def __add__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401
def __sub__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401
def __floordiv__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401
def __mod__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401
def __or__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401
def __eq__(self: Self, other: Any, /) -> Any: ... # noqa: ANN401


#: A year, or a column of them: anything `is_long_year` can compute over.
YearsT = TypeVar("YearsT", bound=SupportsYearArithmetic)

SHORT_YEAR_WEEKS: Final = 52
LONG_YEAR_WEEKS: Final = 53


class classproperty(Generic[T, R]): # noqa: N801
"""Decorator to create a class level property.

Expand Down Expand Up @@ -40,9 +68,15 @@ def __init__(self: Self, func: Callable[[type[T]], R], /) -> None:
self.__name__ = func.__name__
self.__qualname__ = func.__qualname__

def __get__(self: Self, instance: T, owner: type[T], /) -> R:
def __get__(self: Self, instance: object, owner: type[Any], /) -> R:
"""Get the value of the class property.

`owner` is deliberately not tied to `T`. The decorated functions annotate their first
parameter as `type[Self]`, so binding `T` to that made `T` resolve to `Never` for anything
outside the defining class, and every access from elsewhere needed a `type: ignore`. `T` is
only ever used to call `self.func`, which the descriptor protocol already guarantees is
called with its own class.

Arguments:
instance: The instance of the class (ignored)
owner: The class that owns the property
Expand All @@ -51,6 +85,11 @@ def __get__(self: Self, instance: T, owner: type[T], /) -> R:
return value


def is_int(value: object) -> TypeIs[int]:
"""Checks that `value` is an integer, excluding `bool`."""
return isinstance(value, int) and not isinstance(value, bool)


def format_err_msg(_fmt: str, _value: str) -> str:
"""Format error message given a format and a value."""
return (
Expand Down Expand Up @@ -130,23 +169,49 @@ def require_version(module: str, minimum: str, extra: str) -> None:
raise ImportError(msg)


def p_of_year(year: int) -> int:
"""Returns the day of the week of 31 December."""
return (year + year // 4 - year // 100 + year // 400) % 7
def p_of_year(year: YearsT) -> YearsT:
"""Returns the day of the week of 31 December.

Elementwise integer arithmetic only, so this holds for a single `int` and for a column of them
alike: a `pandas.Series`, a `polars.Series` or a `polars.Expr` all come back as the same type.
"""
# Annotated locals rather than direct returns: the protocol's members are `Any`, so the computed
# type is `Any` too, and naming it is how the identity `YearsT -> YearsT` gets stated.
p: YearsT = (year + year // 4 - year // 100 + year // 400) % 7
return p

def weeks_of_year(year: int) -> int:
"""Returns the max number of weeks in a year.

def is_long_year(year: YearsT) -> YearsT:
"""Whether `year` is a long ISO year, the kind that has a week 53.

From wikipedia section on [weeks per year](https://en.wikipedia.org/wiki/ISO_week_date#Weeks_per_year):

If p(y) = (y + y//4 - y//100 + y//400) % 7 then
weeks(y) = 52 + (p(y) ==4 or p(y-1) == 3)
weeks(y) = 52 + (p(y) == 4 or p(y-1) == 3)

Written with `|` rather than `or`, which is what lets the dataframe modules reuse this instead of
keeping their own copy: `or` needs a single truth value and raises on a column. The predicate
rather than the count is the shared piece, because `52 + <boolean column>` is not arithmetic every
backend allows, while `|` over comparisons is.

Arguments:
year: Ordinal year number, or a column of them.

Returns:
Whether the year has 53 weeks, elementwise for a column.
"""
long_year: YearsT = (p_of_year(year) == 4) | (p_of_year(year - 1) == 3) # noqa: PLR2004
return long_year


def weeks_of_year(year: YearsT) -> YearsT:
"""Returns the max number of weeks in a year.

Arguments:
year: Ordinal year number

Returns:
Number of weeks in the year (either 52 or 53)
"""
return 52 + (p_of_year(year) == 4 or p_of_year(year - 1) == 3) # noqa: PLR2004
weeks: YearsT = is_long_year(year) + SHORT_YEAR_WEEKS
return weeks
Loading
Loading