diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e867efec..af653e1e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +# 42.0.0 [#1223](https://github.com/openfisca/openfisca-core/pull/1223) + +#### Breaking changes + +- Changes to `eternity` instants and periods + - Eternity instants are now `` instead of + `` + - Eternity periods are now `, -1))>` + instead of `, inf))>` + - The reason is to avoid mixing data types: `inf` is a float, periods and + instants are integers. Mixed data types make memory optimisations impossible. + - Migration should be straightforward. If you have a test that checks for + `inf`, you should update it to check for `-1` or use the `is_eternal` method. +- `periods.instant` no longer returns `None` + - Now, it raises `periods.InstantError` + +#### New features + +- Introduce `Instant.eternity()` + - This behaviour was duplicated across + - Now it is encapsulated in a single method +- Introduce `Instant.is_eternal` and `Period.is_eternal` + - These methods check if the instant or period are eternity (`bool`). +- Now `periods.instant` parses also ISO calendar strings (weeks) + - For instance, `2022-W01` is now a valid input + +#### Technical changes + +- Update `pendulum` +- Reduce code complexity +- Remove run-time type-checks +- Add typing to the periods module + ### 41.5.7 [#1225](https://github.com/openfisca/openfisca-core/pull/1225) #### Technical changes diff --git a/openfisca_core/commons/misc.py b/openfisca_core/commons/misc.py index 138dd18e1e..93e50c6d90 100644 --- a/openfisca_core/commons/misc.py +++ b/openfisca_core/commons/misc.py @@ -30,10 +30,12 @@ def empty_clone(original: object) -> object: """ + def __init__(_: object) -> None: ... + Dummy = type( "Dummy", (original.__class__,), - {"__init__": lambda _: None}, + {"__init__": __init__}, ) new = Dummy() @@ -69,6 +71,7 @@ def stringify_array(array: None | t.Array[numpy.generic]) -> str: "[, {}, None: + msg = ( + f"'{value}' is not a valid instant string. Instants are described " + "using either the 'YYYY-MM-DD' format, for instance '2015-06-15', " + "or the 'YYYY-Www-D' format, for instance '2015-W24-1'." + ) + super().__init__(msg) + + +class PeriodError(ValueError): + """Raised when an invalid period-like is provided.""" + + def __init__(self, value: str) -> None: + msg = ( + "Expected a period (eg. '2017', 'month:2017-01', 'week:2017-W01-1:3', " + f"...); got: '{value}'. Learn more about legal period formats in " + "OpenFisca: ." + ) + super().__init__(msg) + + +__all__ = ["InstantError", "ParserError", "PeriodError"] diff --git a/openfisca_core/periods/_parsers.py b/openfisca_core/periods/_parsers.py index 95a17fb041..9973b890a0 100644 --- a/openfisca_core/periods/_parsers.py +++ b/openfisca_core/periods/_parsers.py @@ -1,62 +1,92 @@ -from typing import Optional +"""To parse periods and instants from strings.""" -import re +from __future__ import annotations + +import datetime import pendulum -from pendulum.datetime import Date -from pendulum.parsing import ParserError +from . import types as t +from ._errors import InstantError, ParserError, PeriodError from .date_unit import DateUnit from .instant_ import Instant from .period_ import Period -invalid_week = re.compile(r".*(W[1-9]|W[1-9]-[0-9]|W[0-5][0-9]-0)$") +def parse_instant(value: str) -> t.Instant: + """Parse a string into an instant. + + Args: + value (str): The string to parse. + + Returns: + An InstantStr. + + Raises: + InstantError: When the string is not a valid ISO Calendar/Format. + ParserError: When the string couldn't be parsed. + + Examples: + >>> parse_instant("2022") + Instant((2022, 1, 1)) + + >>> parse_instant("2022-02") + Instant((2022, 2, 1)) + + >>> parse_instant("2022-W02-7") + Instant((2022, 1, 16)) -def _parse_period(value: str) -> Optional[Period]: + >>> parse_instant("2022-W013") + Traceback (most recent call last): + openfisca_core.periods._errors.InstantError: '2022-W013' is not a va... + + >>> parse_instant("2022-02-29") + Traceback (most recent call last): + pendulum.parsing.exceptions.ParserError: Unable to parse string [202... + + """ + + if not isinstance(value, t.InstantStr): + raise InstantError(str(value)) + + date = pendulum.parse(value, exact=True) + + if not isinstance(date, datetime.date): + msg = f"Unable to parse string [{value}]" + raise ParserError(msg) + + return Instant((date.year, date.month, date.day)) + + +def parse_period(value: str) -> t.Period: """Parses ISO format/calendar periods. Such as "2012" or "2015-03". Examples: - >>> _parse_period("2022") + >>> parse_period("2022") Period((, Instant((2022, 1, 1)), 1)) - >>> _parse_period("2022-02") + >>> parse_period("2022-02") Period((, Instant((2022, 2, 1)), 1)) - >>> _parse_period("2022-W02-7") + >>> parse_period("2022-W02-7") Period((, Instant((2022, 1, 16)), 1)) """ - # If it's a complex period, next! - if len(value.split(":")) != 1: - return None - # Check for a non-empty string. - if not (value and isinstance(value, str)): - raise AttributeError + try: + instant = parse_instant(value) - # If it's negative, next! - if value[0] == "-": - raise ValueError + except InstantError as error: + raise PeriodError(value) from error - # If it's an invalid week, next! - if invalid_week.match(value): - raise ParserError - - unit = _parse_unit(value) - date = pendulum.parse(value, exact=True) - - if not isinstance(date, Date): - raise ValueError - - instant = Instant((date.year, date.month, date.day)) + unit = parse_unit(value) return Period((unit, instant, 1)) -def _parse_unit(value: str) -> DateUnit: +def parse_unit(value: str) -> t.DateUnit: """Determine the date unit of a date string. Args: @@ -66,32 +96,26 @@ def _parse_unit(value: str) -> DateUnit: A DateUnit. Raises: - ValueError when no DateUnit can be determined. + InstantError: when no DateUnit can be determined. Examples: - >>> _parse_unit("2022") + >>> parse_unit("2022") - >>> _parse_unit("2022-W03-01") + >>> parse_unit("2022-W03-1") """ - length = len(value.split("-")) - isweek = value.find("W") != -1 - if length == 1: - return DateUnit.YEAR + if not isinstance(value, t.InstantStr): + raise InstantError(str(value)) - if length == 2: - if isweek: - return DateUnit.WEEK + length = len(value.split("-")) - return DateUnit.MONTH + if isinstance(value, t.ISOCalendarStr): + return DateUnit.isocalendar[-length] - if length == 3: - if isweek: - return DateUnit.WEEKDAY + return DateUnit.isoformat[-length] - return DateUnit.DAY - raise ValueError +__all__ = ["parse_instant", "parse_period", "parse_unit"] diff --git a/openfisca_core/periods/config.py b/openfisca_core/periods/config.py index 26ce30a5aa..4486a5caf0 100644 --- a/openfisca_core/periods/config.py +++ b/openfisca_core/periods/config.py @@ -1,13 +1,8 @@ import re -from .date_unit import DateUnit +import pendulum -WEEKDAY = DateUnit.WEEKDAY -WEEK = DateUnit.WEEK -DAY = DateUnit.DAY -MONTH = DateUnit.MONTH -YEAR = DateUnit.YEAR -ETERNITY = DateUnit.ETERNITY +from . import types as t # Matches "2015", "2015-01", "2015-01-01" # Does not match "2015-13", "2015-12-32" @@ -15,8 +10,11 @@ r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$", ) -date_by_instant_cache: dict = {} -str_by_instant_cache: dict = {} +date_by_instant_cache: dict[t.Instant, pendulum.Date] = {} +str_by_instant_cache: dict[t.Instant, t.InstantStr] = {} year_or_month_or_day_re = re.compile( r"(18|19|20)\d{2}(-(0?[1-9]|1[0-2])(-([0-2]?\d|3[0-1]))?)?$", ) + + +__all__ = ["INSTANT_PATTERN", "date_by_instant_cache", "str_by_instant_cache"] diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index 61f7fbc66f..c66346c3c2 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -4,10 +4,12 @@ from strenum import StrEnum +from . import types as t + class DateUnitMeta(EnumMeta): @property - def isoformat(cls) -> tuple[DateUnit, ...]: + def isoformat(self) -> tuple[t.DateUnit, ...]: """Creates a :obj:`tuple` of ``key`` with isoformat items. Returns: @@ -27,7 +29,7 @@ def isoformat(cls) -> tuple[DateUnit, ...]: return DateUnit.DAY, DateUnit.MONTH, DateUnit.YEAR @property - def isocalendar(cls) -> tuple[DateUnit, ...]: + def isocalendar(self) -> tuple[t.DateUnit, ...]: """Creates a :obj:`tuple` of ``key`` with isocalendar items. Returns: @@ -64,7 +66,7 @@ class DateUnit(StrEnum, metaclass=DateUnitMeta): {: 'day'} >>> list(DateUnit) - [, , , ...] + [, , >> len(DateUnit) 6 @@ -90,13 +92,19 @@ class DateUnit(StrEnum, metaclass=DateUnitMeta): >>> DateUnit.DAY.value 'day' - .. versionadded:: 35.9.0 - """ + def __contains__(self, other: object) -> bool: + if isinstance(other, str): + return super().__contains__(other) + return NotImplemented + WEEKDAY = "weekday" WEEK = "week" DAY = "day" MONTH = "month" YEAR = "year" ETERNITY = "eternity" + + +__all__ = ["DateUnit"] diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index c1ccc4a3a2..de64e60fe2 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -1,26 +1,29 @@ -from typing import NoReturn, Optional +from __future__ import annotations + +from typing import NoReturn import datetime -import os +import functools import pendulum -from pendulum.parsing import ParserError -from . import _parsers, config +from . import config, types as t +from ._errors import InstantError, PeriodError +from ._parsers import parse_instant, parse_period from .date_unit import DateUnit from .instant_ import Instant from .period_ import Period -def instant(instant) -> Optional[Instant]: +@functools.singledispatch +def instant(value: object) -> t.Instant: """Build a new instant, aka a triple of integers (year, month, day). Args: - instant: An ``instant-like`` object. + value(object): An ``instant-like`` object. Returns: - None: When ``instant`` is None. - :obj:`.Instant`: Otherwise. + :obj:`.Instant`: A new instant. Raises: :exc:`ValueError`: When the arguments were invalid, like "2021-32-13". @@ -47,38 +50,55 @@ def instant(instant) -> Optional[Instant]: >>> instant("2021") Instant((2021, 1, 1)) + >>> instant([2021]) + Instant((2021, 1, 1)) + + >>> instant([2021, 9]) + Instant((2021, 9, 1)) + + >>> instant(None) + Traceback (most recent call last): + openfisca_core.periods._errors.InstantError: 'None' is not a valid i... + """ - if instant is None: - return None - if isinstance(instant, Instant): - return instant - if isinstance(instant, str): - if not config.INSTANT_PATTERN.match(instant): - msg = f"'{instant}' is not a valid instant. Instants are described using the 'YYYY-MM-DD' format, for instance '2015-06-15'." - raise ValueError( - msg, - ) - instant = Instant(int(fragment) for fragment in instant.split("-", 2)[:3]) - elif isinstance(instant, datetime.date): - instant = Instant((instant.year, instant.month, instant.day)) - elif isinstance(instant, int): - instant = (instant,) - elif isinstance(instant, list): - assert 1 <= len(instant) <= 3 - instant = tuple(instant) - elif isinstance(instant, Period): - instant = instant.start - else: - assert isinstance(instant, tuple), instant - assert 1 <= len(instant) <= 3 - if len(instant) == 1: - return Instant((instant[0], 1, 1)) - if len(instant) == 2: - return Instant((instant[0], instant[1], 1)) - return Instant(instant) - - -def instant_date(instant: Optional[Instant]) -> Optional[datetime.date]: + + if isinstance(value, t.SeqInt): + return Instant((list(value) + [1] * 3)[:3]) + + raise InstantError(str(value)) + + +@instant.register +def _(value: None) -> NoReturn: + raise InstantError(str(value)) + + +@instant.register +def _(value: int) -> t.Instant: + return Instant((value, 1, 1)) + + +@instant.register +def _(value: Period) -> t.Instant: + return value.start + + +@instant.register +def _(value: t.Instant) -> t.Instant: + return value + + +@instant.register +def _(value: datetime.date) -> t.Instant: + return Instant((value.year, value.month, value.day)) + + +@instant.register +def _(value: str) -> t.Instant: + return parse_instant(value) + + +def instant_date(instant: None | t.Instant) -> None | datetime.date: """Returns the date representation of an :class:`.Instant`. Args: @@ -104,7 +124,8 @@ def instant_date(instant: Optional[Instant]) -> Optional[datetime.date]: return instant_date -def period(value) -> Period: +@functools.singledispatch +def period(value: object) -> t.Period: """Build a new period, aka a triple (unit, start_instant, size). Args: @@ -124,7 +145,7 @@ def period(value) -> Period: Period((, Instant((2021, 1, 1)), 1)) >>> period(DateUnit.ETERNITY) - Period((, Instant((1, 1, 1)), inf)) + Period((, Instant((-1, -1, -1)), -1)) >>> period(2021) Period((, Instant((2021, 1, 1)), 1)) @@ -147,124 +168,86 @@ def period(value) -> Period: >>> period("day:2014-02-02:3") Period((, Instant((2014, 2, 2)), 3)) - """ - if isinstance(value, Period): - return value - # We return a "day-period", for example - # ``, 1))>``. - if isinstance(value, Instant): - return Period((DateUnit.DAY, value, 1)) - - # For example ``datetime.date(2021, 9, 16)``. - if isinstance(value, datetime.date): - return Period((DateUnit.DAY, instant(value), 1)) + one, two, three = 1, 2, 3 # We return an "eternity-period", for example - # ``, inf))>``. + # ``, -1))>``. if str(value).lower() == DateUnit.ETERNITY: - return Period( - ( - DateUnit.ETERNITY, - instant(datetime.date.min), - float("inf"), - ), - ) + return Period.eternity() - # For example ``2021`` gives - # ``, 1))>``. - if isinstance(value, int): - return Period((DateUnit.YEAR, instant(value), 1)) + # We try to parse from an ISO format/calendar period. + if isinstance(value, t.InstantStr): + return parse_period(value) - # Up to this point, if ``value`` is not a :obj:`str`, we desist. - if not isinstance(value, str): - _raise_error(value) + # A complex period has a ':' in its string. + if isinstance(value, t.PeriodStr): + components = value.split(":") - # There can't be empty strings. - if not value: - _raise_error(value) + # The left-most component must be a valid unit + unit = components[0] - # Try to parse from an ISO format/calendar period. - try: - period = _parsers._parse_period(value) + if unit not in list(DateUnit) or unit == DateUnit.ETERNITY: + raise PeriodError(str(value)) - except (AttributeError, ParserError, ValueError): - _raise_error(value) + # Cast ``unit`` to DateUnit. + unit = DateUnit(unit) - if period is not None: - return period + # The middle component must be a valid iso period + period = parse_period(components[1]) - # A complex period has a ':' in its string. - if ":" not in value: - _raise_error(value) + # Periods like year:2015-03 have a size of 1 + if len(components) == two: + size = one - components = value.split(":") + # if provided, make sure the size is an integer + elif len(components) == three: + try: + size = int(components[2]) - # left-most component must be a valid unit - unit = components[0] + except ValueError as error: + raise PeriodError(str(value)) from error - if unit not in list(DateUnit) or unit == DateUnit.ETERNITY: - _raise_error(value) + # If there are more than 2 ":" in the string, the period is invalid + else: + raise PeriodError(str(value)) - # Cast ``unit`` to DateUnit. - unit = DateUnit(unit) + # Reject ambiguous periods such as month:2014 + if unit_weight(period.unit) > unit_weight(unit): + raise PeriodError(str(value)) - # middle component must be a valid iso period - try: - base_period = _parsers._parse_period(components[1]) + return Period((unit, period.start, size)) - except (AttributeError, ParserError, ValueError): - _raise_error(value) + raise PeriodError(str(value)) - if not base_period: - _raise_error(value) - # period like year:2015-03 have a size of 1 - if len(components) == 2: - size = 1 +@period.register +def _(value: None) -> NoReturn: + raise PeriodError(str(value)) - # if provided, make sure the size is an integer - elif len(components) == 3: - try: - size = int(components[2]) - except ValueError: - _raise_error(value) +@period.register +def _(value: int) -> t.Period: + return Period((DateUnit.YEAR, instant(value), 1)) - # if there is more than 2 ":" in the string, the period is invalid - else: - _raise_error(value) - # reject ambiguous periods such as month:2014 - if unit_weight(base_period.unit) > unit_weight(unit): - _raise_error(value) +@period.register +def _(value: t.Period) -> t.Period: + return value - return Period((unit, base_period.start, size)) +@period.register +def _(value: t.Instant) -> t.Period: + return Period((DateUnit.DAY, value, 1)) -def _raise_error(value: str) -> NoReturn: - """Raise an error. - Examples: - >>> _raise_error("Oi mate!") - Traceback (most recent call last): - ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ... - Learn more about legal period formats in OpenFisca: - .", - ], - ) - raise ValueError(message) +@period.register +def _(value: datetime.date) -> t.Period: + return Period((DateUnit.DAY, instant(value), 1)) -def key_period_size(period: Period) -> str: +def key_period_size(period: t.Period) -> str: """Define a key in order to sort periods by length. It uses two aspects: first, ``unit``, then, ``size``. @@ -287,12 +270,11 @@ def key_period_size(period: Period) -> str: '300_3' """ - unit, start, size = period - return f"{unit_weight(unit)}_{size}" + return f"{unit_weight(period.unit)}_{period.size}" -def unit_weights() -> dict[str, int]: +def unit_weights() -> dict[t.DateUnit, int]: """Assign weights to date units. Examples: @@ -310,7 +292,7 @@ def unit_weights() -> dict[str, int]: } -def unit_weight(unit: str) -> int: +def unit_weight(unit: t.DateUnit) -> int: """Retrieves a specific date unit weight. Examples: @@ -319,3 +301,13 @@ def unit_weight(unit: str) -> int: """ return unit_weights()[unit] + + +__all__ = [ + "instant", + "instant_date", + "key_period_size", + "period", + "unit_weight", + "unit_weights", +] diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 5042209492..f71dbb3222 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import pendulum -from . import config +from . import config, types as t from .date_unit import DateUnit -class Instant(tuple): +class Instant(tuple[int, int, int]): """An instant in time (year, month, day). An :class:`.Instant` represents the most atomic and indivisible @@ -13,10 +15,6 @@ class Instant(tuple): Current implementation considers this unit to be a day, so :obj:`instants <.Instant>` can be thought of as "day dates". - Args: - (tuple(tuple(int, int, int))): - The ``year``, ``month``, and ``day``, accordingly. - Examples: >>> instant = Instant((2021, 9, 13)) @@ -78,35 +76,57 @@ class Instant(tuple): """ + __slots__ = () + def __repr__(self) -> str: return f"{self.__class__.__name__}({super().__repr__()})" - def __str__(self) -> str: + def __str__(self) -> t.InstantStr: instant_str = config.str_by_instant_cache.get(self) if instant_str is None: - config.str_by_instant_cache[self] = instant_str = self.date.isoformat() + instant_str = t.InstantStr(self.date.isoformat()) + config.str_by_instant_cache[self] = instant_str return instant_str + def __lt__(self, other: object) -> bool: + if isinstance(other, Instant): + return super().__lt__(other) + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, Instant): + return super().__le__(other) + return NotImplemented + @property - def date(self): + def date(self) -> pendulum.Date: instant_date = config.date_by_instant_cache.get(self) if instant_date is None: - config.date_by_instant_cache[self] = instant_date = pendulum.date(*self) + instant_date = pendulum.date(*self) + config.date_by_instant_cache[self] = instant_date return instant_date @property - def day(self): + def day(self) -> int: return self[2] @property - def month(self): + def month(self) -> int: return self[1] - def offset(self, offset, unit): + @property + def year(self) -> int: + return self[0] + + @property + def is_eternal(self) -> bool: + return self == self.eternity() + + def offset(self, offset: str | int, unit: t.DateUnit) -> t.Instant | None: """Increments/decrements the given instant with offset units. Args: @@ -135,7 +155,7 @@ def offset(self, offset, unit): Instant((2019, 12, 29)) """ - year, month, day = self + year, month, _ = self assert unit in ( DateUnit.isoformat + DateUnit.isocalendar @@ -195,6 +215,10 @@ def offset(self, offset, unit): return self.__class__((date.year, date.month, date.day)) return None - @property - def year(self): - return self[0] + @classmethod + def eternity(cls) -> t.Instant: + """Return an eternity instant.""" + return cls((-1, -1, -1)) + + +__all__ = ["Instant"] diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 0dcf960bbf..00e833d861 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -1,23 +1,18 @@ from __future__ import annotations -import typing +from collections.abc import Sequence import calendar import datetime import pendulum -from . import helpers +from . import helpers, types as t from .date_unit import DateUnit from .instant_ import Instant -if typing.TYPE_CHECKING: - from collections.abc import Sequence - from pendulum.datetime import Date - - -class Period(tuple): +class Period(tuple[t.DateUnit, t.Instant, int]): """Toolbox to handle date intervals. A :class:`.Period` is a triple (``unit``, ``start``, ``size``). @@ -122,14 +117,16 @@ class Period(tuple): """ + __slots__ = () + def __repr__(self) -> str: return f"{self.__class__.__name__}({super().__repr__()})" - def __str__(self) -> str: + def __str__(self) -> t.PeriodStr: unit, start_instant, size = self if unit == DateUnit.ETERNITY: - return unit.upper() + return t.PeriodStr(unit.upper()) # ISO format date units. f_year, month, day = start_instant @@ -141,56 +138,56 @@ def __str__(self) -> str: if unit == DateUnit.MONTH and size == 12 or unit == DateUnit.YEAR and size == 1: if month == 1: # civil year starting from january - return str(f_year) + return t.PeriodStr(str(f_year)) # rolling year - return f"{DateUnit.YEAR}:{f_year}-{month:02d}" + return t.PeriodStr(f"{DateUnit.YEAR}:{f_year}-{month:02d}") # simple month if unit == DateUnit.MONTH and size == 1: - return f"{f_year}-{month:02d}" + return t.PeriodStr(f"{f_year}-{month:02d}") # several civil years if unit == DateUnit.YEAR and month == 1: - return f"{unit}:{f_year}:{size}" + return t.PeriodStr(f"{unit}:{f_year}:{size}") if unit == DateUnit.DAY: if size == 1: - return f"{f_year}-{month:02d}-{day:02d}" - return f"{unit}:{f_year}-{month:02d}-{day:02d}:{size}" + return t.PeriodStr(f"{f_year}-{month:02d}-{day:02d}") + return t.PeriodStr(f"{unit}:{f_year}-{month:02d}-{day:02d}:{size}") # 1 week if unit == DateUnit.WEEK and size == 1: if week < 10: - return f"{c_year}-W0{week}" + return t.PeriodStr(f"{c_year}-W0{week}") - return f"{c_year}-W{week}" + return t.PeriodStr(f"{c_year}-W{week}") # several weeks if unit == DateUnit.WEEK and size > 1: if week < 10: - return f"{unit}:{c_year}-W0{week}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W0{week}:{size}") - return f"{unit}:{c_year}-W{week}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W{week}:{size}") # 1 weekday if unit == DateUnit.WEEKDAY and size == 1: if week < 10: - return f"{c_year}-W0{week}-{weekday}" + return t.PeriodStr(f"{c_year}-W0{week}-{weekday}") - return f"{c_year}-W{week}-{weekday}" + return t.PeriodStr(f"{c_year}-W{week}-{weekday}") # several weekdays if unit == DateUnit.WEEKDAY and size > 1: if week < 10: - return f"{unit}:{c_year}-W0{week}-{weekday}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W0{week}-{weekday}:{size}") - return f"{unit}:{c_year}-W{week}-{weekday}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W{week}-{weekday}:{size}") # complex period - return f"{unit}:{f_year}-{month:02d}:{size}" + return t.PeriodStr(f"{unit}:{f_year}-{month:02d}:{size}") @property - def unit(self) -> str: + def unit(self) -> t.DateUnit: """The ``unit`` of the ``Period``. Example: @@ -203,7 +200,7 @@ def unit(self) -> str: return self[0] @property - def start(self) -> Instant: + def start(self) -> t.Instant: """The ``Instant`` at which the ``Period`` starts. Example: @@ -229,7 +226,7 @@ def size(self) -> int: return self[2] @property - def date(self) -> Date: + def date(self) -> pendulum.Date: """The date representation of the ``Period`` start date. Examples: @@ -317,7 +314,12 @@ def size_in_days(self) -> int: """ if self.unit in (DateUnit.YEAR, DateUnit.MONTH): - last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + last = self.start.offset(self.size, self.unit) + if last is None: + raise NotImplementedError + last_day = last.offset(-1, DateUnit.DAY) + if last_day is None: + raise NotImplementedError return (last_day.date - self.start.date).days + 1 if self.unit == DateUnit.WEEK: @@ -330,7 +332,7 @@ def size_in_days(self) -> int: raise ValueError(msg) @property - def size_in_weeks(self): + def size_in_weeks(self) -> int: """The ``size`` of the ``Period`` in weeks. Examples: @@ -348,14 +350,14 @@ def size_in_weeks(self): if self.unit == DateUnit.YEAR: start = self.start.date cease = start.add(years=self.size) - delta = pendulum.period(start, cease) - return delta.as_interval().weeks + delta = start.diff(cease) + return delta.in_weeks() if self.unit == DateUnit.MONTH: start = self.start.date cease = start.add(months=self.size) - delta = pendulum.period(start, cease) - return delta.as_interval().weeks + delta = start.diff(cease) + return delta.in_weeks() if self.unit == DateUnit.WEEK: return self.size @@ -364,7 +366,7 @@ def size_in_weeks(self): raise ValueError(msg) @property - def size_in_weekdays(self): + def size_in_weekdays(self) -> int: """The ``size`` of the ``Period`` in weekdays. Examples: @@ -382,8 +384,13 @@ def size_in_weekdays(self): if self.unit == DateUnit.YEAR: return self.size_in_weeks * 7 - if self.unit in DateUnit.MONTH: - last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + if DateUnit.MONTH in self.unit: + last = self.start.offset(self.size, self.unit) + if last is None: + raise NotImplementedError + last_day = last.offset(-1, DateUnit.DAY) + if last_day is None: + raise NotImplementedError return (last_day.date - self.start.date).days + 1 if self.unit == DateUnit.WEEK: @@ -396,11 +403,13 @@ def size_in_weekdays(self): raise ValueError(msg) @property - def days(self): + def days(self) -> int: """Same as ``size_in_days``.""" return (self.stop.date - self.start.date).days + 1 - def intersection(self, start, stop): + def intersection( + self, start: t.Instant | None, stop: t.Instant | None + ) -> t.Period | None: if start is None and stop is None: return self period_start = self[1] @@ -453,17 +462,17 @@ def intersection(self, start, stop): ), ) - def get_subperiods(self, unit: DateUnit) -> Sequence[Period]: + def get_subperiods(self, unit: t.DateUnit) -> Sequence[t.Period]: """Return the list of periods of unit ``unit`` contained in self. Examples: >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)) >>> period.get_subperiods(DateUnit.MONTH) - [Period((, Instant((2021, 1, 1)), 1)),...2021, 12, 1)), 1))] + [Period((, Instant((2021, 1, 1)), 1)),...] >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 2)) >>> period.get_subperiods(DateUnit.YEAR) - [Period((, Instant((2021, 1, 1)), 1)),...((2022, 1, 1)), 1))] + [Period((, Instant((2021, 1, 1)), 1)), P...] """ if helpers.unit_weight(self.unit) < helpers.unit_weight(unit): @@ -499,26 +508,34 @@ def get_subperiods(self, unit: DateUnit) -> Sequence[Period]: msg = f"Cannot subdivide {self.unit} into {unit}" raise ValueError(msg) - def offset(self, offset, unit=None): + def offset(self, offset: str | int, unit: t.DateUnit | None = None) -> t.Period: """Increment (or decrement) the given period with offset units. Examples: >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1) Period((, Instant((2021, 1, 2)), 365)) - >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.DAY) + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset( + ... 1, DateUnit.DAY + ... ) Period((, Instant((2021, 1, 2)), 365)) - >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.MONTH) + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset( + ... 1, DateUnit.MONTH + ... ) Period((, Instant((2021, 2, 1)), 365)) - >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.YEAR) + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset( + ... 1, DateUnit.YEAR + ... ) Period((, Instant((2022, 1, 1)), 365)) >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1) Period((, Instant((2021, 2, 1)), 12)) - >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1, DateUnit.DAY) + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset( + ... 1, DateUnit.DAY + ... ) Period((, Instant((2021, 1, 2)), 12)) >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset( @@ -526,19 +543,27 @@ def offset(self, offset, unit=None): ... ) Period((, Instant((2021, 2, 1)), 12)) - >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1, DateUnit.YEAR) + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset( + ... 1, DateUnit.YEAR + ... ) Period((, Instant((2022, 1, 1)), 12)) >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1) Period((, Instant((2022, 1, 1)), 1)) - >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.DAY) + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset( + ... 1, DateUnit.DAY + ... ) Period((, Instant((2021, 1, 2)), 1)) - >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.MONTH) + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset( + ... 1, DateUnit.MONTH + ... ) Period((, Instant((2021, 2, 1)), 1)) - >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.YEAR) + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset( + ... 1, DateUnit.YEAR + ... ) Period((, Instant((2022, 1, 1)), 1)) >>> Period((DateUnit.DAY, Instant((2011, 2, 28)), 1)).offset(1) @@ -722,15 +747,23 @@ def offset(self, offset, unit=None): Period((, Instant((2014, 12, 31)), 1)) """ + + start: None | t.Instant = self[1].offset( + offset, self[0] if unit is None else unit + ) + + if start is None: + raise NotImplementedError + return self.__class__( ( self[0], - self[1].offset(offset, self[0] if unit is None else unit), + start, self[2], ), ) - def contains(self, other: Period) -> bool: + def contains(self, other: t.Period) -> bool: """Returns ``True`` if the period contains ``other``. For instance, ``period(2015)`` contains ``period(2015-01)``. @@ -739,7 +772,7 @@ def contains(self, other: Period) -> bool: return self.start <= other.start and self.stop >= other.stop @property - def stop(self) -> Instant: + def stop(self) -> t.Instant: """Return the last day of the period as an Instant instance. Examples: @@ -772,10 +805,9 @@ def stop(self) -> Instant: """ unit, start_instant, size = self - year, month, day = start_instant if unit == DateUnit.ETERNITY: - return Instant((float("inf"), float("inf"), float("inf"))) + return Instant.eternity() if unit == DateUnit.YEAR: date = start_instant.date.add(years=size, days=-1) @@ -795,70 +827,92 @@ def stop(self) -> Instant: raise ValueError + @property + def is_eternal(self) -> bool: + return self == self.eternity() + # Reference periods @property - def last_week(self) -> Period: + def last_week(self) -> t.Period: return self.first_week.offset(-1) @property - def last_fortnight(self) -> Period: - start: Instant = self.first_week.start + def last_fortnight(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 1)).offset(-2) @property - def last_2_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_2_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 2)).offset(-2) @property - def last_26_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_26_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 26)).offset(-26) @property - def last_52_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_52_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 52)).offset(-52) @property - def last_month(self) -> Period: + def last_month(self) -> t.Period: return self.first_month.offset(-1) @property - def last_3_months(self) -> Period: - start: Instant = self.first_month.start + def last_3_months(self) -> t.Period: + start: t.Instant = self.first_month.start return self.__class__((DateUnit.MONTH, start, 3)).offset(-3) @property - def last_year(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def last_year(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)).offset(-1) @property - def n_2(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def n_2(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)).offset(-2) @property - def this_year(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def this_year(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)) @property - def first_month(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.MONTH) + def first_month(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.MONTH) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.MONTH, start, 1)) @property - def first_day(self) -> Period: + def first_day(self) -> t.Period: return self.__class__((DateUnit.DAY, self.start, 1)) @property - def first_week(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.WEEK) + def first_week(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.WEEK) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.WEEK, start, 1)) @property - def first_weekday(self) -> Period: + def first_weekday(self) -> t.Period: return self.__class__((DateUnit.WEEKDAY, self.start, 1)) + + @classmethod + def eternity(cls) -> t.Period: + """Return an eternity period.""" + return cls((DateUnit.ETERNITY, Instant.eternity(), -1)) + + +__all__ = ["Period"] diff --git a/openfisca_core/periods/py.typed b/openfisca_core/periods/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/helpers/test_helpers.py b/openfisca_core/periods/tests/helpers/test_helpers.py index 3cbf078a2e..175ea8c873 100644 --- a/openfisca_core/periods/tests/helpers/test_helpers.py +++ b/openfisca_core/periods/tests/helpers/test_helpers.py @@ -47,9 +47,19 @@ def test_instant_date_with_an_invalid_argument(arg, error) -> None: (Period((DateUnit.MONTH, Instant((1, 1, 1)), 12)), "200_12"), (Period((DateUnit.YEAR, Instant((1, 1, 1)), 2)), "300_2"), (Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 1)), "400_1"), - ((DateUnit.DAY, None, 1), "100_1"), - ((DateUnit.MONTH, None, -1000), "200_-1000"), ], ) def test_key_period_size(arg, expected) -> None: assert periods.key_period_size(arg) == expected + + +@pytest.mark.parametrize( + ("arg", "error"), + [ + ((DateUnit.DAY, None, 1), AttributeError), + ((DateUnit.MONTH, None, -1000), AttributeError), + ], +) +def test_key_period_size_when_an_invalid_argument(arg, error): + with pytest.raises(error): + periods.key_period_size(arg) diff --git a/openfisca_core/periods/tests/helpers/test_instant.py b/openfisca_core/periods/tests/helpers/test_instant.py index 73f37ece6f..fb4472814b 100644 --- a/openfisca_core/periods/tests/helpers/test_instant.py +++ b/openfisca_core/periods/tests/helpers/test_instant.py @@ -3,13 +3,12 @@ import pytest from openfisca_core import periods -from openfisca_core.periods import DateUnit, Instant, Period +from openfisca_core.periods import DateUnit, Instant, InstantError, Period @pytest.mark.parametrize( ("arg", "expected"), [ - (None, None), (datetime.date(1, 1, 1), Instant((1, 1, 1))), (Instant((1, 1, 1)), Instant((1, 1, 1))), (Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), Instant((1, 1, 1))), @@ -21,23 +20,9 @@ ("1000", Instant((1000, 1, 1))), ("1000-01", Instant((1000, 1, 1))), ("1000-01-01", Instant((1000, 1, 1))), - ((None,), Instant((None, 1, 1))), - ((None, None), Instant((None, None, 1))), - ((None, None, None), Instant((None, None, None))), - ((datetime.date(1, 1, 1),), Instant((datetime.date(1, 1, 1), 1, 1))), - ((Instant((1, 1, 1)),), Instant((Instant((1, 1, 1)), 1, 1))), - ( - (Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), - Instant((Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), 1, 1)), - ), ((-1,), Instant((-1, 1, 1))), ((-1, -1), Instant((-1, -1, 1))), ((-1, -1, -1), Instant((-1, -1, -1))), - (("-1",), Instant(("-1", 1, 1))), - (("-1", "-1"), Instant(("-1", "-1", 1))), - (("-1", "-1", "-1"), Instant(("-1", "-1", "-1"))), - (("1-1",), Instant(("1-1", 1, 1))), - (("1-1-1",), Instant(("1-1-1", 1, 1))), ], ) def test_instant(arg, expected) -> None: @@ -47,6 +32,7 @@ def test_instant(arg, expected) -> None: @pytest.mark.parametrize( ("arg", "error"), [ + (None, InstantError), (DateUnit.YEAR, ValueError), (DateUnit.ETERNITY, ValueError), ("1000-0", ValueError), @@ -65,10 +51,21 @@ def test_instant(arg, expected) -> None: ("year:1000-01-01:3", ValueError), ("1000-01-01:a", ValueError), ("1000-01-01:1", ValueError), - ((), AssertionError), - ({}, AssertionError), - ("", ValueError), - ((None, None, None, None), AssertionError), + ((), InstantError), + ({}, InstantError), + ("", InstantError), + ((None,), InstantError), + ((None, None), InstantError), + ((None, None, None), InstantError), + ((None, None, None, None), InstantError), + (("-1",), InstantError), + (("-1", "-1"), InstantError), + (("-1", "-1", "-1"), InstantError), + (("1-1",), InstantError), + (("1-1-1",), InstantError), + ((datetime.date(1, 1, 1),), InstantError), + ((Instant((1, 1, 1)),), InstantError), + ((Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), InstantError), ], ) def test_instant_with_an_invalid_argument(arg, error) -> None: diff --git a/openfisca_core/periods/tests/helpers/test_period.py b/openfisca_core/periods/tests/helpers/test_period.py index c31e54c2ca..d2d5c6679a 100644 --- a/openfisca_core/periods/tests/helpers/test_period.py +++ b/openfisca_core/periods/tests/helpers/test_period.py @@ -3,17 +3,17 @@ import pytest from openfisca_core import periods -from openfisca_core.periods import DateUnit, Instant, Period +from openfisca_core.periods import DateUnit, Instant, Period, PeriodError @pytest.mark.parametrize( ("arg", "expected"), [ - ("eternity", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))), - ("ETERNITY", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))), + ("eternity", Period((DateUnit.ETERNITY, Instant((-1, -1, -1)), -1))), + ("ETERNITY", Period((DateUnit.ETERNITY, Instant((-1, -1, -1)), -1))), ( DateUnit.ETERNITY, - Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf"))), + Period((DateUnit.ETERNITY, Instant((-1, -1, -1)), -1)), ), (datetime.date(1, 1, 1), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))), (Instant((1, 1, 1)), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))), @@ -73,60 +73,60 @@ def test_period(arg, expected) -> None: @pytest.mark.parametrize( ("arg", "error"), [ - (None, ValueError), - (DateUnit.YEAR, ValueError), - ("1", ValueError), - ("999", ValueError), - ("1000-0", ValueError), - ("1000-13", ValueError), - ("1000-W0", ValueError), - ("1000-W54", ValueError), - ("1000-0-0", ValueError), - ("1000-1-0", ValueError), - ("1000-2-31", ValueError), - ("1000-W0-0", ValueError), - ("1000-W1-0", ValueError), - ("1000-W1-8", ValueError), - ("a", ValueError), - ("year", ValueError), - ("1:1000", ValueError), - ("a:1000", ValueError), - ("month:1000", ValueError), - ("week:1000", ValueError), - ("day:1000-01", ValueError), - ("weekday:1000-W1", ValueError), - ("1000:a", ValueError), - ("1000:1", ValueError), - ("1000-01:1", ValueError), - ("1000-01-01:1", ValueError), - ("1000-W1:1", ValueError), - ("1000-W1-1:1", ValueError), - ("month:1000:1", ValueError), - ("week:1000:1", ValueError), - ("day:1000:1", ValueError), - ("day:1000-01:1", ValueError), - ("weekday:1000:1", ValueError), - ("weekday:1000-W1:1", ValueError), - ((), ValueError), - ({}, ValueError), - ("", ValueError), - ((None,), ValueError), - ((None, None), ValueError), - ((None, None, None), ValueError), - ((None, None, None, None), ValueError), - ((Instant((1, 1, 1)),), ValueError), - ((Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), ValueError), - ((1,), ValueError), - ((1, 1), ValueError), - ((1, 1, 1), ValueError), - ((-1,), ValueError), - ((-1, -1), ValueError), - ((-1, -1, -1), ValueError), - (("-1",), ValueError), - (("-1", "-1"), ValueError), - (("-1", "-1", "-1"), ValueError), - (("1-1",), ValueError), - (("1-1-1",), ValueError), + (None, PeriodError), + (DateUnit.YEAR, PeriodError), + ("1", PeriodError), + ("999", PeriodError), + ("1000-0", PeriodError), + ("1000-13", PeriodError), + ("1000-W0", PeriodError), + ("1000-W54", PeriodError), + ("1000-0-0", PeriodError), + ("1000-1-0", PeriodError), + ("1000-2-31", PeriodError), + ("1000-W0-0", PeriodError), + ("1000-W1-0", PeriodError), + ("1000-W1-8", PeriodError), + ("a", PeriodError), + ("year", PeriodError), + ("1:1000", PeriodError), + ("a:1000", PeriodError), + ("month:1000", PeriodError), + ("week:1000", PeriodError), + ("day:1000-01", PeriodError), + ("weekday:1000-W1", PeriodError), + ("1000:a", PeriodError), + ("1000:1", PeriodError), + ("1000-01:1", PeriodError), + ("1000-01-01:1", PeriodError), + ("1000-W1:1", PeriodError), + ("1000-W1-1:1", PeriodError), + ("month:1000:1", PeriodError), + ("week:1000:1", PeriodError), + ("day:1000:1", PeriodError), + ("day:1000-01:1", PeriodError), + ("weekday:1000:1", PeriodError), + ("weekday:1000-W1:1", PeriodError), + ((), PeriodError), + ({}, PeriodError), + ("", PeriodError), + ((None,), PeriodError), + ((None, None), PeriodError), + ((None, None, None), PeriodError), + ((None, None, None, None), PeriodError), + ((Instant((1, 1, 1)),), PeriodError), + ((Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), PeriodError), + ((1,), PeriodError), + ((1, 1), PeriodError), + ((1, 1, 1), PeriodError), + ((-1,), PeriodError), + ((-1, -1), PeriodError), + ((-1, -1, -1), PeriodError), + (("-1",), PeriodError), + (("-1", "-1"), PeriodError), + (("-1", "-1", "-1"), PeriodError), + (("1-1",), PeriodError), + (("1-1-1",), PeriodError), ], ) def test_period_with_an_invalid_argument(arg, error) -> None: diff --git a/openfisca_core/periods/tests/test__parsers.py b/openfisca_core/periods/tests/test__parsers.py deleted file mode 100644 index 67a2891a32..0000000000 --- a/openfisca_core/periods/tests/test__parsers.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest -from pendulum.parsing import ParserError - -from openfisca_core.periods import DateUnit, Instant, Period, _parsers - - -@pytest.mark.parametrize( - ("arg", "expected"), - [ - ("1001", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))), - ("1001-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))), - ("1001-12", Period((DateUnit.MONTH, Instant((1001, 12, 1)), 1))), - ("1001-01-01", Period((DateUnit.DAY, Instant((1001, 1, 1)), 1))), - ("1001-W01", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))), - ("1001-W52", Period((DateUnit.WEEK, Instant((1001, 12, 21)), 1))), - ("1001-W01-1", Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 1))), - ], -) -def test__parse_period(arg, expected) -> None: - assert _parsers._parse_period(arg) == expected - - -@pytest.mark.parametrize( - ("arg", "error"), - [ - (None, AttributeError), - ({}, AttributeError), - ((), AttributeError), - ([], AttributeError), - (1, AttributeError), - ("", AttributeError), - ("à", ParserError), - ("1", ValueError), - ("-1", ValueError), - ("999", ParserError), - ("1000-0", ParserError), - ("1000-1", ParserError), - ("1000-1-1", ParserError), - ("1000-00", ParserError), - ("1000-13", ParserError), - ("1000-01-00", ParserError), - ("1000-01-99", ParserError), - ("1000-W0", ParserError), - ("1000-W1", ParserError), - ("1000-W99", ParserError), - ("1000-W1-0", ParserError), - ("1000-W1-1", ParserError), - ("1000-W1-99", ParserError), - ("1000-W01-0", ParserError), - ("1000-W01-00", ParserError), - ], -) -def test__parse_period_with_invalid_argument(arg, error) -> None: - with pytest.raises(error): - _parsers._parse_period(arg) - - -@pytest.mark.parametrize( - ("arg", "expected"), - [ - ("2022", DateUnit.YEAR), - ("2022-01", DateUnit.MONTH), - ("2022-01-01", DateUnit.DAY), - ("2022-W01", DateUnit.WEEK), - ("2022-W01-01", DateUnit.WEEKDAY), - ], -) -def test__parse_unit(arg, expected) -> None: - assert _parsers._parse_unit(arg) == expected diff --git a/openfisca_core/periods/tests/test_parsers.py b/openfisca_core/periods/tests/test_parsers.py new file mode 100644 index 0000000000..c9131414b2 --- /dev/null +++ b/openfisca_core/periods/tests/test_parsers.py @@ -0,0 +1,129 @@ +import pytest + +from openfisca_core.periods import ( + DateUnit, + Instant, + InstantError, + ParserError, + Period, + PeriodError, + _parsers, +) + + +@pytest.mark.parametrize( + ("arg", "expected"), + [ + ("1001", Instant((1001, 1, 1))), + ("1001-01", Instant((1001, 1, 1))), + ("1001-12", Instant((1001, 12, 1))), + ("1001-01-01", Instant((1001, 1, 1))), + ("2028-02-29", Instant((2028, 2, 29))), + ("1001-W01", Instant((1000, 12, 29))), + ("1001-W52", Instant((1001, 12, 21))), + ("1001-W01-1", Instant((1000, 12, 29))), + ], +) +def test_parse_instant(arg, expected) -> None: + assert _parsers.parse_instant(arg) == expected + + +@pytest.mark.parametrize( + ("arg", "error"), + [ + (None, InstantError), + ({}, InstantError), + ((), InstantError), + ([], InstantError), + (1, InstantError), + ("", InstantError), + ("à", InstantError), + ("1", InstantError), + ("-1", InstantError), + ("999", InstantError), + ("1000-0", InstantError), + ("1000-1", ParserError), + ("1000-1-1", InstantError), + ("1000-00", InstantError), + ("1000-13", InstantError), + ("1000-01-00", InstantError), + ("1000-01-99", InstantError), + ("2029-02-29", ParserError), + ("1000-W0", InstantError), + ("1000-W1", InstantError), + ("1000-W99", InstantError), + ("1000-W1-0", InstantError), + ("1000-W1-1", InstantError), + ("1000-W1-99", InstantError), + ("1000-W01-0", InstantError), + ("1000-W01-00", InstantError), + ], +) +def test_parse_instant_with_invalid_argument(arg, error) -> None: + with pytest.raises(error): + _parsers.parse_instant(arg) + + +@pytest.mark.parametrize( + ("arg", "expected"), + [ + ("1001", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))), + ("1001-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))), + ("1001-12", Period((DateUnit.MONTH, Instant((1001, 12, 1)), 1))), + ("1001-01-01", Period((DateUnit.DAY, Instant((1001, 1, 1)), 1))), + ("1001-W01", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))), + ("1001-W52", Period((DateUnit.WEEK, Instant((1001, 12, 21)), 1))), + ("1001-W01-1", Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 1))), + ], +) +def test_parse_period(arg, expected) -> None: + assert _parsers.parse_period(arg) == expected + + +@pytest.mark.parametrize( + ("arg", "error"), + [ + (None, PeriodError), + ({}, PeriodError), + ((), PeriodError), + ([], PeriodError), + (1, PeriodError), + ("", PeriodError), + ("à", PeriodError), + ("1", PeriodError), + ("-1", PeriodError), + ("999", PeriodError), + ("1000-0", PeriodError), + ("1000-1", ParserError), + ("1000-1-1", PeriodError), + ("1000-00", PeriodError), + ("1000-13", PeriodError), + ("1000-01-00", PeriodError), + ("1000-01-99", PeriodError), + ("1000-W0", PeriodError), + ("1000-W1", PeriodError), + ("1000-W99", PeriodError), + ("1000-W1-0", PeriodError), + ("1000-W1-1", PeriodError), + ("1000-W1-99", PeriodError), + ("1000-W01-0", PeriodError), + ("1000-W01-00", PeriodError), + ], +) +def test_parse_period_with_invalid_argument(arg, error) -> None: + with pytest.raises(error): + _parsers.parse_period(arg) + + +@pytest.mark.parametrize( + ("arg", "expected"), + [ + ("2022", DateUnit.YEAR), + ("2022-01", DateUnit.MONTH), + ("2022-01-01", DateUnit.DAY), + ("2022-W01", DateUnit.WEEK), + ("2022-W01-1", DateUnit.WEEKDAY), + ], +) +def test_parse_unit(arg, expected) -> None: + assert _parsers.parse_unit(arg) == expected diff --git a/openfisca_core/periods/types.py b/openfisca_core/periods/types.py new file mode 100644 index 0000000000..092509c621 --- /dev/null +++ b/openfisca_core/periods/types.py @@ -0,0 +1,183 @@ +# TODO(): Properly resolve metaclass types. +# https://github.com/python/mypy/issues/14033 + +from collections.abc import Sequence + +from openfisca_core.types import DateUnit, Instant, Period + +import re + +#: Matches "2015", "2015-01", "2015-01-01" but not "2015-13", "2015-12-32". +iso_format = re.compile(r"^\d{4}(-(?:0[1-9]|1[0-2])(-(?:0[1-9]|[12]\d|3[01]))?)?$") + +#: Matches "2015", "2015-W01", "2015-W53-1" but not "2015-W54", "2015-W10-8". +iso_calendar = re.compile(r"^\d{4}(-W(0[1-9]|[1-4][0-9]|5[0-3]))?(-[1-7])?$") + + +class _SeqIntMeta(type): + def __instancecheck__(self, arg: object) -> bool: + return ( + bool(arg) + and isinstance(arg, Sequence) + and all(isinstance(item, int) for item in arg) + ) + + +class SeqInt(list[int], metaclass=_SeqIntMeta): # type: ignore[misc] + """A sequence of integers. + + Examples: + >>> isinstance([1, 2, 3], SeqInt) + True + + >>> isinstance((1, 2, 3), SeqInt) + True + + >>> isinstance({1, 2, 3}, SeqInt) + False + + >>> isinstance([1, 2, "3"], SeqInt) + False + + >>> isinstance(1, SeqInt) + False + + >>> isinstance([], SeqInt) + False + + """ + + +class _InstantStrMeta(type): + def __instancecheck__(self, arg: object) -> bool: + return isinstance(arg, (ISOFormatStr, ISOCalendarStr)) + + +class InstantStr(str, metaclass=_InstantStrMeta): # type: ignore[misc] + """A string representing an instant in string format. + + Examples: + >>> isinstance("2015", InstantStr) + True + + >>> isinstance("2015-01", InstantStr) + True + + >>> isinstance("2015-W01", InstantStr) + True + + >>> isinstance("2015-W01-12", InstantStr) + False + + >>> isinstance("week:2015-W01:3", InstantStr) + False + + """ + + __slots__ = () + + +class _ISOFormatStrMeta(type): + def __instancecheck__(self, arg: object) -> bool: + return isinstance(arg, str) and bool(iso_format.match(arg)) + + +class ISOFormatStr(str, metaclass=_ISOFormatStrMeta): # type: ignore[misc] + """A string representing an instant in ISO format. + + Examples: + >>> isinstance("2015", ISOFormatStr) + True + + >>> isinstance("2015-01", ISOFormatStr) + True + + >>> isinstance("2015-01-01", ISOFormatStr) + True + + >>> isinstance("2015-13", ISOFormatStr) + False + + >>> isinstance("2015-W01", ISOFormatStr) + False + + """ + + __slots__ = () + + +class _ISOCalendarStrMeta(type): + def __instancecheck__(self, arg: object) -> bool: + return isinstance(arg, str) and bool(iso_calendar.match(arg)) + + +class ISOCalendarStr(str, metaclass=_ISOCalendarStrMeta): # type: ignore[misc] + """A string representing an instant in ISO calendar. + + Examples: + >>> isinstance("2015", ISOCalendarStr) + True + + >>> isinstance("2015-W01", ISOCalendarStr) + True + + >>> isinstance("2015-W11-7", ISOCalendarStr) + True + + >>> isinstance("2015-W010", ISOCalendarStr) + False + + >>> isinstance("2015-01", ISOCalendarStr) + False + + """ + + __slots__ = () + + +class _PeriodStrMeta(type): + def __instancecheck__(self, arg: object) -> bool: + return ( + isinstance(arg, str) + and ":" in arg + and isinstance(arg.split(":")[1], InstantStr) + ) + + +class PeriodStr(str, metaclass=_PeriodStrMeta): # type: ignore[misc] + """A string representing a period. + + Examples: + >>> isinstance("year", PeriodStr) + False + + >>> isinstance("2015", PeriodStr) + False + + >>> isinstance("year:2015", PeriodStr) + True + + >>> isinstance("month:2015-01", PeriodStr) + True + + >>> isinstance("weekday:2015-W01-1:365", PeriodStr) + True + + >>> isinstance("2015-W01:1", PeriodStr) + False + + """ + + __slots__ = () + + +__all__ = [ + "DateUnit", + "ISOCalendarStr", + "ISOFormatStr", + "Instant", + "InstantStr", + "Period", + "PeriodStr", + "SeqInt", +] diff --git a/openfisca_core/types.py b/openfisca_core/types.py index d1c13c1f10..711e6c512f 100644 --- a/openfisca_core/types.py +++ b/openfisca_core/types.py @@ -6,16 +6,17 @@ from typing_extensions import Protocol, TypeAlias import numpy +import pendulum _N_co = TypeVar("_N_co", bound=numpy.generic, covariant=True) #: Type representing an numpy array. Array: TypeAlias = NDArray[_N_co] -L = TypeVar("L") +_L = TypeVar("_L") #: Type representing an array-like object. -ArrayLike: TypeAlias = Sequence[L] +ArrayLike: TypeAlias = Sequence[_L] #: Generic type vars. _T_co = TypeVar("_T_co", covariant=True) @@ -87,6 +88,12 @@ class ParameterNodeAtInstant(Protocol): ... # Periods +#: For example "2000-01". +InstantStr = NewType("InstantStr", str) + +#: For example "1:2000-01-01:day". +PeriodStr = NewType("PeriodStr", str) + class Container(Protocol[_T_co]): def __contains__(self, item: object, /) -> bool: ... @@ -96,15 +103,34 @@ class Indexable(Protocol[_T_co]): def __getitem__(self, index: int, /) -> _T_co: ... -class DateUnit(Container[str], Protocol): ... +class DateUnit(Container[str], Protocol): + def upper(self, /) -> str: ... -class Instant(Indexable[int], Iterable[int], Sized, Protocol): ... +class Instant(Indexable[int], Iterable[int], Sized, Protocol): + @property + def year(self, /) -> int: ... + @property + def month(self, /) -> int: ... + @property + def day(self, /) -> int: ... + @property + def date(self, /) -> pendulum.Date: ... + def __lt__(self, other: object, /) -> bool: ... + def __le__(self, other: object, /) -> bool: ... + def offset(self, offset: str | int, unit: DateUnit, /) -> None | Instant: ... class Period(Indexable[Union[DateUnit, Instant, int]], Protocol): @property def unit(self, /) -> DateUnit: ... + @property + def start(self, /) -> Instant: ... + @property + def size(self, /) -> int: ... + @property + def stop(self, /) -> Instant: ... + def offset(self, offset: str | int, unit: None | DateUnit = None, /) -> Period: ... # Populations diff --git a/openfisca_core/variables/variable.py b/openfisca_core/variables/variable.py index e1aa501080..926e4c59c1 100644 --- a/openfisca_core/variables/variable.py +++ b/openfisca_core/variables/variable.py @@ -2,8 +2,6 @@ from typing import NoReturn -from openfisca_core.types import Formula, Instant - import datetime import re import textwrap @@ -11,7 +9,7 @@ import numpy import sortedcontainers -from openfisca_core import commons, periods +from openfisca_core import commons, periods, types as t from openfisca_core.entities import Entity, GroupEntity from openfisca_core.indexed_enums import Enum, EnumArray from openfisca_core.periods import DateUnit, Period @@ -385,8 +383,8 @@ def get_introspection_data(cls): def get_formula( self, - period: Instant | Period | str | int = None, - ) -> Formula | None: + period: None | t.Instant | t.Period | str | int = None, + ) -> None | t.Formula: """Returns the formula to compute the variable at the given period. If no period is given and the variable has several formulas, the method @@ -399,7 +397,7 @@ def get_formula( Formula used to compute the variable. """ - instant: Instant | None + instant: None | t.Instant if not self.formulas: return None diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 99b5cba7b7..946d07aa4b 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -20,7 +20,6 @@ check-style: $(shell git ls-files "*.py" "*.pyi") lint-doc: \ lint-doc-commons \ lint-doc-entities \ - lint-doc-types \ ; ## Run linters to check for syntax and style errors in the doc. @@ -42,6 +41,7 @@ check-types: @mypy \ openfisca_core/commons \ openfisca_core/entities \ + openfisca_core/periods \ openfisca_core/types.py @$(call print_pass,$@:) diff --git a/setup.py b/setup.py index 9b62476a44..f8fa39b884 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ "dpath >=2.1.4, <3.0", "numexpr >=2.8.4, <3.0", "numpy >=1.24.2, <1.25", - "pendulum >=2.1.2, <3.0.0", + "pendulum >=3.0.0, <4.0.0", "psutil >=5.9.4, <6.0", "pytest >=8.3.3, <9.0", "sortedcontainers >=2.4.0, <3.0", @@ -70,7 +70,7 @@ setup( name="OpenFisca-Core", - version="41.5.7", + version="42.0.0", author="OpenFisca Team", author_email="contact@openfisca.org", classifiers=[