From a7af2169aba53ba4d409744ee7a18adf7b89a102 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 16:31:16 +0200 Subject: [PATCH 01/18] [SPLIT] --- Makefile | 2 + openfisca_core/periods/__init__.py | 54 +- openfisca_core/periods/config.py | 34 +- openfisca_core/periods/date_unit.py | 271 ++++++++ openfisca_core/periods/helpers.py | 592 +++++++++++++----- openfisca_core/periods/instant_.py | 495 ++++++++------- openfisca_core/periods/period_.py | 71 ++- openfisca_core/periods/tests/__init__.py | 0 openfisca_core/periods/tests/test_helpers.py | 90 +++ openfisca_core/periods/tests/test_instant.py | 126 ++++ openfisca_core/types/__init__.py | 4 +- openfisca_core/types/protocols/__init__.py | 1 + .../types/protocols/supports_period.py | 15 + setup.cfg | 7 +- setup.py | 1 + 15 files changed, 1301 insertions(+), 462 deletions(-) create mode 100644 openfisca_core/periods/date_unit.py create mode 100644 openfisca_core/periods/tests/__init__.py create mode 100644 openfisca_core/periods/tests/test_helpers.py create mode 100644 openfisca_core/periods/tests/test_instant.py create mode 100644 openfisca_core/types/protocols/supports_period.py diff --git a/Makefile b/Makefile index 1a555ad9d1..cdf405c27a 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ check-style: \ check-style-doc-commons \ check-style-doc-entities \ check-style-doc-indexed_enums \ + check-style-doc-periods \ check-style-doc-types @$(call pass,$@:) @@ -72,6 +73,7 @@ check-types: \ check-types-strict-commons \ check-types-strict-entities \ check-types-strict-indexed_enums \ + check-types-strict-periods \ check-types-strict-types @$(call pass,$@:) diff --git a/openfisca_core/periods/__init__.py b/openfisca_core/periods/__init__.py index 4cd9db648c..8934ebd001 100644 --- a/openfisca_core/periods/__init__.py +++ b/openfisca_core/periods/__init__.py @@ -21,26 +21,50 @@ # # See: https://www.python.org/dev/peps/pep-0008/#imports -from .config import ( # noqa: F401 - DAY, - MONTH, - YEAR, - ETERNITY, - INSTANT_PATTERN, - date_by_instant_cache, - str_by_instant_cache, - year_or_month_or_day_re, - ) +from typing import Any, Dict + +from .config import INSTANT_PATTERN, YEAR_OR_MONTH_OR_DAY_RE, DATE, LAST # noqa: F401 +from .instant_ import Instant # noqa: F401 +from .period_ import Period # noqa: F401 +from .date_unit import DateUnit # noqa: F401 from .helpers import ( # noqa: F401 N_, instant, instant_date, - period, key_period_size, - unit_weights, - unit_weight, + period, ) -from .instant_ import Instant # noqa: F401 -from .period_ import Period # noqa: F401 +# For backwards compatibility + +from .helpers import unit_weight, unit_weights # noqa: F401 + +for item in DateUnit: + globals()[item.name.upper()] = item.value + +str_by_instant_cache: Dict[Any, Any] = {} +"""Cache to store :obj:`str` reprentations of :obj:`.Instant`. + +.. deprecated:: 35.9.0 + This cache has been deprecated and will be removed in the future. The + functionality is now provided by :func:`functools.lru_cache`. + +""" + +date_by_instant_cache: Dict[Any, Any] = {} +"""Cache to store :obj:`datetime.date` reprentations of :obj:`.Instant`. + +.. deprecated:: 35.9.0 + This cache has been deprecated and will be removed in the future. The + functionality is now provided by :func:`functools.lru_cache`. + +""" + +year_or_month_or_day_re = YEAR_OR_MONTH_OR_DAY_RE +"""??? + +.. deprecated:: 35.9.0 + ??? has been deprecated and it will be removed in 36.0.0. + +""" diff --git a/openfisca_core/periods/config.py b/openfisca_core/periods/config.py index 6e0c698098..9d79e30d92 100644 --- a/openfisca_core/periods/config.py +++ b/openfisca_core/periods/config.py @@ -1,15 +1,27 @@ +import calendar +import datetime +import functools import re -import typing +from typing import Pattern -DAY = 'day' -MONTH = 'month' -YEAR = 'year' -ETERNITY = 'eternity' +INSTANT_PATTERN: Pattern = re.compile(r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$") +"""Pattern to validate a valid :obj:`.Instant`. -# Matches "2015", "2015-01", "2015-01-01" -# Does not match "2015-13", "2015-12-32" -INSTANT_PATTERN = re.compile(r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$") +Matches: "2015", "2015-01", "2015-01-01"… +Does not match: "2015-13", "2015-12-32"… -date_by_instant_cache: typing.Dict = {} -str_by_instant_cache: typing.Dict = {} -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]))?)?$') +""" + +YEAR_OR_MONTH_OR_DAY_RE: Pattern = re.compile(r"(18|19|20)\d{2}(-(0?[1-9]|1[0-2])(-([0-2]?\d|3[0-1]))?)?$") +"""??? + +.. deprecated:: 35.9.0 + ??? has been deprecated and it will be removed in 36.0.0. + +""" + +DATE = functools.lru_cache(maxsize = None)(datetime.date) +"""A memoized date constructor.""" + +LAST = functools.lru_cache(maxsize = None)(calendar.monthrange) +"""A memoized date range constructor, useful for last-of month offsets.""" diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py new file mode 100644 index 0000000000..d655464083 --- /dev/null +++ b/openfisca_core/periods/date_unit.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import enum +from typing import Any, Tuple, TypeVar + +from openfisca_core.indexed_enums import Enum + +T = TypeVar("T", bound = "DateUnit") + + +class DateUnitMeta(enum.EnumMeta): + + def __contains__(self, item: Any) -> bool: + if isinstance(item, str): + return super().__contains__(self[item.upper()]) + + return super().__contains__(item) + + def __getitem__(self, key: object) -> T: + if not isinstance(key, (int, slice, str, DateUnit)): + return NotImplemented + + if isinstance(key, (int, slice)): + return self[self.__dict__["_member_names_"][key]] + + if isinstance(key, str): + return super().__getitem__(key.upper()) + + return super().__getitem__(key.value.upper()) + + @property + def ethereal(self) -> Tuple[DateUnit, ...]: + """Creates a :obj:`tuple` of ``key`` with ethereal items. + + Returns: + tuple(str): A :obj:`tuple` containing the ``keys``. + + Examples: + >>> DateUnit.ethereal + (, , ) + + >>> DateUnit.DAY in DateUnit.ethereal + True + + >>> "DAY" in DateUnit.ethereal + True + + >>> "day" in DateUnit.ethereal + True + + >>> "eternity" in DateUnit.ethereal + False + + """ + + return DateUnit.DAY, DateUnit.MONTH, DateUnit.YEAR + + @property + def eternal(self) -> Tuple[DateUnit, ...]: + """Creates a :obj:`tuple` of ``key`` with eternal items. + + Returns: + tuple(str): A :obj:`tuple` containing the ``keys``. + + Examples: + >>> DateUnit.eternal + (,) + + >>> DateUnit.ETERNITY in DateUnit.eternal + True + + >>> "ETERNITY" in DateUnit.eternal + True + + >>> "eternity" in DateUnit.eternal + True + + >>> "day" in DateUnit.eternal + False + + """ + + return (DateUnit.ETERNITY,) + + +class DateUnit(Enum, metaclass = DateUnitMeta): + """The date units of a rule system. + + Attributes: + index (:obj:`int`): The ``index`` of each item. + name (:obj:`str`): The ``name`` of each item. + value (tuple(str, int)): The ``value`` of each item. + + Examples: + >>> repr(DateUnit) + "" + + >>> repr(DateUnit.DAY) + '' + + >>> str(DateUnit.DAY) + 'DateUnit.DAY' + + >>> dict([(DateUnit.DAY, DateUnit.DAY.value)]) + {: 'day'} + + >>> tuple(DateUnit) + (, , >> len(DateUnit) + 6 + + >>> DateUnit["DAY"] + + + >>> DateUnit["day"] + + + >>> DateUnit[2] + + + >>> DateUnit[-4] + + + >>> DateUnit[DateUnit.DAY] + + + >>> DateUnit("day") + + + >>> DateUnit.DAY in DateUnit + True + + >>> "DAY" in DateUnit + True + + >>> "day" in DateUnit + True + + >>> DateUnit.DAY == DateUnit.DAY + True + + >>> "DAY" == DateUnit.DAY + True + + >>> "day" == DateUnit.DAY + True + + >>> DateUnit.DAY < DateUnit.DAY + False + + >>> DateUnit.DAY > DateUnit.DAY + False + + >>> DateUnit.DAY <= DateUnit.DAY + True + + >>> DateUnit.DAY >= DateUnit.DAY + True + + >>> "DAY" < DateUnit.DAY + False + + >>> "DAY" > DateUnit.DAY + False + + >>> "DAY" <= DateUnit.DAY + True + + >>> "DAY" >= DateUnit.DAY + True + + >>> "day" < DateUnit.DAY + False + + >>> "day" > DateUnit.DAY + False + + >>> "day" <= DateUnit.DAY + True + + >>> "day" >= DateUnit.DAY + True + + >>> DateUnit.DAY.index + 2 + + >>> DateUnit.DAY.name + 'DAY' + + >>> DateUnit.DAY.value + 'day' + + .. versionadded:: 35.9.0 + + """ + + # Attributes + + index: int + name: str + value: str + + # Members + + WEEK_DAY = "week_day" + WEEK = "week" + DAY = "day" + MONTH = "month" + YEAR = "year" + ETERNITY = "eternity" + + __hash__ = object.__hash__ + + def __eq__(self, other): + if isinstance(other, str): + return self.value == other.lower() + + return NotImplemented + + def __lt__(self, other: Any) -> bool: + if isinstance(other, str): + return self.index < DateUnit[other.upper()].index + + return self.index < other + + def __le__(self, other: Any) -> bool: + if isinstance(other, str): + return self.index <= DateUnit[other.upper()].index + + return self.index <= other + + def __gt__(self, other: Any) -> bool: + if isinstance(other, str): + return self.index > DateUnit[other.upper()].index + + return self.index > other + + def __ge__(self, other: Any) -> bool: + if isinstance(other, str): + return self.index >= DateUnit[other.upper()].index + + return self.index >= other + + def upper(self) -> str: + """Uppercases the :class:`.Unit`. + + Returns: + :obj:`str`: The uppercased :class:`.Unit`. + + Examples: + >>> DateUnit.DAY.upper() + 'DAY' + + """ + + return self.value.upper() + + def lower(self) -> str: + """Lowecases the :class:`.Unit`. + + Returns: + :obj:`str`: The lowercased :class:`.Unit`. + + Examples: + >>> DateUnit.DAY.lower() + 'day' + + """ + + return self.value.lower() diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index 9ddf794d06..8d20ed8039 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -1,203 +1,459 @@ +from __future__ import annotations + import datetime -import os +import typing +from typing import Dict, List, Optional, Sequence, Union -from openfisca_core import periods -from openfisca_core.periods import config +from typing_extensions import Literal +from openfisca_core import commons, periods +from openfisca_core.periods import Instant, Period -def N_(message): - return message +from .date_unit import DateUnit + +DOC_URL = "https://openfisca.org/doc/coding-the-legislation" + +InstantLike = Union[ + Sequence[Union[int, str]], + datetime.date, + Instant, + Period, + int, + str, +] + +PeriodLike = Union[ + Literal["ETERNITY", "eternity"], + datetime.date, + Instant, + Period, + int, + str, +] -def instant(instant): +@typing.overload +def instant(instant: None) -> None: + ... + + +@typing.overload +def instant(instant: InstantLike) -> Instant: + ... + + +def instant(instant: Optional[InstantLike] = None) -> Optional[Instant]: """Return a new instant, aka a triple of integers (year, month, day). - >>> instant(2014) - Instant((2014, 1, 1)) - >>> instant('2014') - Instant((2014, 1, 1)) - >>> instant('2014-02') - Instant((2014, 2, 1)) - >>> instant('2014-3-2') - Instant((2014, 3, 2)) - >>> instant(instant('2014-3-2')) - Instant((2014, 3, 2)) - >>> instant(period('month', '2014-3-2')) - Instant((2014, 3, 2)) - - >>> instant(None) + Args: + instant: An ``instant-like`` object. + + Returns: + None: When ``instant`` is None. + :obj:`.Instant`: Otherwise. + + Raises: + :exc:`ValueError`: When the arguments were invalid, like "2021-32-13". + + Examples: + + >>> instant() + + >>> instant((2021,)) + + + >>> instant((2021, 9)) + + + >>> instant(datetime.date(2021, 9, 16)) + + + >>> instant(Instant((2021, 9, 16))) + + + >>> instant(Period((DateUnit.YEAR.value, Instant((2021, 9, 16)), 1))) + + + >>> instant(2021) + + + >>> instant("2021") + + + >>> instant("day:2021-9-16:3") + + + """ + if instant is None: return None - if isinstance(instant, periods.Instant): + + if isinstance(instant, Instant): return instant - if isinstance(instant, str): - if not config.INSTANT_PATTERN.match(instant): - raise ValueError("'{}' is not a valid instant. Instants are described using the 'YYYY-MM-DD' format, for instance '2015-06-15'.".format(instant)) - instant = periods.Instant( - int(fragment) - for fragment in instant.split('-', 2)[:3] + + #: See: :attr`.Period.start`. + if isinstance(instant, Period): + return instant.start + + #: For example ``2021`` gives ````. + if isinstance(instant, int): + return Instant((instant, 1, 1)) + + #: For example ``datetime.date(2021, 9, 16)``. + if isinstance(instant, datetime.date): + return Instant((instant.year, instant.month, instant.day)) + + try: + #: For example if ``instant`` is ``["2014"]``, we will: + #: + #: 1. Try to cast each element to an :obj:`int`. + # + #: 2. Add a date unit recursively (``month``, then ``day``). + #: + if isinstance(instant, (list, tuple)) and len(instant) < 3: + return periods.instant([*[int(unit) for unit in instant], 1]) + + #: For example if ``instant`` is ``["2014", 9, 12, 32]``, we will: + #: + #: 1. Select the first three elements of the collection. + # + #: 2. Try to cast those three elements to an :obj:`int`. + #: + if isinstance(instant, (list, tuple)): + return Instant(tuple(int(unit) for unit in instant[0:3])) + + #: Up to this point, if ``instant`` is not a :obj:`str`, we desist. + if not isinstance(instant, str): + raise ValueError + + #: We look for ``fragments``, for example ``day:2014:3``: + #: + #: - If there are, we split and call :func:`.instant` recursively. + #: + #: - If there are not, we continue. + #: + #: See :meth:`.Period.get_subperiods` and :attr:`.Period.size`. + #: + if instant.find(":") != -1: + return periods.instant(instant.split(":")[1]) + + #: We assume we're dealing with a date in the ISO format, so: + #: + #: - If we can't decompose ``instant``, we call :func:`.instant` + #: recursively, for example given ``"2014"``` we will call + #: ``periods.instant(["2014"])``. + #: + #: - Otherwise, we split ``instant`` and then call :func:`.instant` + #: recursively, for example given ``"2014-9"`` we will call + #: ``periods.instant(["2014", "9"])``. + #: + if instant.find("-") == -1: + return periods.instant([instant]) + + return periods.instant(instant.split("-")) + + except ValueError: + raise ValueError( + f"'{instant}' is not a valid instant. Instants are described " + "using the 'YYYY-MM-DD' format, for example: '2015-06-15'. " + "Learn more about legal period formats in " + f"OpenFisca: <{DOC_URL}/35_periods.html#periods-in-simulations>." ) - elif isinstance(instant, datetime.date): - instant = periods.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, periods.Period): - instant = instant.start - else: - assert isinstance(instant, tuple), instant - assert 1 <= len(instant) <= 3 - if len(instant) == 1: - return periods.Instant((instant[0], 1, 1)) - if len(instant) == 2: - return periods.Instant((instant[0], instant[1], 1)) - return periods.Instant(instant) - - -def instant_date(instant): + + +@typing.overload +def instant_date(instant: None) -> None: + ... + + +@typing.overload +def instant_date(instant: Instant) -> datetime.date: + ... + + +@commons.deprecated(since = "35.9.0", expires = "the future") +def instant_date(instant: Optional[Instant] = None) -> Optional[datetime.date]: + """Returns the date representation of an :class:`.Instant`. + + Args: + instant (:obj:`.Instant`, optional): + + Returns: + None: When ``instant`` is None. + :obj:`datetime.date`: Otherwise. + + Examples: + >>> instant_date() + + >>> instant_date(Instant((2021, 1, 1))) + datetime.date(2021, 1, 1) + + .. deprecated:: 35.9.0 + :func:`.instant_date` has been deprecated and will be + removed in the future. The functionality is now provided by + :attr:`.Instant.date` (cache included). + + """ + if instant is None: return None - instant_date = config.date_by_instant_cache.get(instant) - if instant_date is None: - config.date_by_instant_cache[instant] = instant_date = datetime.date(*instant) - return instant_date + return instant.date + + +def period(value: PeriodLike) -> Period: + """Returns a new period, aka a triple (unit, start_instant, size). + + Args: + value: A ``period-like`` object. + + Returns: + :obj:`.Period`: A period. -def period(value): - """Return a new period, aka a triple (unit, start_instant, size). + Raises: + :exc:`ValueError`: When the arguments were invalid, like "2021-32-13". - >>> period('2014') - Period((YEAR, Instant((2014, 1, 1)), 1)) - >>> period('year:2014') - Period((YEAR, Instant((2014, 1, 1)), 1)) - >>> period('2014-2') - Period((MONTH, Instant((2014, 2, 1)), 1)) - >>> period('2014-02') - Period((MONTH, Instant((2014, 2, 1)), 1)) - >>> period('month:2014-2') - Period((MONTH, Instant((2014, 2, 1)), 1)) + >>> period(Period(("year", Instant((2021, 1, 1)), 1))) + , 1))> + + >>> period(Instant((2021, 1, 1))) + , 1))> + + >>> period("eternity") + , inf))> + + >>> period(datetime.date(2021, 9, 16)) + , 1))> + + >>> period(2021) + , 1))> + + >>> period("2014") + , 1))> + + >>> period("year:2014") + , 1))> + + >>> period("month:2014-2") + , 1))> + + >>> period("year:2014-2") + , 1))> + + >>> period("day:2014-2-2") + , 1))> + + >>> period("day:2014-2-2:3") + , 3))> - >>> period('year:2014-2') - Period((YEAR, Instant((2014, 2, 1)), 1)) """ - if isinstance(value, periods.Period): + + date: str + index: int + input_unit: str + instant: Instant + instant_unit: DateUnit + period_unit: DateUnit + rest: List[str] + size: int + + if isinstance(value, Period): return value - if isinstance(value, periods.Instant): - return periods.Period((config.DAY, value, 1)) - - def parse_simple_period(value): - """ - Parses simple periods respecting the ISO format, such as 2012 or 2015-03 - """ - try: - date = datetime.datetime.strptime(value, '%Y') - except ValueError: - try: - date = datetime.datetime.strptime(value, '%Y-%m') - except ValueError: - try: - date = datetime.datetime.strptime(value, '%Y-%m-%d') - except ValueError: - return None - else: - return periods.Period((config.DAY, periods.Instant((date.year, date.month, date.day)), 1)) - else: - return periods.Period((config.MONTH, periods.Instant((date.year, date.month, 1)), 1)) - else: - return periods.Period((config.YEAR, periods.Instant((date.year, date.month, 1)), 1)) - - def raise_error(value): - message = os.linesep.join([ - "Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '{}'.".format(value), - "Learn more about legal period formats in OpenFisca:", - "." - ]) - raise ValueError(message) - - if value == 'ETERNITY' or value == config.ETERNITY: - return periods.Period(('eternity', instant(datetime.date.min), float("inf"))) - - # check the type + #: We return a "day-period", for example + #: ``, 1))>``. + #: + if isinstance(value, Instant): + return Period((DateUnit.DAY.value, value, 1)) + + #: For example ``datetime.date(2021, 9, 16)``. + if isinstance(value, datetime.date): + instant = periods.instant(value) + return Period((DateUnit.DAY.value, instant, 1)) + + #: We return an "eternity-period", for example + #: ``, inf))>``. + #: + if value == DateUnit.ETERNITY: + instant = periods.instant(datetime.date.min) + return Period((DateUnit.ETERNITY.value, instant, float("inf"))) + + #: For example ``2021`` gives + #: ``, 1))>``. + #: if isinstance(value, int): - return periods.Period((config.YEAR, periods.Instant((value, 1, 1)), 1)) - if not isinstance(value, str): - raise_error(value) - - # try to parse as a simple period - period = parse_simple_period(value) - if period is not None: - return period - - # complex period must have a ':' in their strings - if ":" not in value: - raise_error(value) - - components = value.split(':') - - # left-most component must be a valid unit - unit = components[0] - if unit not in (config.DAY, config.MONTH, config.YEAR): - raise_error(value) - - # middle component must be a valid iso period - base_period = parse_simple_period(components[1]) - if not base_period: - raise_error(value) - - # period like year:2015-03 have a size of 1 - if len(components) == 2: - size = 1 - # if provided, make sure the size is an integer - elif len(components) == 3: - try: - size = int(components[2]) - except ValueError: - raise_error(value) - # if there is more than 2 ":" in the string, the period is invalid - else: - raise_error(value) - - # reject ambiguous period such as month:2014 - if unit_weight(base_period.unit) > unit_weight(unit): - raise_error(value) - - return periods.Period((unit, base_period.start, size)) - - -def key_period_size(period): + instant = periods.instant(value) + return Period((DateUnit.YEAR.value, instant, 1)) + + try: + #: Up to this point, if ``value`` is not a :obj:`str`, we desist. + if not isinstance(value, str): + raise ValueError + + #: We calculate the date unit index based on the indexes of + #: :class:`.DateUnit`. + #: + #: So for example if ``value`` is ``"2021-02"``, the result of ``len`` + #: will be ``2``, and we know we're looking to build a month-period. + #: + #: ``MONTH`` is the 4th member of :class:`.DateUnit`. Because it is + #: an :class:`.indexed_enums.Enum`, we know its index is then ``3``. + #: + #: Then ``5 - 2`` gives us the index of :obj:`.DateUnit.MONTH`, ``3``. + #: + index = DateUnit[-1].index - len(value.split("-")) # type: ignore + instant_unit = DateUnit[index] # type: ignore + + #: We look for ``fragments`` see :func:`.instant`. + #: + #: If there are no fragments, we will delegate the next steps to + #: :func:`.instant`. + #: + if value.find(":") == -1: + instant = periods.instant(value) + return Period((instant_unit.value, instant, 1)) + + #: For example ``month``, ``2014``, and ``1``. + input_unit, *rest = value.split(":") + period_unit = DateUnit[input_unit] + + #: Left-most component must be a valid unit: ``day``, ``month``, or + #: ``year``. + #: + if period_unit not in DateUnit.ethereal: + raise ValueError + + #: Reject ambiguous periods, such as ``month:2014``. + if instant_unit > period_unit: + raise ValueError + + #: Now that we have the ``unit``, we will create an ``instant``. + date, *rest = rest + instant = periods.instant(value) + + #: Periods like ``year:2015-03`` have, by default, a size of 1. + if not rest: + return Period((period_unit.value, instant, 1)) + + #: If provided, let's make sure the ``size`` is an integer. + #: We also ignore any extra element, so for example if the provided + #: ``value`` is ``"year:2021:3:asdf1234"`` we will ignore ``asdf1234``. + size = int(rest[0]) + + return Period((period_unit.value, instant, size)) + + except ValueError: + raise ValueError( + "Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); " + f"got: '{value}'. Learn more about legal period formats in " + f"OpenFisca: <{DOC_URL}/35_periods.html#periods-in-simulations>." + ) + + +def key_period_size(period: Period) -> str: + """Defines a key in order to sort periods by length. + + It uses two aspects: first, ``unit``, then, ``size``. + + Args: + period: An :mod:`.openfisca_core` :obj:`.Period`. + + Returns: + :obj:`str`: A string. + + Examples: + >>> instant = Instant((2021, 9, 14)) + + >>> period = Period((DateUnit.DAY, instant, 1)) + >>> key_period_size(period) + '2_1' + + >>> period = Period(("month", instant, 2)) + >>> key_period_size(period) + '3_2' + + >>> period = Period(("Year", instant, 3)) + >>> key_period_size(period) + '4_3' + + >>> period = Period(("ETERNITY", instant, 4)) + >>> key_period_size(period) + '5_4' + + .. versionchanged:: 35.9.0 + Hereafter uses :attr:`.Unit.weight`. + """ - Defines a key in order to sort periods by length. It uses two aspects : first unit then size - :param period: an OpenFisca period - :return: a string + unit: Union[DateUnit, str] + size: int + + unit, _, size = period + + if isinstance(unit, str): + unit = DateUnit[unit] + + return f"{unit.index}_{size}" - >>> key_period_size(period('2014')) - '2_1' - >>> key_period_size(period('2013')) - '2_1' - >>> key_period_size(period('2014-01')) - '1_1' + +@commons.deprecated(since = "35.9.0", expires = "the future") +def unit_weights() -> Dict[str, int]: + """Finds the weight of each date unit. + + Returns: + dict(str, int): A dictionary with the corresponding values. + + Examples: + >>> unit_weights() + {'week_day': 0, 'week': 1, 'day': 2, 'month': 3, 'year': 4, 'eterni...} + + .. deprecated:: 35.9.0 + :func:`.unit_weights` has been deprecated and will be + removed in the future. The functionality is now provided by + :func:`.Unit.weights`. """ - unit, start, size = period + return {enum.value: enum.index for enum in DateUnit} + - return '{}_{}'.format(unit_weight(unit), size) +@commons.deprecated(since = "35.9.0", expires = "the future") +def unit_weight(unit: DateUnit) -> Optional[int]: + """Finds the weight of a specific date unit. + Args: + unit: The unit to find the weight for. -def unit_weights(): - return { - config.DAY: 100, - config.MONTH: 200, - config.YEAR: 300, - config.ETERNITY: 400, - } + Returns: + int: The weight. + Examples: + >>> unit_weight(DateUnit.DAY) + 2 -def unit_weight(unit): - return unit_weights()[unit] + >>> unit_weight('DAY') + 2 + + >>> unit_weight('day') + 2 + + .. deprecated:: 35.9.0 + :func:`.unit_weight` has been deprecated and will be + removed in the future. The functionality is now provided by + :attr:`.Unit.weight`. + + """ + + if isinstance(unit, str): + unit = DateUnit[unit] + + return unit.index + + +@commons.deprecated(since = "35.9.0", expires = "the future") +def N_(message): + """???""" + + return message diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index c3da65f894..940861ce0e 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -1,249 +1,280 @@ -import calendar +from __future__ import annotations + +import dataclasses import datetime +import functools +from typing import Any, Iterator, Tuple, Union -from openfisca_core import periods -from openfisca_core.periods import config +from typing_extensions import Literal +from dateutil import relativedelta -class Instant(tuple): +from openfisca_core import commons, periods +from openfisca_core.types import SupportsPeriod - def __repr__(self): - """ - Transform instant to to its Python representation as a string. - - >>> repr(instant(2014)) - 'Instant((2014, 1, 1))' - >>> repr(instant('2014-2')) - 'Instant((2014, 2, 1))' - >>> repr(instant('2014-2-3')) - 'Instant((2014, 2, 3))' - """ - return '{}({})'.format(self.__class__.__name__, super(Instant, self).__repr__()) +from .date_unit import DateUnit - def __str__(self): - """ - Transform instant to a string. +DateLike = Tuple[int, ...] +DateKeys = Literal[DateUnit.YEAR, DateUnit.MONTH, DateUnit.DAY] +OffsetBy = Union[Literal["first-of", "last-of"], int] - >>> str(instant(2014)) - '2014-01-01' - >>> str(instant('2014-2')) - '2014-02-01' - >>> str(instant('2014-2-3')) - '2014-02-03' - """ - 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() - return instant_str +@dataclasses.dataclass(init = False, frozen = True) +class Instant: + """An instant in time (year, month, day). - @property - def date(self): - """ - Convert instant to a date. - - >>> instant(2014).date - datetime.date(2014, 1, 1) - >>> instant('2014-2').date - datetime.date(2014, 2, 1) - >>> instant('2014-2-3').date - datetime.date(2014, 2, 3) - """ - instant_date = config.date_by_instant_cache.get(self) - if instant_date is None: - config.date_by_instant_cache[self] = instant_date = datetime.date(*self) - return instant_date + An :class:`.Instant` represents the most atomic and indivisible unit time + of a legislations. - @property - def day(self): - """ - Extract day from instant. + Current implementation considers this unit to be a day, so + :obj:`instants <.Instant>` can be thought of as "day dates". + + Attributes: + year (:obj:`int`): + The year of the :obj:`.Instant`. + month (:obj:`int`): + The month of the :obj:`.Instant`. + day (:obj:`int`): + The day of the :obj:`.Instant.` + date (:obj:`datetime.date`:): + The converted :obj:`.Instant`. + canonical (tuple(int, int, int)): + The ``year``, ``month``, and ``day``, accordingly. + + Args: + units (tuple(int, int, int)): + The ``year``, ``month``, and ``day``, accordingly. + + Examples: + >>> instant = Instant((2021, 9, 13)) - >>> instant(2014).day - 1 - >>> instant('2014-2').day - 1 - >>> instant('2014-2-3').day + >>> repr(Instant) + "" + + >>> repr(instant) + '' + + >>> str(instant) + '2021-09-13' + + >>> dict([(instant, (2021, 9, 13))]) + {: (2021, 9, 13)} + + >>> tuple(instant) + (2021, 9, 13) + + >>> instant[0] + 2021 + + >>> instant[0] in instant + True + + >>> len(instant) 3 - """ - return self[2] - @property - def month(self): - """ - Extract month from instant. - - >>> instant(2014).month - 1 - >>> instant('2014-2').month - 2 - >>> instant('2014-2-3').month - 2 - """ - return self[1] + >>> instant == (2021, 9, 13) + True - def period(self, unit, size = 1): - """ - Create a new period starting at instant. - - >>> instant(2014).period('month') - Period(('month', Instant((2014, 1, 1)), 1)) - >>> instant('2014-2').period('year', 2) - Period(('year', Instant((2014, 2, 1)), 2)) - >>> instant('2014-2-3').period('day', size = 2) - Period(('day', Instant((2014, 2, 3)), 2)) - """ - assert unit in (config.DAY, config.MONTH, config.YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit)) - assert isinstance(size, int) and size >= 1, 'Invalid size: {} of type {}'.format(size, type(size)) - return periods.Period((unit, self, size)) + >>> instant != (2021, 9, 13) + False + + >>> instant > (2020, 9, 13) + True + + >>> instant < (2020, 9, 13) + False + + >>> instant >= (2020, 9, 13) + True + + >>> instant <= (2020, 9, 13) + False + + >>> instant.year + 2021 + + >>> instant.month + 9 + + >>> instant.day + 13 + + >>> instant.canonical + (2021, 9, 13) + + >>> instant.date + datetime.date(2021, 9, 13) + + >>> year, month, day = instant + + """ + + __slots__ = ["year", "month", "day", "date", "canonical"] + year: int + month: int + day: int + date: datetime.date + canonical: DateLike + + def __init__(self, units: DateLike) -> None: + year, month, day = units + object.__setattr__(self, "year", year) + object.__setattr__(self, "month", month) + object.__setattr__(self, "day", day) + object.__setattr__(self, "date", periods.DATE(year, month, day)) + object.__setattr__(self, "canonical", units) + + def __repr__(self) -> str: + return \ + f"<{self.__class__.__name__}" \ + f"({self.year}, {self.month}, {self.day})>" + + @functools.lru_cache(maxsize = None) + def __str__(self) -> str: + return self.date.isoformat() + + def __contains__(self, item: Any) -> bool: + return item in self.canonical + + def __getitem__(self, key: int) -> int: + return self.canonical[key] + + def __iter__(self) -> Iterator[int]: + return iter(self.canonical) + + def __len__(self) -> int: + return len(self.canonical) + + def __eq__(self, other: Any) -> bool: + return self.canonical == other + + def __ne__(self, other: Any) -> bool: + return self.canonical != other + + def __lt__(self, other: Any) -> bool: + return self.canonical < other + + def __le__(self, other: Any) -> bool: + return self.canonical <= other + + def __gt__(self, other: Any) -> bool: + return self.canonical > other + + def __ge__(self, other: Any) -> bool: + return self.canonical >= other + + @commons.deprecated(since = "35.9.0", expires = "the future") + def period(self, unit: DateUnit, size: int = 1) -> SupportsPeriod: + """Creates a new :obj:`.Period` starting at :obj:`.Instant`. + + Args: + unit: ``day`` or ``month`` or ``year``. + size: How many of ``unit``. + + Returns: + A new object :obj:`.Period`. + + Raises: + :exc:`AssertionError`: When ``unit`` is not a date unit. + :exc:`AssertionError`: When ``size`` is not an unsigned :obj:`int`. + + Examples: + >>> Instant((2021, 9, 13)).period(DateUnit.YEAR.value) + , 1))> + + >>> Instant((2021, 9, 13)).period("month", 2) + , 2))> + + .. deprecated:: 35.9.0 + :meth:`.period` has been deprecated and will be removed in the + future. The functionality is now provided by :func:`.period`. - def offset(self, offset, unit): - """ - Increment (or decrement) the given instant with offset units. - - >>> instant(2014).offset(1, 'day') - Instant((2014, 1, 2)) - >>> instant(2014).offset(1, 'month') - Instant((2014, 2, 1)) - >>> instant(2014).offset(1, 'year') - Instant((2015, 1, 1)) - - >>> instant('2014-1-31').offset(1, 'day') - Instant((2014, 2, 1)) - >>> instant('2014-1-31').offset(1, 'month') - Instant((2014, 2, 28)) - >>> instant('2014-1-31').offset(1, 'year') - Instant((2015, 1, 31)) - - >>> instant('2011-2-28').offset(1, 'day') - Instant((2011, 3, 1)) - >>> instant('2011-2-28').offset(1, 'month') - Instant((2011, 3, 28)) - >>> instant('2012-2-29').offset(1, 'year') - Instant((2013, 2, 28)) - - >>> instant(2014).offset(-1, 'day') - Instant((2013, 12, 31)) - >>> instant(2014).offset(-1, 'month') - Instant((2013, 12, 1)) - >>> instant(2014).offset(-1, 'year') - Instant((2013, 1, 1)) - - >>> instant('2011-3-1').offset(-1, 'day') - Instant((2011, 2, 28)) - >>> instant('2011-3-31').offset(-1, 'month') - Instant((2011, 2, 28)) - >>> instant('2012-2-29').offset(-1, 'year') - Instant((2011, 2, 28)) - - >>> instant('2014-1-30').offset(3, 'day') - Instant((2014, 2, 2)) - >>> instant('2014-10-2').offset(3, 'month') - Instant((2015, 1, 2)) - >>> instant('2014-1-1').offset(3, 'year') - Instant((2017, 1, 1)) - - >>> instant(2014).offset(-3, 'day') - Instant((2013, 12, 29)) - >>> instant(2014).offset(-3, 'month') - Instant((2013, 10, 1)) - >>> instant(2014).offset(-3, 'year') - Instant((2011, 1, 1)) - - >>> instant(2014).offset('first-of', 'month') - Instant((2014, 1, 1)) - >>> instant('2014-2').offset('first-of', 'month') - Instant((2014, 2, 1)) - >>> instant('2014-2-3').offset('first-of', 'month') - Instant((2014, 2, 1)) - - >>> instant(2014).offset('first-of', 'year') - Instant((2014, 1, 1)) - >>> instant('2014-2').offset('first-of', 'year') - Instant((2014, 1, 1)) - >>> instant('2014-2-3').offset('first-of', 'year') - Instant((2014, 1, 1)) - - >>> instant(2014).offset('last-of', 'month') - Instant((2014, 1, 31)) - >>> instant('2014-2').offset('last-of', 'month') - Instant((2014, 2, 28)) - >>> instant('2012-2-3').offset('last-of', 'month') - Instant((2012, 2, 29)) - - >>> instant(2014).offset('last-of', 'year') - Instant((2014, 12, 31)) - >>> instant('2014-2').offset('last-of', 'year') - Instant((2014, 12, 31)) - >>> instant('2014-2-3').offset('last-of', 'year') - Instant((2014, 12, 31)) - """ - year, month, day = self - assert unit in (config.DAY, config.MONTH, config.YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit)) - if offset == 'first-of': - if unit == config.MONTH: - day = 1 - elif unit == config.YEAR: - month = 1 - day = 1 - elif offset == 'last-of': - if unit == config.MONTH: - day = calendar.monthrange(year, month)[1] - elif unit == config.YEAR: - month = 12 - day = 31 - else: - assert isinstance(offset, int), 'Invalid offset: {} of type {}'.format(offset, type(offset)) - if unit == config.DAY: - day += offset - if offset < 0: - while day < 1: - month -= 1 - if month == 0: - year -= 1 - month = 12 - day += calendar.monthrange(year, month)[1] - elif offset > 0: - month_last_day = calendar.monthrange(year, month)[1] - while day > month_last_day: - month += 1 - if month == 13: - year += 1 - month = 1 - day -= month_last_day - month_last_day = calendar.monthrange(year, month)[1] - elif unit == config.MONTH: - month += offset - if offset < 0: - while month < 1: - year -= 1 - month += 12 - elif offset > 0: - while month > 12: - year += 1 - month -= 12 - month_last_day = calendar.monthrange(year, month)[1] - if day > month_last_day: - day = month_last_day - elif unit == config.YEAR: - year += offset - # Handle february month of leap year. - month_last_day = calendar.monthrange(year, month)[1] - if day > month_last_day: - day = month_last_day - - return self.__class__((year, month, day)) - - @property - def year(self): """ - Extract year from instant. - - >>> instant(2014).year - 2014 - >>> instant('2014-2').year - 2014 - >>> instant('2014-2-3').year - 2014 + + assert unit in DateUnit.ethereal, \ + f"Invalid unit: {unit} of type {type(unit)}. Expecting any of " \ + f"{', '.join(str(unit) for unit in DateUnit.ethereal)}." + + assert isinstance(size, int) and size >= 1, \ + f"Invalid size: {size} of type {type(size)}. Expecting any " \ + "int >= 1." + + if isinstance(unit, str): + unit = DateUnit[unit] + + return periods.period(f"{unit.value}:{str(self)}:{size}") + + @functools.lru_cache(maxsize = None) + def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: + """Increments/decrements the given instant with offset units. + + Args: + offset: How much of ``unit`` to offset. + unit: What to offset + + Returns: + :obj:`.Instant`: A new :obj:`.Instant` in time. + + Raises: + :exc:`AssertionError`: When ``unit`` is not a date unit. + :exc:`AssertionError`: When ``offset`` is not either ``first-of``, + ``last-of``, or any :obj:`int`. + + Examples: + >>> Instant((2020, 12, 31)).offset("first-of", DateUnit.MONTH.value) + + + >>> Instant((2020, 1, 1)).offset("last-of", "year") + + + >>> Instant((2020, 1, 1)).offset(1, DateUnit.YEAR.value) + + + >>> Instant((2020, 1, 1)).offset(-3, "day") + + """ - return self[0] + + #: Use current ``year`` fro the offset. + year = self.year + + #: Use current ``month`` fro the offset. + month = self.month + + #: Use current ``day`` fro the offset. + day = self.day + + assert unit in DateUnit.ethereal, \ + f"Invalid unit: {unit} of type {type(unit)}. Expecting any of " \ + f"{', '.join(str(unit) for unit in DateUnit.ethereal)}." + + if offset == "first-of" and unit == DateUnit.YEAR: + return self.__class__((year, 1, 1)) + + if offset == "first-of" and unit == DateUnit.MONTH: + return self.__class__((year, month, 1)) + + if offset == "last-of" and unit == DateUnit.YEAR: + return self.__class__((year, 12, 31)) + + if offset == "last-of" and unit == DateUnit.MONTH: + day = periods.LAST(year, month)[1] + return self.__class__((year, month, day)) + + assert isinstance(offset, int), \ + f"Invalid offset: {offset} of type {type(offset)}. Expecting " \ + "any int." + + if unit == DateUnit.YEAR: + date = self.date + relativedelta.relativedelta(years = offset) + return self.__class__((date.year, date.month, date.day)) + + if unit == DateUnit.MONTH: + date = self.date + relativedelta.relativedelta(months = offset) + return self.__class__((date.year, date.month, date.day)) + + if unit == DateUnit.DAY: + date = self.date + relativedelta.relativedelta(days = offset) + return self.__class__((date.year, date.month, date.day)) + + return self diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 808540f28a..25da8cf6c8 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -2,8 +2,9 @@ import calendar -from openfisca_core import periods -from openfisca_core.periods import config, helpers +from openfisca_core.periods import Instant + +from .date_unit import DateUnit class Period(tuple): @@ -14,20 +15,24 @@ class Period(tuple): (year, month, day) triple, and where size is an integer > 1. Since a period is a triple it can be used as a dictionary key. + + Examples: + >>> instant = Instant((2021, 9, 1)) + >>> period = Period((DateUnit.YEAR.value, instant, 3)) + + >>> repr(Period) + "" + + >>> repr(period) + ", 3))>" + + >>> str(period) + 'year:2021-09:3' + """ - def __repr__(self): - """ - Transform period to to its Python representation as a string. - - >>> repr(period('year', 2014)) - "Period(('year', Instant((2014, 1, 1)), 1))" - >>> repr(period('month', '2014-2')) - "Period(('month', Instant((2014, 2, 1)), 1))" - >>> repr(period('day', '2014-2-3')) - "Period(('day', Instant((2014, 2, 3)), 1))" - """ - return '{}({})'.format(self.__class__.__name__, super(Period, self).__repr__()) + def __repr__(self) -> str: + return f"<{self.__class__.__name__}({super().__repr__()})>" def __str__(self): """ @@ -57,26 +62,26 @@ def __str__(self): """ unit, start_instant, size = self - if unit == config.ETERNITY: + if unit == DateUnit.ETERNITY: return 'ETERNITY' year, month, day = start_instant # 1 year long period - if (unit == config.MONTH and size == 12 or unit == config.YEAR and size == 1): + if (unit == DateUnit.MONTH and size == 12 or unit == DateUnit.YEAR and size == 1): if month == 1: # civil year starting from january return str(year) else: # rolling year - return '{}:{}-{:02d}'.format(config.YEAR, year, month) + return '{}:{}-{:02d}'.format(DateUnit.YEAR.value, year, month) # simple month - if unit == config.MONTH and size == 1: + if unit == DateUnit.MONTH and size == 1: return '{}-{:02d}'.format(year, month) # several civil years - if unit == config.YEAR and month == 1: + if unit == DateUnit.YEAR and month == 1: return '{}:{}:{}'.format(unit, year, size) - if unit == config.DAY: + if unit == DateUnit.DAY: if size == 1: return '{}-{:02d}-{:02d}'.format(year, month, day) else: @@ -170,17 +175,17 @@ def get_subperiods(self, unit): >>> period('year:2014:2').get_subperiods(YEAR) >>> [period('2014'), period('2015')] """ - if helpers.unit_weight(self.unit) < helpers.unit_weight(unit): + if self.unit < unit: raise ValueError('Cannot subdivide {0} into {1}'.format(self.unit, unit)) - if unit == config.YEAR: - return [self.this_year.offset(i, config.YEAR) for i in range(self.size)] + if unit == DateUnit.YEAR: + return [self.this_year.offset(i, DateUnit.YEAR.value) for i in range(self.size)] - if unit == config.MONTH: - return [self.first_month.offset(i, config.MONTH) for i in range(self.size_in_months)] + if unit == DateUnit.MONTH: + return [self.first_month.offset(i, DateUnit.MONTH.value) for i in range(self.size_in_months)] - if unit == config.DAY: - return [self.first_day.offset(i, config.DAY) for i in range(self.size_in_days)] + if unit == DateUnit.DAY: + return [self.first_day.offset(i, DateUnit.DAY.value) for i in range(self.size_in_days)] def offset(self, offset, unit = None): """ @@ -345,9 +350,9 @@ def size_in_months(self): >>> period('year', '2012', 1).size_in_months 12 """ - if (self[0] == config.MONTH): + if (self[0] == DateUnit.MONTH): return self[2] - if(self[0] == config.YEAR): + if(self[0] == DateUnit.YEAR): return self[2] * 12 raise ValueError("Cannot calculate number of months in {0}".format(self[0])) @@ -363,10 +368,10 @@ def size_in_days(self): """ unit, instant, length = self - if unit == config.DAY: + if unit == DateUnit.DAY: return length - if unit in [config.MONTH, config.YEAR]: - last_day = self.start.offset(length, unit).offset(-1, config.DAY) + if unit in [DateUnit.MONTH, DateUnit.YEAR]: + last_day = self.start.offset(length, unit).offset(-1, DateUnit.DAY.value) return (last_day.date - self.start.date).days + 1 raise ValueError("Cannot calculate number of days in {0}".format(unit)) @@ -409,7 +414,7 @@ def stop(self) -> periods.Instant: """ unit, start_instant, size = self year, month, day = start_instant - if unit == config.ETERNITY: + if unit == DateUnit.ETERNITY: return periods.Instant((float("inf"), float("inf"), float("inf"))) if unit == 'day': if size > 1: diff --git a/openfisca_core/periods/tests/__init__.py b/openfisca_core/periods/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/test_helpers.py b/openfisca_core/periods/tests/test_helpers.py new file mode 100644 index 0000000000..c19f7b7e94 --- /dev/null +++ b/openfisca_core/periods/tests/test_helpers.py @@ -0,0 +1,90 @@ +import pytest + +from openfisca_core import periods +from openfisca_core.periods import Instant, Period +from openfisca_core.taxbenefitsystems import TaxBenefitSystem + + +@pytest.mark.parametrize("args", [ + TaxBenefitSystem, + [2021, "-12"], + "2021-31-12", + "2021-foo", + object, + ]) +def test_instant_with_invalid_arguments(args): + """Raises a ValueError when called with invalid arguments.""" + + with pytest.raises(ValueError, match = str(args)): + periods.instant(args) + + +@pytest.mark.parametrize("actual, expected", [ + (periods.instant((2021, 9, 16)), Instant((2021, 9, 16))), + (periods.instant(["2021", "9"]), Instant((2021, 9, 1))), + (periods.instant(["2021", "09", "16"]), Instant((2021, 9, 16))), + (periods.instant((2021, "9", "16")), Instant((2021, 9, 16))), + (periods.instant((2021, 9, 16, 42)), Instant((2021, 9, 16))), + (periods.instant("2021-09"), Instant((2021, 9, 1))), + (periods.instant("2021-9-16"), Instant((2021, 9, 16))), + (periods.instant("year:2021"), Instant((2021, 1, 1))), + (periods.instant("year:2021:1"), Instant((2021, 1, 1))), + (periods.instant("month:2021-9:2"), Instant((2021, 9, 1))), + ]) +def test_instant(actual, expected): + """It works :).""" + + assert actual == expected + + +def test_instant_date_deprecation(): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + periods.instant_date() + + +@pytest.mark.parametrize("args", [ + TaxBenefitSystem, + [2021, "-12"], + "2021-31-12", + "2021-foo", + "day:2014", + object, + ]) +def test_period_with_invalid_arguments(args): + """Raises a ValueError when called with invalid arguments.""" + + with pytest.raises(ValueError, match = str(args)): + periods.period(args) + + +@pytest.mark.parametrize("actual, expected", [ + ( + periods.period("ETERNITY"), + Period(('eternity', Instant((1, 1, 1)), float("inf"))), + ), + ( + periods.period("2014-2"), + Period(('month', Instant((2014, 2, 1)), 1)), + ), + ( + periods.period("2014-02"), + Period(('month', Instant((2014, 2, 1)), 1)), + ), + ( + periods.period("2014-02-02"), + Period(('day', Instant((2014, 2, 2)), 1)), + ), + ]) +def test_period(actual, expected): + """It works :).""" + + assert actual == expected + + +def test_N__deprecation(): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + periods.N_(object()) diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py new file mode 100644 index 0000000000..3751ca6587 --- /dev/null +++ b/openfisca_core/periods/tests/test_instant.py @@ -0,0 +1,126 @@ +import pytest + +from openfisca_core.periods import Instant, DateUnit + + +@pytest.fixture +def instant(): + return Instant((2021, 12, 31)) + + +def test_instant_init_with_nothing(): + """Raises ValueError when no units are passed.""" + + with pytest.raises(ValueError, match = "(expected 3, got 0)"): + Instant(()) + + +def test_instant_init_with_year(): + """Raises ValueError when only year is passed.""" + + with pytest.raises(ValueError, match = "(expected 3, got 1)"): + Instant((2021,)) + + +def test_instant_init_with_year_and_month(): + """Raises ValueError when only year and month are passed.""" + + with pytest.raises(ValueError, match = "(expected 3, got 2)"): + Instant((2021, 12)) + + +def test_instant_init_when_bad_year(): + """Raises ValueError when the year is out of bounds.""" + + with pytest.raises(ValueError, match = "year 0 is out of range"): + Instant((0, 13, 31)) + + +def test_instant_init_when_bad_month(): + """Raises ValueError when the month is out of bounds.""" + + with pytest.raises(ValueError, match = "month must be in 1..12"): + Instant((2021, 0, 31)) + + +def test_instant_init_when_bad_day(): + """Raises ValueError when the day is out of bounds.""" + + with pytest.raises(ValueError, match = "day is out of range for month"): + Instant((2021, 12, 0)) + + +def test_period_deprecation(instant): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + instant.period(DateUnit.DAY.value) + + +def test_period_for_eternity(instant): + """Throws an AssertionError when called with the eternity unit.""" + + with pytest.raises(AssertionError, match = "eternity"): + instant.period(DateUnit.ETERNITY.value) + + +def test_period_with_invalid_size(instant): + """Throws an AssertionError when called with an invalid size.""" + + with pytest.raises(AssertionError, match = "int >= 1"): + instant.period(DateUnit.DAY.value, size = 0) + + +def test_offset_for_eternity(instant): + """Throws an AssertionError when called with the eternity unit.""" + + with pytest.raises(AssertionError, match = "eternity"): + instant.offset("first-of", DateUnit.ETERNITY.value) + + +def test_offset_with_invalid_offset(instant): + """Throws an AssertionError when called with an invalid offset.""" + + with pytest.raises(AssertionError, match = "any int"): + instant.offset("doomsday", DateUnit.YEAR.value) + + +@pytest.mark.parametrize("actual, offset, expected", [ + ((2020, 1, 1), (1, DateUnit.DAY.value), (2020, 1, 2)), + ((2020, 1, 1), (1, DateUnit.MONTH.value), (2020, 2, 1)), + ((2020, 1, 1), (1, DateUnit.YEAR.value), (2021, 1, 1)), + ((2020, 1, 31), (1, DateUnit.DAY.value), (2020, 2, 1)), + ((2020, 1, 31), (1, DateUnit.MONTH.value), (2020, 2, 29)), + ((2020, 1, 31), (1, DateUnit.YEAR.value), (2021, 1, 31)), + ((2020, 2, 28), (1, DateUnit.DAY.value), (2020, 2, 29)), + ((2020, 2, 28), (1, DateUnit.MONTH.value), (2020, 3, 28)), + ((2020, 2, 29), (1, DateUnit.YEAR.value), (2021, 2, 28)), + ((2020, 1, 1), (-1, DateUnit.DAY.value), (2019, 12, 31)), + ((2020, 1, 1), (-1, DateUnit.MONTH.value), (2019, 12, 1)), + ((2020, 1, 1), (-1, DateUnit.YEAR.value), (2019, 1, 1)), + ((2020, 3, 1), (-1, DateUnit.DAY.value), (2020, 2, 29)), + ((2020, 3, 31), (-1, DateUnit.MONTH.value), (2020, 2, 29)), + ((2020, 2, 29), (-1, DateUnit.YEAR.value), (2019, 2, 28)), + ((2020, 1, 30), (3, DateUnit.DAY.value), (2020, 2, 2)), + ((2020, 10, 2), (3, DateUnit.MONTH.value), (2021, 1, 2)), + ((2020, 1, 1), (3, DateUnit.YEAR.value), (2023, 1, 1)), + ((2020, 1, 1), (-3, DateUnit.DAY.value), (2019, 12, 29)), + ((2020, 1, 1), (-3, DateUnit.MONTH.value), (2019, 10, 1)), + ((2020, 1, 1), (-3, DateUnit.YEAR.value), (2017, 1, 1)), + ((2020, 1, 1), ("first-of", DateUnit.MONTH.value), (2020, 1, 1)), + ((2020, 2, 1), ("first-of", DateUnit.MONTH.value), (2020, 2, 1)), + ((2020, 2, 3), ("first-of", DateUnit.MONTH.value), (2020, 2, 1)), + ((2020, 1, 1), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), + ((2020, 2, 1), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), + ((2020, 2, 3), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), + ((2020, 1, 1), ("last-of", DateUnit.MONTH.value), (2020, 1, 31)), + ((2020, 2, 1), ("last-of", DateUnit.MONTH.value), (2020, 2, 29)), + ((2020, 2, 3), ("last-of", DateUnit.MONTH.value), (2020, 2, 29)), + ((2020, 1, 1), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), + ((2020, 2, 1), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), + ((2020, 2, 3), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), + ]) +def test_offset(actual, offset, expected): + """It works ;).""" + + assert Instant(actual).offset(*offset) == Instant(expected) diff --git a/openfisca_core/types/__init__.py b/openfisca_core/types/__init__.py index e6f72ecb3d..5f230c46ec 100644 --- a/openfisca_core/types/__init__.py +++ b/openfisca_core/types/__init__.py @@ -14,6 +14,7 @@ * :class:`.HasVariables` * :class:`.SupportsEncode` * :class:`.SupportsFormula` + * :class:`.SupportsPeriod` * :class:`.SupportsRole` Note: @@ -60,9 +61,10 @@ HasVariables, SupportsEncode, SupportsFormula, + SupportsPeriod, SupportsRole, ) __all__ = ["Builder", "Descriptor", "HasHolders", "HasPlural", *__all__] __all__ = ["HasVariables", "SupportsEncode", "SupportsFormula", *__all__] -__all__ = ["SupportsRole", *__all__] +__all__ = ["SupportsPeriod", "SupportsRole", *__all__] diff --git a/openfisca_core/types/protocols/__init__.py b/openfisca_core/types/protocols/__init__.py index d44e025734..7b2e4bb052 100644 --- a/openfisca_core/types/protocols/__init__.py +++ b/openfisca_core/types/protocols/__init__.py @@ -5,4 +5,5 @@ from .has_variables import HasVariables # noqa: F401 from .supports_encode import SupportsEncode # noqa: F401 from .supports_formula import SupportsFormula # noqa: F401 +from .supports_period import SupportsPeriod # noqa: F401 from .supports_role import SupportsRole # noqa: F401 diff --git a/openfisca_core/types/protocols/supports_period.py b/openfisca_core/types/protocols/supports_period.py new file mode 100644 index 0000000000..1b10e83b2b --- /dev/null +++ b/openfisca_core/types/protocols/supports_period.py @@ -0,0 +1,15 @@ +from typing_extensions import Protocol + + +class SupportsPeriod(Protocol): + """Base type for any model implementing a period/instant-like behaviour. + + Type-checking against abstractions rather than implementations helps in + (a) decoupling the codebse, thanks to structural subtyping, and + (b) documenting/enforcing the blueprints of the different OpenFisca models. + + .. versionadded:: 35.7.0 + + """ + + ... diff --git a/setup.cfg b/setup.cfg index c6f0809aae..587833ea9c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ hang-closing = true extend-ignore = D ignore = E128,E251,F403,F405,E501,W503,W504 in-place = true -include-in-doctest = openfisca_core/commons openfisca_core/entities openfisca_core/indexed_enums openfisca_core/types +include-in-doctest = openfisca_core/commons openfisca_core/entities openfisca_core/indexed_enums openfisca_core/periods openfisca_core/types jobs = 0 rst-directives = attribute, deprecated, seealso, versionadded, versionchanged rst-roles = any, attr, class, data, exc, func, meth, obj @@ -24,7 +24,7 @@ score = no addopts = --cov-report=term-missing:skip-covered --cov-fail-under=78.69 --doctest-modules --disable-pytest-warnings --showlocals doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL NUMBER NORMALIZE_WHITESPACE python_files = **/*.py -testpaths = openfisca_core/commons openfisca_core/entities openfisca_core/indexed_enums openfisca_core/types tests +testpaths = openfisca_core/commons openfisca_core/entities openfisca_core/indexed_enums openfisca_core/periods openfisca_core/types tests [mypy] ignore_missing_imports = True @@ -38,5 +38,8 @@ ignore_errors = True [mypy-openfisca_core.indexed_enums.tests.*] ignore_errors = True +[mypy-openfisca_core.periods.tests.*] +ignore_errors = True + [mypy-openfisca_core.scripts.*] ignore_errors = True diff --git a/setup.py b/setup.py index 512cdf08a0..84691d50b9 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ 'numpy >= 1.11, < 1.21', 'psutil >= 5.4.7, < 6.0.0', 'pytest >= 4.4.1, < 6.0.0', # For openfisca test + 'python-dateutil == 2.8.2', 'PyYAML >= 3.10', 'sortedcontainers == 2.2.2', 'typing-extensions >= 3.0.0.0, < 4.0.0.0', From 0193f93e11b2a14182f3801fd7f0acc755c4e192 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 16:51:45 +0200 Subject: [PATCH 02/18] Fix str doctests --- openfisca_core/periods/date_unit.py | 5 ++- openfisca_core/periods/period_.py | 54 +++++++++++++++++------------ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index d655464083..4093fe99e7 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -99,7 +99,7 @@ class DateUnit(Enum, metaclass = DateUnitMeta): '' >>> str(DateUnit.DAY) - 'DateUnit.DAY' + 'day' >>> dict([(DateUnit.DAY, DateUnit.DAY.value)]) {: 'day'} @@ -212,6 +212,9 @@ class DateUnit(Enum, metaclass = DateUnitMeta): __hash__ = object.__hash__ + def __str__(self) -> str: + return self.value + def __eq__(self, other): if isinstance(other, str): return self.value == other.lower() diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 25da8cf6c8..e114992b7e 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -38,32 +38,40 @@ def __str__(self): """ Transform period to a string. - >>> str(period(YEAR, 2014)) - '2014' - - >>> str(period(YEAR, '2014-2')) - 'year:2014-02' - >>> str(period(MONTH, '2014-2')) - '2014-02' - - >>> str(period(YEAR, 2012, size = 2)) - 'year:2012:2' - >>> str(period(MONTH, 2012, size = 2)) - 'month:2012-01:2' - >>> str(period(MONTH, 2012, size = 12)) - '2012' - - >>> str(period(YEAR, '2012-3', size = 2)) - 'year:2012-03:2' - >>> str(period(MONTH, '2012-3', size = 2)) - 'month:2012-03:2' - >>> str(period(MONTH, '2012-3', size = 12)) - 'year:2012-03' + >>> str(Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1))) + '2021' + + >>> str(Period((DateUnit.YEAR, Instant((2021, 2, 1)), 1))) + 'year:2021-02' + + >>> str(Period((DateUnit.MONTH, Instant((2021, 2, 1)), 1))) + '2021-02' + + >>> str(Period((DateUnit.YEAR, Instant((2021, 1, 1)), 2))) + 'year:2021:2' + + >>> str(Period((DateUnit.MONTH, Instant((2021, 1, 1)), 2))) + 'month:2021-01:2' + + >>> str(Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12))) + '2021' + + >>> str(Period((DateUnit.YEAR, Instant((2021, 3, 1)), 2))) + 'year:2021-03:2' + + >>> str(Period((DateUnit.MONTH, Instant((2021, 3, 1)), 2))) + 'month:2021-03:2' + + >>> str(Period((DateUnit.MONTH, Instant((2021, 3, 1)), 12))) + 'year:2021-03' + """ unit, start_instant, size = self + if unit == DateUnit.ETERNITY: return 'ETERNITY' + year, month, day = start_instant # 1 year long period @@ -73,10 +81,12 @@ def __str__(self): return str(year) else: # rolling year - return '{}:{}-{:02d}'.format(DateUnit.YEAR.value, year, month) + return '{}:{}-{:02d}'.format(DateUnit.YEAR, year, month) + # simple month if unit == DateUnit.MONTH and size == 1: return '{}-{:02d}'.format(year, month) + # several civil years if unit == DateUnit.YEAR and month == 1: return '{}:{}:{}'.format(unit, year, size) From 7ac9b752fd01ad5e87840ae56a99a1ae193d1db0 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 17:35:37 +0200 Subject: [PATCH 03/18] Failing test --- openfisca_core/periods/period_.py | 54 +++++++++++++++++-------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index e114992b7e..6ae5e8849b 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -110,27 +110,29 @@ def days(self): """ Count the number of days in period. - >>> period('day', 2014).days + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).days 365 - >>> period('month', 2014).days + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).days 365 - >>> period('year', 2014).days + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).days 365 - >>> period('day', '2014-2').days + >>> Period((DateUnit.DAY, Instant((2021, 2, 1)), 1)).days 28 - >>> period('month', '2014-2').days + >>> Period((DateUnit.MONTH, Instant((2021, 2, 1)), 1)).days 28 - >>> period('year', '2014-2').days + >>> Period((DateUnit.YEAR, Instant((2021, 2, 1)), 1)).days 365 - >>> period('day', '2014-2-3').days + >>> Period((DateUnit.DAY, Instant((2021, 2, 3)), 1)).days 1 - >>> period('month', '2014-2-3').days + >>> Period((DateUnit.MONTH, Instant((2021, 2, 3)), 1)).days 28 - >>> period('year', '2014-2-3').days + >>> Period((DateUnit.YEAR, Instant((2021, 2, 3)), 1)).days 365 + """ + return (self.stop.date - self.start.date).days + 1 def intersection(self, start, stop): @@ -201,22 +203,22 @@ def offset(self, offset, unit = None): """ Increment (or decrement) the given period with offset units. - >>> period('day', 2014).offset(1) + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).offset(1) Period(('day', Instant((2014, 1, 2)), 365)) - >>> period('day', 2014).offset(1, 'day') + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).offset(1, 'day') Period(('day', Instant((2014, 1, 2)), 365)) - >>> period('day', 2014).offset(1, 'month') + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).offset(1, 'month') Period(('day', Instant((2014, 2, 1)), 365)) - >>> period('day', 2014).offset(1, 'year') + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).offset(1, 'year') Period(('day', Instant((2015, 1, 1)), 365)) - >>> period('month', 2014).offset(1) + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).offset(1) Period(('month', Instant((2014, 2, 1)), 12)) - >>> period('month', 2014).offset(1, 'day') + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).offset(1, 'day') Period(('month', Instant((2014, 1, 2)), 12)) - >>> period('month', 2014).offset(1, 'month') + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).offset(1, 'month') Period(('month', Instant((2014, 2, 1)), 12)) - >>> period('month', 2014).offset(1, 'year') + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).offset(1, 'year') Period(('month', Instant((2015, 1, 1)), 12)) >>> period('year', 2014).offset(1) @@ -249,9 +251,9 @@ def offset(self, offset, unit = None): >>> period('year', '2014-1-30').offset(3) Period(('year', Instant((2017, 1, 30)), 1)) - >>> period('day', 2014).offset(-3) + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).offset(-3) Period(('day', Instant((2013, 12, 29)), 365)) - >>> period('month', 2014).offset(-3) + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).offset(-3) Period(('month', Instant((2013, 10, 1)), 12)) >>> period('year', 2014).offset(-3) Period(('year', Instant((2011, 1, 1)), 1)) @@ -387,7 +389,7 @@ def size_in_days(self): raise ValueError("Cannot calculate number of days in {0}".format(unit)) @property - def start(self) -> periods.Instant: + def start(self) -> Instant: """ Return the first day of the period as an Instant instance. @@ -397,15 +399,15 @@ def start(self) -> periods.Instant: return self[1] @property - def stop(self) -> periods.Instant: + def stop(self) -> Instant: """ Return the last day of the period as an Instant instance. >>> period('year', 2014).stop Instant((2014, 12, 31)) - >>> period('month', 2014).stop + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).stop Instant((2014, 12, 31)) - >>> period('day', 2014).stop + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).stop Instant((2014, 12, 31)) >>> period('year', '2012-2-29').stop @@ -424,8 +426,10 @@ def stop(self) -> periods.Instant: """ unit, start_instant, size = self year, month, day = start_instant + if unit == DateUnit.ETERNITY: - return periods.Instant((float("inf"), float("inf"), float("inf"))) + return Instant((float("inf"), float("inf"), float("inf"))) + if unit == 'day': if size > 1: day += size - 1 @@ -461,7 +465,7 @@ def stop(self) -> periods.Instant: year += 1 month = 1 day -= month_last_day - return periods.Instant((year, month, day)) + return Instant((year, month, day)) @property def unit(self): From 6a3f356835dd95815c10743a14f40e91eb603555 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 18:28:39 +0200 Subject: [PATCH 04/18] Update date unit methods --- openfisca_core/periods/date_unit.py | 56 ++++++++++++++++------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index 4093fe99e7..b94bf5da6a 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -104,8 +104,8 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> dict([(DateUnit.DAY, DateUnit.DAY.value)]) {: 'day'} - >>> tuple(DateUnit) - (, , >> list(DateUnit) + [, , >> len(DateUnit) 6 @@ -210,43 +210,49 @@ class DateUnit(Enum, metaclass = DateUnitMeta): YEAR = "year" ETERNITY = "eternity" - __hash__ = object.__hash__ + __hash__ = Enum.__hash__ def __str__(self) -> str: return self.value - def __eq__(self, other): - if isinstance(other, str): - return self.value == other.lower() + def __eq__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__eq__(other) - return NotImplemented + return self.value == other.lower() - def __lt__(self, other: Any) -> bool: - if isinstance(other, str): - return self.index < DateUnit[other.upper()].index + def __ne__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__ne__(other) - return self.index < other + return self.value != other.lower() - def __le__(self, other: Any) -> bool: - if isinstance(other, str): - return self.index <= DateUnit[other.upper()].index + def __lt__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__lt__(other) - return self.index <= other + return self.index < DateUnit[other].index - def __gt__(self, other: Any) -> bool: - if isinstance(other, str): - return self.index > DateUnit[other.upper()].index + def __le__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__le__(other) - return self.index > other + return self.index <= DateUnit[other].index - def __ge__(self, other: Any) -> bool: - if isinstance(other, str): - return self.index >= DateUnit[other.upper()].index + def __gt__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__gt__(other) - return self.index >= other + return self.index > DateUnit[other].index + + def __ge__(self, other: object) -> bool: + if not isinstance(other, str): + return super().__ge__(other) + + return self.index >= DateUnit[other].index def upper(self) -> str: - """Uppercases the :class:`.Unit`. + """Uppercases the :class:`.DateUnit`. Returns: :obj:`str`: The uppercased :class:`.Unit`. @@ -260,7 +266,7 @@ def upper(self) -> str: return self.value.upper() def lower(self) -> str: - """Lowecases the :class:`.Unit`. + """Lowecases the :class:`.DateUnit`. Returns: :obj:`str`: The lowercased :class:`.Unit`. From 6ffdaeeca349c8aeba370ca4f19bd2db3ffe6859 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 18:36:08 +0200 Subject: [PATCH 05/18] update properties --- openfisca_core/periods/date_unit.py | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index b94bf5da6a..4c45bebbb7 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -29,26 +29,26 @@ def __getitem__(self, key: object) -> T: return super().__getitem__(key.value.upper()) @property - def ethereal(self) -> Tuple[DateUnit, ...]: - """Creates a :obj:`tuple` of ``key`` with ethereal items. + def isoformat(self) -> Tuple[DateUnit, ...]: + """Creates a :obj:`tuple` of ``key`` with isoformat items. Returns: tuple(str): A :obj:`tuple` containing the ``keys``. Examples: - >>> DateUnit.ethereal - (, , ) + >>> DateUnit.isoformat + (, , >> DateUnit.DAY in DateUnit.ethereal + >>> DateUnit.DAY in DateUnit.isoformat True - >>> "DAY" in DateUnit.ethereal + >>> "DAY" in DateUnit.isoformat True - >>> "day" in DateUnit.ethereal + >>> "day" in DateUnit.isoformat True - >>> "eternity" in DateUnit.ethereal + >>> DateUnit.WEEK in DateUnit.isoformat False """ @@ -56,31 +56,31 @@ def ethereal(self) -> Tuple[DateUnit, ...]: return DateUnit.DAY, DateUnit.MONTH, DateUnit.YEAR @property - def eternal(self) -> Tuple[DateUnit, ...]: - """Creates a :obj:`tuple` of ``key`` with eternal items. + def isocalendar(self) -> Tuple[DateUnit, ...]: + """Creates a :obj:`tuple` of ``key`` with isocalendar items. Returns: tuple(str): A :obj:`tuple` containing the ``keys``. Examples: - >>> DateUnit.eternal - (,) + >>> DateUnit.isocalendar + (, , >> DateUnit.ETERNITY in DateUnit.eternal + >>> DateUnit.WEEK in DateUnit.isocalendar True - >>> "ETERNITY" in DateUnit.eternal + >>> "WEEK" in DateUnit.isocalendar True - >>> "eternity" in DateUnit.eternal + >>> "week" in DateUnit.isocalendar True - >>> "day" in DateUnit.eternal + >>> "day" in DateUnit.isocalendar False """ - return (DateUnit.ETERNITY,) + return DateUnit.WEEK_DAY, DateUnit.WEEK, DateUnit.YEAR class DateUnit(Enum, metaclass = DateUnitMeta): From fcf450f5c2f57008fd67f21fef490821a24e2b79 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 18:57:45 +0200 Subject: [PATCH 06/18] update operations --- openfisca_core/periods/date_unit.py | 110 +++++++++++++++++++++------- 1 file changed, 85 insertions(+), 25 deletions(-) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index 4c45bebbb7..c84d11e999 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -1,7 +1,7 @@ from __future__ import annotations import enum -from typing import Any, Tuple, TypeVar +from typing import Tuple, TypeVar from openfisca_core.indexed_enums import Enum @@ -10,22 +10,28 @@ class DateUnitMeta(enum.EnumMeta): - def __contains__(self, item: Any) -> bool: + def __contains__(self, item: object) -> bool: + if not isinstance(item, (str, int, DateUnit)): + return NotImplemented + if isinstance(item, str): - return super().__contains__(self[item.upper()]) + return super().__contains__(self[item]) + + if isinstance(item, int): + return super().__contains__(self[item]) return super().__contains__(item) def __getitem__(self, key: object) -> T: - if not isinstance(key, (int, slice, str, DateUnit)): + if not isinstance(key, (str, int, slice, DateUnit)): return NotImplemented - if isinstance(key, (int, slice)): - return self[self.__dict__["_member_names_"][key]] - if isinstance(key, str): return super().__getitem__(key.upper()) + if isinstance(key, (int, slice)): + return self[self.__dict__["_member_names_"][key]] + return super().__getitem__(key.value.upper()) @property @@ -137,6 +143,9 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> "day" in DateUnit True + >>> 2 in DateUnit + True + >>> DateUnit.DAY == DateUnit.DAY True @@ -146,6 +155,9 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> "day" == DateUnit.DAY True + >>> 2 == DateUnit.DAY + True + >>> DateUnit.DAY < DateUnit.DAY False @@ -179,6 +191,18 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> "day" <= DateUnit.DAY True + >>> 2 >= DateUnit.DAY + True + + >>> 2 < DateUnit.DAY + False + + >>> 2 > DateUnit.DAY + False + + >>> 2 <= DateUnit.DAY + True + >>> "day" >= DateUnit.DAY True @@ -216,40 +240,76 @@ def __str__(self) -> str: return self.value def __eq__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__eq__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented + + if isinstance(other, str): + return self.index == DateUnit[other].index - return self.value == other.lower() + if isinstance(other, int): + return self.index == other + + return super().__eq__(other) def __ne__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__ne__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented - return self.value != other.lower() + if isinstance(other, str): + return self.index != DateUnit[other].index + + if isinstance(other, int): + return self.index != other + + return super().__ne__(other) def __lt__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__lt__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented + + if isinstance(other, str): + return self.index < DateUnit[other].index + + if isinstance(other, int): + return self.index < other - return self.index < DateUnit[other].index + return super().__lt__(other) def __le__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__le__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented + + if isinstance(other, str): + return self.index <= DateUnit[other].index + + if isinstance(other, int): + return self.index <= other - return self.index <= DateUnit[other].index + return super().__le__(other) def __gt__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__gt__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented + + if isinstance(other, str): + return self.index > DateUnit[other].index - return self.index > DateUnit[other].index + if isinstance(other, int): + return self.index > other + + return super().__gt__(other) def __ge__(self, other: object) -> bool: - if not isinstance(other, str): - return super().__ge__(other) + if not isinstance(other, (str, int, DateUnit)): + return NotImplemented + + if isinstance(other, str): + return self.index >= DateUnit[other].index + + if isinstance(other, int): + return self.index >= other - return self.index >= DateUnit[other].index + return super().__ge__(other) def upper(self) -> str: """Uppercases the :class:`.DateUnit`. From b0cf76b968008dd8498c7add8551ad3490817a12 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 19:11:47 +0200 Subject: [PATCH 07/18] Add tests to date unit --- openfisca_core/periods/date_unit.py | 66 ------------------- .../periods/tests/test_date_unit.py | 51 ++++++++++++++ 2 files changed, 51 insertions(+), 66 deletions(-) create mode 100644 openfisca_core/periods/tests/test_date_unit.py diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index c84d11e999..c55d988d3f 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -48,12 +48,6 @@ def isoformat(self) -> Tuple[DateUnit, ...]: >>> DateUnit.DAY in DateUnit.isoformat True - >>> "DAY" in DateUnit.isoformat - True - - >>> "day" in DateUnit.isoformat - True - >>> DateUnit.WEEK in DateUnit.isoformat False @@ -75,12 +69,6 @@ def isocalendar(self) -> Tuple[DateUnit, ...]: >>> DateUnit.WEEK in DateUnit.isocalendar True - >>> "WEEK" in DateUnit.isocalendar - True - - >>> "week" in DateUnit.isocalendar - True - >>> "day" in DateUnit.isocalendar False @@ -137,27 +125,9 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> DateUnit.DAY in DateUnit True - >>> "DAY" in DateUnit - True - - >>> "day" in DateUnit - True - - >>> 2 in DateUnit - True - >>> DateUnit.DAY == DateUnit.DAY True - >>> "DAY" == DateUnit.DAY - True - - >>> "day" == DateUnit.DAY - True - - >>> 2 == DateUnit.DAY - True - >>> DateUnit.DAY < DateUnit.DAY False @@ -170,42 +140,6 @@ class DateUnit(Enum, metaclass = DateUnitMeta): >>> DateUnit.DAY >= DateUnit.DAY True - >>> "DAY" < DateUnit.DAY - False - - >>> "DAY" > DateUnit.DAY - False - - >>> "DAY" <= DateUnit.DAY - True - - >>> "DAY" >= DateUnit.DAY - True - - >>> "day" < DateUnit.DAY - False - - >>> "day" > DateUnit.DAY - False - - >>> "day" <= DateUnit.DAY - True - - >>> 2 >= DateUnit.DAY - True - - >>> 2 < DateUnit.DAY - False - - >>> 2 > DateUnit.DAY - False - - >>> 2 <= DateUnit.DAY - True - - >>> "day" >= DateUnit.DAY - True - >>> DateUnit.DAY.index 2 diff --git a/openfisca_core/periods/tests/test_date_unit.py b/openfisca_core/periods/tests/test_date_unit.py new file mode 100644 index 0000000000..a164ef7683 --- /dev/null +++ b/openfisca_core/periods/tests/test_date_unit.py @@ -0,0 +1,51 @@ +import pytest + +from openfisca_core.periods import DateUnit + + +@pytest.mark.parametrize("operation", [ + "DAY" in DateUnit, + "day" in DateUnit, + 2 in DateUnit, + "DAY" == DateUnit.DAY, + "day" == DateUnit.DAY, + 2 == DateUnit.DAY, + "DAY" < DateUnit.MONTH, + "day" < DateUnit.MONTH, + 2 < DateUnit.MONTH, + "MONTH" > DateUnit.DAY, + "month" > DateUnit.DAY, + 3 > DateUnit.DAY, + "DAY" <= DateUnit.DAY, + "day" <= DateUnit.DAY, + 2 <= DateUnit.DAY, + "DAY" >= DateUnit.DAY, + "day" >= DateUnit.DAY, + 2 >= DateUnit.DAY, + ]) +def test_date_unit(operation): + """It works! :)""" + + assert operation + + +@pytest.mark.parametrize("operation", [ + "DAY" in DateUnit.isoformat, + "day" in DateUnit.isoformat, + 2 in DateUnit.isoformat, + ]) +def test_isoformat(operation): + """It works! :)""" + + assert operation + + +@pytest.mark.parametrize("operation", [ + "WEEK" in DateUnit.isocalendar, + "week" in DateUnit.isocalendar, + 1 in DateUnit.isocalendar, + ]) +def test_isocalendar(operation): + """It works! :)""" + + assert operation From a41ee1d45b97cbdf342acec10a65ce23eed1e26f Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 19:18:15 +0200 Subject: [PATCH 08/18] Fix failing tests --- openfisca_core/periods/date_unit.py | 4 ++-- openfisca_core/periods/helpers.py | 2 +- openfisca_core/periods/instant_.py | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index c55d988d3f..492e969144 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -177,7 +177,7 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, (str, int, DateUnit)): return NotImplemented - if isinstance(other, str): + if isinstance(other, str) and other.upper() in self._member_names_: return self.index == DateUnit[other].index if isinstance(other, int): @@ -189,7 +189,7 @@ def __ne__(self, other: object) -> bool: if not isinstance(other, (str, int, DateUnit)): return NotImplemented - if isinstance(other, str): + if isinstance(other, str) and other.upper() in self._member_names_: return self.index != DateUnit[other].index if isinstance(other, int): diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index 8d20ed8039..9d0417e277 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -322,7 +322,7 @@ def period(value: PeriodLike) -> Period: #: Left-most component must be a valid unit: ``day``, ``month``, or #: ``year``. #: - if period_unit not in DateUnit.ethereal: + if period_unit not in DateUnit.isoformat: raise ValueError #: Reject ambiguous periods, such as ``month:2014``. diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 940861ce0e..7716ae1288 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -191,9 +191,9 @@ def period(self, unit: DateUnit, size: int = 1) -> SupportsPeriod: """ - assert unit in DateUnit.ethereal, \ + assert unit in DateUnit.isoformat, \ f"Invalid unit: {unit} of type {type(unit)}. Expecting any of " \ - f"{', '.join(str(unit) for unit in DateUnit.ethereal)}." + f"{', '.join(str(unit) for unit in DateUnit.isoformat)}." assert isinstance(size, int) and size >= 1, \ f"Invalid size: {size} of type {type(size)}. Expecting any " \ @@ -244,9 +244,9 @@ def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: #: Use current ``day`` fro the offset. day = self.day - assert unit in DateUnit.ethereal, \ + assert unit in DateUnit.isoformat, \ f"Invalid unit: {unit} of type {type(unit)}. Expecting any of " \ - f"{', '.join(str(unit) for unit in DateUnit.ethereal)}." + f"{', '.join(str(unit) for unit in DateUnit.isoformat)}." if offset == "first-of" and unit == DateUnit.YEAR: return self.__class__((year, 1, 1)) From f15293ed96a8b73244be8f9b4c19bd7f622f87c9 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 19:42:57 +0200 Subject: [PATCH 09/18] Add missing types --- openfisca_core/periods/instant_.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 7716ae1288..485fcce76a 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -199,10 +199,7 @@ def period(self, unit: DateUnit, size: int = 1) -> SupportsPeriod: f"Invalid size: {size} of type {type(size)}. Expecting any " \ "int >= 1." - if isinstance(unit, str): - unit = DateUnit[unit] - - return periods.period(f"{unit.value}:{str(self)}:{size}") + return periods.period(f"{unit}:{str(self)}:{size}") @functools.lru_cache(maxsize = None) def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: @@ -221,13 +218,13 @@ def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: ``last-of``, or any :obj:`int`. Examples: - >>> Instant((2020, 12, 31)).offset("first-of", DateUnit.MONTH.value) + >>> Instant((2020, 12, 31)).offset("first-of", DateUnit.MONTH) >>> Instant((2020, 1, 1)).offset("last-of", "year") - >>> Instant((2020, 1, 1)).offset(1, DateUnit.YEAR.value) + >>> Instant((2020, 1, 1)).offset(1, DateUnit.YEAR) >>> Instant((2020, 1, 1)).offset(-3, "day") @@ -236,13 +233,13 @@ def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: """ #: Use current ``year`` fro the offset. - year = self.year + year: int = self.year #: Use current ``month`` fro the offset. - month = self.month + month: int = self.month #: Use current ``day`` fro the offset. - day = self.day + day: int = self.day assert unit in DateUnit.isoformat, \ f"Invalid unit: {unit} of type {type(unit)}. Expecting any of " \ From abc71d842819991830604cb09fa35cb2f8c75564 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 20:19:40 +0200 Subject: [PATCH 10/18] Fix tests --- openfisca_core/periods/period_.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 6ae5e8849b..b7151b9368 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -110,24 +110,21 @@ def days(self): """ Count the number of days in period. - >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 1)).days - 365 - >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 1)).days - 365 >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).days 365 - >>> Period((DateUnit.DAY, Instant((2021, 2, 1)), 1)).days - 28 >>> Period((DateUnit.MONTH, Instant((2021, 2, 1)), 1)).days 28 + >>> Period((DateUnit.YEAR, Instant((2021, 2, 1)), 1)).days 365 >>> Period((DateUnit.DAY, Instant((2021, 2, 3)), 1)).days 1 + >>> Period((DateUnit.MONTH, Instant((2021, 2, 3)), 1)).days 28 + >>> Period((DateUnit.YEAR, Instant((2021, 2, 3)), 1)).days 365 From 405a177a8086fd5ebabfb63f538b0e39c4b999ce Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 21 Sep 2021 20:26:13 +0200 Subject: [PATCH 11/18] Fix tests --- openfisca_core/periods/period_.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index b7151b9368..b837b42a15 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -178,12 +178,16 @@ def get_subperiods(self, unit): Examples: - >>> period('2017').get_subperiods(MONTH) - >>> [period('2017-01'), period('2017-02'), ... period('2017-12')] + >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)) + >>> period.get_subperiods(DateUnit.MONTH) + [, 1))>, ...2021, 12, 1)>, 1))>] + + >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 2)) + >>> period.get_subperiods(DateUnit.YEAR) + [, 1))>, ...t(2022, 1, 1)>, 1))>] - >>> period('year:2014:2').get_subperiods(YEAR) - >>> [period('2014'), period('2015')] """ + if self.unit < unit: raise ValueError('Cannot subdivide {0} into {1}'.format(self.unit, unit)) From 4881016729e0a5e19720a0935fd4ea3d528d480e Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Wed, 22 Sep 2021 21:20:01 +0200 Subject: [PATCH 12/18] Add hypothesis --- .gitignore | 29 +++++++++++++++-------------- setup.py | 1 + 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 4b56efc6da..57ad5ba471 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,23 @@ -.venv +*.egg-info +*.mo +*.pyc +*~ +.hypothesis +.mypy_cache +.noseids .project -.spyderproject .pydevproject -.vscode +.pytest_cache .settings/ +.spyderproject +.tags* +.venv +.vscode .vscode/ +/.coverage +/cover +/tags build/ dist/ doc/ -*.egg-info -*.mo -*.pyc -*~ -/cover -/.coverage -/tags -.tags* -.noseids -.pytest_cache -.mypy_cache performance.json diff --git a/setup.py b/setup.py index 84691d50b9..70f700460e 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'flake8-docstrings == 1.6.0', 'flake8-print >= 3.1.0, < 4.0.0', 'flake8-rst-docstrings < 1.0.0', + 'hypothesis[numpy] == 6.21.6', 'mypy == 0.910', 'openfisca-country-template >= 3.10.0, < 4.0.0', 'openfisca-extension-template >= 1.2.0rc0, < 2.0.0', From 2b71eb5f647133a4ba8f322fef90a76562731a97 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Wed, 22 Sep 2021 21:20:37 +0200 Subject: [PATCH 13/18] Test instants with hypothesis --- openfisca_core/periods/instant_.py | 5 +- openfisca_core/periods/tests/test_instant.py | 279 +++++++++++++------ 2 files changed, 201 insertions(+), 83 deletions(-) diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 485fcce76a..d684573308 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -60,8 +60,8 @@ class Instant: >>> dict([(instant, (2021, 9, 13))]) {: (2021, 9, 13)} - >>> tuple(instant) - (2021, 9, 13) + >>> list(instant) + [2021, 9, 13] >>> instant[0] 2021 @@ -201,7 +201,6 @@ def period(self, unit: DateUnit, size: int = 1) -> SupportsPeriod: return periods.period(f"{unit}:{str(self)}:{size}") - @functools.lru_cache(maxsize = None) def offset(self, offset: OffsetBy, unit: DateUnit) -> Instant: """Increments/decrements the given instant with offset units. diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py index 3751ca6587..ded989d2fa 100644 --- a/openfisca_core/periods/tests/test_instant.py +++ b/openfisca_core/periods/tests/test_instant.py @@ -1,126 +1,245 @@ +import calendar +import datetime + +import hypothesis import pytest +from hypothesis import strategies as st from openfisca_core.periods import Instant, DateUnit +# Number of days in a month, for each month. +DAYS_IN_MONTH = calendar.mdays -@pytest.fixture -def instant(): - return Instant((2021, 12, 31)) +# Valid ranges for years. +MIN_YEAR = datetime.MINYEAR +MAX_YEAR = datetime.MAXYEAR +OK_YEARS = range(MIN_YEAR, MAX_YEAR + 1) +ST_YEARS = st.integers(OK_YEARS[0] - 1, OK_YEARS[-1] + 1) + +# Valid ranges for months. +MIN_MONTH = datetime.date.min.month +MAX_MONTH = datetime.date.max.month +OK_MONTHS = range(MIN_MONTH, MAX_MONTH + 1) +ST_MONTHS = st.integers(OK_MONTHS[0] - 1, OK_MONTHS[-1] + 1) +# Valid ranges for days. +MIN_DAY = datetime.date.min.day +MAX_DAY = datetime.date.max.day +OK_DAYS = range(MIN_DAY, MAX_DAY + 1) +ST_DAYS = st.integers(OK_DAYS[0] - 1, OK_DAYS[-1] + 1) -def test_instant_init_with_nothing(): - """Raises ValueError when no units are passed.""" +# Valid date unit ranges for offset. +OK_UNITS = DateUnit.isoformat - with pytest.raises(ValueError, match = "(expected 3, got 0)"): - Instant(()) +# Fail if test execution time is slower than 1ms. +DEADLINE = datetime.timedelta(milliseconds = 1) -def test_instant_init_with_year(): - """Raises ValueError when only year is passed.""" +def one_of(*sts): + """Random floats, strings, etc…""" - with pytest.raises(ValueError, match = "(expected 3, got 1)"): - Instant((2021,)) + return st.one_of(*sts, st.floats(), st.text(), st.none()) + + +@pytest.fixture(scope = "module") +def instant(): + """An instant.""" + return Instant((2020, 12, 31)) -def test_instant_init_with_year_and_month(): - """Raises ValueError when only year and month are passed.""" - with pytest.raises(ValueError, match = "(expected 3, got 2)"): - Instant((2021, 12)) +def test_instant_with_wrong_arity(): + """Raises ValueError when the wrong number of units are passed.""" + with pytest.raises(ValueError, match = f"expected 3"): + Instant((1,)) -def test_instant_init_when_bad_year(): - """Raises ValueError when the year is out of bounds.""" - with pytest.raises(ValueError, match = "year 0 is out of range"): - Instant((0, 13, 31)) +@hypothesis.given(one_of(ST_YEARS), one_of(ST_MONTHS), one_of(ST_DAYS)) +@hypothesis.settings(deadline = DEADLINE) +def test_instant(year, month, day): + """Raises with wrong year/month/day, works otherwise.""" + # All units have to be integers, otherwise we raise. + for unit in year, month, day: + if not isinstance(unit, int): + with pytest.raises(TypeError, match = "integer"): + Instant((year, month, day)) + return -def test_instant_init_when_bad_month(): - """Raises ValueError when the month is out of bounds.""" + # We draw exact number of days for the month ``month``. + if month in OK_MONTHS: + ok_days = range(1, DAYS_IN_MONTH[month] + 1) - with pytest.raises(ValueError, match = "month must be in 1..12"): - Instant((2021, 0, 31)) + # If it is a leap year and ``month`` is February, we add ``29``. + if calendar.isleap(year) and month == 2: + ok_days = range(1, ok_days.stop + 1) + # Year has to be within a valid range, otherwise we fail. + if year not in OK_YEARS: + with pytest.raises(ValueError, match = f"year {year} is out of range"): + Instant((year, month, day)) + return -def test_instant_init_when_bad_day(): - """Raises ValueError when the day is out of bounds.""" + # Month has to be within a valid range, otherwise we fail. + if month not in OK_MONTHS: + with pytest.raises(ValueError, match = "month must be in 1..12"): + Instant((year, month, day)) + return - with pytest.raises(ValueError, match = "day is out of range for month"): - Instant((2021, 12, 0)) + # Day has to be within a valid range, otherwise we fail. + if day not in ok_days: + with pytest.raises(ValueError, match = "day is out of range"): + Instant((year, month, day)) + return + + instant = Instant((year, month, day)) + + assert instant.canonical == (year, month, day) def test_period_deprecation(instant): """Throws a deprecation warning when called.""" with pytest.warns(DeprecationWarning): - instant.period(DateUnit.DAY.value) + instant.period(DateUnit.DAY) def test_period_for_eternity(instant): """Throws an AssertionError when called with the eternity unit.""" with pytest.raises(AssertionError, match = "eternity"): - instant.period(DateUnit.ETERNITY.value) + instant.period(DateUnit.ETERNITY) def test_period_with_invalid_size(instant): """Throws an AssertionError when called with an invalid size.""" with pytest.raises(AssertionError, match = "int >= 1"): - instant.period(DateUnit.DAY.value, size = 0) + instant.period(DateUnit.DAY, size = 0) + + +@hypothesis.given( + st.sampled_from(OK_YEARS), + st.sampled_from(OK_MONTHS), + st.sampled_from(OK_DAYS), + one_of(st.sampled_from(("first-of", "last-of", *OK_YEARS))), + one_of(st.sampled_from(DateUnit)), + ) +@hypothesis.settings(deadline = DEADLINE) +def test_offset(year, month, day, offset, unit): + """Raises when called with invalid values, works otherwise.""" + + # We calculate the valid offset values for year. + min_offset = - year + 1 + max_offset = MAX_YEAR - year + 1 + ok_offsets = range(min_offset, max_offset) + + # We already know it raises if out of bounds, so we continue. + if offset not in ok_offsets: + return + + # We draw exact number of days for the month ``month``. + if month in OK_MONTHS: + ok_days = range(1, DAYS_IN_MONTH[month] + 1) + + # If it is a leap year and ``month`` is February, we add ``29``. + if calendar.isleap(year) and month == 2: + ok_days = range(1, ok_days.stop + 1) + + # We already know it raises if out of bounds, so we continue. + if day not in ok_days: + return + + # Now we can create our Instant. + start = Instant((year, month, day)) + + # If the unit is invalid, we raise. + if unit not in OK_UNITS: + with pytest.raises(AssertionError, match = "day, month, year"): + start.offset(offset, unit) + return + + # If the unit is day, we can only do integer offsets. + if unit == DateUnit.DAY and isinstance(offset, str): + with pytest.raises(AssertionError, match = "any int"): + start.offset(offset, unit) + return + + # Up to this point, we know any non str/int offset is invalid. + if not isinstance(offset, (str, int)): + with pytest.raises(AssertionError, match = "any int"): + start.offset(offset, unit) + return + + # Any string not in nth-of is invalid. + if isinstance(offset, str) and offset not in ("first-of", "last-of"): + with pytest.raises(AssertionError, match = "any int"): + start.offset(offset, unit) + return + + # Finally if unit is year, it can't be out of bounds. + if unit == DateUnit.YEAR and offset not in ok_offsets: + with pytest.raises(ValueError, match = "out of range"): + start.offset(offset, unit) + return + + # Now we know our offset should always work. + after = start.offset(offset, unit) + + if offset == "first-of" and unit == DateUnit.YEAR: + assert after.canonical == (year, 1, 1) + return + + if offset == "first-of" and unit == DateUnit.MONTH: + assert after.canonical == (year, start.month, 1) + return + + if offset == "last-of" and unit == DateUnit.YEAR: + assert after.canonical == (year, 12, 31) + return + + if offset == "last-of" and unit == DateUnit.MONTH: + assert after.canonical == (year, start.month, ok_days[-1]) + return + + # We test the actual offset values for month/day below. + if unit != DateUnit.YEAR: + return + + # Leap year! + if day == 29 and ok_days[-1] == 29: + after.canonical == (year + offset, month, day - 1) + return + + assert after.canonical == (year + offset, month, day) + + +@pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), (1, DateUnit.MONTH), (2020, 2, 1)), + ((2020, 1, 31), (1, DateUnit.MONTH), (2020, 2, 29)), + ((2020, 2, 28), (1, DateUnit.MONTH), (2020, 3, 28)), + ((2020, 1, 1), (-1, DateUnit.MONTH), (2019, 12, 1)), + ((2020, 3, 31), (-1, DateUnit.MONTH), (2020, 2, 29)), + ((2020, 10, 2), (3, DateUnit.MONTH), (2021, 1, 2)), + ((2020, 1, 1), (-3, DateUnit.MONTH), (2019, 10, 1)), + ]) +def test_offset_month(start, offset, after): + """It works, including leap years ;).""" + assert Instant(start).offset(*offset) == Instant(after) -def test_offset_for_eternity(instant): - """Throws an AssertionError when called with the eternity unit.""" - with pytest.raises(AssertionError, match = "eternity"): - instant.offset("first-of", DateUnit.ETERNITY.value) - - -def test_offset_with_invalid_offset(instant): - """Throws an AssertionError when called with an invalid offset.""" - - with pytest.raises(AssertionError, match = "any int"): - instant.offset("doomsday", DateUnit.YEAR.value) - - -@pytest.mark.parametrize("actual, offset, expected", [ - ((2020, 1, 1), (1, DateUnit.DAY.value), (2020, 1, 2)), - ((2020, 1, 1), (1, DateUnit.MONTH.value), (2020, 2, 1)), - ((2020, 1, 1), (1, DateUnit.YEAR.value), (2021, 1, 1)), - ((2020, 1, 31), (1, DateUnit.DAY.value), (2020, 2, 1)), - ((2020, 1, 31), (1, DateUnit.MONTH.value), (2020, 2, 29)), - ((2020, 1, 31), (1, DateUnit.YEAR.value), (2021, 1, 31)), - ((2020, 2, 28), (1, DateUnit.DAY.value), (2020, 2, 29)), - ((2020, 2, 28), (1, DateUnit.MONTH.value), (2020, 3, 28)), - ((2020, 2, 29), (1, DateUnit.YEAR.value), (2021, 2, 28)), - ((2020, 1, 1), (-1, DateUnit.DAY.value), (2019, 12, 31)), - ((2020, 1, 1), (-1, DateUnit.MONTH.value), (2019, 12, 1)), - ((2020, 1, 1), (-1, DateUnit.YEAR.value), (2019, 1, 1)), - ((2020, 3, 1), (-1, DateUnit.DAY.value), (2020, 2, 29)), - ((2020, 3, 31), (-1, DateUnit.MONTH.value), (2020, 2, 29)), - ((2020, 2, 29), (-1, DateUnit.YEAR.value), (2019, 2, 28)), - ((2020, 1, 30), (3, DateUnit.DAY.value), (2020, 2, 2)), - ((2020, 10, 2), (3, DateUnit.MONTH.value), (2021, 1, 2)), - ((2020, 1, 1), (3, DateUnit.YEAR.value), (2023, 1, 1)), - ((2020, 1, 1), (-3, DateUnit.DAY.value), (2019, 12, 29)), - ((2020, 1, 1), (-3, DateUnit.MONTH.value), (2019, 10, 1)), - ((2020, 1, 1), (-3, DateUnit.YEAR.value), (2017, 1, 1)), - ((2020, 1, 1), ("first-of", DateUnit.MONTH.value), (2020, 1, 1)), - ((2020, 2, 1), ("first-of", DateUnit.MONTH.value), (2020, 2, 1)), - ((2020, 2, 3), ("first-of", DateUnit.MONTH.value), (2020, 2, 1)), - ((2020, 1, 1), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), - ((2020, 2, 1), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), - ((2020, 2, 3), ("first-of", DateUnit.YEAR.value), (2020, 1, 1)), - ((2020, 1, 1), ("last-of", DateUnit.MONTH.value), (2020, 1, 31)), - ((2020, 2, 1), ("last-of", DateUnit.MONTH.value), (2020, 2, 29)), - ((2020, 2, 3), ("last-of", DateUnit.MONTH.value), (2020, 2, 29)), - ((2020, 1, 1), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), - ((2020, 2, 1), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), - ((2020, 2, 3), ("last-of", DateUnit.YEAR.value), (2020, 12, 31)), +@pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), (1, DateUnit.DAY), (2020, 1, 2)), + ((2020, 1, 31), (1, DateUnit.DAY), (2020, 2, 1)), + ((2020, 2, 28), (1, DateUnit.DAY), (2020, 2, 29)), + ((2020, 1, 1), (-1, DateUnit.DAY), (2019, 12, 31)), + ((2020, 3, 1), (-1, DateUnit.DAY), (2020, 2, 29)), + ((2020, 1, 30), (3, DateUnit.DAY), (2020, 2, 2)), + ((2020, 1, 1), (-3, DateUnit.DAY), (2019, 12, 29)), ]) -def test_offset(actual, offset, expected): - """It works ;).""" +def test_offset_day(start, offset, after): + """It works, including leap years ;).""" - assert Instant(actual).offset(*offset) == Instant(expected) + assert Instant(start).offset(*offset) == Instant(after) From e3c2b470bfcc85b3ef7e5c1751269845eac96901 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Thu, 23 Sep 2021 14:20:20 +0200 Subject: [PATCH 14/18] Only test contract with hypothesis --- openfisca_core/periods/tests/test_instant.py | 74 ++++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py index ded989d2fa..a4c58c644e 100644 --- a/openfisca_core/periods/tests/test_instant.py +++ b/openfisca_core/periods/tests/test_instant.py @@ -57,7 +57,7 @@ def test_instant_with_wrong_arity(): @hypothesis.given(one_of(ST_YEARS), one_of(ST_MONTHS), one_of(ST_DAYS)) @hypothesis.settings(deadline = DEADLINE) -def test_instant(year, month, day): +def test_instant_contract(year, month, day): """Raises with wrong year/month/day, works otherwise.""" # All units have to be integers, otherwise we raise. @@ -127,7 +127,7 @@ def test_period_with_invalid_size(instant): one_of(st.sampled_from(DateUnit)), ) @hypothesis.settings(deadline = DEADLINE) -def test_offset(year, month, day, offset, unit): +def test_offset_contract(year, month, day, offset, unit): """Raises when called with invalid values, works otherwise.""" # We calculate the valid offset values for year. @@ -185,44 +185,60 @@ def test_offset(year, month, day, offset, unit): return # Now we know our offset should always work. - after = start.offset(offset, unit) + assert start.offset(offset, unit) - if offset == "first-of" and unit == DateUnit.YEAR: - assert after.canonical == (year, 1, 1) - return - if offset == "first-of" and unit == DateUnit.MONTH: - assert after.canonical == (year, start.month, 1) - return +@pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), ("first-of", DateUnit.MONTH), (2020, 1, 1)), + ((2020, 1, 1), ("first-of", DateUnit.YEAR), (2020, 1, 1)), + ((2020, 2, 1), ("first-of", DateUnit.MONTH), (2020, 2, 1)), + ((2020, 2, 1), ("first-of", DateUnit.YEAR), (2020, 1, 1)), + ((2020, 2, 3), ("first-of", DateUnit.MONTH), (2020, 2, 1)), + ((2020, 2, 3), ("first-of", DateUnit.YEAR), (2020, 1, 1)), + ]) +def test_offset_first_of(start, offset, after): + """It works ;).""" - if offset == "last-of" and unit == DateUnit.YEAR: - assert after.canonical == (year, 12, 31) - return + assert Instant(start).offset(*offset) == Instant(after) - if offset == "last-of" and unit == DateUnit.MONTH: - assert after.canonical == (year, start.month, ok_days[-1]) - return - # We test the actual offset values for month/day below. - if unit != DateUnit.YEAR: - return +@pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), ("last-of", DateUnit.MONTH), (2020, 1, 31)), + ((2020, 1, 1), ("last-of", DateUnit.YEAR), (2020, 12, 31)), + ((2020, 2, 1), ("last-of", DateUnit.MONTH), (2020, 2, 29)), + ((2020, 2, 1), ("last-of", DateUnit.YEAR), (2020, 12, 31)), + ((2020, 2, 3), ("last-of", DateUnit.MONTH), (2020, 2, 29)), + ((2020, 2, 3), ("last-of", DateUnit.YEAR), (2020, 12, 31)), + ]) +def test_offset_last_of(start, offset, after): + """It works ;).""" - # Leap year! - if day == 29 and ok_days[-1] == 29: - after.canonical == (year + offset, month, day - 1) - return + assert Instant(start).offset(*offset) == Instant(after) + + +@pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), (-1, DateUnit.YEAR), (2019, 1, 1)), + ((2020, 1, 1), (-3, DateUnit.YEAR), (2017, 1, 1)), + ((2020, 1, 1), (1, DateUnit.YEAR), (2021, 1, 1)), + ((2020, 1, 1), (3, DateUnit.YEAR), (2023, 1, 1)), + ((2020, 1, 31), (1, DateUnit.YEAR), (2021, 1, 31)), + ((2020, 2, 29), (-1, DateUnit.YEAR), (2019, 2, 28)), + ((2020, 2, 29), (1, DateUnit.YEAR), (2021, 2, 28)), + ]) +def test_offset_year(start, offset, after): + """It works, including leap years ;).""" - assert after.canonical == (year + offset, month, day) + assert Instant(start).offset(*offset) == Instant(after) @pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), (-1, DateUnit.MONTH), (2019, 12, 1)), + ((2020, 1, 1), (-3, DateUnit.MONTH), (2019, 10, 1)), ((2020, 1, 1), (1, DateUnit.MONTH), (2020, 2, 1)), ((2020, 1, 31), (1, DateUnit.MONTH), (2020, 2, 29)), + ((2020, 10, 2), (3, DateUnit.MONTH), (2021, 1, 2)), ((2020, 2, 28), (1, DateUnit.MONTH), (2020, 3, 28)), - ((2020, 1, 1), (-1, DateUnit.MONTH), (2019, 12, 1)), ((2020, 3, 31), (-1, DateUnit.MONTH), (2020, 2, 29)), - ((2020, 10, 2), (3, DateUnit.MONTH), (2021, 1, 2)), - ((2020, 1, 1), (-3, DateUnit.MONTH), (2019, 10, 1)), ]) def test_offset_month(start, offset, after): """It works, including leap years ;).""" @@ -231,13 +247,13 @@ def test_offset_month(start, offset, after): @pytest.mark.parametrize("start, offset, after", [ + ((2020, 1, 1), (-1, DateUnit.DAY), (2019, 12, 31)), + ((2020, 1, 1), (-3, DateUnit.DAY), (2019, 12, 29)), ((2020, 1, 1), (1, DateUnit.DAY), (2020, 1, 2)), + ((2020, 1, 30), (3, DateUnit.DAY), (2020, 2, 2)), ((2020, 1, 31), (1, DateUnit.DAY), (2020, 2, 1)), ((2020, 2, 28), (1, DateUnit.DAY), (2020, 2, 29)), - ((2020, 1, 1), (-1, DateUnit.DAY), (2019, 12, 31)), ((2020, 3, 1), (-1, DateUnit.DAY), (2020, 2, 29)), - ((2020, 1, 30), (3, DateUnit.DAY), (2020, 2, 2)), - ((2020, 1, 1), (-3, DateUnit.DAY), (2019, 12, 29)), ]) def test_offset_day(start, offset, after): """It works, including leap years ;).""" From 77d8bbef6eaba40fc7f1c6cd3b5e4269056b9030 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Thu, 23 Sep 2021 14:26:25 +0200 Subject: [PATCH 15/18] Only test contract with hypothesis --- openfisca_core/periods/tests/test_instant.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py index a4c58c644e..419717241f 100644 --- a/openfisca_core/periods/tests/test_instant.py +++ b/openfisca_core/periods/tests/test_instant.py @@ -93,9 +93,9 @@ def test_instant_contract(year, month, day): Instant((year, month, day)) return - instant = Instant((year, month, day)) + *units, = Instant((year, month, day)) - assert instant.canonical == (year, month, day) + assert units == [year, month, day] def test_period_deprecation(instant): @@ -185,7 +185,7 @@ def test_offset_contract(year, month, day, offset, unit): return # Now we know our offset should always work. - assert start.offset(offset, unit) + assert isinstance(start.offset(offset, unit), Instant) @pytest.mark.parametrize("start, offset, after", [ From 9e61984e93dc9bf59d261bacc5f20e211452d28a Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Thu, 23 Sep 2021 14:26:52 +0200 Subject: [PATCH 16/18] Pimp date unit test --- openfisca_core/periods/tests/test_date_unit.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openfisca_core/periods/tests/test_date_unit.py b/openfisca_core/periods/tests/test_date_unit.py index a164ef7683..a9d7e1dbac 100644 --- a/openfisca_core/periods/tests/test_date_unit.py +++ b/openfisca_core/periods/tests/test_date_unit.py @@ -4,21 +4,32 @@ @pytest.mark.parametrize("operation", [ + # Contains "DAY" in DateUnit, "day" in DateUnit, 2 in DateUnit, + + # Equality "DAY" == DateUnit.DAY, "day" == DateUnit.DAY, 2 == DateUnit.DAY, + + # Less than. "DAY" < DateUnit.MONTH, "day" < DateUnit.MONTH, 2 < DateUnit.MONTH, + + # Greater than. "MONTH" > DateUnit.DAY, "month" > DateUnit.DAY, 3 > DateUnit.DAY, + + # Less or equal than. "DAY" <= DateUnit.DAY, "day" <= DateUnit.DAY, 2 <= DateUnit.DAY, + + # Greater or equal than. "DAY" >= DateUnit.DAY, "day" >= DateUnit.DAY, 2 >= DateUnit.DAY, From 19c72c0426c11b6b23e7978c017c2866fd157e6b Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Thu, 23 Sep 2021 16:24:59 +0200 Subject: [PATCH 17/18] Test helpers.instant with hypothesis --- openfisca_core/periods/helpers.py | 66 +++++++------- openfisca_core/periods/tests/test_helpers.py | 92 ++++++++++++++++---- 2 files changed, 108 insertions(+), 50 deletions(-) diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index 9d0417e277..594afa98bd 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -92,68 +92,68 @@ def instant(instant: Optional[InstantLike] = None) -> Optional[Instant]: if isinstance(instant, Instant): return instant - #: See: :attr`.Period.start`. + # See: :attr`.Period.start`. if isinstance(instant, Period): return instant.start - #: For example ``2021`` gives ````. + # For example ``2021`` gives ````. if isinstance(instant, int): return Instant((instant, 1, 1)) - #: For example ``datetime.date(2021, 9, 16)``. + # For example ``datetime.date(2021, 9, 16)``. if isinstance(instant, datetime.date): return Instant((instant.year, instant.month, instant.day)) try: - #: For example if ``instant`` is ``["2014"]``, we will: - #: - #: 1. Try to cast each element to an :obj:`int`. + # For example if ``instant`` is ``["2014"]``, we will: + # + # 1. Try to cast each element to an :obj:`int`. + # + # 2. Add a date unit recursively (``month``, then ``day``). # - #: 2. Add a date unit recursively (``month``, then ``day``). - #: if isinstance(instant, (list, tuple)) and len(instant) < 3: return periods.instant([*[int(unit) for unit in instant], 1]) - #: For example if ``instant`` is ``["2014", 9, 12, 32]``, we will: - #: - #: 1. Select the first three elements of the collection. + # For example if ``instant`` is ``["2014", 9, 12, 32]``, we will: + # + # 1. Select the first three elements of the collection. + # + # 2. Try to cast those three elements to an :obj:`int`. # - #: 2. Try to cast those three elements to an :obj:`int`. - #: if isinstance(instant, (list, tuple)): return Instant(tuple(int(unit) for unit in instant[0:3])) - #: Up to this point, if ``instant`` is not a :obj:`str`, we desist. + # Up to this point, if ``instant`` is not a :obj:`str`, we desist. if not isinstance(instant, str): raise ValueError - #: We look for ``fragments``, for example ``day:2014:3``: - #: - #: - If there are, we split and call :func:`.instant` recursively. - #: - #: - If there are not, we continue. - #: - #: See :meth:`.Period.get_subperiods` and :attr:`.Period.size`. - #: + # We look for ``fragments``, for example ``day:2014:3``: + # + # - If there are, we split and call :func:`.instant` recursively. + # + # - If there are not, we continue. + # + # See :meth:`.Period.get_subperiods` and :attr:`.Period.size`. + # if instant.find(":") != -1: return periods.instant(instant.split(":")[1]) - #: We assume we're dealing with a date in the ISO format, so: - #: - #: - If we can't decompose ``instant``, we call :func:`.instant` - #: recursively, for example given ``"2014"``` we will call - #: ``periods.instant(["2014"])``. - #: - #: - Otherwise, we split ``instant`` and then call :func:`.instant` - #: recursively, for example given ``"2014-9"`` we will call - #: ``periods.instant(["2014", "9"])``. - #: + # We assume we're dealing with a date in the ISO format, so: + # + # - If we can't decompose ``instant``, we call :func:`.instant` + # recursively, for example given ``"2014"``` we will call + # ``periods.instant(["2014"])``. + # + # - Otherwise, we split ``instant`` and then call :func:`.instant` + # recursively, for example given ``"2014-9"`` we will call + # ``periods.instant(["2014", "9"])``. + # if instant.find("-") == -1: return periods.instant([instant]) return periods.instant(instant.split("-")) - except ValueError: + except (ValueError, TypeError): raise ValueError( f"'{instant}' is not a valid instant. Instants are described " "using the 'YYYY-MM-DD' format, for example: '2015-06-15'. " diff --git a/openfisca_core/periods/tests/test_helpers.py b/openfisca_core/periods/tests/test_helpers.py index c19f7b7e94..370efbebfe 100644 --- a/openfisca_core/periods/tests/test_helpers.py +++ b/openfisca_core/periods/tests/test_helpers.py @@ -1,22 +1,81 @@ +import datetime + +import hypothesis import pytest +from hypothesis import strategies as st from openfisca_core import periods -from openfisca_core.periods import Instant, Period -from openfisca_core.taxbenefitsystems import TaxBenefitSystem - - -@pytest.mark.parametrize("args", [ - TaxBenefitSystem, - [2021, "-12"], - "2021-31-12", - "2021-foo", - object, - ]) -def test_instant_with_invalid_arguments(args): - """Raises a ValueError when called with invalid arguments.""" - - with pytest.raises(ValueError, match = str(args)): - periods.instant(args) +from openfisca_core.periods import DateUnit, Instant, Period + +# Valid ranges for years. +MIN_YEAR = datetime.MINYEAR +MAX_YEAR = datetime.MAXYEAR +OK_YEARS = range(MIN_YEAR, MAX_YEAR + 1) + +# Fail if test execution time is slower than 1ms. +DEADLINE = datetime.timedelta(milliseconds = 1) + + +def one_of(*sts): + """Random floats, strings, etc…""" + + return st.one_of( + *sts, + st.integers(datetime.MINYEAR, datetime.MAXYEAR), + st.floats(), + st.text(), + st.none(), + st.dates(), + st.lists(*sts), + st.tuples(*sts), + ) + + +@hypothesis.given( + one_of( + st.sampled_from(( + Period((DateUnit.YEAR, Instant((1, 1, 1)), 3)), + Instant((1, 1, 1)), + [2021, "-12"], + "2021-31-12", + "2021-foo", + object() + )), + ) + ) +@hypothesis.settings(deadline = DEADLINE, max_examples = 1000) +def test_instant_contract(instant): + """Raises when called with invalid arguments, works otherwise.""" + + if instant is None: + assert not periods.instant(instant) + return + + if isinstance(instant, (Period, Instant, datetime.date, int)): + assert isinstance(periods.instant(instant), Instant) + return + + if isinstance(instant, str): + if instant.strip().isdigit() and int(instant) in OK_YEARS: + assert isinstance(periods.instant(instant), Instant) + return + + if isinstance(instant, (list, tuple)): + if len(instant) == 0: + assert isinstance(periods.instant(instant), Instant) + return + + if all(isinstance(unit, int) for unit in instant): + assert isinstance(periods.instant(instant), Instant) + return + + if all(isinstance(unit, str) for unit in instant): + if all(unit.strip().isdigit() for unit in instant): + assert isinstance(periods.instant(instant), Instant) + return + + with pytest.raises(ValueError): + periods.instant(instant) @pytest.mark.parametrize("actual, expected", [ @@ -45,7 +104,6 @@ def test_instant_date_deprecation(): @pytest.mark.parametrize("args", [ - TaxBenefitSystem, [2021, "-12"], "2021-31-12", "2021-foo", From 841c8fb25eb7c1328290764a241ecb4a4333e32f Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Thu, 23 Sep 2021 19:18:32 +0200 Subject: [PATCH 18/18] Test helpers.period with hypothesis --- openfisca_core/periods/helpers.py | 127 +++++++++++-------- openfisca_core/periods/period_.py | 68 +++++++++- openfisca_core/periods/tests/test_helpers.py | 115 +++++++++++++---- openfisca_core/periods/tests/test_instant.py | 9 +- 4 files changed, 232 insertions(+), 87 deletions(-) diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index 594afa98bd..81c67f595d 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -105,28 +105,40 @@ def instant(instant: Optional[InstantLike] = None) -> Optional[Instant]: return Instant((instant.year, instant.month, instant.day)) try: - # For example if ``instant`` is ``["2014"]``, we will: - # - # 1. Try to cast each element to an :obj:`int`. - # - # 2. Add a date unit recursively (``month``, then ``day``). - # - if isinstance(instant, (list, tuple)) and len(instant) < 3: - return periods.instant([*[int(unit) for unit in instant], 1]) - - # For example if ``instant`` is ``["2014", 9, 12, 32]``, we will: - # - # 1. Select the first three elements of the collection. - # - # 2. Try to cast those three elements to an :obj:`int`. - # if isinstance(instant, (list, tuple)): + # If it is a list/tuple, we expect it to be of int/str. + if not all(isinstance(unit, (int, str)) for unit in instant): + raise ValueError + + # There can't be empty strings or zeros. + if not all(instant): + raise ValueError + + # For example if ``instant`` is ``["2014"]``, we will: + # + # 1. Try to cast each element to an :obj:`int`. + # + # 2. Add a date unit recursively (``month``, then ``day``). + # + if len(instant) < 3: + return periods.instant([*[int(unit) for unit in instant], 1]) + + # For example if ``instant`` is ``["2014", 9, 12, 32]``, we will: + # + # 1. Select the first three elements of the collection. + # + # 2. Try to cast those three elements to an :obj:`int`. + # return Instant(tuple(int(unit) for unit in instant[0:3])) # Up to this point, if ``instant`` is not a :obj:`str`, we desist. if not isinstance(instant, str): raise ValueError + # There can't be empty strings or. + if not instant: + raise ValueError + # We look for ``fragments``, for example ``day:2014:3``: # # - If there are, we split and call :func:`.instant` recursively. @@ -153,7 +165,7 @@ def instant(instant: Optional[InstantLike] = None) -> Optional[Instant]: return periods.instant(instant.split("-")) - except (ValueError, TypeError): + except ValueError: raise ValueError( f"'{instant}' is not a valid instant. Instants are described " "using the 'YYYY-MM-DD' format, for example: '2015-06-15'. " @@ -262,84 +274,93 @@ def period(value: PeriodLike) -> Period: if isinstance(value, Period): return value - #: We return a "day-period", for example - #: ``, 1))>``. - #: + # We return a "day-period", for example + # ``, 1))>``. + # if isinstance(value, Instant): return Period((DateUnit.DAY.value, value, 1)) - #: For example ``datetime.date(2021, 9, 16)``. + # For example ``datetime.date(2021, 9, 16)``. if isinstance(value, datetime.date): instant = periods.instant(value) return Period((DateUnit.DAY.value, instant, 1)) - #: We return an "eternity-period", for example - #: ``, inf))>``. - #: + # We return an "eternity-period", for example + # ``, inf))>``. + # if value == DateUnit.ETERNITY: instant = periods.instant(datetime.date.min) return Period((DateUnit.ETERNITY.value, instant, float("inf"))) - #: For example ``2021`` gives - #: ``, 1))>``. - #: + # For example ``2021`` gives + # ``, 1))>``. + # if isinstance(value, int): instant = periods.instant(value) return Period((DateUnit.YEAR.value, instant, 1)) try: - #: Up to this point, if ``value`` is not a :obj:`str`, we desist. + # Up to this point, if ``value`` is not a :obj:`str`, we desist. if not isinstance(value, str): raise ValueError - #: We calculate the date unit index based on the indexes of - #: :class:`.DateUnit`. - #: - #: So for example if ``value`` is ``"2021-02"``, the result of ``len`` - #: will be ``2``, and we know we're looking to build a month-period. - #: - #: ``MONTH`` is the 4th member of :class:`.DateUnit`. Because it is - #: an :class:`.indexed_enums.Enum`, we know its index is then ``3``. - #: - #: Then ``5 - 2`` gives us the index of :obj:`.DateUnit.MONTH`, ``3``. - #: + # There can't be empty strings. + if not value: + raise ValueError + + # We calculate the date unit index based on the indexes of + # :class:`.DateUnit`. + # + # So for example if ``value`` is ``"2021-02"``, the result of ``len`` + # will be ``2``, and we know we're looking to build a month-period. + # + # ``MONTH`` is the 4th member of :class:`.DateUnit`. Because it is + # an :class:`.indexed_enums.Enum`, we know its index is then ``3``. + # + # Then ``5 - 2`` gives us the index of :obj:`.DateUnit.MONTH`, ``3``. + # index = DateUnit[-1].index - len(value.split("-")) # type: ignore instant_unit = DateUnit[index] # type: ignore - #: We look for ``fragments`` see :func:`.instant`. - #: - #: If there are no fragments, we will delegate the next steps to - #: :func:`.instant`. - #: + # We look for ``fragments`` see :func:`.instant`. + # + # If there are no fragments, we will delegate the next steps to + # :func:`.instant`. + # if value.find(":") == -1: instant = periods.instant(value) return Period((instant_unit.value, instant, 1)) - #: For example ``month``, ``2014``, and ``1``. + # For example ``month``, ``2014``, and ``1``. input_unit, *rest = value.split(":") + + # Up to this point, ``unit`` can't be empty or invalid. + if input_unit not in DateUnit.isoformat: + raise ValueError + period_unit = DateUnit[input_unit] - #: Left-most component must be a valid unit: ``day``, ``month``, or - #: ``year``. - #: + # Left-most component must be a valid unit: ``day``, ``month``, or + # ``year``. + # if period_unit not in DateUnit.isoformat: raise ValueError - #: Reject ambiguous periods, such as ``month:2014``. + # Reject ambiguous periods, such as ``month:2014``. if instant_unit > period_unit: raise ValueError - #: Now that we have the ``unit``, we will create an ``instant``. + # Now that we have the ``unit``, we will create an ``instant``. date, *rest = rest instant = periods.instant(value) - #: Periods like ``year:2015-03`` have, by default, a size of 1. + # Periods like ``year:2015-03`` have, by default, a size of 1. if not rest: return Period((period_unit.value, instant, 1)) - #: If provided, let's make sure the ``size`` is an integer. - #: We also ignore any extra element, so for example if the provided - #: ``value`` is ``"year:2021:3:asdf1234"`` we will ignore ``asdf1234``. + # If provided, let's make sure the ``size`` is an integer. + # We also ignore any extra element, so for example if the provided + # ``value`` is ``"year:2021:3:asdf1234"`` we will ignore ``asdf1234``. size = int(rest[0]) return Period((period_unit.value, instant, size)) diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index b837b42a15..041e454321 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -8,27 +8,81 @@ class Period(tuple): - """ - Toolbox to handle date intervals. + """Toolbox to handle date intervals. + + A :class:`.Period` is a triple (``unit``, ``start``, ``size``). - A period is a triple (unit, start, size), where unit is either "month" or "year", where start format is a - (year, month, day) triple, and where size is an integer > 1. + Attributes: + unit (:obj:`.DateUnit`): + Either an :meth:`~DateUnit.isoformat` unit (``day``, ``month``, + ``year``), an :meth:`~DateUnit.isocalendar` one (``week_day``, + ``week``, ``year``), or :obj:`~DateUnit.ETERNITY`. + start (:obj:`.Instant`): + The "instant" the :obj:`.Period` starts at. + size (:obj:`int`): + The amount of ``unit``, starting at ``start``, at least ``1``. - Since a period is a triple it can be used as a dictionary key. + Args: + fragments (tuple(.DateUnit, .Instant, int)): + The ``unit``, ``start``, and ``size``, accordingly. Examples: >>> instant = Instant((2021, 9, 1)) - >>> period = Period((DateUnit.YEAR.value, instant, 3)) + >>> period = Period((DateUnit.YEAR, instant, 3)) >>> repr(Period) "" >>> repr(period) - ", 3))>" + ', , 3))>' >>> str(period) 'year:2021-09:3' + # >>> dict([period, instant]) + + >>> list(period) + [, , 3] + + >>> period[0] + + + >>> period[0] in period + True + + >>> len(period) + 3 + + >>> period == Period((DateUnit.YEAR, instant, 3)) + True + + >>> period != Period((DateUnit.YEAR, instant, 3)) + False + + >>> period > Period((DateUnit.YEAR, instant, 3)) + False + + >>> period < Period((DateUnit.YEAR, instant, 3)) + False + + >>> period >= Period((DateUnit.YEAR, instant, 3)) + True + + >>> period <= Period((DateUnit.YEAR, instant, 3)) + True + + >>> period.unit + + + >>> period.start + + + >>> period.size + 3 + + # >>> period.canonical + (DateUnit.YEAR, instant, 3) + """ def __repr__(self) -> str: diff --git a/openfisca_core/periods/tests/test_helpers.py b/openfisca_core/periods/tests/test_helpers.py index 370efbebfe..817ee7b867 100644 --- a/openfisca_core/periods/tests/test_helpers.py +++ b/openfisca_core/periods/tests/test_helpers.py @@ -13,7 +13,10 @@ OK_YEARS = range(MIN_YEAR, MAX_YEAR + 1) # Fail if test execution time is slower than 1ms. -DEADLINE = datetime.timedelta(milliseconds = 1) +DEADLINE = datetime.timedelta(milliseconds = 10) + +# Number of random examples to run. +MAX_EXAMPLES = 5000 def one_of(*sts): @@ -26,11 +29,25 @@ def one_of(*sts): st.text(), st.none(), st.dates(), + st.dates().map(datetime.date.isoformat), st.lists(*sts), st.tuples(*sts), ) +def isintdate(date): + return date.strip().isdecimal() and int(date) in OK_YEARS + + +def isisodate(date): + try: + return datetime.date.fromisoformat(date) + except ValueError: + with pytest.raises(ValueError): + periods.instant(date) + return + + @hypothesis.given( one_of( st.sampled_from(( @@ -43,7 +60,7 @@ def one_of(*sts): )), ) ) -@hypothesis.settings(deadline = DEADLINE, max_examples = 1000) +@hypothesis.settings(deadline = DEADLINE, max_examples = MAX_EXAMPLES) def test_instant_contract(instant): """Raises when called with invalid arguments, works otherwise.""" @@ -56,7 +73,23 @@ def test_instant_contract(instant): return if isinstance(instant, str): - if instant.strip().isdigit() and int(instant) in OK_YEARS: + if instant.find(":") != -1: + unit, date, *rest = instant.split(":") + + if isintdate(date): + instant = periods.instant(":".join([unit, date, *rest])) + assert isinstance(instant, Instant) + return + + if instant.find("-") != -1 and isisodate(instant): + assert isinstance(periods.instant(instant), Instant) + return + + if isintdate(instant): + assert isinstance(periods.instant(instant), Instant) + return + + if instant.find("-") != -1 and isisodate(instant): assert isinstance(periods.instant(instant), Instant) return @@ -69,26 +102,25 @@ def test_instant_contract(instant): assert isinstance(periods.instant(instant), Instant) return - if all(isinstance(unit, str) for unit in instant): - if all(unit.strip().isdigit() for unit in instant): - assert isinstance(periods.instant(instant), Instant) - return + if all(isinstance(unit, str) and isintdate(unit) for unit in instant): + assert isinstance(periods.instant(instant), Instant) + return with pytest.raises(ValueError): periods.instant(instant) @pytest.mark.parametrize("actual, expected", [ - (periods.instant((2021, 9, 16)), Instant((2021, 9, 16))), - (periods.instant(["2021", "9"]), Instant((2021, 9, 1))), - (periods.instant(["2021", "09", "16"]), Instant((2021, 9, 16))), - (periods.instant((2021, "9", "16")), Instant((2021, 9, 16))), - (periods.instant((2021, 9, 16, 42)), Instant((2021, 9, 16))), (periods.instant("2021-09"), Instant((2021, 9, 1))), (periods.instant("2021-9-16"), Instant((2021, 9, 16))), + (periods.instant("month:2021-9:2"), Instant((2021, 9, 1))), (periods.instant("year:2021"), Instant((2021, 1, 1))), (periods.instant("year:2021:1"), Instant((2021, 1, 1))), - (periods.instant("month:2021-9:2"), Instant((2021, 9, 1))), + (periods.instant((2021, "9", "16")), Instant((2021, 9, 16))), + (periods.instant((2021, 9, 16)), Instant((2021, 9, 16))), + (periods.instant((2021, 9, 16, 42)), Instant((2021, 9, 16))), + (periods.instant(["2021", "09", "16"]), Instant((2021, 9, 16))), + (periods.instant(["2021", "9"]), Instant((2021, 9, 1))), ]) def test_instant(actual, expected): """It works :).""" @@ -103,18 +135,53 @@ def test_instant_date_deprecation(): periods.instant_date() -@pytest.mark.parametrize("args", [ - [2021, "-12"], - "2021-31-12", - "2021-foo", - "day:2014", - object, - ]) -def test_period_with_invalid_arguments(args): - """Raises a ValueError when called with invalid arguments.""" +@hypothesis.given( + one_of( + st.sampled_from(( + Period((DateUnit.YEAR, Instant((1, 1, 1)), 3)), + Instant((1, 1, 1)), + DateUnit.ETERNITY, + [2021, "-12"], + "2021-31-12", + "2021-foo", + object() + )), + ) + ) +@hypothesis.settings(deadline = DEADLINE, max_examples = MAX_EXAMPLES) +def test_period_contract(period): + """Raises when called with invalid arguments, works otherwise.""" - with pytest.raises(ValueError, match = str(args)): - periods.period(args) + if period is None: + assert not periods.instant(period) + return + + if period and period == DateUnit.ETERNITY: + assert isinstance(periods.period(period), Period) + return + + if isinstance(period, (Period, Instant, datetime.date, int)): + assert isinstance(periods.period(period), Period) + return + + if isinstance(period, str): + if period.find(":") != -1: + unit, date, *rest = period.split(":") + + if unit in DateUnit.isoformat and isisodate(date): + assert isinstance(periods.period(period), Period) + return + + if isintdate(period): + assert isinstance(periods.period(period), Period) + return + + if isisodate(period): + assert isinstance(periods.period(period), Period) + return + + with pytest.raises(ValueError): + periods.period(period) @pytest.mark.parametrize("actual, expected", [ diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py index 419717241f..86b682cd36 100644 --- a/openfisca_core/periods/tests/test_instant.py +++ b/openfisca_core/periods/tests/test_instant.py @@ -32,7 +32,10 @@ OK_UNITS = DateUnit.isoformat # Fail if test execution time is slower than 1ms. -DEADLINE = datetime.timedelta(milliseconds = 1) +DEADLINE = datetime.timedelta(milliseconds = 10) + +# Number of random examples to run. +MAX_EXAMPLES = 5000 def one_of(*sts): @@ -56,7 +59,7 @@ def test_instant_with_wrong_arity(): @hypothesis.given(one_of(ST_YEARS), one_of(ST_MONTHS), one_of(ST_DAYS)) -@hypothesis.settings(deadline = DEADLINE) +@hypothesis.settings(deadline = DEADLINE, max_examples = MAX_EXAMPLES) def test_instant_contract(year, month, day): """Raises with wrong year/month/day, works otherwise.""" @@ -126,7 +129,7 @@ def test_period_with_invalid_size(instant): one_of(st.sampled_from(("first-of", "last-of", *OK_YEARS))), one_of(st.sampled_from(DateUnit)), ) -@hypothesis.settings(deadline = DEADLINE) +@hypothesis.settings(deadline = DEADLINE, max_examples = MAX_EXAMPLES) def test_offset_contract(year, month, day, offset, unit): """Raises when called with invalid values, works otherwise."""