diff --git a/docs/user-guide/api-tour.md b/docs/user-guide/api-tour.md index ee5b8e4..3a94616 100644 --- a/docs/user-guide/api-tour.md +++ b/docs/user-guide/api-tour.md @@ -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 @@ -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 diff --git a/docs/user-guide/dataframe-modules.md b/docs/user-guide/dataframe-modules.md index 3efc622..b3df3cb 100644 --- a/docs/user-guide/dataframe-modules.md +++ b/docs/user-guide/dataframe-modules.md @@ -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 diff --git a/src/iso_week_date/_base.py b/src/iso_week_date/_base.py index 9e62596..7634c8f 100644 --- a/src/iso_week_date/_base.py +++ b/src/iso_week_date/_base.py @@ -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 @@ -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. @@ -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 @@ -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) @@ -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_ @@ -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) @@ -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) @@ -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 diff --git a/src/iso_week_date/_utils.py b/src/iso_week_date/_utils.py index 7f20b8e..0a699bb 100644 --- a/src/iso_week_date/_utils.py +++ b/src/iso_week_date/_utils.py @@ -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. @@ -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 @@ -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 ( @@ -130,18 +169,43 @@ 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 + ` 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 @@ -149,4 +213,5 @@ def weeks_of_year(year: int) -> int: 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 diff --git a/src/iso_week_date/isoweek.py b/src/iso_week_date/isoweek.py index 49ca8b7..1c745d8 100644 --- a/src/iso_week_date/isoweek.py +++ b/src/iso_week_date/isoweek.py @@ -6,6 +6,7 @@ from iso_week_date._base import BaseIsoWeek from iso_week_date._patterns import ISOWEEK__DATE_FORMAT, ISOWEEK__FORMAT, ISOWEEK_PATTERN +from iso_week_date._utils import is_int if TYPE_CHECKING: from datetime import tzinfo @@ -542,9 +543,7 @@ def to_datetime(self: Self, weekday: int = 1) -> datetime: >>> IsoWeek("2025-W01").to_datetime(3) datetime.datetime(2025, 1, 1, 0, 0) """ - # `bool` is excluded explicitly: `isinstance(True, int)` and `True in range(1, 8)` both hold, - # so a bare range check would interpolate the literal string "True" into the parsed value. - if not isinstance(weekday, int) or isinstance(weekday, bool): + if not is_int(weekday): msg = f"`weekday` must be an integer between 1 and 7, found {type(weekday)}" raise TypeError(msg) if weekday not in range(1, 8): @@ -632,7 +631,8 @@ def __add__( New `IsoWeek` or generator of `IsoWeek` object(s) with the result of the addition. Raises: - TypeError: If `other` is not `int` or `Iterable` of `int`. + TypeError: If `other` is not `int` or `Iterable` of `int` (`bool` is not accepted). + OverflowError: If the result would fall outside ISO years 0001 to 9999. Examples: >>> from iso_week_date import IsoWeek @@ -642,7 +642,7 @@ def __add__( >>> tuple(str(iw) for iw in IsoWeek("2025-W01") + (1, 2, 3)) ('2025-W02', '2025-W03', '2025-W04') """ - if isinstance(other, int): + if is_int(other): return self.from_date(self.to_date() + timedelta(weeks=other)) if isinstance(other, Iterable): @@ -650,7 +650,7 @@ def __add__( # (generator, `map`, `filter`, ...) would otherwise be exhausted by the check below and # the returned generator would silently yield nothing. others = tuple(other) - if all(isinstance(_other, int) for _other in others): + if all(map(is_int, others)): return (self + _other for _other in others) msg = f"Cannot add type {type(other)} to `IsoWeek`. Addition is supported with `int` type" @@ -740,7 +740,9 @@ def __sub__( # pyright: ignore[reportIncompatibleMethodOverride] on the type of `other`. Raises: - TypeError: If `other` is not `int`, `IsoWeek` or `Iterable` of those types. + TypeError: If `other` is not `int`, `IsoWeek` or `Iterable` of those types (`bool` is not + accepted). + OverflowError: If the result would fall outside ISO years 0001 to 9999. Examples: >>> from iso_week_date import IsoWeek @@ -754,7 +756,7 @@ def __sub__( # pyright: ignore[reportIncompatibleMethodOverride] >>> IsoWeek("2025-W01") - IsoWeek("2024-W51") 2 """ - if isinstance(other, int): + if is_int(other): return self.from_date(self.to_date() - timedelta(weeks=other)) if isinstance(other, IsoWeek) and self.offset_ == other.offset_: @@ -763,7 +765,7 @@ def __sub__( # pyright: ignore[reportIncompatibleMethodOverride] if isinstance(other, Iterable): # See `__add__`: materializing keeps one-shot iterators usable after the check below. others = tuple(other) - if all(isinstance(_other, (int, IsoWeek)) for _other in others): + if all(is_int(_other) or isinstance(_other, IsoWeek) for _other in others): return (self - _other for _other in others) msg = ( @@ -1028,7 +1030,8 @@ def nth(self: Self, n: int) -> date: Raises: TypeError: If `n` is not an integer (`bool` is not accepted). - ValueError: If `n` is not between 1 and 7. + ValueError: If `n` is not between 1 and 7, or if the requested day falls outside ISO + years 0001 to 9999. Examples: >>> from iso_week_date import IsoWeek @@ -1038,16 +1041,17 @@ def nth(self: Self, n: int) -> date: >>> IsoWeek("2025-W01").nth(7) datetime.date(2025, 1, 5) """ - # `bool` is rejected for consistency with `to_datetime`: a weekday is not a truth value, and - # accepting it here while rejecting it there would be the surprising half of the pair. - if not isinstance(n, int) or isinstance(n, bool): + if not is_int(n): msg = f"`n` must be an integer, found {type(n)}" raise TypeError(msg) if n not in range(1, 8): msg = f"`n` must be between 1 and 7, found {n}" raise ValueError(msg) - return self.days[n - 1] + # The requested day only, rather than `self.days[n - 1]`: building all seven spent six extra + # `strptime` calls, and in the last representable week it failed on the days that spill into + # year 10000 even when the requested one was well inside range. + return self.to_date(n) @overload def weeksout( @@ -1109,7 +1113,7 @@ def weeksout( >>> tuple(isoweek.weeksout(4, step=2)) ('2025-W02', '2025-W04') """ - if not isinstance(n_weeks, int): + if not is_int(n_weeks): msg = f"`n_weeks` must be an integer, found {type(n_weeks)} type" raise TypeError(msg) diff --git a/src/iso_week_date/isoweekdate.py b/src/iso_week_date/isoweekdate.py index d77c2cd..fc6818e 100644 --- a/src/iso_week_date/isoweekdate.py +++ b/src/iso_week_date/isoweekdate.py @@ -6,6 +6,7 @@ from iso_week_date._base import BaseIsoWeek from iso_week_date._patterns import ISOWEEKDATE__DATE_FORMAT, ISOWEEKDATE__FORMAT, ISOWEEKDATE_PATTERN +from iso_week_date._utils import is_int if TYPE_CHECKING: from datetime import tzinfo @@ -631,7 +632,7 @@ def __add__( >>> tuple(str(iwd) for iwd in IsoWeekDate("2025-W01-1") + (1, 2)) ('2025-W01-2', '2025-W01-3') """ - if isinstance(other, int): + if is_int(other): return self.from_date(self.to_date() + timedelta(days=other)) if isinstance(other, Iterable): @@ -639,7 +640,7 @@ def __add__( # (generator, `map`, `filter`, ...) would otherwise be exhausted by the check below and # the returned generator would silently yield nothing. others = tuple(other) - if all(isinstance(_other, int) for _other in others): + if all(map(is_int, others)): return (self + _other for _other in others) msg = f"Cannot add type {type(other)} to `IsoWeekDate`. Addition is supported with `int` type" @@ -741,7 +742,7 @@ def __sub__( # pyright: ignore[reportIncompatibleMethodOverride] >>> IsoWeekDate("2025-W01-1") - IsoWeekDate("2024-W52-3") 5 """ - if isinstance(other, int): + if is_int(other): return self.from_date(self.to_date() - timedelta(days=other)) if isinstance(other, IsoWeekDate) and self.offset_ == other.offset_: @@ -750,7 +751,7 @@ def __sub__( # pyright: ignore[reportIncompatibleMethodOverride] if isinstance(other, Iterable): # See `__add__`: materializing keeps one-shot iterators usable after the check below. others = tuple(other) - if all(isinstance(_other, (int, IsoWeekDate)) for _other in others): + if all(is_int(_other) or isinstance(_other, IsoWeekDate) for _other in others): return (self - _other for _other in others) msg = ( @@ -1061,7 +1062,7 @@ def daysout( >>> tuple(iwd.daysout(6, step=2)) ('2025-W01-2', '2025-W01-4', '2025-W01-6') """ - if not isinstance(n_days, int): + if not is_int(n_days): msg = f"`n_days` must be integer, found {type(n_days)} type" raise TypeError(msg) diff --git a/src/iso_week_date/pandas_utils.py b/src/iso_week_date/pandas_utils.py index d2036ad..59bfdb0 100644 --- a/src/iso_week_date/pandas_utils.py +++ b/src/iso_week_date/pandas_utils.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from iso_week_date._patterns import ISOWEEK__DATE_FORMAT, ISOWEEK_PATTERN, ISOWEEKDATE__DATE_FORMAT, ISOWEEKDATE_PATTERN -from iso_week_date._utils import require_version +from iso_week_date._utils import LONG_YEAR_WEEKS, is_long_year, require_version require_version("pandas", minimum="1.1.0", extra="pandas") @@ -38,6 +38,13 @@ def _datetime_to_format( ) -> pd.Series[str]: """Converts series of `date` or `datetime` values to series of `str` values in `_format` format. + The value is assembled from `Series.dt.isocalendar()` rather than rendered with + `Series.dt.strftime(_format)`. `strftime` delegates to the platform C library, whose `%G` padding + is not portable: on glibc with Python < 3.14 an ISO year below 1000 renders unpadded, so + `date(1, 1, 1)` becomes `"1-W01"` instead of `"0001-W01"`. That is the same defect + `BaseIsoWeek._format_isocalendar` fixes for the scalar path, and this is its vectorised + counterpart, so both paths now zero-pad in Python and agree on every platform. + Arguments: series: series of `date` or `datetime` values offset: offset in days or `pd.Timedelta`. It represents how many days to add to the date before converting to @@ -67,7 +74,17 @@ def _datetime_to_format( raise TypeError(msg) _offset = pd.Timedelta(days=offset) if isinstance(offset, int) else offset - return (series - _offset).dt.strftime(_format) + shifted = series - _offset + isocalendar = shifted.dt.isocalendar() + + formatted = isocalendar["year"].astype(str).str.zfill(4) + "-W" + isocalendar["week"].astype(str).str.zfill(2) + if "%u" in _format: + formatted = formatted + "-" + isocalendar["day"].astype(str) + + # Nulls are restored from the input rather than left to propagate: on pandas < 3 `astype(str)` + # renders a missing `UInt32` as the literal string "", which would concatenate into + # "-W" instead of staying null the way `strftime` did. + return formatted.where(shifted.notna()) def datetime_to_isoweek(series: pd.Series[pd.Timestamp], offset: OffsetType = 0) -> pd.Series[str]: @@ -253,18 +270,29 @@ def _match_series(series: pd.Series[Any], pattern: str) -> bool: `str.fullmatch` is used rather than `str.match` for the reason spelled out in `iso_week_date._utils.match_isoweek`: `str.match` would accept a trailing newline. - The match result is filled with `False` because an `object` series can hold values that are - neither null nor `str` (a list, a dict, a number alongside strings). `str.fullmatch` returns - `NaN` for those, and `NaN` is truthy, so an unfilled `all()` reported a series of lists as - correctly formatted. Only nulls in the *input* are excused, and those are masked out separately. + Matching the format is necessary but not sufficient: weeks `01` to `53` are all well-formed, yet + only long ISO years have a week 53. The week number is therefore checked against its year through + the very same `is_long_year` helper `IsoWeek._validate` uses, so this answers the same question as + `IsoWeek(value)` and is a usable precondition for `isoweek_to_datetime`, which cannot represent + `"2023-W53"` either: pandas 3.0 and later raise on it, and earlier versions silently return + `2024-01-01`, rolling a week that does not exist into the next ISO year. The check is what catches + the second case, where nothing else complains. + + The match result is compared against `True` rather than taken for granted, because an `object` + series can hold values that are neither null nor `str` (a list, a dict, a number alongside + strings). `str.fullmatch` returns `NaN` for those, and `NaN` is truthy, so a bare `all()` reported + a series of lists as correctly formatted. `eq` rather than `fillna(False)`: filling an object + column downcasts it, which pandas 2.2 deprecates and a later version will change. Only nulls in + the *input* are excused, and those are masked out separately. Arguments: series: Series of `str` values pattern: pattern to match Returns: - `True` if all non-null values match `pattern`, `False` otherwise. An empty or all-null - series returns `True`, since it contains nothing that violates the format. + `True` if all non-null values match `pattern` and name a week that exists in their year, + `False` otherwise. An empty or all-null series returns `True`, since it contains nothing + that violates the format. Raises: TypeError: If `series` is not of type `pd.Series` @@ -281,7 +309,23 @@ def _match_series(series: pd.Series[Any], pattern: str) -> bool: # are a plain `False`: the only `TypeError` this function raises is for a non-`pd.Series`. return False - return bool(matches[series.notna()].fillna(value=False).all()) + present = series.notna() + if not bool(matches[present].eq(other=True).all()): + return False + + # Every present value is well-formed, so the fixed-width layout makes the year and week readable + # by position and the casts cannot fail. + values = series[present] + if values.empty: + return True + + year = values.str[:4].astype(int) + week = values.str[6:8].astype(int) + + # Weeks 01 to 52 exist in every year, so only a week 53 has anything left to prove. `is_long_year` + # is the same helper `IsoWeek._validate` uses, applied to a column instead of a single year. + in_calendar = (week != LONG_YEAR_WEEKS) | is_long_year(year) + return bool(in_calendar.all()) def is_isoweek_series(series: pd.Series[Any]) -> bool: diff --git a/src/iso_week_date/polars_utils.py b/src/iso_week_date/polars_utils.py index 1baec40..96ba65c 100644 --- a/src/iso_week_date/polars_utils.py +++ b/src/iso_week_date/polars_utils.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Generic, TypeVar, overload from iso_week_date._patterns import ISOWEEK__DATE_FORMAT, ISOWEEK_PATTERN, ISOWEEKDATE__DATE_FORMAT, ISOWEEKDATE_PATTERN -from iso_week_date._utils import require_version +from iso_week_date._utils import LONG_YEAR_WEEKS, is_long_year, require_version require_version("polars", minimum="0.18.0", extra="polars") @@ -309,17 +309,28 @@ def _match_series(series: pl.Series | pl.Expr, pattern: str) -> bool | pl.Expr: ISO Week strings, but `str.contains` refuses to look at them and raises. It also settles the `Null` dtype, where an all-null series is missing data rather than malformed data. For any dtype that is not string-like the cast either fails or yields values that cannot match, so the answer - stays `False` either way. `String` rather than `String` because the latter does not exist on the - declared polars floor. + stays `False` either way. + + Matching the format is necessary but not sufficient: weeks `01` to `53` are all well-formed, yet + only long ISO years have a week 53. The week number is therefore checked against its year through + the very same `is_long_year` helper `IsoWeek._validate` uses, so this answers the same question as + `IsoWeek(value)` and is a usable precondition for `isoweek_to_datetime`, which refuses to convert + `"2023-W53"` at all. + + The whole answer is built as one expression, because the `Expr` path has no data to inspect and + cannot branch. A malformed value makes the week comparison null, and polars' Kleene logic keeps + `False & null` at `False`, so the format verdict still wins. A null *input* leaves both operands + null, which `fill_null` then excuses. Arguments: series: Series or Expr of `str` values pattern: pattern to match. It is already anchored by `iso_week_date._patterns`. Returns: - For a `pl.Series`, `True` if all non-null values match `pattern` and `False` otherwise; an - empty or all-null series returns `True`, since it contains nothing that violates the - format. For a `pl.Expr`, a boolean `Expr` computing the same answer. + For a `pl.Series`, `True` if all non-null values match `pattern` and name a week that exists + in their year, `False` otherwise; an empty or all-null series returns `True`, since it + contains nothing that violates the format. For a `pl.Expr`, a boolean `Expr` computing + the same answer. Raises: TypeError: If `series` is not of type `pl.Series` or `pl.Expr` @@ -329,7 +340,16 @@ def _match_series(series: pl.Series | pl.Expr, pattern: str) -> bool | pl.Expr: raise TypeError(msg) try: - return series.cast(pl.String()).str.contains(pattern).fill_null(value=True).all() + values = series.cast(pl.String()) + # Every well-formed value has the same fixed-width layout, so year and week are readable by + # position. A malformed value yields null here instead of failing the cast. + year = values.str.slice(0, 4).cast(pl.Int32, strict=False) + week = values.str.slice(6, 2).cast(pl.Int32, strict=False) + # Weeks 01 to 52 exist in every year, so only a week 53 has anything left to prove. + # `is_long_year` is the same helper `IsoWeek._validate` uses, applied to a column. + in_calendar = (week != LONG_YEAR_WEEKS) | is_long_year(year) + matches = values.str.contains(pattern) & in_calendar + return matches.fill_null(value=True).all() except (InvalidOperationError, SchemaError, ComputeError): # A dtype that is neither string-like nor castable to one (nested types, for instance) holds # nothing in ISO Week format, so it is a plain `False` rather than an error. Narrowed to the diff --git a/tests/base_test.py b/tests/base_test.py index ad8ce98..427d41d 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -213,13 +213,103 @@ def test_range_valid( _start = IsoWeek(start) _end = _start + n_weeks_out - lenoffset_ = 0 if inclusive == "both" else 1 if inclusive in {"left", "right"} else 2 - - _len = (n_weeks_out - lenoffset_) // step + 1 _range = tuple(IsoWeek.range(_start, _end, step=step, inclusive=inclusive, as_str=as_str)) - assert all(isinstance(w, str if as_str else IsoWeek) for w in _range) - assert len(_range) == _len + + # Asserted against the values themselves rather than a length formula: the grid is anchored at + # `start` and `inclusive` only removes the two endpoints from it. A count alone accepted the + # earlier off-by-one, where a start-exclusive call shifted the whole grid and so dropped `end` + # too. `end` is on the grid only when the span divides by `step`, and there is nothing for + # `inclusive` to drop when it is not. + grid = [_start + i for i in range(0, n_weeks_out + 1, step)] + expected = [ + week + for week in grid + if not (week == _start and inclusive in {"right", "neither"}) + and not (week == _end and inclusive in {"left", "neither"}) + ] + + assert list(_range) == ([str(week) for week in expected] if as_str else expected) + + +@pytest.mark.parametrize( + ("end", "inclusive", "expected"), + [ + # `end` is on the grid, so `inclusive` has both endpoints to keep or drop. + ("2025-W05", "both", ("2025-W01", "2025-W03", "2025-W05")), + ("2025-W05", "left", ("2025-W01", "2025-W03")), + ("2025-W05", "right", ("2025-W03", "2025-W05")), + ("2025-W05", "neither", ("2025-W03",)), + # `end` is off the grid, so it is never generated and there is no `end` to drop. + ("2025-W06", "both", ("2025-W01", "2025-W03", "2025-W05")), + ("2025-W06", "left", ("2025-W01", "2025-W03", "2025-W05")), + ("2025-W06", "right", ("2025-W03", "2025-W05")), + ("2025-W06", "neither", ("2025-W03", "2025-W05")), + ], +) +def test_range_keeps_the_endpoint_that_inclusive_names( + end: str, + inclusive: Literal["both", "left", "right", "neither"], + expected: tuple[str, ...], +) -> None: + """A stepped range must still honour the endpoints, `end` included. + + Every start-exclusive call used to shift the grid by one week instead of dropping `start` from + it, so `inclusive="right"` returned `('2025-W02', '2025-W04')`: neither endpoint, and values that + are not `start + k * step` at all. + """ + assert tuple(IsoWeek.range("2025-W01", end, step=2, inclusive=inclusive)) == expected + + +def test_compact_pattern_derives_from_the_dashed_pattern() -> None: + """`_compact_pattern` is what `from_compact` matches, so it cannot drift from `_pattern` unnoticed. + + It was dead code carrying a coverage exemption while `from_compact` checked only the string + length, leaving the dashed `_validate` to produce the error. + """ + assert IsoWeek._compact_pattern.fullmatch("2023W01") is not None + assert IsoWeekDate._compact_pattern.fullmatch("2023W011") is not None + + # The dashed form is exactly what the compact pattern must not accept. + assert IsoWeek._compact_pattern.fullmatch("2023-W01") is None + assert IsoWeekDate._compact_pattern.fullmatch("2023-W01-1") is None + + +@pytest.mark.parametrize( + ("compact", "expected"), + [ + ("2023W01", isoweek), + ("2023W011", isoweekdate), + ], +) +def test_from_compact_accepts_the_compact_form(compact: str, expected: T) -> None: + """A well-formed compact value round-trips to the same object as the dashed one.""" + assert type(expected).from_compact(compact) == expected + + +@pytest.mark.parametrize( + ("value", "compact_format"), + [ + ("2023W0x", "YYYYWNN"), + ("2023W54", "YYYYWNN"), + ("2023W00", "YYYYWNN"), + ("0000W01", "YYYYWNN"), + ("2023-W01", "YYYYWNN"), + ("2023W0110", "YYYYWNND"), + ("2023W011x", "YYYYWNND"), + ("2023W0118", "YYYYWNND"), + ], +) +def test_from_compact_reports_the_compact_format(value: str, compact_format: str) -> None: + """A malformed compact value is reported against the format the caller actually used. + + The dashed `_validate` used to produce this error, naming `YYYY-WNN` for a value the caller had + written without dashes. + """ + cls: type[IsoWeek | IsoWeekDate] = IsoWeek if compact_format == "YYYYWNN" else IsoWeekDate + + with pytest.raises(ValueError, match=re.escape(f"'{compact_format}' pattern")): + cls.from_compact(value) def test_quarters() -> None: @@ -241,6 +331,7 @@ def test_quarters() -> None: ({"start": "2023-W03"}, ValueError, "`start` must be before `end` value"), ({"end": "2022-W52"}, ValueError, "`start` must be before `end` value"), ({"step": 1.0}, TypeError, "`step` must be integer"), + ({"step": True}, TypeError, "`step` must be integer"), ({"step": 0}, ValueError, "`step` value must be greater than or equal to 1"), ( {"inclusive": "invalid"}, @@ -403,7 +494,7 @@ def test_comparisons_invalid_offset(comparison_op: str) -> None: ], ) def test_compact_format(obj: BaseIsoWeek, fmt: str) -> None: - assert obj._compact_format == fmt # type: ignore[arg-type] + assert obj._compact_format == fmt def test_from_today() -> None: diff --git a/tests/frame_utils/pandas_test.py b/tests/frame_utils/pandas_test.py index ca0ff05..a63e5ce 100644 --- a/tests/frame_utils/pandas_test.py +++ b/tests/frame_utils/pandas_test.py @@ -312,6 +312,32 @@ def test_is_isoweek_series_answers_on_content_not_dtype(series: pd.Series, expec assert is_isoweek_series(series) is expected +@pytest.mark.skipif( + tuple(int(part) for part in pd.__version__.split(".")[:2]) < (3, 0), + reason="a pre-1677 value in a non-nanosecond Series requires pandas >= 3.0", +) +@pytest.mark.parametrize("_date", [date(1, 1, 1), date(9, 3, 2), date(999, 6, 1), date(1000, 1, 3)]) +def test_datetime_to_isoweek_zero_pads_the_iso_year(_date: date) -> None: + """pandas must zero-pad the ISO year exactly as `IsoWeek.from_date` does. + + `dt.strftime("%G")` delegates to the platform C library, whose padding is not portable: on glibc + with Python < 3.14 an ISO year below 1000 renders unpadded, so this produced `"1-W01"`. The + scalar path stopped depending on it in `BaseIsoWeek._format_isocalendar`; the vectorised path now + builds the string the same way. + + `datetime64[us]` rather than the default `[ns]`, which cannot represent a year before 1677. The + guard is pandas 3.0 rather than the 2.0 that introduced non-nanosecond units: up to 2.2 every + constructor still routed values through a nanosecond `Timestamp` first and raised + `OutOfBoundsDatetime` before the unit could take effect. + """ + series = pd.Series([_date], dtype="datetime64[us]") + + expected = IsoWeek.from_date(_date).value_ + result = datetime_to_isoweek(series).iloc[0] + + assert result == expected, f"pandas produced {result!r}, scalar class gives {expected!r}" + + @pytest.mark.parametrize("weekday", [1.0, True, False, "1", Decimal(1)]) def test_isoweek_to_datetime_rejects_non_int_weekday(weekday: Any) -> None: """A non-`int` `weekday` must fail as a `TypeError` before it reaches the parser. diff --git a/tests/frame_utils/parity_test.py b/tests/frame_utils/parity_test.py index dd5f29b..e7df4fc 100644 --- a/tests/frame_utils/parity_test.py +++ b/tests/frame_utils/parity_test.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import date -from typing import Any +from typing import TYPE_CHECKING, Any import pytest @@ -17,10 +17,13 @@ import pandas as pd import polars as pl +from polars.exceptions import PolarsError from iso_week_date import IsoWeek, pandas_utils, polars_utils -from iso_week_date._patterns import ISOWEEK_PATTERN -from iso_week_date._utils import match_isoweek +from iso_week_date._utils import LONG_YEAR_WEEKS, is_long_year, weeks_of_year + +if TYPE_CHECKING: + from collections.abc import Callable pytestmark = [pytest.mark.pandas, pytest.mark.polars] @@ -141,32 +144,109 @@ def test_conversions_propagate_nulls_identically() -> None: ["0000-W01"], ["2023-W54"], ["2023-W00"], + # Well-formed but not on the calendar: only long ISO years have a week 53. + ["2023-W53"], + ["2021-W53"], + ["2022-W53"], + ["2020-W53"], + ["0001-W52"], + ["9999-W52"], + ["2020-W53", "2023-W53"], ], ) -def test_is_isoweek_series_agrees_with_the_scalar_pattern(values: list[str]) -> None: - """Both backends must accept exactly the strings the shared pattern accepts. +def test_is_isoweek_series_agrees_with_the_scalar_class(values: list[str]) -> None: + """Both backends must accept exactly the strings `IsoWeek` accepts, and nothing else. + + The scalar class is the reference rather than the shared pattern alone, so the two vectorised + week-count implementations cannot drift from `weeks_of_year`, and the pandas (Python `re`) and + polars (Rust `regex`) engines cannot drift from `_validate` on which strings are well-formed. + A check that disagreed here would be useless as a precondition: the conversions reject exactly + what `IsoWeek` rejects. + """ + expected = all(_is_valid_isoweek(value) for value in values) + + assert pandas_utils.is_isoweek_series(pd.Series(values, dtype="object")) is expected + assert polars_utils.is_isoweek_series(pl.Series(values, dtype=pl.String)) is expected + + +def _is_valid_isoweek(value: str) -> bool: + """Whether the scalar class accepts `value`.""" + try: + IsoWeek(value) + except ValueError: + return False + return True + - The reference is `match_isoweek`, the same helper `BaseIsoWeek._validate` uses, so the regex - engines used by pandas (Python `re`) and polars (Rust `regex`) cannot drift apart from the - scalar classes on which strings are well-formed. +@pytest.mark.parametrize( + "vectorised", + [ + pytest.param(lambda years: is_long_year(pd.Series(years)).tolist(), id="pandas-series"), + pytest.param(lambda years: is_long_year(pl.Series(years, dtype=pl.Int32)).to_list(), id="polars-series"), + pytest.param( + lambda years: ( + pl.DataFrame({"year": pl.Series(years, dtype=pl.Int32)}) + .select(long=is_long_year(pl.col("year")))["long"] + .to_list() + ), + id="polars-expr", + ), + ], +) +def test_is_long_year_vectorises_without_drifting_from_the_scalar( + vectorised: Callable[[list[int]], list[bool]], +) -> None: + """One implementation serves the scalar class and both backends, so it must vectorise faithfully. + + `is_long_year` is written with `|` instead of `or` precisely so the dataframe modules can reuse it + rather than keeping their own copy. What could still go wrong is the vectorising: integer width, + floor-division or modulo semantics differing from Python's. All 9999 representable years are + compared rather than sampled, since the disagreements would be sparse and year-specific. """ - expected = all(match_isoweek(ISOWEEK_PATTERN, v) is not None for v in values) + years = list(range(1, 10_000)) + expected = [weeks_of_year(year) == LONG_YEAR_WEEKS for year in years] - assert pandas_utils.is_isoweek_series(pd.Series(values, dtype="object")) == expected - assert polars_utils.is_isoweek_series(pl.Series(values, dtype=pl.String)) == expected + assert vectorised(years) == expected @pytest.mark.parametrize("value", ["2023-W53", "2021-W53", "2022-W53"]) -def test_is_isoweek_series_is_a_format_check_not_a_calendar_check(value: str) -> None: - """Documents a deliberate gap: the helpers check the format, not the week-number-vs-year rule. +def test_is_isoweek_series_rejects_weeks_the_year_does_not_have(value: str) -> None: + """The format is necessary but not sufficient, and the checks now enforce both halves. - `2023-W53` matches the pattern (weeks 01-53 are syntactically valid) but `IsoWeek("2023-W53")` - raises, because 2023 has only 52 weeks. Closing the gap would need a vectorised `weeks_of_year` - in both backends; until then the asymmetry is pinned here so it is a known property rather than - a surprise, and both backends at least agree on it. + These values match the pattern (weeks 01-53 are syntactically valid) but name a week their year + does not have, and no backend can convert them faithfully. The checks used to answer `True` for + all of them, so guarding a conversion with one bought the caller nothing. """ with pytest.raises(ValueError, match="Invalid week number"): IsoWeek(value) + assert pandas_utils.is_isoweek_series(pd.Series([value], dtype="object")) is False + assert polars_utils.is_isoweek_series(pl.Series([value], dtype=pl.String)) is False + + assert _pandas_week_after_conversion(value) != value + with pytest.raises(PolarsError): + polars_utils.isoweek_to_datetime(pl.Series([value], dtype=pl.String)) + + +def _pandas_week_after_conversion(value: str) -> str | None: + """The ISO week pandas actually lands on, or `None` when it refuses to convert. + + Deliberately not a `pytest.raises`: refusing is only what pandas 3.0 and later do. Below that the + conversion succeeds and returns `2024-01-01` for `"2023-W53"`, silently rolling a week that does + not exist into the next ISO year, which is the more dangerous of the two outcomes and the reason + the check has to answer `False` on its own rather than lean on the conversion to complain. + """ + try: + converted = pandas_utils.isoweek_to_datetime(pd.Series([value], dtype="object")) + except ValueError: + return None + return IsoWeek.from_date(converted.iloc[0].date()).value_ + + +@pytest.mark.parametrize("value", ["2020-W53", "2015-W53", "2026-W53"]) +def test_is_isoweek_series_accepts_week_53_of_a_long_year(value: str) -> None: + """The calendar rule must not over-reject: a week 53 that exists is still valid.""" + assert IsoWeek(value).week == 53 # noqa: PLR2004 + assert pandas_utils.is_isoweek_series(pd.Series([value], dtype="object")) is True assert polars_utils.is_isoweek_series(pl.Series([value], dtype=pl.String)) is True diff --git a/tests/frame_utils/polars_test.py b/tests/frame_utils/polars_test.py index 41e718f..bc0b7b9 100644 --- a/tests/frame_utils/polars_test.py +++ b/tests/frame_utils/polars_test.py @@ -244,8 +244,8 @@ def test_datetime_to_isoweek_zero_pads_the_iso_year(_date: date) -> None: through Rust chrono rather than the scalar path, so anchoring on the scalar class means any divergence shows up here rather than as a corrupt string in a user's dataframe. - There is no pandas equivalent: `datetime64[ns]` cannot represent a year before 1677, and the - non-nanosecond units that can need pandas >= 2.0, above this project's declared floor. + The pandas equivalent lives in `pandas_test.py` and is guarded on pandas >= 2.0, since + `datetime64[ns]` cannot represent a year before 1677. """ expected = IsoWeek.from_date(_date).value_ result = datetime_to_isoweek(pl.Series([_date])).item() @@ -370,6 +370,28 @@ def test_is_isoweek_series_answers_on_content_not_dtype(series: pl.Series, expec assert is_isoweek_series(series) is expected +@pytest.mark.parametrize( + ("series", "expected"), + [ + (pl.Series(["2023-W53"]), False), + (pl.Series(["2020-W53"]), True), + (pl.Series(["2020-W53", "2023-W53"]), False), + (pl.Series(["2020-W53", None]), True), + (pl.Series(["2020-W53"], dtype=pl.Categorical), True), + ], +) +def test_is_isoweek_series_checks_the_calendar_on_the_lazy_path(series: pl.Series, expected: bool) -> None: + """The calendar rule is part of the one expression, so it must hold for an `Expr` too. + + The value table itself lives in `parity_test.py`, which checks both backends against `IsoWeek`. + What is unique here is that the rule survives deferred evaluation. + """ + lazy = pl.DataFrame({"a": series}).select(check=is_isoweek_series(pl.col("a")))["check"].item() + + assert lazy is expected + assert lazy is is_isoweek_series(series) + + def test_is_isoweek_series_nested_dtype_is_eager_only() -> None: """The `except` cannot fire for an `Expr`, so a nested column is `False` eagerly and raises lazily.""" series = pl.Series([["2023-W01"]]) diff --git a/tests/isoweek/addition_test.py b/tests/isoweek/addition_test.py index da720c8..14136f4 100644 --- a/tests/isoweek/addition_test.py +++ b/tests/isoweek/addition_test.py @@ -69,7 +69,12 @@ def test_add_one_shot_iterator_raise(isoweek_constructor: type[IsoWeek], factory _ = obj + factory() -@pytest.mark.parametrize("other", [timedelta(weeks=2), (1, 2, timedelta(weeks=2)), 1.0, "1", ("1", 2)]) +# `bool` is in the list because `isinstance(True, int)` holds: `obj + True` used to read as "one +# week later" and quietly produce a value the caller never asked for. +@pytest.mark.parametrize( + "other", + [timedelta(weeks=2), (1, 2, timedelta(weeks=2)), 1.0, "1", ("1", 2), True, False, (1, True)], +) def test_add_raise(isoweek_constructor: type[IsoWeek], other: Any) -> None: obj = isoweek_constructor(value) with pytest.raises(TypeError, match="Cannot add type"): diff --git a/tests/isoweek/subtraction_test.py b/tests/isoweek/subtraction_test.py index 35490e8..7818d36 100644 --- a/tests/isoweek/subtraction_test.py +++ b/tests/isoweek/subtraction_test.py @@ -103,7 +103,11 @@ def test_sub_one_shot_iterator_raise(isoweek_constructor: type[IsoWeek], factory _ = obj - factory() -@pytest.mark.parametrize("other", [timedelta(weeks=2), (1, timedelta(weeks=2)), 1.0, "1", ("1", 2)]) +# See `addition_test.py`: `bool` is an `int` subclass and must not pass for a week count. +@pytest.mark.parametrize( + "other", + [timedelta(weeks=2), (1, timedelta(weeks=2)), 1.0, "1", ("1", 2), True, False, (1, True)], +) def test_sub_raise(isoweek_constructor: type[IsoWeek], other: Any) -> None: obj = isoweek_constructor(value) with pytest.raises(TypeError, match="Cannot subtract type"): diff --git a/tests/isoweek/weeksout_test.py b/tests/isoweek/weeksout_test.py index 1056209..1db900f 100644 --- a/tests/isoweek/weeksout_test.py +++ b/tests/isoweek/weeksout_test.py @@ -19,6 +19,7 @@ (1, 2, None, None), (10, 1, None, None), (1.0, 1, TypeError, "`n_weeks` must be an integer"), + (True, 1, TypeError, "`n_weeks` must be an integer"), (0, 1, ValueError, "`n_weeks` must be strictly positive"), (-2, 1, ValueError, "`n_weeks` must be strictly positive"), ], diff --git a/tests/isoweekdate/addition_test.py b/tests/isoweekdate/addition_test.py index fbe300e..c86cbac 100644 --- a/tests/isoweekdate/addition_test.py +++ b/tests/isoweekdate/addition_test.py @@ -74,7 +74,11 @@ def test_add_one_shot_iterator_raise( _ = obj + factory() -@pytest.mark.parametrize("other", [timedelta(weeks=2), (1, 2, timedelta(weeks=2)), 1.0, "1", ("1", 2)]) +# See `tests/isoweek/addition_test.py`: `bool` is an `int` subclass and must not pass for a day count. +@pytest.mark.parametrize( + "other", + [timedelta(weeks=2), (1, 2, timedelta(weeks=2)), 1.0, "1", ("1", 2), True, False, (1, True)], +) def test_add_raise(isoweekdate_constructor: type[IsoWeekDate], other: Any) -> None: obj = isoweekdate_constructor(value) with pytest.raises(TypeError, match="Cannot add type"): diff --git a/tests/isoweekdate/daysout_test.py b/tests/isoweekdate/daysout_test.py index a663065..973fe09 100644 --- a/tests/isoweekdate/daysout_test.py +++ b/tests/isoweekdate/daysout_test.py @@ -19,6 +19,7 @@ (1, 2, None, None), (10, 1, None, None), (1.0, 1, TypeError, "`n_days` must be integer"), + (True, 1, TypeError, "`n_days` must be integer"), (0, 1, ValueError, "`n_days` must be strictly positive"), (-2, 1, ValueError, "`n_days` must be strictly positive"), ], diff --git a/tests/isoweekdate/subtraction_test.py b/tests/isoweekdate/subtraction_test.py index d3c5af6..3952628 100644 --- a/tests/isoweekdate/subtraction_test.py +++ b/tests/isoweekdate/subtraction_test.py @@ -110,7 +110,11 @@ def test_sub_one_shot_iterator_raise( _ = obj - factory() -@pytest.mark.parametrize("other", [timedelta(weeks=2), (1, timedelta(weeks=2)), 1.0, "1", ("1", 2)]) +# See `tests/isoweek/addition_test.py`: `bool` is an `int` subclass and must not pass for a day count. +@pytest.mark.parametrize( + "other", + [timedelta(weeks=2), (1, timedelta(weeks=2)), 1.0, "1", ("1", 2), True, False, (1, True)], +) def test_sub_raise(isoweekdate_constructor: type[IsoWeekDate], other: Any) -> None: obj = isoweekdate_constructor(value) with pytest.raises(TypeError, match="Cannot subtract type"): diff --git a/tests/year_bounds_test.py b/tests/year_bounds_test.py new file mode 100644 index 0000000..df03cdc --- /dev/null +++ b/tests/year_bounds_test.py @@ -0,0 +1,107 @@ +"""Behaviour at the documented `0001`-`9999` ISO year bounds. + +`IsoWeek` and `IsoWeekDate` are backed by `datetime.date`, so the first and last representable weeks +sit next to an edge that arithmetic can fall off. Stepping over it surfaces the standard library's own +error, which is a documented limitation rather than a bug: guarding every operation would cost every +caller something to protect a range essentially nobody reaches. These tests pin which error each path +produces, so the documentation cannot drift from the behaviour. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import TYPE_CHECKING, Any + +import pytest + +from iso_week_date import IsoWeek, IsoWeekDate + +if TYPE_CHECKING: + from collections.abc import Callable + +FIRST_WEEK = "0001-W01" +LAST_WEEK = "9999-W52" +FIRST_WEEK_DATE = "0001-W01-1" +#: The last ISO week date that `datetime.date` can hold. `9999-W52-6` and `-7` are well-formed and +#: constructible, but fall in year 10000, so they cannot be converted at all. +LAST_WEEK_DATE = "9999-W52-5" +UNREPRESENTABLE_WEEK_DATE = "9999-W52-7" + + +@pytest.mark.parametrize( + ("operation", "expected_exception"), + [ + # `timedelta` arithmetic overflows... + pytest.param(lambda: IsoWeek(LAST_WEEK).next(), OverflowError, id="isoweek-next"), + pytest.param(lambda: IsoWeek(FIRST_WEEK).previous(), OverflowError, id="isoweek-previous"), + pytest.param(lambda: IsoWeek(LAST_WEEK) + 1, OverflowError, id="isoweek-add"), + pytest.param(lambda: IsoWeek(FIRST_WEEK) - 1, OverflowError, id="isoweek-sub"), + pytest.param(lambda: tuple(IsoWeek(LAST_WEEK).add((1,))), OverflowError, id="isoweek-add-iterable"), + pytest.param(lambda: tuple(IsoWeek(FIRST_WEEK).sub((1,))), OverflowError, id="isoweek-sub-iterable"), + pytest.param(lambda: tuple(IsoWeek(LAST_WEEK).weeksout(2)), OverflowError, id="isoweek-weeksout"), + pytest.param(lambda: IsoWeekDate(LAST_WEEK_DATE) + 1, OverflowError, id="isoweekdate-add"), + pytest.param(lambda: IsoWeekDate(FIRST_WEEK_DATE) - 1, OverflowError, id="isoweekdate-sub"), + pytest.param(lambda: tuple(IsoWeekDate(LAST_WEEK_DATE).daysout(2)), OverflowError, id="isoweekdate-daysout"), + # ...while `strptime` rejects the year, so the two halves of the API disagree on the type. + pytest.param(lambda: IsoWeek(LAST_WEEK).days, ValueError, id="isoweek-days"), + pytest.param(lambda: IsoWeek(LAST_WEEK).nth(7), ValueError, id="isoweek-nth"), + pytest.param(lambda: IsoWeek(LAST_WEEK).to_date(7), ValueError, id="isoweek-to-date"), + pytest.param(lambda: IsoWeek(LAST_WEEK).to_datetime(7), ValueError, id="isoweek-to-datetime"), + pytest.param(lambda: IsoWeekDate(LAST_WEEK_DATE).next(), OverflowError, id="isoweekdate-next"), + pytest.param(lambda: IsoWeekDate(FIRST_WEEK_DATE).previous(), OverflowError, id="isoweekdate-previous"), + # Constructible but not representable: the conversion fails before any arithmetic happens. + pytest.param(lambda: IsoWeekDate(UNREPRESENTABLE_WEEK_DATE).to_date(), ValueError, id="isoweekdate-unrepr"), + pytest.param(lambda: IsoWeekDate(UNREPRESENTABLE_WEEK_DATE) + 1, ValueError, id="isoweekdate-unrepr-add"), + ], +) +def test_crossing_the_year_bounds_raises( + operation: Callable[[], Any], + expected_exception: type[Exception], +) -> None: + """Which error each path produces is a documented limitation, so it is pinned rather than fixed. + + `except (OverflowError, ValueError)` is what the docs tell callers near the bounds to write, and + that advice only holds while every path here raises one of the two. + """ + with pytest.raises(expected_exception): + operation() + + with pytest.raises((OverflowError, ValueError)): + operation() + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (IsoWeek(FIRST_WEEK), date(1, 1, 1)), + (IsoWeekDate(FIRST_WEEK_DATE), date(1, 1, 1)), + (IsoWeek(LAST_WEEK), date(9999, 12, 27)), + (IsoWeekDate(LAST_WEEK_DATE), date(9999, 12, 31)), + ], +) +def test_the_bounds_themselves_are_representable(value: IsoWeek | IsoWeekDate, expected: date) -> None: + """The extreme values that *do* fit still convert; only stepping past them fails.""" + assert value.to_date() == expected + assert value.to_datetime() == datetime(expected.year, expected.month, expected.day) + + +@pytest.mark.parametrize(("weekday", "expected"), [(1, date(9999, 12, 27)), (5, date(9999, 12, 31))]) +def test_the_last_week_resolves_the_weekdays_that_fit(weekday: int, expected: date) -> None: + """`9999-W52` spills into year 10000 only from its sixth day on. + + `nth` reached these through `self.days`, which materialises the whole week, so every weekday + raised even when the requested one was well inside range. + """ + assert IsoWeek(LAST_WEEK).nth(weekday) == expected + assert IsoWeek(LAST_WEEK).to_date(weekday) == expected + + +@pytest.mark.parametrize("value", [FIRST_WEEK, LAST_WEEK]) +def test_in_range_operations_at_the_bounds_still_work(value: str) -> None: + """Only the operations that actually cross the edge raise; the rest are unaffected.""" + week = IsoWeek(value) + + assert week.year in {1, 9999} + assert week.week == int(value[-2:]) + assert week.to_compact() == value.replace("-", "") + assert IsoWeek.from_compact(value.replace("-", "")) == week