diff --git a/.conda/meta.yaml b/.conda/meta.yaml index 6e5b572204..abec48667e 100644 --- a/.conda/meta.yaml +++ b/.conda/meta.yaml @@ -30,14 +30,15 @@ requirements: {% for req in data.get('install_requires', []) %} - {{ req }} {% endfor %} - # - python >=3.9,<4.0 # - PyYAML >=6.0,<7.0 # - dpath >=2.1.4,<3.0.0 # - importlib-metadata >=6.1.0,<7.0 # - numexpr >=2.8.4,<=3.0 # - numpy >=1.24.2,<1.25.0 + # - pendulum >=2.1.2,<3.0.0 # - psutil >=5.9.4,<6.0.0 # - pytest >=7.2.2,<8.0.0 + # - python >=3.9,<4.0 # - sortedcontainers >=2.4.0 # - typing-extensions >=4.5.0,<5.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 32d46ee2e3..31fc5c320c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 40.1.0 [#1174](https://github.com/openfisca/openfisca-core/pull/1174) + +#### New Features + +* Allows for dispatching and dividing inputs over a broader range. + * For example, divide a monthly variable by week. + ### 40.0.1 [#1184](https://github.com/openfisca/openfisca-core/pull/1184) #### Technical changes diff --git a/openfisca_core/data_storage/in_memory_storage.py b/openfisca_core/data_storage/in_memory_storage.py index c8ab3be3ee..8fb472046b 100644 --- a/openfisca_core/data_storage/in_memory_storage.py +++ b/openfisca_core/data_storage/in_memory_storage.py @@ -1,6 +1,7 @@ import numpy from openfisca_core import periods +from openfisca_core.periods import DateUnit class InMemoryStorage: @@ -14,7 +15,7 @@ def __init__(self, is_eternal=False): def get(self, period): if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) values = self._arrays.get(period) @@ -24,7 +25,7 @@ def get(self, period): def put(self, value, period): if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) self._arrays[period] = value @@ -35,7 +36,7 @@ def delete(self, period=None): return if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) self._arrays = { diff --git a/openfisca_core/data_storage/on_disk_storage.py b/openfisca_core/data_storage/on_disk_storage.py index f481dfd7bc..94422913a1 100644 --- a/openfisca_core/data_storage/on_disk_storage.py +++ b/openfisca_core/data_storage/on_disk_storage.py @@ -4,6 +4,7 @@ import numpy from openfisca_core import periods +from openfisca_core.periods import DateUnit from openfisca_core.indexed_enums import EnumArray @@ -28,7 +29,7 @@ def _decode_file(self, file): def get(self, period): if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) values = self._files.get(period) @@ -38,7 +39,7 @@ def get(self, period): def put(self, value, period): if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) filename = str(period) @@ -55,7 +56,7 @@ def delete(self, period=None): return if self.is_eternal: - period = periods.period(periods.ETERNITY) + period = periods.period(DateUnit.ETERNITY) period = periods.period(period) if period is not None: diff --git a/openfisca_core/holders/helpers.py b/openfisca_core/holders/helpers.py index 2bea4197e8..0e88964fc7 100644 --- a/openfisca_core/holders/helpers.py +++ b/openfisca_core/holders/helpers.py @@ -3,7 +3,6 @@ import numpy from openfisca_core import periods -from openfisca_core.periods import Period log = logging.getLogger(__name__) @@ -21,7 +20,9 @@ def set_input_dispatch_by_period(holder, period, array): period_size = period.size period_unit = period.unit - if holder.variable.definition_period == periods.ETERNITY: + if holder.variable.definition_period not in ( + periods.DateUnit.isoformat + periods.DateUnit.isocalendar + ): raise ValueError( "set_input_dispatch_by_period can't be used for eternal variables." ) @@ -30,7 +31,7 @@ def set_input_dispatch_by_period(holder, period, array): after_instant = period.start.offset(period_size, period_unit) # Cache the input data, skipping the existing cached months - sub_period = Period((cached_period_unit, period.start, 1)) + sub_period = periods.Period((cached_period_unit, period.start, 1)) while sub_period.start < after_instant: existing_array = holder.get_array(sub_period) if existing_array is None: @@ -55,7 +56,9 @@ def set_input_divide_by_period(holder, period, array): period_size = period.size period_unit = period.unit - if holder.variable.definition_period == periods.ETERNITY: + if holder.variable.definition_period not in ( + periods.DateUnit.isoformat + periods.DateUnit.isocalendar + ): raise ValueError( "set_input_divide_by_period can't be used for eternal variables." ) @@ -65,7 +68,7 @@ def set_input_divide_by_period(holder, period, array): # Count the number of elementary periods to change, and the difference with what is already known. remaining_array = array.copy() - sub_period = Period((cached_period_unit, period.start, 1)) + sub_period = periods.Period((cached_period_unit, period.start, 1)) sub_periods_count = 0 while sub_period.start < after_instant: existing_array = holder.get_array(sub_period) @@ -78,7 +81,7 @@ def set_input_divide_by_period(holder, period, array): # Cache the input data if sub_periods_count > 0: divided_array = remaining_array / sub_periods_count - sub_period = Period((cached_period_unit, period.start, 1)) + sub_period = periods.Period((cached_period_unit, period.start, 1)) while sub_period.start < after_instant: if holder.get_array(sub_period) is None: holder._set(sub_period, divided_array) diff --git a/openfisca_core/holders/holder.py b/openfisca_core/holders/holder.py index 2c00216d90..fc0d6718bf 100644 --- a/openfisca_core/holders/holder.py +++ b/openfisca_core/holders/holder.py @@ -30,9 +30,8 @@ def __init__(self, variable, population): self.population = population self.variable = variable self.simulation = population.simulation - self._memory_storage = storage.InMemoryStorage( - is_eternal=(self.variable.definition_period == periods.ETERNITY) - ) + self._eternal = self.variable.definition_period == periods.DateUnit.ETERNITY + self._memory_storage = storage.InMemoryStorage(is_eternal=self._eternal) # By default, do not activate on-disk storage, or variable dropping self._disk_storage = None @@ -71,9 +70,7 @@ def create_disk_storage(self, directory=None, preserve=False): if not os.path.isdir(storage_dir): os.mkdir(storage_dir) return storage.OnDiskStorage( - storage_dir, - is_eternal=(self.variable.definition_period == periods.ETERNITY), - preserve_storage_dir=preserve, + storage_dir, self._eternal, preserve_storage_dir=preserve ) def delete_arrays(self, period=None): @@ -121,7 +118,7 @@ def get_memory_usage(self) -> MemoryUsage: >>> entity = entities.Entity("", "", "", "") >>> class MyVariable(variables.Variable): - ... definition_period = "year" + ... definition_period = periods.DateUnit.YEAR ... entity = entity ... value_type = int @@ -197,7 +194,7 @@ def set_input( >>> entity = entities.Entity("", "", "", "") >>> class MyVariable(variables.Variable): - ... definition_period = "year" + ... definition_period = periods.DateUnit.YEAR ... entity = entity ... value_type = int @@ -221,16 +218,18 @@ def set_input( """ period = periods.period(period) - if ( - period.unit == periods.ETERNITY - and self.variable.definition_period != periods.ETERNITY - ): + + if period.unit == periods.DateUnit.ETERNITY and not self._eternal: error_message = os.linesep.join( [ - "Unable to set a value for variable {0} for periods.ETERNITY.", - "{0} is only defined for {1}s. Please adapt your input.", + "Unable to set a value for variable {1} for {0}.", + "{1} is only defined for {2}s. Please adapt your input.", ] - ).format(self.variable.name, self.variable.definition_period) + ).format( + periods.DateUnit.ETERNITY.upper(), + self.variable.name, + self.variable.definition_period, + ) raise errors.PeriodMismatchError( self.variable.name, period, @@ -279,10 +278,11 @@ def _to_array(self, value): def _set(self, period, value): value = self._to_array(value) - if self.variable.definition_period != periods.ETERNITY: + if not self._eternal: if period is None: raise ValueError( - "A period must be specified to set values, except for variables with periods.ETERNITY as as period_definition." + f"A period must be specified to set values, except for variables with " + f"{periods.DateUnit.ETERNITY.upper()} as as period_definition.", ) if self.variable.definition_period != period.unit or period.size > 1: name = self.variable.name diff --git a/openfisca_core/holders/tests/__init__.py b/openfisca_core/holders/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/holders/tests/test_helpers.py b/openfisca_core/holders/tests/test_helpers.py index 749d262724..d76040d676 100644 --- a/openfisca_core/holders/tests/test_helpers.py +++ b/openfisca_core/holders/tests/test_helpers.py @@ -1,9 +1,9 @@ import pytest -from openfisca_core import holders, periods, tools +from openfisca_core import holders, tools from openfisca_core.entities import Entity from openfisca_core.holders import Holder -from openfisca_core.periods import Instant, Period +from openfisca_core.periods import DateUnit, Instant, Period from openfisca_core.populations import Population from openfisca_core.variables import Variable @@ -37,15 +37,36 @@ def population(people): @pytest.mark.parametrize( "dispatch_unit, definition_unit, values, expected", [ - [periods.YEAR, periods.YEAR, [1.0], [3.0]], - [periods.YEAR, periods.MONTH, [1.0], [36.0]], - [periods.YEAR, periods.DAY, [1.0], [1096.0]], - [periods.MONTH, periods.YEAR, [1.0], [1.0]], - [periods.MONTH, periods.MONTH, [1.0], [3.0]], - [periods.MONTH, periods.DAY, [1.0], [90.0]], - [periods.DAY, periods.YEAR, [1.0], [1.0]], - [periods.DAY, periods.MONTH, [1.0], [1.0]], - [periods.DAY, periods.DAY, [1.0], [3.0]], + [DateUnit.YEAR, DateUnit.YEAR, [1.0], [3.0]], + [DateUnit.YEAR, DateUnit.MONTH, [1.0], [36.0]], + [DateUnit.YEAR, DateUnit.DAY, [1.0], [1096.0]], + [DateUnit.YEAR, DateUnit.WEEK, [1.0], [157.0]], + [DateUnit.YEAR, DateUnit.WEEKDAY, [1.0], [1096.0]], + [DateUnit.MONTH, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.MONTH, DateUnit.MONTH, [1.0], [3.0]], + [DateUnit.MONTH, DateUnit.DAY, [1.0], [90.0]], + [DateUnit.MONTH, DateUnit.WEEK, [1.0], [13.0]], + [DateUnit.MONTH, DateUnit.WEEKDAY, [1.0], [90.0]], + [DateUnit.DAY, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.DAY, [1.0], [3.0]], + [DateUnit.DAY, DateUnit.WEEK, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.WEEKDAY, [1.0], [3.0]], + [DateUnit.WEEK, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.DAY, [1.0], [21.0]], + [DateUnit.WEEK, DateUnit.WEEK, [1.0], [3.0]], + [DateUnit.WEEK, DateUnit.WEEKDAY, [1.0], [21.0]], + [DateUnit.WEEK, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.DAY, [1.0], [21.0]], + [DateUnit.WEEK, DateUnit.WEEK, [1.0], [3.0]], + [DateUnit.WEEK, DateUnit.WEEKDAY, [1.0], [21.0]], + [DateUnit.WEEKDAY, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.DAY, [1.0], [3.0]], + [DateUnit.WEEKDAY, DateUnit.WEEK, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.WEEKDAY, [1.0], [3.0]], ], ) def test_set_input_dispatch_by_period( @@ -71,15 +92,31 @@ def test_set_input_dispatch_by_period( @pytest.mark.parametrize( "divide_unit, definition_unit, values, expected", [ - [periods.YEAR, periods.YEAR, [3.0], [1.0]], - [periods.YEAR, periods.MONTH, [36.0], [1.0]], - [periods.YEAR, periods.DAY, [1095.0], [1.0]], - [periods.MONTH, periods.YEAR, [1.0], [1.0]], - [periods.MONTH, periods.MONTH, [3.0], [1.0]], - [periods.MONTH, periods.DAY, [90.0], [1.0]], - [periods.DAY, periods.YEAR, [1.0], [1.0]], - [periods.DAY, periods.MONTH, [1.0], [1.0]], - [periods.DAY, periods.DAY, [3.0], [1.0]], + [DateUnit.YEAR, DateUnit.YEAR, [3.0], [1.0]], + [DateUnit.YEAR, DateUnit.MONTH, [36.0], [1.0]], + [DateUnit.YEAR, DateUnit.DAY, [1095.0], [1.0]], + [DateUnit.YEAR, DateUnit.WEEK, [157.0], [1.0]], + [DateUnit.YEAR, DateUnit.WEEKDAY, [1095.0], [1.0]], + [DateUnit.MONTH, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.MONTH, DateUnit.MONTH, [3.0], [1.0]], + [DateUnit.MONTH, DateUnit.DAY, [90.0], [1.0]], + [DateUnit.MONTH, DateUnit.WEEK, [13.0], [1.0]], + [DateUnit.MONTH, DateUnit.WEEKDAY, [90.0], [1.0]], + [DateUnit.DAY, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.DAY, [3.0], [1.0]], + [DateUnit.DAY, DateUnit.WEEK, [1.0], [1.0]], + [DateUnit.DAY, DateUnit.WEEKDAY, [3.0], [1.0]], + [DateUnit.WEEK, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.WEEK, DateUnit.DAY, [21.0], [1.0]], + [DateUnit.WEEK, DateUnit.WEEK, [3.0], [1.0]], + [DateUnit.WEEK, DateUnit.WEEKDAY, [21.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.YEAR, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.MONTH, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.DAY, [3.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.WEEK, [1.0], [1.0]], + [DateUnit.WEEKDAY, DateUnit.WEEKDAY, [3.0], [1.0]], ], ) def test_set_input_divide_by_period( diff --git a/openfisca_core/periods/__init__.py b/openfisca_core/periods/__init__.py index 9b3840437c..4669c7ff4f 100644 --- a/openfisca_core/periods/__init__.py +++ b/openfisca_core/periods/__init__.py @@ -23,23 +23,24 @@ from .config import ( # noqa: F401 DAY, - MONTH, - YEAR, ETERNITY, INSTANT_PATTERN, + MONTH, + WEEK, + WEEKDAY, + YEAR, date_by_instant_cache, str_by_instant_cache, year_or_month_or_day_re, ) - +from .date_unit import DateUnit # noqa: F401 from .helpers import ( # noqa: F401 instant, instant_date, - period, key_period_size, - unit_weights, + period, unit_weight, + unit_weights, ) - from .instant_ import Instant # noqa: F401 from .period_ import Period # noqa: F401 diff --git a/openfisca_core/periods/_parsers.py b/openfisca_core/periods/_parsers.py new file mode 100644 index 0000000000..64b2077831 --- /dev/null +++ b/openfisca_core/periods/_parsers.py @@ -0,0 +1,102 @@ +from typing import Optional + +import re + +import pendulum +from pendulum.datetime import Date +from pendulum.parsing import ParserError + +from .date_unit import DateUnit +from .instant_ import Instant +from .period_ import Period + +invalid_week = re.compile(r".*(W[1-9]|W[1-9]-[0-9]|W[0-5][0-9]-0)$") + + +def _parse_period(value: str) -> Optional[Period]: + """Parses ISO format/calendar periods. + + Such as "2012" or "2015-03". + + Examples: + >>> _parse_period("2022") + Period((, Instant((2022, 1, 1)), 1)) + + >>> _parse_period("2022-02") + Period((, Instant((2022, 2, 1)), 1)) + + >>> _parse_period("2022-W02-7") + Period((, Instant((2022, 1, 16)), 1)) + + """ + + # If it's a complex period, next! + if len(value.split(":")) != 1: + return None + + # Check for a non-empty string. + if not (value and isinstance(value, str)): + raise AttributeError + + # If it's negative, next! + if value[0] == "-": + raise ValueError + + # If it's an invalid week, next! + if invalid_week.match(value): + raise ParserError + + unit = _parse_unit(value) + date = pendulum.parse(value, exact=True) + + if not isinstance(date, Date): + raise ValueError + + instant = Instant((date.year, date.month, date.day)) + + return Period((unit, instant, 1)) + + +def _parse_unit(value: str) -> DateUnit: + """Determine the date unit of a date string. + + Args: + value (str): The date string to parse. + + Returns: + A DateUnit. + + Raises: + ValueError when no DateUnit can be determined. + + Examples: + >>> _parse_unit("2022") + + + >>> _parse_unit("2022-W03-01") + + + """ + + length = len(value.split("-")) + isweek = value.find("W") != -1 + + if length == 1: + return DateUnit.YEAR + + elif length == 2: + if isweek: + return DateUnit.WEEK + + else: + return DateUnit.MONTH + + elif length == 3: + if isweek: + return DateUnit.WEEKDAY + + else: + return DateUnit.DAY + + else: + raise ValueError diff --git a/openfisca_core/periods/config.py b/openfisca_core/periods/config.py index 315628400c..17807160e4 100644 --- a/openfisca_core/periods/config.py +++ b/openfisca_core/periods/config.py @@ -1,10 +1,13 @@ import re -import typing -DAY = "day" -MONTH = "month" -YEAR = "year" -ETERNITY = "eternity" +from .date_unit import DateUnit + +WEEKDAY = DateUnit.WEEKDAY +WEEK = DateUnit.WEEK +DAY = DateUnit.DAY +MONTH = DateUnit.MONTH +YEAR = DateUnit.YEAR +ETERNITY = DateUnit.ETERNITY # Matches "2015", "2015-01", "2015-01-01" # Does not match "2015-13", "2015-12-32" @@ -12,8 +15,8 @@ r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$" ) -date_by_instant_cache: typing.Dict = {} -str_by_instant_cache: typing.Dict = {} +date_by_instant_cache: dict = {} +str_by_instant_cache: 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]))?)?$" ) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py new file mode 100644 index 0000000000..a813211495 --- /dev/null +++ b/openfisca_core/periods/date_unit.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from enum import EnumMeta + +from strenum import StrEnum + + +class DateUnitMeta(EnumMeta): + @property + 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.isoformat + (, , >> DateUnit.DAY in DateUnit.isoformat + True + + >>> DateUnit.WEEK in DateUnit.isoformat + False + + """ + + return DateUnit.DAY, DateUnit.MONTH, DateUnit.YEAR + + @property + 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.isocalendar + (, , >> DateUnit.WEEK in DateUnit.isocalendar + True + + >>> "day" in DateUnit.isocalendar + False + + """ + + return DateUnit.WEEKDAY, DateUnit.WEEK, DateUnit.YEAR + + +class DateUnit(StrEnum, metaclass=DateUnitMeta): + """The date units of a rule system. + + Examples: + >>> repr(DateUnit) + "" + + >>> repr(DateUnit.DAY) + "" + + >>> str(DateUnit.DAY) + 'day' + + >>> dict([(DateUnit.DAY, DateUnit.DAY.value)]) + {: 'day'} + + >>> list(DateUnit) + [, , , ...] + + >>> len(DateUnit) + 6 + + >>> DateUnit["DAY"] + + + >>> DateUnit(DateUnit.DAY) + + + >>> DateUnit.DAY in DateUnit + True + + >>> "day" in list(DateUnit) + True + + >>> DateUnit.DAY == "day" + True + + >>> DateUnit.DAY.name + 'DAY' + + >>> DateUnit.DAY.value + 'day' + + .. versionadded:: 35.9.0 + + """ + + WEEKDAY = "weekday" + WEEK = "week" + DAY = "day" + MONTH = "month" + YEAR = "year" + ETERNITY = "eternity" diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index fa4596f9e4..2ce4e0cd35 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -1,31 +1,54 @@ -from typing import Dict +from typing import NoReturn, Optional import datetime import os -from . import config +import pendulum +from pendulum.parsing import ParserError + +from . import _parsers, config +from .date_unit import DateUnit from .instant_ import Instant from .period_ import Period -def instant(instant): - """Return a new instant, aka a triple of integers (year, month, day). +def instant(instant) -> Optional[Instant]: + """Build a new instant, aka a triple of integers (year, month, day). + + 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((2021,)) + Instant((2021, 1, 1)) + + >>> instant((2021, 9)) + Instant((2021, 9, 1)) + + >>> instant(datetime.date(2021, 9, 16)) + Instant((2021, 9, 16)) + + >>> instant(Instant((2021, 9, 16))) + Instant((2021, 9, 16)) + + >>> instant(Period((DateUnit.YEAR, Instant((2021, 9, 16)), 1))) + Instant((2021, 9, 16)) - >>> 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(2021) + Instant((2021, 1, 1)) + + >>> instant("2021") + Instant((2021, 1, 1)) - >>> instant(None) """ + if instant is None: return None if isinstance(instant, Instant): @@ -33,9 +56,7 @@ def instant(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 - ) + f"'{instant}' is not a valid instant. Instants are described using the 'YYYY-MM-DD' format, for instance '2015-06-15'." ) instant = Instant(int(fragment) for fragment in instant.split("-", 2)[:3]) elif isinstance(instant, datetime.date): @@ -57,153 +78,250 @@ def instant(instant): return Instant(instant) -def instant_date(instant): +def instant_date(instant: Optional[Instant]) -> 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((2021, 1, 1))) + Date(2021, 1, 1) + + """ + 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) + config.date_by_instant_cache[instant] = instant_date = pendulum.date(*instant) + return instant_date def period(value) -> Period: - """Return a new period, aka a triple (unit, start_instant, size). - - >>> 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('year:2014-2') - Period((YEAR, Instant((2014, 2, 1)), 1)) + """Build a new period, aka a triple (unit, start_instant, size). + + Args: + value: A ``period-like`` object. + + Returns: + :obj:`.Period`: A period. + + Raises: + :exc:`ValueError`: When the arguments were invalid, like "2021-32-13". + + Examples: + >>> period(Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1))) + Period((, Instant((2021, 1, 1)), 1)) + + >>> period(Instant((2021, 1, 1))) + Period((, Instant((2021, 1, 1)), 1)) + + >>> period(DateUnit.ETERNITY) + Period((, Instant((1, 1, 1)), inf)) + + >>> period(2021) + Period((, Instant((2021, 1, 1)), 1)) + + >>> period("2014") + Period((, Instant((2014, 1, 1)), 1)) + + >>> period("year:2014") + Period((, Instant((2014, 1, 1)), 1)) + + >>> period("month:2014-02") + Period((, Instant((2014, 2, 1)), 1)) + + >>> period("year:2014-02") + Period((, Instant((2014, 2, 1)), 1)) + + >>> period("day:2014-02-02") + Period((, Instant((2014, 2, 2)), 1)) + + >>> period("day:2014-02-02:3") + Period((, Instant((2014, 2, 2)), 3)) + + """ + if isinstance(value, Period): return value + # We return a "day-period", for example + # ``, 1))>``. if isinstance(value, Instant): - return Period((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 Period( - (config.DAY, Instant((date.year, date.month, date.day)), 1) - ) - else: - return Period((config.MONTH, Instant((date.year, date.month, 1)), 1)) - else: - return Period((config.YEAR, 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:", - ".", - ] + return Period((DateUnit.DAY, value, 1)) + + # For example ``datetime.date(2021, 9, 16)``. + if isinstance(value, datetime.date): + return Period((DateUnit.DAY, instant(value), 1)) + + # We return an "eternity-period", for example + # ``, inf))>``. + if str(value).lower() == DateUnit.ETERNITY: + return Period( + ( + DateUnit.ETERNITY, + instant(datetime.date.min), + float("inf"), + ) ) - raise ValueError(message) - if value == "ETERNITY" or value == config.ETERNITY: - return Period(("eternity", instant(datetime.date.min), float("inf"))) - - # check the type + # For example ``2021`` gives + # ``, 1))>``. if isinstance(value, int): - return Period((config.YEAR, Instant((value, 1, 1)), 1)) + return Period((DateUnit.YEAR, instant(value), 1)) + + # Up to this point, if ``value`` is not a :obj:`str`, we desist. if not isinstance(value, str): - raise_error(value) + _raise_error(value) + + # There can't be empty strings. + if not value: + _raise_error(value) + + # Try to parse from an ISO format/calendar period. + try: + period = _parsers._parse_period(value) + + except (AttributeError, ParserError, ValueError): + _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 + # A complex period has a ':' in its string. if ":" not in value: - raise_error(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) + + if unit not in list(DateUnit) or unit == DateUnit.ETERNITY: + _raise_error(value) + + # Cast ``unit`` to DateUnit. + unit = DateUnit(unit) # middle component must be a valid iso period - base_period = parse_simple_period(components[1]) + try: + base_period = _parsers._parse_period(components[1]) + + except (AttributeError, ParserError, ValueError): + _raise_error(value) + if not base_period: - raise_error(value) + _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) + _raise_error(value) + # if there is more than 2 ":" in the string, the period is invalid else: - raise_error(value) + _raise_error(value) - # reject ambiguous period such as month:2014 + # reject ambiguous periods such as month:2014 if unit_weight(base_period.unit) > unit_weight(unit): - raise_error(value) + _raise_error(value) return Period((unit, base_period.start, size)) -def key_period_size(period): +def _raise_error(value: str) -> NoReturn: + """Raise an error. + + Examples: + >>> _raise_error("Oi mate!") + Traceback (most recent call last): + ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ... + Learn more about legal period formats in OpenFisca: + .", + ] + ) + raise ValueError(message) + + +def key_period_size(period: Period) -> str: + """Define 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) + '100_1' - >>> key_period_size(period('2014')) - '2_1' - >>> key_period_size(period('2013')) - '2_1' - >>> key_period_size(period('2014-01')) - '1_1' + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> key_period_size(period) + '300_3' """ unit, start, size = period - return "{}_{}".format(unit_weight(unit), size) + return f"{unit_weight(unit)}_{size}" -def unit_weights() -> Dict[str, int]: +def unit_weights() -> dict[str, int]: + """Assign weights to date units. + + Examples: + >>> unit_weights() + {: 100, ...ETERNITY: 'eternity'>: 400} + + """ + return { - config.DAY: 100, - config.MONTH: 200, - config.YEAR: 300, - config.ETERNITY: 400, + DateUnit.WEEKDAY: 100, + DateUnit.WEEK: 200, + DateUnit.DAY: 100, + DateUnit.MONTH: 200, + DateUnit.YEAR: 300, + DateUnit.ETERNITY: 400, } def unit_weight(unit: str) -> int: + """Retrieves a specific date unit weight. + + Examples: + >>> unit_weight(DateUnit.DAY) + 100 + + """ + return unit_weights()[unit] diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 9290e3fb53..9d0893ba41 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -1,238 +1,198 @@ -import calendar -import datetime +import pendulum from . import config +from .date_unit import DateUnit class Instant(tuple): - 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__()) + """An instant in time (year, month, day). - def __str__(self): - """ - Transform instant to a string. + An :class:`.Instant` represents the most atomic and indivisible + legislation's date unit. - >>> str(instant(2014)) - '2014-01-01' - >>> str(instant('2014-2')) - '2014-02-01' - >>> str(instant('2014-2-3')) - '2014-02-03' + Current implementation considers this unit to be a day, so + :obj:`instants <.Instant>` can be thought of as "day dates". - """ + Args: + (tuple(tuple(int, int, int))): + The ``year``, ``month``, and ``day``, accordingly. + + Examples: + >>> instant = Instant((2021, 9, 13)) + + >>> repr(Instant) + "" + + >>> repr(instant) + 'Instant((2021, 9, 13))' + + >>> str(instant) + '2021-09-13' + + >>> dict([(instant, (2021, 9, 13))]) + {Instant((2021, 9, 13)): (2021, 9, 13)} + + >>> list(instant) + [2021, 9, 13] + + >>> instant[0] + 2021 + + >>> instant[0] in instant + True + + >>> len(instant) + 3 + + >>> instant == (2021, 9, 13) + True + + >>> 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.date + Date(2021, 9, 13) + + >>> year, month, day = instant + + """ + + def __repr__(self): + return f"{self.__class__.__name__}({super().__repr__()})" + + def __str__(self): 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 @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) + config.date_by_instant_cache[self] = instant_date = pendulum.date(*self) + return instant_date @property def day(self): - """ - Extract day from instant. - - >>> instant(2014).day - 1 - >>> instant('2014-2').day - 1 - >>> instant('2014-2-3').day - 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] def offset(self, offset, unit): + """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) + Instant((2020, 12, 1)) + + >>> Instant((2020, 1, 1)).offset("last-of", DateUnit.YEAR) + Instant((2020, 12, 31)) + + >>> Instant((2020, 1, 1)).offset(1, DateUnit.YEAR) + Instant((2021, 1, 1)) + + >>> Instant((2020, 1, 1)).offset(-3, DateUnit.DAY) + Instant((2019, 12, 29)) + """ - 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)) + DateUnit.isoformat + DateUnit.isocalendar + ), f"Invalid unit: {unit} of type {type(unit)}" + if offset == "first-of": - if unit == config.MONTH: - day = 1 - elif unit == config.YEAR: - month = 1 - day = 1 + if unit == DateUnit.YEAR: + return self.__class__((year, 1, 1)) + + elif unit == DateUnit.MONTH: + return self.__class__((year, month, 1)) + + elif unit == DateUnit.WEEK: + date = self.date + date = date.start_of("week") + return self.__class__((date.year, date.month, date.day)) + elif offset == "last-of": - if unit == config.MONTH: - day = calendar.monthrange(year, month)[1] - elif unit == config.YEAR: - month = 12 - day = 31 + if unit == DateUnit.YEAR: + return self.__class__((year, 12, 31)) + + elif unit == DateUnit.MONTH: + date = self.date + date = date.end_of("month") + return self.__class__((date.year, date.month, date.day)) + + elif unit == DateUnit.WEEK: + date = self.date + date = date.end_of("week") + return self.__class__((date.year, date.month, date.day)) + 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)) + assert isinstance( + offset, int + ), f"Invalid offset: {offset} of type {type(offset)}" + + if unit == DateUnit.YEAR: + date = self.date + date = date.add(years=offset) + return self.__class__((date.year, date.month, date.day)) + + elif unit == DateUnit.MONTH: + date = self.date + date = date.add(months=offset) + return self.__class__((date.year, date.month, date.day)) + + elif unit == DateUnit.WEEK: + date = self.date + date = date.add(weeks=offset) + return self.__class__((date.year, date.month, date.day)) + + elif unit in (DateUnit.DAY, DateUnit.WEEKDAY): + date = self.date + date = date.add(days=offset) + return self.__class__((date.year, date.month, date.day)) @property def year(self): - """ - Extract year from instant. - - >>> instant(2014).year - 2014 - >>> instant('2014-2').year - 2014 - >>> instant('2014-2-3').year - 2014 - """ return self[0] diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index b541e34210..f7b901c58e 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -1,126 +1,407 @@ from __future__ import annotations +import typing + import calendar +import datetime +from collections.abc import Sequence + +import pendulum -from . import config, helpers +from . import helpers +from .date_unit import DateUnit from .instant_ import Instant +if typing.TYPE_CHECKING: + from pendulum.datetime import Date + class Period(tuple): - """ - Toolbox to handle date intervals. + """Toolbox to handle date intervals. + + A :class:`.Period` is a triple (``unit``, ``start``, ``size``). + + Attributes: + unit (:obj:`str`): + Either ``year``, ``month``, ``day`` or ``eternity``. + start (:obj:`.Instant`): + The "instant" the :obj:`.Period` starts at. + size (:obj:`int`): + The amount of ``unit``, starting at ``start``, at least ``1``. + + Args: + (tuple(tuple(str, .Instant, int))): + The ``unit``, ``start``, and ``size``, accordingly. + + Examples: + >>> instant = Instant((2021, 10, 1)) + >>> period = Period((DateUnit.YEAR, instant, 3)) + + >>> repr(Period) + "" + + >>> repr(period) + "Period((, Instant((2021, 10, 1)), 3))" + + >>> str(period) + 'year:2021-10:3' + + >>> dict([period, instant]) + Traceback (most recent call last): + ValueError: dictionary update sequence element #0 has length 3... + + >>> list(period) + [, Instant((2021, 10, 1)), 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.days + 1096 + + >>> period.size_in_months + 36 + + >>> period.size_in_days + 1096 + + >>> period.stop + Instant((2024, 9, 30)) + + >>> period.unit + + + >>> period.last_3_months + Period((, Instant((2021, 7, 1)), 3)) + + >>> period.last_month + Period((, Instant((2021, 9, 1)), 1)) + + >>> period.last_year + Period((, Instant((2020, 1, 1)), 1)) + + >>> period.n_2 + Period((, Instant((2019, 1, 1)), 1)) + + >>> period.this_year + Period((, Instant((2021, 1, 1)), 1)) + + >>> period.first_month + Period((, Instant((2021, 10, 1)), 1)) - 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. + >>> period.first_day + Period((, Instant((2021, 10, 1)), 1)) Since a period is a triple it can be used as a dictionary key. + """ - 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(self.__class__, self).__repr__() - ) - - 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' - """ + def __repr__(self) -> str: + return f"{self.__class__.__name__}({super().__repr__()})" + def __str__(self) -> str: unit, start_instant, size = self - if unit == config.ETERNITY: - return "ETERNITY" - year, month, day = start_instant + + if unit == DateUnit.ETERNITY: + return unit.upper() + + # ISO format date units. + f_year, month, day = start_instant + + # ISO calendar date units. + c_year, week, weekday = datetime.date(f_year, month, day).isocalendar() # 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) + return str(f_year) else: # rolling year - return "{}:{}-{:02d}".format(config.YEAR, year, month) + return f"{DateUnit.YEAR}:{f_year}-{month:02d}" + # simple month - if unit == config.MONTH and size == 1: - return "{}-{:02d}".format(year, month) + if unit == DateUnit.MONTH and size == 1: + return f"{f_year}-{month:02d}" + # several civil years - if unit == config.YEAR and month == 1: - return "{}:{}:{}".format(unit, year, size) + if unit == DateUnit.YEAR and month == 1: + return f"{unit}:{f_year}:{size}" - if unit == config.DAY: + if unit == DateUnit.DAY: if size == 1: - return "{}-{:02d}-{:02d}".format(year, month, day) + return f"{f_year}-{month:02d}-{day:02d}" else: - return "{}:{}-{:02d}-{:02d}:{}".format(unit, year, month, day, size) + return f"{unit}:{f_year}-{month:02d}-{day:02d}:{size}" + + # 1 week + if unit == DateUnit.WEEK and size == 1: + if week < 10: + return f"{c_year}-W0{week}" + + return f"{c_year}-W{week}" + + # several weeks + if unit == DateUnit.WEEK and size > 1: + if week < 10: + return f"{unit}:{c_year}-W0{week}:{size}" + + return f"{unit}:{c_year}-W{week}:{size}" + + # 1 weekday + if unit == DateUnit.WEEKDAY and size == 1: + if week < 10: + return f"{c_year}-W0{week}-{weekday}" + + return f"{c_year}-W{week}-{weekday}" + + # several weekdays + if unit == DateUnit.WEEKDAY and size > 1: + if week < 10: + return f"{unit}:{c_year}-W0{week}-{weekday}:{size}" + + return f"{unit}:{c_year}-W{week}-{weekday}:{size}" # complex period - return "{}:{}-{:02d}:{}".format(unit, year, month, size) + return f"{unit}:{f_year}-{month:02d}:{size}" + + @property + def unit(self) -> str: + """The ``unit`` of the ``Period``. + + Example: + >>> instant = Instant((2021, 10, 1)) + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.unit + + + """ + + return self[0] + + @property + def start(self) -> Instant: + """The ``Instant`` at which the ``Period`` starts. + + Example: + >>> instant = Instant((2021, 10, 1)) + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.start + Instant((2021, 10, 1)) + + """ + + return self[1] @property - def date(self): - assert ( - self.size == 1 - ), '"date" is undefined for a period of size > 1: {}'.format(self) + def size(self) -> int: + """The ``size`` of the ``Period``. + + Example: + >>> instant = Instant((2021, 10, 1)) + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size + 3 + + """ + + return self[2] + + @property + def date(self) -> Date: + """The date representation of the ``Period`` start date. + + Examples: + >>> instant = Instant((2021, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 1)) + >>> period.date + Date(2021, 10, 1) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.date + Traceback (most recent call last): + ValueError: "date" is undefined for a period of size > 1: year:2021-10:3. + + """ + + if self.size != 1: + raise ValueError(f'"date" is undefined for a period of size > 1: {self}.') + return self.start.date @property - def days(self): + def size_in_years(self) -> int: + """The ``size`` of the ``Period`` in years. + + Examples: + >>> instant = Instant((2021, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size_in_years + 3 + + >>> period = Period((DateUnit.MONTH, instant, 3)) + >>> period.size_in_years + Traceback (most recent call last): + ValueError: Can't calculate number of years in a month. + + """ + + if self.unit == DateUnit.YEAR: + return self.size + + raise ValueError(f"Can't calculate number of years in a {self.unit}.") + + @property + def size_in_months(self) -> int: + """The ``size`` of the ``Period`` in months. + + Examples: + >>> instant = Instant((2021, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size_in_months + 36 + + >>> period = Period((DateUnit.DAY, instant, 3)) + >>> period.size_in_months + Traceback (most recent call last): + ValueError: Can't calculate number of months in a day. + """ - Count the number of days in period. - - >>> period('day', 2014).days - 365 - >>> period('month', 2014).days - 365 - >>> period('year', 2014).days - 365 - - >>> period('day', '2014-2').days - 28 - >>> period('month', '2014-2').days - 28 - >>> period('year', '2014-2').days - 365 - - >>> period('day', '2014-2-3').days - 1 - >>> period('month', '2014-2-3').days - 28 - >>> period('year', '2014-2-3').days - 365 + + if self.unit == DateUnit.YEAR: + return self.size * 12 + + if self.unit == DateUnit.MONTH: + return self.size + + raise ValueError(f"Can't calculate number of months in a {self.unit}.") + + @property + def size_in_days(self) -> int: + """The ``size`` of the ``Period`` in days. + + Examples: + >>> instant = Instant((2019, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size_in_days + 1096 + + >>> period = Period((DateUnit.MONTH, instant, 3)) + >>> period.size_in_days + 92 + + """ + + if self.unit in (DateUnit.YEAR, DateUnit.MONTH): + last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + return (last_day.date - self.start.date).days + 1 + + if self.unit == DateUnit.WEEK: + return self.size * 7 + + if self.unit in (DateUnit.DAY, DateUnit.WEEKDAY): + return self.size + + raise ValueError(f"Can't calculate number of days in a {self.unit}.") + + @property + def size_in_weeks(self): + """The ``size`` of the ``Period`` in weeks. + + Examples: + >>> instant = Instant((2019, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size_in_weeks + 156 + + >>> period = Period((DateUnit.YEAR, instant, 5)) + >>> period.size_in_weeks + 261 + + """ + + if self.unit == DateUnit.YEAR: + start = self.start.date + cease = start.add(years=self.size) + delta = pendulum.period(start, cease) + return delta.as_interval().weeks + + if self.unit == DateUnit.MONTH: + start = self.start.date + cease = start.add(months=self.size) + delta = pendulum.period(start, cease) + return delta.as_interval().weeks + + if self.unit == DateUnit.WEEK: + return self.size + + raise ValueError(f"Can't calculate number of weeks in a {self.unit}.") + + @property + def size_in_weekdays(self): + """The ``size`` of the ``Period`` in weekdays. + + Examples: + >>> instant = Instant((2019, 10, 1)) + + >>> period = Period((DateUnit.YEAR, instant, 3)) + >>> period.size_in_weekdays + 1092 + + >>> period = Period((DateUnit.WEEK, instant, 3)) + >>> period.size_in_weekdays + 21 + """ + + if self.unit == DateUnit.YEAR: + return self.size_in_weeks * 7 + + if self.unit in DateUnit.MONTH: + last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + return (last_day.date - self.start.date).days + 1 + + if self.unit == DateUnit.WEEK: + return self.size * 7 + + if self.unit in (DateUnit.DAY, DateUnit.WEEKDAY): + return self.size + + raise ValueError(f"Can't calculate number of weekdays in a {self.unit}.") + + @property + def days(self): + """Same as ``size_in_days``.""" return (self.stop.date - self.start.date).days + 1 def intersection(self, start, stop): @@ -146,7 +427,7 @@ def intersection(self, start, stop): ): return self.__class__( ( - "year", + DateUnit.YEAR, intersection_start, intersection_stop.year - intersection_start.year + 1, ) @@ -158,7 +439,7 @@ def intersection(self, start, stop): ): return self.__class__( ( - "month", + DateUnit.MONTH, intersection_start, ( (intersection_stop.year - intersection_start.year) * 12 @@ -170,176 +451,231 @@ def intersection(self, start, stop): ) return self.__class__( ( - "day", + DateUnit.DAY, intersection_start, (intersection_stop.date - intersection_start.date).days + 1, ) ) - def get_subperiods(self, unit): - """ - Return the list of all the periods of unit ``unit`` contained in self. + def get_subperiods(self, unit: DateUnit) -> Sequence[Period]: + """Return the list of periods of unit ``unit`` contained in self. Examples: + >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)) + >>> period.get_subperiods(DateUnit.MONTH) + [Period((, Instant((2021, 1, 1)), 1)),...2021, 12, 1)), 1))] - >>> period('2017').get_subperiods(MONTH) - >>> [period('2017-01'), period('2017-02'), ... period('2017-12')] + >>> period = Period((DateUnit.YEAR, Instant((2021, 1, 1)), 2)) + >>> period.get_subperiods(DateUnit.YEAR) + [Period((, Instant((2021, 1, 1)), 1)),...((2022, 1, 1)), 1))] - >>> period('year:2014:2').get_subperiods(YEAR) - >>> [period('2014'), period('2015')] """ + if helpers.unit_weight(self.unit) < helpers.unit_weight(unit): - raise ValueError("Cannot subdivide {0} into {1}".format(self.unit, unit)) + raise ValueError(f"Cannot subdivide {self.unit} into {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) for i in range(self.size)] - if unit == config.MONTH: + if unit == DateUnit.MONTH: return [ - self.first_month.offset(i, config.MONTH) + self.first_month.offset(i, DateUnit.MONTH) for i in range(self.size_in_months) ] - if unit == config.DAY: + if unit == DateUnit.DAY: return [ - self.first_day.offset(i, config.DAY) for i in range(self.size_in_days) + self.first_day.offset(i, DateUnit.DAY) for i in range(self.size_in_days) ] + if unit == DateUnit.WEEK: + return [ + self.first_week.offset(i, DateUnit.WEEK) + for i in range(self.size_in_weeks) + ] + + if unit == DateUnit.WEEKDAY: + return [ + self.first_weekday.offset(i, DateUnit.WEEKDAY) + for i in range(self.size_in_weekdays) + ] + + raise ValueError(f"Cannot subdivide {self.unit} into {unit}") + def offset(self, offset, unit=None): + """Increment (or decrement) the given period with offset units. + + Examples: + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1) + Period((, Instant((2021, 1, 2)), 365)) + + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.DAY) + Period((, Instant((2021, 1, 2)), 365)) + + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.MONTH) + Period((, Instant((2021, 2, 1)), 365)) + + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(1, DateUnit.YEAR) + Period((, Instant((2022, 1, 1)), 365)) + + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1) + Period((, Instant((2021, 2, 1)), 12)) + + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1, DateUnit.DAY) + Period((, Instant((2021, 1, 2)), 12)) + + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1, DateUnit.MONTH) + Period((, Instant((2021, 2, 1)), 12)) + + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(1, DateUnit.YEAR) + Period((, Instant((2022, 1, 1)), 12)) + + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1) + Period((, Instant((2022, 1, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.DAY) + Period((, Instant((2021, 1, 2)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.MONTH) + Period((, Instant((2021, 2, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2021, 1, 1)), 1)).offset(1, DateUnit.YEAR) + Period((, Instant((2022, 1, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2011, 2, 28)), 1)).offset(1) + Period((, Instant((2011, 3, 1)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2011, 2, 28)), 1)).offset(1) + Period((, Instant((2011, 3, 28)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2011, 2, 28)), 1)).offset(1) + Period((, Instant((2012, 2, 28)), 1)) + + >>> Period((DateUnit.DAY, Instant((2011, 3, 1)), 1)).offset(-1) + Period((, Instant((2011, 2, 28)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2011, 3, 1)), 1)).offset(-1) + Period((, Instant((2011, 2, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2011, 3, 1)), 1)).offset(-1) + Period((, Instant((2010, 3, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 1, 30)), 1)).offset(3) + Period((, Instant((2014, 2, 2)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 1, 30)), 1)).offset(3) + Period((, Instant((2014, 4, 30)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 30)), 1)).offset(3) + Period((, Instant((2017, 1, 30)), 1)) + + >>> Period((DateUnit.DAY, Instant((2021, 1, 1)), 365)).offset(-3) + Period((, Instant((2020, 12, 29)), 365)) + + >>> Period((DateUnit.MONTH, Instant((2021, 1, 1)), 12)).offset(-3) + Period((, Instant((2020, 10, 1)), 12)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 1)), 1)).offset(-3) + Period((, Instant((2011, 1, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 4)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 1)), 4)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 4)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("first-of") + Period((, Instant((2014, 2, 1)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 1)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("first-of") + Period((, Instant((2014, 2, 1)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 1)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 4)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 30)), 1)).offset("first-of") + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 30)), 1)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 30)), 1)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("first-of") + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 1)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("first-of", DateUnit.YEAR) + Period((, Instant((2014, 1, 1)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 28)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 1)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 4)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 28)), 4)) + + >>> Period((DateUnit.DAY, Instant((2014, 2, 3)), 4)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("last-of") + Period((, Instant((2014, 2, 28)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 28)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 1)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("last-of") + Period((, Instant((2014, 2, 28)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 28)), 4)) + + >>> Period((DateUnit.MONTH, Instant((2014, 2, 3)), 4)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 4)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("last-of") + Period((, Instant((2014, 12, 31)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 1, 1)), 1)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 1, 31)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("last-of") + Period((, Instant((2014, 12, 31)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.MONTH) + Period((, Instant((2014, 2, 28)), 1)) + + >>> Period((DateUnit.YEAR, Instant((2014, 2, 3)), 1)).offset("last-of", DateUnit.YEAR) + Period((, Instant((2014, 12, 31)), 1)) + """ - Increment (or decrement) the given period with offset units. - - >>> period('day', 2014).offset(1) - Period(('day', Instant((2014, 1, 2)), 365)) - >>> period('day', 2014).offset(1, 'day') - Period(('day', Instant((2014, 1, 2)), 365)) - >>> period('day', 2014).offset(1, 'month') - Period(('day', Instant((2014, 2, 1)), 365)) - >>> period('day', 2014).offset(1, 'year') - Period(('day', Instant((2015, 1, 1)), 365)) - - >>> period('month', 2014).offset(1) - Period(('month', Instant((2014, 2, 1)), 12)) - >>> period('month', 2014).offset(1, 'day') - Period(('month', Instant((2014, 1, 2)), 12)) - >>> period('month', 2014).offset(1, 'month') - Period(('month', Instant((2014, 2, 1)), 12)) - >>> period('month', 2014).offset(1, 'year') - Period(('month', Instant((2015, 1, 1)), 12)) - - >>> period('year', 2014).offset(1) - Period(('year', Instant((2015, 1, 1)), 1)) - >>> period('year', 2014).offset(1, 'day') - Period(('year', Instant((2014, 1, 2)), 1)) - >>> period('year', 2014).offset(1, 'month') - Period(('year', Instant((2014, 2, 1)), 1)) - >>> period('year', 2014).offset(1, 'year') - Period(('year', Instant((2015, 1, 1)), 1)) - - >>> period('day', '2011-2-28').offset(1) - Period(('day', Instant((2011, 3, 1)), 1)) - >>> period('month', '2011-2-28').offset(1) - Period(('month', Instant((2011, 3, 28)), 1)) - >>> period('year', '2011-2-28').offset(1) - Period(('year', Instant((2012, 2, 28)), 1)) - - >>> period('day', '2011-3-1').offset(-1) - Period(('day', Instant((2011, 2, 28)), 1)) - >>> period('month', '2011-3-1').offset(-1) - Period(('month', Instant((2011, 2, 1)), 1)) - >>> period('year', '2011-3-1').offset(-1) - Period(('year', Instant((2010, 3, 1)), 1)) - - >>> period('day', '2014-1-30').offset(3) - Period(('day', Instant((2014, 2, 2)), 1)) - >>> period('month', '2014-1-30').offset(3) - Period(('month', Instant((2014, 4, 30)), 1)) - >>> period('year', '2014-1-30').offset(3) - Period(('year', Instant((2017, 1, 30)), 1)) - - >>> period('day', 2014).offset(-3) - Period(('day', Instant((2013, 12, 29)), 365)) - >>> period('month', 2014).offset(-3) - Period(('month', Instant((2013, 10, 1)), 12)) - >>> period('year', 2014).offset(-3) - Period(('year', Instant((2011, 1, 1)), 1)) - - >>> period('day', '2014-2-3').offset('first-of', 'month') - Period(('day', Instant((2014, 2, 1)), 1)) - >>> period('day', '2014-2-3').offset('first-of', 'year') - Period(('day', Instant((2014, 1, 1)), 1)) - - >>> period('day', '2014-2-3', 4).offset('first-of', 'month') - Period(('day', Instant((2014, 2, 1)), 4)) - >>> period('day', '2014-2-3', 4).offset('first-of', 'year') - Period(('day', Instant((2014, 1, 1)), 4)) - - >>> period('month', '2014-2-3').offset('first-of') - Period(('month', Instant((2014, 2, 1)), 1)) - >>> period('month', '2014-2-3').offset('first-of', 'month') - Period(('month', Instant((2014, 2, 1)), 1)) - >>> period('month', '2014-2-3').offset('first-of', 'year') - Period(('month', Instant((2014, 1, 1)), 1)) - - >>> period('month', '2014-2-3', 4).offset('first-of') - Period(('month', Instant((2014, 2, 1)), 4)) - >>> period('month', '2014-2-3', 4).offset('first-of', 'month') - Period(('month', Instant((2014, 2, 1)), 4)) - >>> period('month', '2014-2-3', 4).offset('first-of', 'year') - Period(('month', Instant((2014, 1, 1)), 4)) - - >>> period('year', 2014).offset('first-of') - Period(('year', Instant((2014, 1, 1)), 1)) - >>> period('year', 2014).offset('first-of', 'month') - Period(('year', Instant((2014, 1, 1)), 1)) - >>> period('year', 2014).offset('first-of', 'year') - Period(('year', Instant((2014, 1, 1)), 1)) - - >>> period('year', '2014-2-3').offset('first-of') - Period(('year', Instant((2014, 1, 1)), 1)) - >>> period('year', '2014-2-3').offset('first-of', 'month') - Period(('year', Instant((2014, 2, 1)), 1)) - >>> period('year', '2014-2-3').offset('first-of', 'year') - Period(('year', Instant((2014, 1, 1)), 1)) - - >>> period('day', '2014-2-3').offset('last-of', 'month') - Period(('day', Instant((2014, 2, 28)), 1)) - >>> period('day', '2014-2-3').offset('last-of', 'year') - Period(('day', Instant((2014, 12, 31)), 1)) - - >>> period('day', '2014-2-3', 4).offset('last-of', 'month') - Period(('day', Instant((2014, 2, 28)), 4)) - >>> period('day', '2014-2-3', 4).offset('last-of', 'year') - Period(('day', Instant((2014, 12, 31)), 4)) - - >>> period('month', '2014-2-3').offset('last-of') - Period(('month', Instant((2014, 2, 28)), 1)) - >>> period('month', '2014-2-3').offset('last-of', 'month') - Period(('month', Instant((2014, 2, 28)), 1)) - >>> period('month', '2014-2-3').offset('last-of', 'year') - Period(('month', Instant((2014, 12, 31)), 1)) - - >>> period('month', '2014-2-3', 4).offset('last-of') - Period(('month', Instant((2014, 2, 28)), 4)) - >>> period('month', '2014-2-3', 4).offset('last-of', 'month') - Period(('month', Instant((2014, 2, 28)), 4)) - >>> period('month', '2014-2-3', 4).offset('last-of', 'year') - Period(('month', Instant((2014, 12, 31)), 4)) - - >>> period('year', 2014).offset('last-of') - Period(('year', Instant((2014, 12, 31)), 1)) - >>> period('year', 2014).offset('last-of', 'month') - Period(('year', Instant((2014, 1, 31)), 1)) - >>> period('year', 2014).offset('last-of', 'year') - Period(('year', Instant((2014, 12, 31)), 1)) - - >>> period('year', '2014-2-3').offset('last-of') - Period(('year', Instant((2014, 12, 31)), 1)) - >>> period('year', '2014-2-3').offset('last-of', 'month') - Period(('year', Instant((2014, 2, 28)), 1)) - >>> period('year', '2014-2-3').offset('last-of', 'year') - Period(('year', Instant((2014, 12, 31)), 1)) - """ + return self.__class__( ( self[0], @@ -349,141 +685,98 @@ def offset(self, offset, unit=None): ) def contains(self, other: Period) -> bool: + """Returns ``True`` if the period contains ``other``. + + For instance, ``period(2015)`` contains ``period(2015-01)``. + """ - Returns ``True`` if the period contains ``other``. For instance, ``period(2015)`` contains ``period(2015-01)`` - """ + return self.start <= other.start and self.stop >= other.stop @property - def size(self): - """ - Return the size of the period. + def stop(self) -> Instant: + """Return the last day of the period as an Instant instance. - >>> period('month', '2012-2-29', 4).size - 4 - """ - return self[2] + Examples: + >>> Period((DateUnit.YEAR, Instant((2022, 1, 1)), 1)).stop + Instant((2022, 12, 31)) - @property - def size_in_months(self): - """ - Return the size of the period in months. + >>> Period((DateUnit.MONTH, Instant((2022, 1, 1)), 12)).stop + Instant((2022, 12, 31)) - >>> period('month', '2012-2-29', 4).size_in_months - 4 - >>> period('year', '2012', 1).size_in_months - 12 - """ - if self[0] == config.MONTH: - return self[2] - if self[0] == config.YEAR: - return self[2] * 12 - raise ValueError("Cannot calculate number of months in {0}".format(self[0])) + >>> Period((DateUnit.DAY, Instant((2022, 1, 1)), 365)).stop + Instant((2022, 12, 31)) - @property - def size_in_days(self): - """ - Return the size of the period in days. + >>> Period((DateUnit.YEAR, Instant((2012, 2, 29)), 1)).stop + Instant((2013, 2, 27)) - >>> period('month', '2012-2-29', 4).size_in_days - 28 - >>> period('year', '2012', 1).size_in_days - 366 - """ - unit, instant, length = self + >>> Period((DateUnit.MONTH, Instant((2012, 2, 29)), 1)).stop + Instant((2012, 3, 28)) - if unit == config.DAY: - return length - if unit in [config.MONTH, config.YEAR]: - last_day = self.start.offset(length, unit).offset(-1, config.DAY) - return (last_day.date - self.start.date).days + 1 + >>> Period((DateUnit.DAY, Instant((2012, 2, 29)), 1)).stop + Instant((2012, 2, 29)) - raise ValueError("Cannot calculate number of days in {0}".format(unit)) + >>> Period((DateUnit.YEAR, Instant((2012, 2, 29)), 2)).stop + Instant((2014, 2, 27)) - @property - def start(self) -> Instant: - """ - Return the first day of the period as an Instant instance. + >>> Period((DateUnit.MONTH, Instant((2012, 2, 29)), 2)).stop + Instant((2012, 4, 28)) - >>> period('month', '2012-2-29', 4).start - Instant((2012, 2, 29)) - """ - return self[1] + >>> Period((DateUnit.DAY, Instant((2012, 2, 29)), 2)).stop + Instant((2012, 3, 1)) - @property - 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 - Instant((2014, 12, 31)) - >>> period('day', 2014).stop - Instant((2014, 12, 31)) - - >>> period('year', '2012-2-29').stop - Instant((2013, 2, 28)) - >>> period('month', '2012-2-29').stop - Instant((2012, 3, 28)) - >>> period('day', '2012-2-29').stop - Instant((2012, 2, 29)) - - >>> period('year', '2012-2-29', 2).stop - Instant((2014, 2, 28)) - >>> period('month', '2012-2-29', 2).stop - Instant((2012, 4, 28)) - >>> period('day', '2012-2-29', 2).stop - Instant((2012, 3, 1)) """ + unit, start_instant, size = self year, month, day = start_instant - if unit == config.ETERNITY: + + if unit == DateUnit.ETERNITY: return Instant((float("inf"), float("inf"), float("inf"))) - if unit == "day": - if size > 1: - day += size - 1 - 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 == DateUnit.YEAR: + date = start_instant.date.add(years=size, days=-1) + return Instant((date.year, date.month, date.day)) + + elif unit == DateUnit.MONTH: + date = start_instant.date.add(months=size, days=-1) + return Instant((date.year, date.month, date.day)) + + elif unit == DateUnit.WEEK: + date = start_instant.date.add(weeks=size, days=-1) + return Instant((date.year, date.month, date.day)) + + elif unit in (DateUnit.DAY, DateUnit.WEEKDAY): + date = start_instant.date.add(days=size - 1) + return Instant((date.year, date.month, date.day)) + else: - if unit == "month": - month += size - while month > 12: - year += 1 - month -= 12 - else: - assert unit == "year", "Invalid unit: {} of type {}".format( - unit, type(unit) - ) - year += size - day -= 1 - if day < 1: - month -= 1 - if month == 0: - year -= 1 - month = 12 - day += calendar.monthrange(year, month)[1] - else: - month_last_day = calendar.monthrange(year, month)[1] - if day > month_last_day: - month += 1 - if month == 13: - year += 1 - month = 1 - day -= month_last_day - return Instant((year, month, day)) + raise ValueError + + # Reference periods @property - def unit(self) -> str: - return self[0] + def last_week(self) -> Period: + return self.first_week.offset(-1) - # Reference periods + @property + def last_fortnight(self) -> Period: + start: Instant = self.first_week.start + return self.__class__((DateUnit.WEEK, start, 1)).offset(-2) + + @property + def last_2_weeks(self) -> Period: + start: Instant = self.first_week.start + return self.__class__((DateUnit.WEEK, start, 2)).offset(-2) + + @property + def last_26_weeks(self) -> Period: + start: Instant = self.first_week.start + return self.__class__((DateUnit.WEEK, start, 26)).offset(-26) + + @property + def last_52_weeks(self) -> Period: + start: Instant = self.first_week.start + return self.__class__((DateUnit.WEEK, start, 52)).offset(-52) @property def last_month(self) -> Period: @@ -492,28 +785,37 @@ def last_month(self) -> Period: @property def last_3_months(self) -> Period: start: Instant = self.first_month.start - return self.__class__((config.MONTH, start, 3)).offset(-3) + return self.__class__((DateUnit.MONTH, start, 3)).offset(-3) @property def last_year(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)).offset(-1) + start: Instant = self.start.offset("first-of", DateUnit.YEAR) + return self.__class__((DateUnit.YEAR, start, 1)).offset(-1) @property def n_2(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)).offset(-2) + start: Instant = self.start.offset("first-of", DateUnit.YEAR) + return self.__class__((DateUnit.YEAR, start, 1)).offset(-2) @property def this_year(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)) + start: Instant = self.start.offset("first-of", DateUnit.YEAR) + return self.__class__((DateUnit.YEAR, start, 1)) @property def first_month(self) -> Period: - start: Instant = self.start.offset("first-of", config.MONTH) - return self.__class__((config.MONTH, start, 1)) + start: Instant = self.start.offset("first-of", DateUnit.MONTH) + return self.__class__((DateUnit.MONTH, start, 1)) @property def first_day(self) -> Period: - return self.__class__((config.DAY, self.start, 1)) + return self.__class__((DateUnit.DAY, self.start, 1)) + + @property + def first_week(self) -> Period: + start: Instant = self.start.offset("first-of", DateUnit.WEEK) + return self.__class__((DateUnit.WEEK, start, 1)) + + @property + def first_weekday(self) -> Period: + return self.__class__((DateUnit.WEEKDAY, self.start, 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/helpers/__init__.py b/openfisca_core/periods/tests/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/helpers/test_helpers.py b/openfisca_core/periods/tests/helpers/test_helpers.py new file mode 100644 index 0000000000..bb409323d1 --- /dev/null +++ b/openfisca_core/periods/tests/helpers/test_helpers.py @@ -0,0 +1,55 @@ +import datetime + +import pytest + +from openfisca_core import periods +from openfisca_core.periods import DateUnit, Instant, Period + + +@pytest.mark.parametrize( + "arg, expected", + [ + [None, None], + [Instant((1, 1, 1)), datetime.date(1, 1, 1)], + [Instant((4, 2, 29)), datetime.date(4, 2, 29)], + [(1, 1, 1), datetime.date(1, 1, 1)], + ], +) +def test_instant_date(arg, expected): + assert periods.instant_date(arg) == expected + + +@pytest.mark.parametrize( + "arg, error", + [ + [Instant((-1, 1, 1)), ValueError], + [Instant((1, -1, 1)), ValueError], + [Instant((1, 13, -1)), ValueError], + [Instant((1, 1, -1)), ValueError], + [Instant((1, 1, 32)), ValueError], + [Instant((1, 2, 29)), ValueError], + [Instant(("1", 1, 1)), TypeError], + [(1,), TypeError], + [(1, 1), TypeError], + ], +) +def test_instant_date_with_an_invalid_argument(arg, error): + with pytest.raises(error): + periods.instant_date(arg) + + +@pytest.mark.parametrize( + "arg, expected", + [ + [Period((DateUnit.WEEKDAY, Instant((1, 1, 1)), 5)), "100_5"], + [Period((DateUnit.WEEK, Instant((1, 1, 1)), 26)), "200_26"], + [Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), "100_365"], + [Period((DateUnit.MONTH, Instant((1, 1, 1)), 12)), "200_12"], + [Period((DateUnit.YEAR, Instant((1, 1, 1)), 2)), "300_2"], + [Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 1)), "400_1"], + [(DateUnit.DAY, None, 1), "100_1"], + [(DateUnit.MONTH, None, -1000), "200_-1000"], + ], +) +def test_key_period_size(arg, expected): + assert periods.key_period_size(arg) == expected diff --git a/openfisca_core/periods/tests/helpers/test_instant.py b/openfisca_core/periods/tests/helpers/test_instant.py new file mode 100644 index 0000000000..cb74c55ca4 --- /dev/null +++ b/openfisca_core/periods/tests/helpers/test_instant.py @@ -0,0 +1,76 @@ +import datetime + +import pytest + +from openfisca_core import periods +from openfisca_core.periods import DateUnit, Instant, Period + + +@pytest.mark.parametrize( + "arg, expected", + [ + [None, None], + [datetime.date(1, 1, 1), Instant((1, 1, 1))], + [Instant((1, 1, 1)), Instant((1, 1, 1))], + [Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), Instant((1, 1, 1))], + [-1, Instant((-1, 1, 1))], + [0, Instant((0, 1, 1))], + [1, Instant((1, 1, 1))], + [999, Instant((999, 1, 1))], + [1000, Instant((1000, 1, 1))], + ["1000", Instant((1000, 1, 1))], + ["1000-01", Instant((1000, 1, 1))], + ["1000-01-01", Instant((1000, 1, 1))], + [(None,), Instant((None, 1, 1))], + [(None, None), Instant((None, None, 1))], + [(None, None, None), Instant((None, None, None))], + [(datetime.date(1, 1, 1),), Instant((datetime.date(1, 1, 1), 1, 1))], + [(Instant((1, 1, 1)),), Instant((Instant((1, 1, 1)), 1, 1))], + [ + (Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), + Instant((Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), 1, 1)), + ], + [(-1,), Instant((-1, 1, 1))], + [(-1, -1), Instant((-1, -1, 1))], + [(-1, -1, -1), Instant((-1, -1, -1))], + [("-1",), Instant(("-1", 1, 1))], + [("-1", "-1"), Instant(("-1", "-1", 1))], + [("-1", "-1", "-1"), Instant(("-1", "-1", "-1"))], + [("1-1",), Instant(("1-1", 1, 1))], + [("1-1-1",), Instant(("1-1-1", 1, 1))], + ], +) +def test_instant(arg, expected): + assert periods.instant(arg) == expected + + +@pytest.mark.parametrize( + "arg, error", + [ + [DateUnit.YEAR, ValueError], + [DateUnit.ETERNITY, ValueError], + ["1000-0", ValueError], + ["1000-0-0", ValueError], + ["1000-1", ValueError], + ["1000-1-1", ValueError], + ["1", ValueError], + ["a", ValueError], + ["year", ValueError], + ["eternity", ValueError], + ["999", ValueError], + ["1:1000-01-01", ValueError], + ["a:1000-01-01", ValueError], + ["year:1000-01-01", ValueError], + ["year:1000-01-01:1", ValueError], + ["year:1000-01-01:3", ValueError], + ["1000-01-01:a", ValueError], + ["1000-01-01:1", ValueError], + [(), AssertionError], + [{}, AssertionError], + ["", ValueError], + [(None, None, None, None), AssertionError], + ], +) +def test_instant_with_an_invalid_argument(arg, error): + with pytest.raises(error): + periods.instant(arg) diff --git a/openfisca_core/periods/tests/helpers/test_period.py b/openfisca_core/periods/tests/helpers/test_period.py new file mode 100644 index 0000000000..7d50abe102 --- /dev/null +++ b/openfisca_core/periods/tests/helpers/test_period.py @@ -0,0 +1,134 @@ +import datetime + +import pytest + +from openfisca_core import periods +from openfisca_core.periods import DateUnit, Instant, Period + + +@pytest.mark.parametrize( + "arg, expected", + [ + ["eternity", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))], + ["ETERNITY", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))], + [ + DateUnit.ETERNITY, + Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf"))), + ], + [datetime.date(1, 1, 1), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))], + [Instant((1, 1, 1)), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))], + [ + Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), + Period((DateUnit.DAY, Instant((1, 1, 1)), 365)), + ], + [-1, Period((DateUnit.YEAR, Instant((-1, 1, 1)), 1))], + [0, Period((DateUnit.YEAR, Instant((0, 1, 1)), 1))], + [1, Period((DateUnit.YEAR, Instant((1, 1, 1)), 1))], + [999, Period((DateUnit.YEAR, Instant((999, 1, 1)), 1))], + [1000, Period((DateUnit.YEAR, Instant((1000, 1, 1)), 1))], + ["1001", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["1001-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))], + ["1001-01-01", Period((DateUnit.DAY, Instant((1001, 1, 1)), 1))], + ["1004-02-29", Period((DateUnit.DAY, Instant((1004, 2, 29)), 1))], + ["1001-W01", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))], + ["1001-W01-1", Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 1))], + ["year:1001", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-01", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-01-01", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-W01", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 1))], + ["year:1001-W01-1", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 1))], + ["year:1001:1", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-01:1", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-01-01:1", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["year:1001-W01:1", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 1))], + ["year:1001-W01-1:1", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 1))], + ["year:1001:3", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 3))], + ["year:1001-01:3", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 3))], + ["year:1001-01-01:3", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 3))], + ["year:1001-W01:3", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 3))], + ["year:1001-W01-1:3", Period((DateUnit.YEAR, Instant((1000, 12, 29)), 3))], + ["month:1001-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))], + ["month:1001-01-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))], + ["week:1001-W01", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))], + ["week:1001-W01-1", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))], + ["month:1001-01:1", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))], + ["month:1001-01:3", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 3))], + ["month:1001-01-01:3", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 3))], + ["week:1001-W01:1", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))], + ["week:1001-W01:3", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 3))], + ["week:1001-W01-1:3", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 3))], + ["day:1001-01-01", Period((DateUnit.DAY, Instant((1001, 1, 1)), 1))], + ["day:1001-01-01:3", Period((DateUnit.DAY, Instant((1001, 1, 1)), 3))], + ["weekday:1001-W01-1", Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 1))], + [ + "weekday:1001-W01-1:3", + Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 3)), + ], + ], +) +def test_period(arg, expected): + assert periods.period(arg) == expected + + +@pytest.mark.parametrize( + "arg, error", + [ + [None, ValueError], + [DateUnit.YEAR, ValueError], + ["1", ValueError], + ["999", ValueError], + ["1000-0", ValueError], + ["1000-13", ValueError], + ["1000-W0", ValueError], + ["1000-W54", ValueError], + ["1000-0-0", ValueError], + ["1000-1-0", ValueError], + ["1000-2-31", ValueError], + ["1000-W0-0", ValueError], + ["1000-W1-0", ValueError], + ["1000-W1-8", ValueError], + ["a", ValueError], + ["year", ValueError], + ["1:1000", ValueError], + ["a:1000", ValueError], + ["month:1000", ValueError], + ["week:1000", ValueError], + ["day:1000-01", ValueError], + ["weekday:1000-W1", ValueError], + ["1000:a", ValueError], + ["1000:1", ValueError], + ["1000-01:1", ValueError], + ["1000-01-01:1", ValueError], + ["1000-W1:1", ValueError], + ["1000-W1-1:1", ValueError], + ["month:1000:1", ValueError], + ["week:1000:1", ValueError], + ["day:1000:1", ValueError], + ["day:1000-01:1", ValueError], + ["weekday:1000:1", ValueError], + ["weekday:1000-W1:1", ValueError], + [(), ValueError], + [{}, ValueError], + ["", ValueError], + [(None,), ValueError], + [(None, None), ValueError], + [(None, None, None), ValueError], + [(None, None, None, None), ValueError], + [(Instant((1, 1, 1)),), ValueError], + [(Period((DateUnit.DAY, Instant((1, 1, 1)), 365)),), ValueError], + [(1,), ValueError], + [(1, 1), ValueError], + [(1, 1, 1), ValueError], + [(-1,), ValueError], + [(-1, -1), ValueError], + [(-1, -1, -1), ValueError], + [("-1",), ValueError], + [("-1", "-1"), ValueError], + [("-1", "-1", "-1"), ValueError], + [("1-1",), ValueError], + [("1-1-1",), ValueError], + ], +) +def test_period_with_an_invalid_argument(arg, error): + with pytest.raises(error): + periods.period(arg) diff --git a/openfisca_core/periods/tests/test__parsers.py b/openfisca_core/periods/tests/test__parsers.py new file mode 100644 index 0000000000..6c88c9cd11 --- /dev/null +++ b/openfisca_core/periods/tests/test__parsers.py @@ -0,0 +1,69 @@ +import pytest +from pendulum.parsing import ParserError + +from openfisca_core.periods import DateUnit, Instant, Period, _parsers + + +@pytest.mark.parametrize( + "arg, expected", + [ + ["1001", Period((DateUnit.YEAR, Instant((1001, 1, 1)), 1))], + ["1001-01", Period((DateUnit.MONTH, Instant((1001, 1, 1)), 1))], + ["1001-12", Period((DateUnit.MONTH, Instant((1001, 12, 1)), 1))], + ["1001-01-01", Period((DateUnit.DAY, Instant((1001, 1, 1)), 1))], + ["1001-W01", Period((DateUnit.WEEK, Instant((1000, 12, 29)), 1))], + ["1001-W52", Period((DateUnit.WEEK, Instant((1001, 12, 21)), 1))], + ["1001-W01-1", Period((DateUnit.WEEKDAY, Instant((1000, 12, 29)), 1))], + ], +) +def test__parse_period(arg, expected): + assert _parsers._parse_period(arg) == expected + + +@pytest.mark.parametrize( + "arg, error", + [ + [None, AttributeError], + [{}, AttributeError], + [(), AttributeError], + [[], AttributeError], + [1, AttributeError], + ["", AttributeError], + ["à", ParserError], + ["1", ValueError], + ["-1", ValueError], + ["999", ParserError], + ["1000-0", ParserError], + ["1000-1", ParserError], + ["1000-1-1", ParserError], + ["1000-00", ParserError], + ["1000-13", ParserError], + ["1000-01-00", ParserError], + ["1000-01-99", ParserError], + ["1000-W0", ParserError], + ["1000-W1", ParserError], + ["1000-W99", ParserError], + ["1000-W1-0", ParserError], + ["1000-W1-1", ParserError], + ["1000-W1-99", ParserError], + ["1000-W01-0", ParserError], + ["1000-W01-00", ParserError], + ], +) +def test__parse_period_with_invalid_argument(arg, error): + with pytest.raises(error): + _parsers._parse_period(arg) + + +@pytest.mark.parametrize( + "arg, expected", + [ + ["2022", DateUnit.YEAR], + ["2022-01", DateUnit.MONTH], + ["2022-01-01", DateUnit.DAY], + ["2022-W01", DateUnit.WEEK], + ["2022-W01-01", DateUnit.WEEKDAY], + ], +) +def test__parse_unit(arg, expected): + assert _parsers._parse_unit(arg) == expected diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py new file mode 100644 index 0000000000..21549008f4 --- /dev/null +++ b/openfisca_core/periods/tests/test_instant.py @@ -0,0 +1,32 @@ +import pytest + +from openfisca_core.periods import DateUnit, Instant + + +@pytest.mark.parametrize( + "instant, offset, unit, expected", + [ + [Instant((2020, 2, 29)), "first-of", DateUnit.YEAR, Instant((2020, 1, 1))], + [Instant((2020, 2, 29)), "first-of", DateUnit.MONTH, Instant((2020, 2, 1))], + [Instant((2020, 2, 29)), "first-of", DateUnit.WEEK, Instant((2020, 2, 24))], + [Instant((2020, 2, 29)), "first-of", DateUnit.DAY, None], + [Instant((2020, 2, 29)), "first-of", DateUnit.WEEKDAY, None], + [Instant((2020, 2, 29)), "last-of", DateUnit.YEAR, Instant((2020, 12, 31))], + [Instant((2020, 2, 29)), "last-of", DateUnit.MONTH, Instant((2020, 2, 29))], + [Instant((2020, 2, 29)), "last-of", DateUnit.WEEK, Instant((2020, 3, 1))], + [Instant((2020, 2, 29)), "last-of", DateUnit.DAY, None], + [Instant((2020, 2, 29)), "last-of", DateUnit.WEEKDAY, None], + [Instant((2020, 2, 29)), -3, DateUnit.YEAR, Instant((2017, 2, 28))], + [Instant((2020, 2, 29)), -3, DateUnit.MONTH, Instant((2019, 11, 29))], + [Instant((2020, 2, 29)), -3, DateUnit.WEEK, Instant((2020, 2, 8))], + [Instant((2020, 2, 29)), -3, DateUnit.DAY, Instant((2020, 2, 26))], + [Instant((2020, 2, 29)), -3, DateUnit.WEEKDAY, Instant((2020, 2, 26))], + [Instant((2020, 2, 29)), 3, DateUnit.YEAR, Instant((2023, 2, 28))], + [Instant((2020, 2, 29)), 3, DateUnit.MONTH, Instant((2020, 5, 29))], + [Instant((2020, 2, 29)), 3, DateUnit.WEEK, Instant((2020, 3, 21))], + [Instant((2020, 2, 29)), 3, DateUnit.DAY, Instant((2020, 3, 3))], + [Instant((2020, 2, 29)), 3, DateUnit.WEEKDAY, Instant((2020, 3, 3))], + ], +) +def test_offset(instant, offset, unit, expected): + assert instant.offset(offset, unit) == expected diff --git a/openfisca_core/periods/tests/test_period.py b/openfisca_core/periods/tests/test_period.py new file mode 100644 index 0000000000..6553c4fd9b --- /dev/null +++ b/openfisca_core/periods/tests/test_period.py @@ -0,0 +1,283 @@ +import pytest + +from openfisca_core.periods import DateUnit, Instant, Period + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2022, 1, 1)), 1, "2022"], + [DateUnit.MONTH, Instant((2022, 1, 1)), 12, "2022"], + [DateUnit.YEAR, Instant((2022, 3, 1)), 1, "year:2022-03"], + [DateUnit.MONTH, Instant((2022, 3, 1)), 12, "year:2022-03"], + [DateUnit.YEAR, Instant((2022, 1, 1)), 3, "year:2022:3"], + [DateUnit.YEAR, Instant((2022, 1, 3)), 3, "year:2022:3"], + ], +) +def test_str_with_years(date_unit, instant, size, expected): + assert str(Period((date_unit, instant, size))) == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.MONTH, Instant((2022, 1, 1)), 1, "2022-01"], + [DateUnit.MONTH, Instant((2022, 1, 1)), 3, "month:2022-01:3"], + [DateUnit.MONTH, Instant((2022, 3, 1)), 3, "month:2022-03:3"], + ], +) +def test_str_with_months(date_unit, instant, size, expected): + assert str(Period((date_unit, instant, size))) == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.DAY, Instant((2022, 1, 1)), 1, "2022-01-01"], + [DateUnit.DAY, Instant((2022, 1, 1)), 3, "day:2022-01-01:3"], + [DateUnit.DAY, Instant((2022, 3, 1)), 3, "day:2022-03-01:3"], + ], +) +def test_str_with_days(date_unit, instant, size, expected): + assert str(Period((date_unit, instant, size))) == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.WEEK, Instant((2022, 1, 1)), 1, "2021-W52"], + [DateUnit.WEEK, Instant((2022, 1, 1)), 3, "week:2021-W52:3"], + [DateUnit.WEEK, Instant((2022, 3, 1)), 1, "2022-W09"], + [DateUnit.WEEK, Instant((2022, 3, 1)), 3, "week:2022-W09:3"], + ], +) +def test_str_with_weeks(date_unit, instant, size, expected): + assert str(Period((date_unit, instant, size))) == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.WEEKDAY, Instant((2022, 1, 1)), 1, "2021-W52-6"], + [DateUnit.WEEKDAY, Instant((2022, 1, 1)), 3, "weekday:2021-W52-6:3"], + [DateUnit.WEEKDAY, Instant((2022, 3, 1)), 1, "2022-W09-2"], + [DateUnit.WEEKDAY, Instant((2022, 3, 1)), 3, "weekday:2022-W09-2:3"], + ], +) +def test_str_with_weekdays(date_unit, instant, size, expected): + assert str(Period((date_unit, instant, size))) == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2022, 12, 1)), 1, 1], + [DateUnit.YEAR, Instant((2022, 1, 1)), 2, 2], + ], +) +def test_size_in_years(date_unit, instant, size, expected): + period = Period((date_unit, instant, size)) + assert period.size_in_years == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2020, 1, 1)), 1, 12], + [DateUnit.YEAR, Instant((2022, 1, 1)), 2, 24], + [DateUnit.MONTH, Instant((2012, 1, 3)), 3, 3], + ], +) +def test_size_in_months(date_unit, instant, size, expected): + period = Period((date_unit, instant, size)) + assert period.size_in_months == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2022, 12, 1)), 1, 365], + [DateUnit.YEAR, Instant((2020, 1, 1)), 1, 366], + [DateUnit.YEAR, Instant((2022, 1, 1)), 2, 730], + [DateUnit.MONTH, Instant((2022, 12, 1)), 1, 31], + [DateUnit.MONTH, Instant((2020, 2, 3)), 1, 29], + [DateUnit.MONTH, Instant((2022, 1, 3)), 3, 31 + 28 + 31], + [DateUnit.MONTH, Instant((2012, 1, 3)), 3, 31 + 29 + 31], + [DateUnit.DAY, Instant((2022, 12, 31)), 1, 1], + [DateUnit.DAY, Instant((2022, 12, 31)), 3, 3], + [DateUnit.WEEK, Instant((2022, 12, 31)), 1, 7], + [DateUnit.WEEK, Instant((2022, 12, 31)), 3, 21], + [DateUnit.WEEKDAY, Instant((2022, 12, 31)), 1, 1], + [DateUnit.WEEKDAY, Instant((2022, 12, 31)), 3, 3], + ], +) +def test_size_in_days(date_unit, instant, size, expected): + period = Period((date_unit, instant, size)) + assert period.size_in_days == expected + assert period.size_in_days == period.days + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2022, 12, 1)), 1, 52], + [DateUnit.YEAR, Instant((2020, 1, 1)), 5, 261], + [DateUnit.MONTH, Instant((2022, 12, 1)), 1, 4], + [DateUnit.MONTH, Instant((2020, 2, 3)), 1, 4], + [DateUnit.MONTH, Instant((2022, 1, 3)), 3, 12], + [DateUnit.MONTH, Instant((2012, 1, 3)), 3, 13], + [DateUnit.WEEK, Instant((2022, 12, 31)), 1, 1], + [DateUnit.WEEK, Instant((2022, 12, 31)), 3, 3], + ], +) +def test_size_in_weeks(date_unit, instant, size, expected): + period = Period((date_unit, instant, size)) + assert period.size_in_weeks == expected + + +@pytest.mark.parametrize( + "date_unit, instant, size, expected", + [ + [DateUnit.YEAR, Instant((2022, 12, 1)), 1, 364], + [DateUnit.YEAR, Instant((2020, 1, 1)), 1, 364], + [DateUnit.YEAR, Instant((2022, 1, 1)), 2, 728], + [DateUnit.MONTH, Instant((2022, 12, 1)), 1, 31], + [DateUnit.MONTH, Instant((2020, 2, 3)), 1, 29], + [DateUnit.MONTH, Instant((2022, 1, 3)), 3, 31 + 28 + 31], + [DateUnit.MONTH, Instant((2012, 1, 3)), 3, 31 + 29 + 31], + [DateUnit.DAY, Instant((2022, 12, 31)), 1, 1], + [DateUnit.DAY, Instant((2022, 12, 31)), 3, 3], + [DateUnit.WEEK, Instant((2022, 12, 31)), 1, 7], + [DateUnit.WEEK, Instant((2022, 12, 31)), 3, 21], + [DateUnit.WEEKDAY, Instant((2022, 12, 31)), 1, 1], + [DateUnit.WEEKDAY, Instant((2022, 12, 31)), 3, 3], + ], +) +def test_size_in_weekdays(date_unit, instant, size, expected): + period = Period((date_unit, instant, size)) + assert period.size_in_weekdays == expected + + +@pytest.mark.parametrize( + "period_unit, sub_unit, instant, start, cease, count", + [ + [ + DateUnit.YEAR, + DateUnit.YEAR, + Instant((2022, 12, 31)), + Instant((2022, 1, 1)), + Instant((2024, 1, 1)), + 3, + ], + [ + DateUnit.YEAR, + DateUnit.MONTH, + Instant((2022, 12, 31)), + Instant((2022, 12, 1)), + Instant((2025, 11, 1)), + 36, + ], + [ + DateUnit.YEAR, + DateUnit.DAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2025, 12, 30)), + 1096, + ], + [ + DateUnit.YEAR, + DateUnit.WEEK, + Instant((2022, 12, 31)), + Instant((2022, 12, 26)), + Instant((2025, 12, 15)), + 156, + ], + [ + DateUnit.YEAR, + DateUnit.WEEKDAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2025, 12, 26)), + 1092, + ], + [ + DateUnit.MONTH, + DateUnit.MONTH, + Instant((2022, 12, 31)), + Instant((2022, 12, 1)), + Instant((2023, 2, 1)), + 3, + ], + [ + DateUnit.MONTH, + DateUnit.DAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 3, 30)), + 90, + ], + [ + DateUnit.DAY, + DateUnit.DAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 2)), + 3, + ], + [ + DateUnit.DAY, + DateUnit.WEEKDAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 2)), + 3, + ], + [ + DateUnit.WEEK, + DateUnit.DAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 20)), + 21, + ], + [ + DateUnit.WEEK, + DateUnit.WEEK, + Instant((2022, 12, 31)), + Instant((2022, 12, 26)), + Instant((2023, 1, 9)), + 3, + ], + [ + DateUnit.WEEK, + DateUnit.WEEKDAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 20)), + 21, + ], + [ + DateUnit.WEEKDAY, + DateUnit.DAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 2)), + 3, + ], + [ + DateUnit.WEEKDAY, + DateUnit.WEEKDAY, + Instant((2022, 12, 31)), + Instant((2022, 12, 31)), + Instant((2023, 1, 2)), + 3, + ], + ], +) +def test_subperiods(period_unit, sub_unit, instant, start, cease, count): + period = Period((period_unit, instant, 3)) + subperiods = period.get_subperiods(sub_unit) + assert len(subperiods) == count + assert subperiods[0] == Period((sub_unit, start, 1)) + assert subperiods[-1] == Period((sub_unit, cease, 1)) diff --git a/openfisca_core/scripts/measure_performances.py b/openfisca_core/scripts/measure_performances.py index a8e1925417..75125b8863 100644 --- a/openfisca_core/scripts/measure_performances.py +++ b/openfisca_core/scripts/measure_performances.py @@ -9,11 +9,11 @@ import sys import time -import numpy +import numpy as np from numpy.core.defchararray import startswith from openfisca_core import periods, simulations -from openfisca_core.periods import ETERNITY +from openfisca_core.periods import DateUnit from openfisca_core.entities import build_entity from openfisca_core.variables import Variable from openfisca_core.taxbenefitsystems import TaxBenefitSystem @@ -82,7 +82,7 @@ class city_code(Variable): value_type = "FixedStr" max_length = 5 entity = Famille - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY label = """Code INSEE "city_code" de la commune de résidence de la famille""" @@ -107,7 +107,7 @@ def formula(self, simulation, period): if age_en_mois is not None: return age_en_mois // 12 birth = simulation.calculate("birth", period) - return (numpy.datetime64(period.date) - birth).astype("timedelta64[Y]") + return (np.datetime64(period.date) - birth).astype("timedelta64[Y]") class dom_tom(Variable): @@ -116,11 +116,9 @@ class dom_tom(Variable): label = "La famille habite-t-elle les DOM-TOM ?" def formula(self, simulation, period): - period = period.start.period("year").offset("first-of") + period = period.start.period(DateUnit.YEAR).offset("first-of") city_code = simulation.calculate("city_code", period) - return numpy.logical_or( - startswith(city_code, "97"), startswith(city_code, "98") - ) + return np.logical_or(startswith(city_code, "97"), startswith(city_code, "98")) class revenu_disponible(Variable): @@ -129,7 +127,7 @@ class revenu_disponible(Variable): label = "Revenu disponible de l'individu" def formula(self, simulation, period): - period = period.start.period("year").offset("first-of") + period = period.start.period(DateUnit.YEAR).offset("first-of") rsa = simulation.calculate("rsa", period) salaire_imposable = simulation.calculate("salaire_imposable", period) return rsa + salaire_imposable * 0.7 @@ -141,17 +139,17 @@ class rsa(Variable): label = "RSA" def formula_2010_01_01(self, simulation, period): - period = period.start.period("month").offset("first-of") + period = period.start.period(DateUnit.MONTH).offset("first-of") salaire_imposable = simulation.calculate("salaire_imposable", period) return (salaire_imposable < 500) * 100.0 def formula_2011_01_01(self, simulation, period): - period = period.start.period("month").offset("first-of") + period = period.start.period(DateUnit.MONTH).offset("first-of") salaire_imposable = simulation.calculate("salaire_imposable", period) return (salaire_imposable < 500) * 200.0 def formula_2013_01_01(self, simulation, period): - period = period.start.period("month").offset("first-of") + period = period.start.period(DateUnit.MONTH).offset("first-of") salaire_imposable = simulation.calculate("salaire_imposable", period) return (salaire_imposable < 500) * 300 @@ -162,7 +160,7 @@ class salaire_imposable(Variable): label = "Salaire imposable" def formula(individu, period): - period = period.start.period("year").offset("first-of") + period = period.start.period(DateUnit.YEAR).offset("first-of") dom_tom = individu.famille("dom_tom", period) salaire_net = individu("salaire_net", period) return salaire_net * 0.9 - 100 * dom_tom @@ -174,7 +172,7 @@ class salaire_net(Variable): label = "Salaire net" def formula(self, simulation, period): - period = period.start.period("year").offset("first-of") + period = period.start.period(DateUnit.YEAR).offset("first-of") salaire_brut = simulation.calculate("salaire_brut", period) return salaire_brut * 0.8 @@ -209,11 +207,11 @@ def check_revenu_disponible(year, city_code, expected_revenu_disponible): individu = simulation.populations["individu"] individu.count = 6 individu.step_size = 2 - simulation.get_or_new_holder("city_code").array = numpy.array( + simulation.get_or_new_holder("city_code").array = np.array( [city_code, city_code, city_code] ) - famille.members_entity_id = numpy.array([0, 0, 1, 1, 2, 2]) - simulation.get_or_new_holder("salaire_brut").array = numpy.array( + famille.members_entity_id = np.array([0, 0, 1, 1, 2, 2]) + simulation.get_or_new_holder("salaire_brut").array = np.array( [0.0, 0.0, 50000.0, 0.0, 100000.0, 0.0] ) revenu_disponible = simulation.calculate("revenu_disponible") @@ -237,34 +235,34 @@ def main(): level=logging.DEBUG if args.verbose else logging.WARNING, stream=sys.stdout ) - check_revenu_disponible(2009, "75101", numpy.array([0, 0, 25200, 0, 50400, 0])) + check_revenu_disponible(2009, "75101", np.array([0, 0, 25200, 0, 50400, 0])) check_revenu_disponible( - 2010, "75101", numpy.array([1200, 1200, 25200, 1200, 50400, 1200]) + 2010, "75101", np.array([1200, 1200, 25200, 1200, 50400, 1200]) ) check_revenu_disponible( - 2011, "75101", numpy.array([2400, 2400, 25200, 2400, 50400, 2400]) + 2011, "75101", np.array([2400, 2400, 25200, 2400, 50400, 2400]) ) check_revenu_disponible( - 2012, "75101", numpy.array([2400, 2400, 25200, 2400, 50400, 2400]) + 2012, "75101", np.array([2400, 2400, 25200, 2400, 50400, 2400]) ) check_revenu_disponible( - 2013, "75101", numpy.array([3600, 3600, 25200, 3600, 50400, 3600]) + 2013, "75101", np.array([3600, 3600, 25200, 3600, 50400, 3600]) ) check_revenu_disponible( - 2009, "97123", numpy.array([-70.0, -70.0, 25130.0, -70.0, 50330.0, -70.0]) + 2009, "97123", np.array([-70.0, -70.0, 25130.0, -70.0, 50330.0, -70.0]) ) check_revenu_disponible( - 2010, "97123", numpy.array([1130.0, 1130.0, 25130.0, 1130.0, 50330.0, 1130.0]) + 2010, "97123", np.array([1130.0, 1130.0, 25130.0, 1130.0, 50330.0, 1130.0]) ) check_revenu_disponible( - 2011, "98456", numpy.array([2330.0, 2330.0, 25130.0, 2330.0, 50330.0, 2330.0]) + 2011, "98456", np.array([2330.0, 2330.0, 25130.0, 2330.0, 50330.0, 2330.0]) ) check_revenu_disponible( - 2012, "98456", numpy.array([2330.0, 2330.0, 25130.0, 2330.0, 50330.0, 2330.0]) + 2012, "98456", np.array([2330.0, 2330.0, 25130.0, 2330.0, 50330.0, 2330.0]) ) check_revenu_disponible( - 2013, "98456", numpy.array([3530.0, 3530.0, 25130.0, 3530.0, 50330.0, 3530.0]) + 2013, "98456", np.array([3530.0, 3530.0, 25130.0, 3530.0, 50330.0, 3530.0]) ) diff --git a/openfisca_core/scripts/measure_performances_fancy_indexing.py b/openfisca_core/scripts/measure_performances_fancy_indexing.py index 3b2dcd5f45..b72f436033 100644 --- a/openfisca_core/scripts/measure_performances_fancy_indexing.py +++ b/openfisca_core/scripts/measure_performances_fancy_indexing.py @@ -1,17 +1,18 @@ # flake8: noqa T001 -import numpy import timeit + +import numpy as np + from openfisca_france import CountryTaxBenefitSystem -from openfisca_core.model_api import * # noqa analysis:ignore tbs = CountryTaxBenefitSystem() N = 200000 al_plaf_acc = tbs.get_parameters_at_instant("2015-01-01").prestations.al_plaf_acc -zone_apl = numpy.random.choice([1, 2, 3], N) -al_nb_pac = numpy.random.choice(6, N) -couple = numpy.random.choice([True, False], N) +zone_apl = np.random.choice([1, 2, 3], N) +al_nb_pac = np.random.choice(6, N) +couple = np.random.choice([True, False], N) formatted_zone = concat( "plafond_pour_accession_a_la_propriete_zone_", zone_apl ) # zone_apl returns 1, 2 or 3 but the parameters have a long name diff --git a/openfisca_core/simulations/simulation.py b/openfisca_core/simulations/simulation.py index e1f8c02690..95c90a9ee4 100644 --- a/openfisca_core/simulations/simulation.py +++ b/openfisca_core/simulations/simulation.py @@ -10,7 +10,7 @@ from openfisca_core import commons, periods from openfisca_core.errors import CycleError, SpiralError, VariableNotFoundError from openfisca_core.indexed_enums import Enum, EnumArray -from openfisca_core.periods import Period +from openfisca_core.periods import DateUnit, Period from openfisca_core.tracers import ( FullTracer, SimpleTracer, @@ -185,16 +185,19 @@ def calculate_add(self, variable_name: str, period): period.unit ): raise ValueError( - "Unable to compute variable '{0}' for period {1}: '{0}' can only be computed for {2}-long periods. You can use the DIVIDE option to get an estimate of {0} by dividing the yearly value by 12, or change the requested period to 'period.this_year'.".format( - variable.name, period, variable.definition_period - ) + f"Unable to compute variable '{variable.name}' for period " + f"{period}: '{variable.name}' can only be computed for " + f"{variable.definition_period}-long periods. You can use the " + f"DIVIDE option to get an estimate of {variable.name}." ) - if variable.definition_period not in [periods.DAY, periods.MONTH, periods.YEAR]: + if variable.definition_period not in ( + DateUnit.isoformat + DateUnit.isocalendar + ): raise ValueError( - "Unable to sum constant variable '{}' over period {}: only variables defined daily, monthly, or yearly can be summed over time.".format( - variable.name, period - ) + f"Unable to ADD constant variable '{variable.name}' over " + f"the period {period}: eternal variables can't be summed " + "over time." ) return sum( @@ -215,30 +218,68 @@ def calculate_divide(self, variable_name: str, period): if period is not None and not isinstance(period, Period): period = periods.period(period) - # Check that the requested period matches definition_period - if variable.definition_period != periods.YEAR: + if ( + periods.unit_weight(variable.definition_period) + < periods.unit_weight(period.unit) + or period.size > 1 + ): raise ValueError( - "Unable to divide the value of '{}' over time on period {}: only variables defined yearly can be divided over time.".format( - variable_name, period - ) + f"Can't calculate variable '{variable.name}' for period " + f"{period}: '{variable.name}' can only be computed for " + f"{variable.definition_period}-long periods. You can use the " + f"ADD option to get an estimate of {variable.name}." ) - if period.size != 1: + if variable.definition_period not in ( + DateUnit.isoformat + DateUnit.isocalendar + ): raise ValueError( - "DIVIDE option can only be used for a one-year or a one-month requested period" + f"Unable to DIVIDE constant variable '{variable.name}' over " + f"the period {period}: eternal variables can't be divided " + "over time." ) - if period.unit == periods.MONTH: - computation_period = period.this_year - return self.calculate(variable_name, period=computation_period) / 12.0 - elif period.unit == periods.YEAR: - return self.calculate(variable_name, period) - - raise ValueError( - "Unable to divide the value of '{}' to match period {}.".format( - variable_name, period + if ( + period.unit not in (DateUnit.isoformat + DateUnit.isocalendar) + or period.size != 1 + ): + raise ValueError( + f"Unable to DIVIDE constant variable '{variable.name}' over " + f"the period {period}: eternal variables can't be used " + "as a denominator to divide a variable over time." ) - ) + + if variable.definition_period == DateUnit.YEAR: + calculation_period = period.this_year + + elif variable.definition_period == DateUnit.MONTH: + calculation_period = period.first_month + + elif variable.definition_period == DateUnit.DAY: + calculation_period = period.first_day + + elif variable.definition_period == DateUnit.WEEK: + calculation_period = period.first_week + + else: + calculation_period = period.first_weekday + + if period.unit == DateUnit.YEAR: + denominator = calculation_period.size_in_years + + elif period.unit == DateUnit.MONTH: + denominator = calculation_period.size_in_months + + elif period.unit == DateUnit.DAY: + denominator = calculation_period.size_in_days + + elif period.unit == DateUnit.WEEK: + denominator = calculation_period.size_in_weeks + + else: + denominator = calculation_period.size_in_weekdays + + return self.calculate(variable_name, calculation_period) / denominator def calculate_output(self, variable_name: str, period): """ @@ -290,19 +331,29 @@ def _check_period_consistency(self, period, variable): """ Check that a period matches the variable definition_period """ - if variable.definition_period == periods.ETERNITY: + if variable.definition_period == DateUnit.ETERNITY: return # For variables which values are constant in time, all periods are accepted - if variable.definition_period == periods.MONTH and period.unit != periods.MONTH: + if variable.definition_period == DateUnit.YEAR and period.unit != DateUnit.YEAR: + raise ValueError( + "Unable to compute variable '{0}' for period {1}: '{0}' must be computed for a whole year. You can use the DIVIDE option to get an estimate of {0} by dividing the yearly value by 12, or change the requested period to 'period.this_year'.".format( + variable.name, period + ) + ) + + if ( + variable.definition_period == DateUnit.MONTH + and period.unit != DateUnit.MONTH + ): raise ValueError( "Unable to compute variable '{0}' for period {1}: '{0}' must be computed for a whole month. You can use the ADD option to sum '{0}' over the requested period, or change the requested period to 'period.first_month'.".format( variable.name, period ) ) - if variable.definition_period == periods.YEAR and period.unit != periods.YEAR: + if variable.definition_period == DateUnit.WEEK and period.unit != DateUnit.WEEK: raise ValueError( - "Unable to compute variable '{0}' for period {1}: '{0}' must be computed for a whole year. You can use the DIVIDE option to get an estimate of {0} by dividing the yearly value by 12, or change the requested period to 'period.this_year'.".format( + "Unable to compute variable '{0}' for period {1}: '{0}' must be computed for a whole week. You can use the ADD option to sum '{0}' over the requested period, or change the requested period to 'period.first_week'.".format( variable.name, period ) ) @@ -310,9 +361,7 @@ def _check_period_consistency(self, period, variable): if period.size != 1: raise ValueError( "Unable to compute variable '{0}' for period {1}: '{0}' must be computed for a whole {2}. You can use the ADD option to sum '{0}' over the requested period.".format( - variable.name, - period, - "month" if variable.definition_period == periods.MONTH else "year", + variable.name, period, variable.definition_period ) ) diff --git a/openfisca_core/taxbenefitsystems/tax_benefit_system.py b/openfisca_core/taxbenefitsystems/tax_benefit_system.py index c038a9ad0e..399fd479dc 100644 --- a/openfisca_core/taxbenefitsystems/tax_benefit_system.py +++ b/openfisca_core/taxbenefitsystems/tax_benefit_system.py @@ -445,7 +445,7 @@ def get_parameters_at_instant( """ - key: Instant + key: Optional[Instant] msg: str if isinstance(instant, Instant): diff --git a/openfisca_core/tools/simulation_dumper.py b/openfisca_core/tools/simulation_dumper.py index 988ccd6236..b33dd9b4e9 100644 --- a/openfisca_core/tools/simulation_dumper.py +++ b/openfisca_core/tools/simulation_dumper.py @@ -3,11 +3,11 @@ import os -import numpy +import numpy as np from openfisca_core.simulations import Simulation from openfisca_core.data_storage import OnDiskStorage -from openfisca_core.periods import ETERNITY +from openfisca_core.periods import DateUnit def dump_simulation(simulation, directory): @@ -74,46 +74,42 @@ def _dump_holder(holder, directory): def _dump_entity(population, directory): path = os.path.join(directory, population.entity.key) os.mkdir(path) - numpy.save(os.path.join(path, "id.npy"), population.ids) + np.save(os.path.join(path, "id.npy"), population.ids) if population.entity.is_person: return - numpy.save(os.path.join(path, "members_position.npy"), population.members_position) - numpy.save( - os.path.join(path, "members_entity_id.npy"), population.members_entity_id - ) + np.save(os.path.join(path, "members_position.npy"), population.members_position) + np.save(os.path.join(path, "members_entity_id.npy"), population.members_entity_id) flattened_roles = population.entity.flattened_roles if len(flattened_roles) == 0: - encoded_roles = numpy.int64(0) + encoded_roles = np.int64(0) else: - encoded_roles = numpy.select( + encoded_roles = np.select( [population.members_role == role for role in flattened_roles], [role.key for role in flattened_roles], ) - numpy.save(os.path.join(path, "members_role.npy"), encoded_roles) + np.save(os.path.join(path, "members_role.npy"), encoded_roles) def _restore_entity(population, directory): path = os.path.join(directory, population.entity.key) - population.ids = numpy.load(os.path.join(path, "id.npy")) + population.ids = np.load(os.path.join(path, "id.npy")) if population.entity.is_person: return - population.members_position = numpy.load(os.path.join(path, "members_position.npy")) - population.members_entity_id = numpy.load( - os.path.join(path, "members_entity_id.npy") - ) - encoded_roles = numpy.load(os.path.join(path, "members_role.npy")) + population.members_position = np.load(os.path.join(path, "members_position.npy")) + population.members_entity_id = np.load(os.path.join(path, "members_entity_id.npy")) + encoded_roles = np.load(os.path.join(path, "members_role.npy")) flattened_roles = population.entity.flattened_roles if len(flattened_roles) == 0: - population.members_role = numpy.int64(0) + population.members_role = np.int64(0) else: - population.members_role = numpy.select( + population.members_role = np.select( [encoded_roles == role.key for role in flattened_roles], [role for role in flattened_roles], ) @@ -126,7 +122,7 @@ def _restore_holder(simulation, variable, directory): storage_dir = os.path.join(directory, variable) is_variable_eternal = ( simulation.tax_benefit_system.get_variable(variable).definition_period - == ETERNITY + == DateUnit.ETERNITY ) disk_storage = OnDiskStorage( storage_dir, is_eternal=is_variable_eternal, preserve_storage_dir=True diff --git a/openfisca_core/types/_data.py b/openfisca_core/types/_data.py index d8153dc9b4..96a729ddca 100644 --- a/openfisca_core/types/_data.py +++ b/openfisca_core/types/_data.py @@ -3,6 +3,7 @@ # from nptyping import types, NDArray as Array from numpy.typing import NDArray as Array # noqa: F401 + # import numpy # NumpyT = TypeVar("NumpyT", numpy.bytes_, numpy.number, numpy.object_, numpy.str_) diff --git a/openfisca_core/types/_domain.py b/openfisca_core/types/_domain.py index 91e295e3bf..bce6bb9aa0 100644 --- a/openfisca_core/types/_domain.py +++ b/openfisca_core/types/_domain.py @@ -1,11 +1,12 @@ from __future__ import annotations -import numpy +import abc + import typing_extensions from typing import Any, Optional from typing_extensions import Protocol -import abc +import numpy class Entity(Protocol): diff --git a/openfisca_core/variables/tests/__init__.py b/openfisca_core/variables/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/variables/tests/test_definition_period.py b/openfisca_core/variables/tests/test_definition_period.py new file mode 100644 index 0000000000..7938aaeaef --- /dev/null +++ b/openfisca_core/variables/tests/test_definition_period.py @@ -0,0 +1,43 @@ +import pytest + +from openfisca_core import periods +from openfisca_core.variables import Variable + + +@pytest.fixture +def variable(persons): + class TestVariable(Variable): + value_type = float + entity = persons + + return TestVariable + + +def test_weekday_variable(variable): + variable.definition_period = periods.WEEKDAY + assert variable() + + +def test_week_variable(variable): + variable.definition_period = periods.WEEK + assert variable() + + +def test_day_variable(variable): + variable.definition_period = periods.DAY + assert variable() + + +def test_month_variable(variable): + variable.definition_period = periods.MONTH + assert variable() + + +def test_year_variable(variable): + variable.definition_period = periods.YEAR + assert variable() + + +def test_eternity_variable(variable): + variable.definition_period = periods.ETERNITY + assert variable() diff --git a/openfisca_core/variables/variable.py b/openfisca_core/variables/variable.py index 2dd550fb30..e4f7464e5d 100644 --- a/openfisca_core/variables/variable.py +++ b/openfisca_core/variables/variable.py @@ -13,7 +13,7 @@ from openfisca_core import periods, tools from openfisca_core.entities import Entity from openfisca_core.indexed_enums import Enum, EnumArray -from openfisca_core.periods import Period +from openfisca_core.periods import DateUnit, Period from openfisca_core.types import Formula, Instant from . import config, helpers @@ -39,7 +39,7 @@ class Variable: .. attribute:: definition_period - `Period `_ the variable is defined for. Possible value: ``MONTH``, ``YEAR``, ``ETERNITY``. + `Period `_ the variable is defined for. Possible value: ``DateUnit.DAY``, ``DateUnit.MONTH``, ``DateUnit.YEAR``, ``DateUnit.ETERNITY``. .. attribute:: formulas @@ -65,7 +65,7 @@ class Variable: .. attribute:: dtype - NumPy `dtype `_ used under the hood for the variable. + Numpy `dtype `_ used under the hood for the variable. .. attribute:: end @@ -136,10 +136,7 @@ def __init__(self, baseline_variable=None): ) self.entity = self.set(attr, "entity", required=True, setter=self.set_entity) self.definition_period = self.set( - attr, - "definition_period", - required=True, - allowed_values=(periods.DAY, periods.MONTH, periods.YEAR, periods.ETERNITY), + attr, "definition_period", required=True, allowed_values=DateUnit ) self.label = self.set(attr, "label", allowed_type=str, setter=self.set_label) self.end = self.set(attr, "end", allowed_type=str, setter=self.set_end) @@ -407,6 +404,8 @@ def get_formula( """ + instant: Optional[Instant] + if not self.formulas: return None @@ -423,6 +422,9 @@ def get_formula( except ValueError: instant = periods.instant(period) + if instant is None: + return None + if self.end and instant.date > self.end: return None diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 9ccd94a6d1..a15b704576 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -60,5 +60,6 @@ lint-typing-strict-%: ## Run code formatters to correct style errors. format-style: $(shell git ls-files "*.py") @$(call print_help,$@:) - @autopep8 $? + @isort openfisca_core/periods + @black $? @$(call print_pass,$@:) diff --git a/openfisca_tasks/test_code.mk b/openfisca_tasks/test_code.mk index 63fdd4386a..c60c294bf7 100644 --- a/openfisca_tasks/test_code.mk +++ b/openfisca_tasks/test_code.mk @@ -34,6 +34,7 @@ test-core: $(shell pytest --quiet --quiet --collect-only 2> /dev/null | cut -f 1 @pytest --quiet --capture=no --xdoctest --xdoctest-verbose=0 \ openfisca_core/commons \ openfisca_core/holders \ + openfisca_core/periods \ openfisca_core/types @PYTEST_ADDOPTS="$${PYTEST_ADDOPTS} ${pytest_args}" \ coverage run -m \ diff --git a/setup.cfg b/setup.cfg index 617aa78fbd..df758aa180 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,7 @@ extend-ignore = # hang-closing = true # ignore = E128,E251,F403,F405,E501,RST301,W503,W504 in-place = true -include-in-doctest = openfisca_core/commons openfisca_core/holders openfisca_core/types +include-in-doctest = openfisca_core/commons openfisca_core/holders openfisca_core/periods openfisca_core/types # Recommend matching the black line length (default 88), # rather than using the flake8 default of 79: max-line-length = 88 @@ -33,6 +33,18 @@ disable = all enable = C0115,C0116,R0401 score = no +[isort] +case_sensitive = true +force_alphabetical_sort_within_sections = false +group_by_package = true +include_trailing_comma = true +known_first_party = openfisca_core +known_third_party = openfisca_country_template,openfisca_extension_template +known_typing = mypy,mypy_extensions,nptyping,types,typing,typing_extensions +multi_line_output = 8 +py_version = 37 +sections = FUTURE,TYPING,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER + [coverage:paths] source = . */site-packages @@ -63,5 +75,8 @@ ignore_errors = True [mypy-openfisca_core.holders.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 8ff461668b..2d4edefa91 100644 --- a/setup.py +++ b/setup.py @@ -29,12 +29,14 @@ "PyYAML >=6.0, <7.0", "dpath >=2.1.4, <3.0", "importlib-metadata >=6.1.0, <7.0", - "numexpr >=2.8.4, < 3.0", + "numexpr >=2.8.4, <3.0", "numpy >=1.24.2, <1.25", - "psutil >=5.9.4, < 6.0", - "pytest >=7.2.2, < 8.0", - "sortedcontainers >=2.4.0, < 3.0", - "typing_extensions >=4.5.0, < 5.0", + "pendulum >=2.1.2, <3.0.0", + "psutil >=5.9.4, <6.0", + "pytest >=7.2.2, <8.0", + "sortedcontainers >=2.4.0, <3.0", + "typing_extensions >=4.5.0, <5.0", + "StrEnum >=0.4.8, <0.5.0", # 3.11.x backport ] api_requirements = [ @@ -64,7 +66,7 @@ setup( name="OpenFisca-Core", - version="40.0.1", + version="40.1.0", author="OpenFisca Team", author_email="contact@openfisca.org", classifiers=[ diff --git a/tests/core/parameters_fancy_indexing/test_fancy_indexing.py b/tests/core/parameters_fancy_indexing/test_fancy_indexing.py index 9fcd76dfff..4d682680c4 100644 --- a/tests/core/parameters_fancy_indexing/test_fancy_indexing.py +++ b/tests/core/parameters_fancy_indexing/test_fancy_indexing.py @@ -3,13 +3,14 @@ import os import re -import numpy +import numpy as np import pytest -from openfisca_core.tools import assert_near from openfisca_core.parameters import ParameterNode, Parameter, ParameterNotFound -from openfisca_core.model_api import * # noqa +from openfisca_core.indexed_enums import Enum +from openfisca_core.tools import assert_near + LOCAL_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -23,26 +24,26 @@ def get_message(error): def test_on_leaf(): - zone = numpy.asarray(["z1", "z2", "z2", "z1"]) + zone = np.asarray(["z1", "z2", "z2", "z1"]) assert_near(P.single.owner[zone], [100, 200, 200, 100]) def test_on_node(): - housing_occupancy_status = numpy.asarray(["owner", "owner", "tenant", "tenant"]) + housing_occupancy_status = np.asarray(["owner", "owner", "tenant", "tenant"]) node = P.single[housing_occupancy_status] assert_near(node.z1, [100, 100, 300, 300]) assert_near(node["z1"], [100, 100, 300, 300]) def test_double_fancy_indexing(): - zone = numpy.asarray(["z1", "z2", "z2", "z1"]) - housing_occupancy_status = numpy.asarray(["owner", "owner", "tenant", "tenant"]) + zone = np.asarray(["z1", "z2", "z2", "z1"]) + housing_occupancy_status = np.asarray(["owner", "owner", "tenant", "tenant"]) assert_near(P.single[housing_occupancy_status][zone], [100, 200, 400, 300]) def test_double_fancy_indexing_on_node(): - family_status = numpy.asarray(["single", "couple", "single", "couple"]) - housing_occupancy_status = numpy.asarray(["owner", "owner", "tenant", "tenant"]) + family_status = np.asarray(["single", "couple", "single", "couple"]) + housing_occupancy_status = np.asarray(["owner", "owner", "tenant", "tenant"]) node = P[family_status][housing_occupancy_status] assert_near(node.z1, [100, 500, 300, 700]) assert_near(node["z1"], [100, 500, 300, 700]) @@ -51,13 +52,13 @@ def test_double_fancy_indexing_on_node(): def test_triple_fancy_indexing(): - family_status = numpy.asarray( + family_status = np.asarray( ["single", "single", "single", "single", "couple", "couple", "couple", "couple"] ) - housing_occupancy_status = numpy.asarray( + housing_occupancy_status = np.asarray( ["owner", "owner", "tenant", "tenant", "owner", "owner", "tenant", "tenant"] ) - zone = numpy.asarray(["z1", "z2", "z1", "z2", "z1", "z2", "z1", "z2"]) + zone = np.asarray(["z1", "z2", "z1", "z2", "z1", "z2", "z1", "z2"]) assert_near( P[family_status][housing_occupancy_status][zone], [100, 200, 300, 400, 500, 600, 700, 800], @@ -65,7 +66,7 @@ def test_triple_fancy_indexing(): def test_wrong_key(): - zone = numpy.asarray(["z1", "z2", "z2", "toto"]) + zone = np.asarray(["z1", "z2", "z2", "toto"]) with pytest.raises(ParameterNotFound) as e: P.single.owner[zone] assert "'rate.single.owner.toto' was not found" in get_message(e.value) @@ -86,7 +87,7 @@ def test_inhomogenous(): ) P = parameters.rate("2015-01-01") - housing_occupancy_status = numpy.asarray(["owner", "owner", "tenant", "tenant"]) + housing_occupancy_status = np.asarray(["owner", "owner", "tenant", "tenant"]) with pytest.raises(ValueError) as error: P.couple[housing_occupancy_status] assert "'rate.couple.owner.toto' exists" in get_message(error.value) @@ -108,7 +109,7 @@ def test_inhomogenous_2(): ) P = parameters.rate("2015-01-01") - housing_occupancy_status = numpy.asarray(["owner", "owner", "tenant", "tenant"]) + housing_occupancy_status = np.asarray(["owner", "owner", "tenant", "tenant"]) with pytest.raises(ValueError) as e: P.couple[housing_occupancy_status] assert "'rate.couple.tenant.toto' exists" in get_message(e.value) @@ -133,7 +134,7 @@ def test_inhomogenous_3(): ) P = parameters.rate("2015-01-01") - zone = numpy.asarray(["z1", "z2", "z2", "z1"]) + zone = np.asarray(["z1", "z2", "z2", "z1"]) with pytest.raises(ValueError) as e: P.couple.tenant[zone] assert "'rate.couple.tenant.z4' is a node" in get_message(e.value) @@ -144,7 +145,7 @@ def test_inhomogenous_3(): def test_with_properties_starting_by_number(): - city_code = numpy.asarray(["75012", "75007", "75015"]) + city_code = np.asarray(["75012", "75007", "75015"]) assert_near(P_2[city_code], [100, 300, 200]) @@ -152,7 +153,7 @@ def test_with_properties_starting_by_number(): def test_with_bareme(): - city_code = numpy.asarray(["75012", "75007", "75015"]) + city_code = np.asarray(["75012", "75007", "75015"]) with pytest.raises(NotImplementedError) as e: P_3[city_code] assert re.findall( @@ -166,5 +167,5 @@ class TypesZone(Enum): z1 = "Zone 1" z2 = "Zone 2" - zone = numpy.asarray([TypesZone.z1, TypesZone.z2, TypesZone.z2, TypesZone.z1]) + zone = np.asarray([TypesZone.z1, TypesZone.z2, TypesZone.z2, TypesZone.z1]) assert_near(P.single.owner[zone], [100, 200, 200, 100]) diff --git a/tests/core/test_calculate_output.py b/tests/core/test_calculate_output.py index 926db52535..ecf59b5f7d 100644 --- a/tests/core/test_calculate_output.py +++ b/tests/core/test_calculate_output.py @@ -2,27 +2,28 @@ from openfisca_country_template import entities, situation_examples -from openfisca_core import periods, simulations, tools +from openfisca_core import simulations, tools +from openfisca_core.periods import DateUnit from openfisca_core.simulations import SimulationBuilder from openfisca_core.variables import Variable class simple_variable(Variable): entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH value_type = int class variable_with_calculate_output_add(Variable): entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH value_type = int calculate_output = simulations.calculate_output_add class variable_with_calculate_output_divide(Variable): entity = entities.Person - definition_period = periods.YEAR + definition_period = DateUnit.YEAR value_type = int calculate_output = simulations.calculate_output_divide diff --git a/tests/core/test_countries.py b/tests/core/test_countries.py index 66376ee9e2..8263ac3c44 100644 --- a/tests/core/test_countries.py +++ b/tests/core/test_countries.py @@ -2,6 +2,7 @@ from openfisca_core import periods, populations, tools from openfisca_core.errors import VariableNameConflictError, VariableNotFoundError +from openfisca_core.periods import DateUnit from openfisca_core.simulations import SimulationBuilder from openfisca_core.variables import Variable @@ -65,12 +66,6 @@ def test_calculate_variable_with_wrong_definition_period(simulation): ), f"Expected '{word}' in error message '{error_message}'" -@pytest.mark.parametrize("simulation", [({}, PERIOD)], indirect=True) -def test_divide_option_on_month_defined_variable(simulation): - with pytest.raises(ValueError): - simulation.person("disposable_income", PERIOD, options=[populations.DIVIDE]) - - @pytest.mark.parametrize("simulation", [({}, PERIOD)], indirect=True) def test_divide_option_with_complex_period(simulation): quarter = PERIOD.last_3_months @@ -79,7 +74,7 @@ def test_divide_option_with_complex_period(simulation): simulation.household("housing_tax", quarter, options=[populations.DIVIDE]) error_message = str(error.value) - expected_words = ["DIVIDE", "one-year", "one-month", "period"] + expected_words = ["Can't", "calculate", "month", "year"] for word in expected_words: assert ( @@ -106,7 +101,7 @@ def test_variable_with_reference(make_simulation, isolated_tax_benefit_system): assert result > 0 class disposable_income(Variable): - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(household, period): return household.empty_array() @@ -122,7 +117,7 @@ def formula(household, period): def test_variable_name_conflict(tax_benefit_system): class disposable_income(Variable): reference = "disposable_income" - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(household, period): return household.empty_array() diff --git a/tests/core/test_cycles.py b/tests/core/test_cycles.py index 0a9166d020..14886532c6 100644 --- a/tests/core/test_cycles.py +++ b/tests/core/test_cycles.py @@ -4,6 +4,7 @@ from openfisca_core import periods, tools from openfisca_core.errors import CycleError +from openfisca_core.periods import DateUnit from openfisca_core.simulations import SimulationBuilder from openfisca_core.variables import Variable @@ -22,7 +23,7 @@ def simulation(tax_benefit_system): class variable1(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("variable2", period) @@ -31,7 +32,7 @@ def formula(person, period): class variable2(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("variable1", period) @@ -41,7 +42,7 @@ def formula(person, period): class variable3(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("variable4", period.last_month) @@ -50,7 +51,7 @@ def formula(person, period): class variable4(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("variable3", period) @@ -61,7 +62,7 @@ def formula(person, period): class variable5(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): variable6 = person("variable6", period.last_month) @@ -71,7 +72,7 @@ def formula(person, period): class variable6(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): variable5 = person("variable5", period) @@ -81,7 +82,7 @@ def formula(person, period): class variable7(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): variable5 = person("variable5", period) @@ -92,7 +93,7 @@ def formula(person, period): class cotisation(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): if period.start.month == 12: diff --git a/tests/core/test_formulas.py b/tests/core/test_formulas.py index 9f33462d4f..e45b93d3ef 100644 --- a/tests/core/test_formulas.py +++ b/tests/core/test_formulas.py @@ -2,7 +2,8 @@ from openfisca_country_template import entities -from openfisca_core import commons, periods +from openfisca_core import commons +from openfisca_core.periods import DateUnit from openfisca_core.simulations import SimulationBuilder from openfisca_core.variables import Variable @@ -12,14 +13,14 @@ class choice(Variable): value_type = int entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH class uses_multiplication(Variable): value_type = int entity = entities.Person label = "Variable with formula that uses multiplication" - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): choice = person("choice", period) @@ -31,7 +32,7 @@ class returns_scalar(Variable): value_type = int entity = entities.Person label = "Variable with formula that returns a scalar value" - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): return 666 @@ -41,7 +42,7 @@ class uses_switch(Variable): value_type = int entity = entities.Person label = "Variable with formula that uses switch" - definition_period = periods.MONTH + definition_period = DateUnit.MONTH def formula(person, period): choice = person("choice", period) @@ -110,7 +111,7 @@ def test_group_encapsulation(): """ from openfisca_core.taxbenefitsystems import TaxBenefitSystem from openfisca_core.entities import build_entity - from openfisca_core.periods import ETERNITY + from openfisca_core.periods import DateUnit person_entity = build_entity( key="person", @@ -151,12 +152,12 @@ def test_group_encapsulation(): class household_level_variable(Variable): value_type = int entity = household_entity - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY class projected_family_level_variable(Variable): value_type = int entity = family_entity - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY def formula(family, period): return family.household("household_level_variable", period) diff --git a/tests/core/test_holders.py b/tests/core/test_holders.py index 51d2ecab4f..0d4f30fe4d 100644 --- a/tests/core/test_holders.py +++ b/tests/core/test_holders.py @@ -8,6 +8,7 @@ from openfisca_core import holders, periods, tools from openfisca_core.errors import PeriodMismatchError from openfisca_core.memory_config import MemoryConfig +from openfisca_core.periods import DateUnit from openfisca_core.simulations import SimulationBuilder from openfisca_core.holders import Holder @@ -106,9 +107,9 @@ def test_permanent_variable_filled(single): simulation = single holder = simulation.person.get_holder("birth") value = numpy.asarray(["1980-01-01"], dtype=holder.variable.dtype) - holder.set_input(periods.period(periods.ETERNITY), value) + holder.set_input(periods.period(DateUnit.ETERNITY), value) assert holder.get_array(None) == value - assert holder.get_array(periods.ETERNITY) == value + assert holder.get_array(DateUnit.ETERNITY) == value assert holder.get_array("2016-01") == value diff --git a/tests/core/test_opt_out_cache.py b/tests/core/test_opt_out_cache.py index 37b2f281e6..01efb315bf 100644 --- a/tests/core/test_opt_out_cache.py +++ b/tests/core/test_opt_out_cache.py @@ -3,7 +3,7 @@ from openfisca_country_template.entities import Person from openfisca_core import periods -from openfisca_core.periods import MONTH +from openfisca_core.periods import DateUnit from openfisca_core.variables import Variable @@ -14,14 +14,14 @@ class input(Variable): value_type = int entity = Person label = "Input variable" - definition_period = MONTH + definition_period = DateUnit.MONTH class intermediate(Variable): value_type = int entity = Person label = "Intermediate result that don't need to be cached" - definition_period = MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("input", period) @@ -31,7 +31,7 @@ class output(Variable): value_type = int entity = Person label = "Output variable" - definition_period = MONTH + definition_period = DateUnit.MONTH def formula(person, period): return person("intermediate", period) diff --git a/tests/core/test_periods.py b/tests/core/test_periods.py deleted file mode 100644 index 2a93d5e6f1..0000000000 --- a/tests/core/test_periods.py +++ /dev/null @@ -1,218 +0,0 @@ -# -*- coding: utf-8 -*- - - -import pytest - -from openfisca_core.periods import Period, Instant, YEAR, MONTH, DAY, period - -first_jan = Instant((2014, 1, 1)) -first_march = Instant((2014, 3, 1)) - - -""" -Test Period -> String -""" - - -# Years - - -def test_year(): - assert str(Period((YEAR, first_jan, 1))) == "2014" - - -def test_12_months_is_a_year(): - assert str(Period((MONTH, first_jan, 12))) == "2014" - - -def test_rolling_year(): - assert str(Period((MONTH, first_march, 12))) == "year:2014-03" - assert str(Period((YEAR, first_march, 1))) == "year:2014-03" - - -def test_several_years(): - assert str(Period((YEAR, first_jan, 3))) == "year:2014:3" - assert str(Period((YEAR, first_march, 3))) == "year:2014-03:3" - - -# Months - - -def test_month(): - assert str(Period((MONTH, first_jan, 1))) == "2014-01" - - -def test_several_months(): - assert str(Period((MONTH, first_jan, 3))) == "month:2014-01:3" - assert str(Period((MONTH, first_march, 3))) == "month:2014-03:3" - - -# Days - - -def test_day(): - assert str(Period((DAY, first_jan, 1))) == "2014-01-01" - - -def test_several_days(): - assert str(Period((DAY, first_jan, 3))) == "day:2014-01-01:3" - assert str(Period((DAY, first_march, 3))) == "day:2014-03-01:3" - - -""" -Test String -> Period -""" - - -# Years - - -def test_parsing_year(): - assert period("2014") == Period((YEAR, first_jan, 1)) - - -def test_parsing_rolling_year(): - assert period("year:2014-03") == Period((YEAR, first_march, 1)) - - -def test_parsing_several_years(): - assert period("year:2014:2") == Period((YEAR, first_jan, 2)) - - -def test_wrong_syntax_several_years(): - with pytest.raises(ValueError): - period("2014:2") - - -# Months - - -def test_parsing_month(): - assert period("2014-01") == Period((MONTH, first_jan, 1)) - - -def test_parsing_several_months(): - assert period("month:2014-03:3") == Period((MONTH, first_march, 3)) - - -def test_wrong_syntax_several_months(): - with pytest.raises(ValueError): - period("2014-3:3") - - -# Days - - -def test_parsing_day(): - assert period("2014-01-01") == Period((DAY, first_jan, 1)) - - -def test_parsing_several_days(): - assert period("day:2014-03-01:3") == Period((DAY, first_march, 3)) - - -def test_wrong_syntax_several_days(): - with pytest.raises(ValueError): - period("2014-2-3:2") - - -def test_day_size_in_days(): - assert Period(("day", Instant((2014, 12, 31)), 1)).size_in_days == 1 - - -def test_3_day_size_in_days(): - assert Period(("day", Instant((2014, 12, 31)), 3)).size_in_days == 3 - - -def test_month_size_in_days(): - assert Period(("month", Instant((2014, 12, 1)), 1)).size_in_days == 31 - - -def test_leap_month_size_in_days(): - assert Period(("month", Instant((2012, 2, 3)), 1)).size_in_days == 29 - - -def test_3_month_size_in_days(): - assert Period(("month", Instant((2013, 1, 3)), 3)).size_in_days == 31 + 28 + 31 - - -def test_leap_3_month_size_in_days(): - assert Period(("month", Instant((2012, 1, 3)), 3)).size_in_days == 31 + 29 + 31 - - -def test_year_size_in_days(): - assert Period(("year", Instant((2014, 12, 1)), 1)).size_in_days == 365 - - -def test_leap_year_size_in_days(): - assert Period(("year", Instant((2012, 1, 1)), 1)).size_in_days == 366 - - -def test_2_years_size_in_days(): - assert Period(("year", Instant((2014, 1, 1)), 2)).size_in_days == 730 - - -# Misc - - -def test_wrong_date(): - with pytest.raises(ValueError): - period("2006-31-03") - - -def test_ambiguous_period(): - with pytest.raises(ValueError): - period("month:2014") - - -def test_deprecated_signature(): - with pytest.raises(TypeError): - period(MONTH, 2014) - - -def test_wrong_argument(): - with pytest.raises(ValueError): - period({}) - - -def test_wrong_argument_1(): - with pytest.raises(ValueError): - period([]) - - -def test_none(): - with pytest.raises(ValueError): - period(None) - - -def test_empty_string(): - with pytest.raises(ValueError): - period("") - - -@pytest.mark.parametrize( - "test", - [ - (period("year:2014:2"), YEAR, 2, period("2014"), period("2015")), - (period(2017), MONTH, 12, period("2017-01"), period("2017-12")), - (period("year:2014:2"), MONTH, 24, period("2014-01"), period("2015-12")), - (period("month:2014-03:3"), MONTH, 3, period("2014-03"), period("2014-05")), - (period(2017), DAY, 365, period("2017-01-01"), period("2017-12-31")), - (period("year:2014:2"), DAY, 730, period("2014-01-01"), period("2015-12-31")), - ( - period("month:2014-03:3"), - DAY, - 92, - period("2014-03-01"), - period("2014-05-31"), - ), - ], -) -def test_subperiods(test): - def check_subperiods(period, unit, length, first, last): - subperiods = period.get_subperiods(unit) - assert len(subperiods) == length - assert subperiods[0] == first - assert subperiods[-1] == last - - check_subperiods(*test) diff --git a/tests/core/test_projectors.py b/tests/core/test_projectors.py index 089b4a8be9..27391711c3 100644 --- a/tests/core/test_projectors.py +++ b/tests/core/test_projectors.py @@ -1,8 +1,11 @@ +import numpy as np + +from openfisca_core.entities import build_entity +from openfisca_core.indexed_enums import Enum +from openfisca_core.periods import DateUnit from openfisca_core.simulations.simulation_builder import SimulationBuilder from openfisca_core.taxbenefitsystems import TaxBenefitSystem -from openfisca_core.entities import build_entity -from openfisca_core.model_api import Enum, Variable, ETERNITY -import numpy +from openfisca_core.variables import Variable def test_shortcut_to_containing_entity_provided(): @@ -135,14 +138,14 @@ class household_enum_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY class projected_enum_variable(Variable): value_type = Enum possible_values = enum default_value = enum.FIRST_OPTION entity = person - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY def formula(person, period): return person.household("household_enum_variable", period) @@ -164,7 +167,7 @@ def formula(person, period): assert ( simulation.calculate("projected_enum_variable", "2021-01-01").decode_to_str() - == numpy.array(["SECOND_OPTION"] * 3) + == np.array(["SECOND_OPTION"] * 3) ).all() @@ -206,7 +209,7 @@ class household_projected_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY def formula(household, period): return household.value_from_first_person( @@ -218,7 +221,7 @@ class person_enum_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = person - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY system.add_variables(household_projected_variable, person_enum_variable) @@ -242,7 +245,7 @@ class person_enum_variable(Variable): simulation.calculate( "household_projected_variable", "2021-01-01" ).decode_to_str() - == numpy.array(["SECOND_OPTION"]) + == np.array(["SECOND_OPTION"]) ).all() @@ -297,14 +300,14 @@ class household_level_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household_entity - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY class projected_family_level_variable(Variable): value_type = Enum possible_values = enum default_value = enum.FIRST_OPTION entity = family_entity - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY def formula(family, period): return family.household("household_level_variable", period) @@ -312,7 +315,7 @@ def formula(family, period): class decoded_projected_family_level_variable(Variable): value_type = str entity = family_entity - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY def formula(family, period): return family.household("household_level_variable", period).decode_to_str() @@ -344,9 +347,9 @@ def formula(family, period): simulation.calculate( "projected_family_level_variable", "2021-01-01" ).decode_to_str() - == numpy.array(["SECOND_OPTION"]) + == np.array(["SECOND_OPTION"]) ).all() assert ( simulation.calculate("decoded_projected_family_level_variable", "2021-01-01") - == numpy.array(["SECOND_OPTION"]) + == np.array(["SECOND_OPTION"]) ).all() diff --git a/tests/core/test_reforms.py b/tests/core/test_reforms.py index a34de52bf4..5d2a08e816 100644 --- a/tests/core/test_reforms.py +++ b/tests/core/test_reforms.py @@ -2,12 +2,15 @@ import pytest -from openfisca_core import periods +from openfisca_country_template.entities import Household, Person + +from openfisca_core import holders, periods, simulations +from openfisca_core.parameters import ValuesHistory, ParameterNode +from openfisca_core.periods import DateUnit from openfisca_core.periods import Instant +from openfisca_core.reforms import Reform from openfisca_core.tools import assert_near -from openfisca_core.parameters import ValuesHistory, ParameterNode -from openfisca_country_template.entities import Household, Person -from openfisca_core.model_api import * # noqa analysis:ignore +from openfisca_core.variables import Variable class goes_to_school(Variable): @@ -15,7 +18,7 @@ class goes_to_school(Variable): default_value = True entity = Person label = "The person goes to school (only relevant for children)" - definition_period = MONTH + definition_period = DateUnit.MONTH class WithBasicIncomeNeutralized(Reform): @@ -317,7 +320,7 @@ class new_variable(Variable): value_type = int label = "Nouvelle variable introduite par la réforme" entity = Household - definition_period = MONTH + definition_period = DateUnit.MONTH def formula(household, period): return household.empty_array() + 10 @@ -340,7 +343,7 @@ class new_dated_variable(Variable): value_type = int label = "Nouvelle variable introduite par la réforme" entity = Household - definition_period = MONTH + definition_period = DateUnit.MONTH def formula_2010_01_01(household, period): return household.empty_array() + 10 @@ -364,7 +367,7 @@ def apply(self): def test_update_variable(make_simulation, tax_benefit_system): class disposable_income(Variable): - definition_period = MONTH + definition_period = DateUnit.MONTH def formula_2018(household, period): return household.empty_array() + 10 @@ -401,7 +404,7 @@ def apply(self): def test_replace_variable(tax_benefit_system): class disposable_income(Variable): - definition_period = MONTH + definition_period = DateUnit.MONTH entity = Person label = "Disposable income" value_type = float @@ -463,9 +466,9 @@ class some_variable(Variable): value_type = int entity = Person label = "Variable with many attributes" - definition_period = MONTH - set_input = set_input_divide_by_period - calculate_output = calculate_output_add + definition_period = DateUnit.MONTH + set_input = holders.set_input_divide_by_period + calculate_output = simulations.calculate_output_add tax_benefit_system.add_variable(some_variable) diff --git a/tests/core/test_simulation_builder.py b/tests/core/test_simulation_builder.py index 00549618e5..464401d99a 100644 --- a/tests/core/test_simulation_builder.py +++ b/tests/core/test_simulation_builder.py @@ -5,9 +5,10 @@ from openfisca_country_template import entities, situation_examples -from openfisca_core import periods, tools +from openfisca_core import tools from openfisca_core.errors import SituationParsingError from openfisca_core.indexed_enums import Enum +from openfisca_core.periods import DateUnit from openfisca_core.populations import Population from openfisca_core.simulations import Simulation, SimulationBuilder from openfisca_core.tools import test_runner @@ -17,7 +18,7 @@ @pytest.fixture def int_variable(persons): class intvar(Variable): - definition_period = periods.ETERNITY + definition_period = DateUnit.ETERNITY value_type = int entity = persons @@ -30,7 +31,7 @@ def __init__(self): @pytest.fixture def date_variable(persons): class datevar(Variable): - definition_period = periods.ETERNITY + definition_period = DateUnit.ETERNITY value_type = datetime.date entity = persons @@ -43,7 +44,7 @@ def __init__(self): @pytest.fixture def enum_variable(): class TestEnum(Variable): - definition_period = periods.ETERNITY + definition_period = DateUnit.ETERNITY value_type = Enum dtype = "O" default_value = "0" diff --git a/tests/core/tools/test_runner/test_yaml_runner.py b/tests/core/tools/test_runner/test_yaml_runner.py index ebce9e026e..82ff4fe5e7 100644 --- a/tests/core/tools/test_runner/test_yaml_runner.py +++ b/tests/core/tools/test_runner/test_yaml_runner.py @@ -1,15 +1,15 @@ import os from typing import List -# import pytest +import pytest import numpy from openfisca_core.tools.test_runner import _get_tax_benefit_system, YamlItem, YamlFile -# from openfisca_core.errors import VariableNotFound +from openfisca_core.errors import VariableNotFound from openfisca_core.variables import Variable from openfisca_core.populations import Population from openfisca_core.entities import Entity -from openfisca_core.periods import ETERNITY +from openfisca_core.periods import DateUnit class TaxBenefitSystem: @@ -72,7 +72,7 @@ def __init__(self, test): class TestVariable(Variable): - definition_period = ETERNITY + definition_period = DateUnit.ETERNITY value_type = float def __init__(self): @@ -83,18 +83,13 @@ def __init__(self): self.dtype = numpy.float32 -# def test_variable_not_found(): -# test = {"output": {"unknown_variable": 0}} -# with pytest.raises(VariableNotFound) as excinfo: -# test_item = TestItem(test) -# test_item.check_output() -# assert excinfo.value.variable_name == "unknown_variable" - - -def test_tax_benefit_systems_with_cache(): - baseline = TaxBenefitSystem() - ab_tax_benefit_system = _get_tax_benefit_system(baseline, [], []) - assert baseline != ab_tax_benefit_system +@pytest.mark.skip(reason="Deprecated node constructor") +def test_variable_not_found(): + test = {"output": {"unknown_variable": 0}} + with pytest.raises(VariableNotFound) as excinfo: + test_item = TestItem(test) + test_item.check_output() + assert excinfo.value.variable_name == "unknown_variable" def test_tax_benefit_systems_with_reform_cache(): @@ -151,46 +146,48 @@ def test_extensions_order(): ) # extensions order is ignored in cache -# def test_performance_graph_option_output(): -# test = { -# "input": {"salary": {"2017-01": 2000}}, -# "output": {"salary": {"2017-01": 2000}}, -# } -# test_item = TestItem(test) -# test_item.options = {"performance_graph": True} +@pytest.mark.skip(reason="Deprecated node constructor") +def test_performance_graph_option_output(): + test = { + "input": {"salary": {"2017-01": 2000}}, + "output": {"salary": {"2017-01": 2000}}, + } + test_item = TestItem(test) + test_item.options = {"performance_graph": True} -# paths = ["./performance_graph.html"] + paths = ["./performance_graph.html"] -# clean_performance_files(paths) + clean_performance_files(paths) -# test_item.runtest() + test_item.runtest() -# assert test_item.simulation.trace -# for path in paths: -# assert os.path.isfile(path) + assert test_item.simulation.trace + for path in paths: + assert os.path.isfile(path) -# clean_performance_files(paths) + clean_performance_files(paths) -# def test_performance_tables_option_output(): -# test = { -# "input": {"salary": {"2017-01": 2000}}, -# "output": {"salary": {"2017-01": 2000}}, -# } -# test_item = TestItem(test) -# test_item.options = {"performance_tables": True} +@pytest.mark.skip(reason="Deprecated node constructor") +def test_performance_tables_option_output(): + test = { + "input": {"salary": {"2017-01": 2000}}, + "output": {"salary": {"2017-01": 2000}}, + } + test_item = TestItem(test) + test_item.options = {"performance_tables": True} -# paths = ["performance_table.csv", "aggregated_performance_table.csv"] + paths = ["performance_table.csv", "aggregated_performance_table.csv"] -# clean_performance_files(paths) + clean_performance_files(paths) -# test_item.runtest() + test_item.runtest() -# assert test_item.simulation.trace -# for path in paths: -# assert os.path.isfile(path) + assert test_item.simulation.trace + for path in paths: + assert os.path.isfile(path) -# clean_performance_files(paths) + clean_performance_files(paths) def clean_performance_files(paths: List[str]): diff --git a/tests/core/variables/test_annualize.py b/tests/core/variables/test_annualize.py index 1e7c8294f3..7bf85d9a46 100644 --- a/tests/core/variables/test_annualize.py +++ b/tests/core/variables/test_annualize.py @@ -1,10 +1,11 @@ -import numpy +import numpy as np from pytest import fixture -from openfisca_core import periods -from openfisca_core.model_api import * # noqa analysis:ignore from openfisca_country_template.entities import Person -from openfisca_core.variables import get_annualized_variable + +from openfisca_core import periods +from openfisca_core.periods import DateUnit +from openfisca_core.variables import Variable, get_annualized_variable @fixture @@ -14,11 +15,11 @@ def monthly_variable(): class monthly_variable(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH def formula(person, period, parameters): variable.calculation_count += 1 - return numpy.asarray([100]) + return np.asarray([100]) variable = monthly_variable() variable.calculation_count = calculation_count @@ -34,7 +35,7 @@ def __init__(self, variable): def __call__(self, variable_name: str, period): if period.start.month == 1: - return numpy.asarray([100]) + return np.asarray([100]) else: return self.variable.get_formula(period)(self, period, None) @@ -45,7 +46,8 @@ def test_without_annualize(monthly_variable): person = PopulationMock(monthly_variable) yearly_sum = sum( - person("monthly_variable", month) for month in period.get_subperiods(MONTH) + person("monthly_variable", month) + for month in period.get_subperiods(DateUnit.MONTH) ) assert monthly_variable.calculation_count == 11 @@ -59,7 +61,8 @@ def test_with_annualize(monthly_variable): person = PopulationMock(annualized_variable) yearly_sum = sum( - person("monthly_variable", month) for month in period.get_subperiods(MONTH) + person("monthly_variable", month) + for month in period.get_subperiods(DateUnit.MONTH) ) assert monthly_variable.calculation_count == 0 @@ -75,7 +78,8 @@ def test_with_partial_annualize(monthly_variable): person = PopulationMock(annualized_variable) yearly_sum = sum( - person("monthly_variable", month) for month in period.get_subperiods(MONTH) + person("monthly_variable", month) + for month in period.get_subperiods(DateUnit.MONTH) ) assert monthly_variable.calculation_count == 11 diff --git a/tests/core/variables/test_definition_period.py b/tests/core/variables/test_definition_period.py new file mode 100644 index 0000000000..7938aaeaef --- /dev/null +++ b/tests/core/variables/test_definition_period.py @@ -0,0 +1,43 @@ +import pytest + +from openfisca_core import periods +from openfisca_core.variables import Variable + + +@pytest.fixture +def variable(persons): + class TestVariable(Variable): + value_type = float + entity = persons + + return TestVariable + + +def test_weekday_variable(variable): + variable.definition_period = periods.WEEKDAY + assert variable() + + +def test_week_variable(variable): + variable.definition_period = periods.WEEK + assert variable() + + +def test_day_variable(variable): + variable.definition_period = periods.DAY + assert variable() + + +def test_month_variable(variable): + variable.definition_period = periods.MONTH + assert variable() + + +def test_year_variable(variable): + variable.definition_period = periods.YEAR + assert variable() + + +def test_eternity_variable(variable): + variable.definition_period = periods.ETERNITY + assert variable() diff --git a/tests/core/variables/test_variables.py b/tests/core/variables/test_variables.py index a60a1cfdcb..15c482b73b 100644 --- a/tests/core/variables/test_variables.py +++ b/tests/core/variables/test_variables.py @@ -2,16 +2,17 @@ import datetime -from openfisca_core.model_api import Variable -from openfisca_core.periods import MONTH, ETERNITY -from openfisca_core.simulation_builder import SimulationBuilder -from openfisca_core.tools import assert_near +from pytest import fixture, raises, mark import openfisca_country_template as country_template import openfisca_country_template.situation_examples from openfisca_country_template.entities import Person -from pytest import fixture, raises, mark +from openfisca_core.periods import DateUnit +from openfisca_core.simulation_builder import SimulationBuilder +from openfisca_core.tools import assert_near +from openfisca_core.variables import Variable + # Check which date is applied whether it comes from Variable attribute (end) # or formula(s) dates. @@ -67,7 +68,7 @@ def get_message(error): class variable__no_date(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable without date." @@ -88,7 +89,7 @@ def test_variable__no_date(): class variable__strange_end_attribute(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with dubious end attribute, no formula." end = "1989-00-00" @@ -113,7 +114,7 @@ def test_variable__strange_end_attribute(): class variable__end_attribute(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with end attribute, no formula." end = "1989-12-31" @@ -141,7 +142,7 @@ def test_variable__end_attribute_set_input(simulation): class end_attribute__one_simple_formula(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with end attribute, one formula without date." end = "1989-12-31" @@ -187,7 +188,7 @@ def test_dates__end_attribute__one_simple_formula(): class no_end_attribute__one_formula__strange_name(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable without end attribute, one stangely named formula." def formula_2015_toto(individu, period): @@ -208,7 +209,7 @@ def test_add__no_end_attribute__one_formula__strange_name(): class no_end_attribute__one_formula__start(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable without end attribute, one dated formula." def formula_2000_01_01(individu, period): @@ -241,8 +242,8 @@ class no_end_attribute__one_formula__eternity(Variable): value_type = int entity = Person definition_period = ( - ETERNITY # For this entity, this variable shouldn't evolve through time - ) + DateUnit.ETERNITY + ) # For this entity, this variable shouldn't evolve through time label = "Variable without end attribute, one dated formula." def formula_2000_01_01(individu, period): @@ -278,7 +279,7 @@ def test_call__no_end_attribute__one_formula__eternity_after(simulation): class no_end_attribute__formulas__start_formats(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable without end attribute, multiple dated formulas." def formula_2000(individu, period): @@ -339,7 +340,7 @@ def test_call__no_end_attribute__formulas__start_formats(simulation): class no_attribute__formulas__different_names__dates_overlap(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable, no end attribute, multiple dated formulas with different names but same dates." def formula_2000(individu, period): @@ -364,7 +365,7 @@ def test_add__no_attribute__formulas__different_names__dates_overlap(): class no_attribute__formulas__different_names__no_overlap(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable, no end attribute, multiple dated formulas with different names and no date overlap." def formula_2000_01_01(individu, period): @@ -404,7 +405,7 @@ def test_call__no_attribute__formulas__different_names__no_overlap(simulation): class end_attribute__one_formula__start(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with end attribute, one dated formula." end = "2001-12-31" @@ -432,7 +433,7 @@ def test_call__end_attribute__one_formula__start(simulation): class stop_attribute_before__one_formula__start(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with stop attribute only coming before formula start." end = "1990-01-01" @@ -454,7 +455,7 @@ def test_add__stop_attribute_before__one_formula__start(): class end_attribute_restrictive__one_formula(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = ( "Variable with end attribute, one dated formula and dates intervals overlap." ) @@ -484,7 +485,7 @@ def test_call__end_attribute_restrictive__one_formula(simulation): class end_attribute__formulas__different_names(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH label = "Variable with end attribute, multiple dated formulas with different names." end = "2010-12-31" @@ -535,7 +536,7 @@ def test_unexpected_attr(): class variable_with_strange_attr(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = DateUnit.MONTH unexpected = "???" with raises(ValueError): diff --git a/tests/fixtures/appclient.py b/tests/fixtures/appclient.py index 035bd6f259..5edcfc2c98 100644 --- a/tests/fixtures/appclient.py +++ b/tests/fixtures/appclient.py @@ -20,7 +20,7 @@ def test_client(tax_benefit_system): class new_variable(Variable): value_type = float entity = entities.Person - definition_period = periods.MONTH + definition_period = DateUnit.MONTH label = "New variable" reference = "https://law.gov.example/new_variable" # Always use the most official source diff --git a/tests/fixtures/variables.py b/tests/fixtures/variables.py index cd0d9b70ce..aab7cda58d 100644 --- a/tests/fixtures/variables.py +++ b/tests/fixtures/variables.py @@ -1,9 +1,9 @@ -from openfisca_core import periods +from openfisca_core.periods import DateUnit from openfisca_core.variables import Variable class TestVariable(Variable): - definition_period = periods.ETERNITY + definition_period = DateUnit.ETERNITY value_type = float def __init__(self, entity):