diff --git a/CHANGELOG.md b/CHANGELOG.md index 601cdab211..aba4c8f2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +# 39.0.0 [#1138](https://github.com/openfisca/openfisca-core/pull/1138) + +#### Breaking changes + +##### Renames + +- Rename `periods.period.get_subperiods` to `periods.period.subperiods`. + +##### Deprecations + +- Deprecate `INSTANT_PATTERN` + - The feature is now provided by `periods.parse` +- Deprecate `instant_date`. + - The feature is now provided by `periods.instant.date`. +- Deprecate `periods.{unit_weight, unit_weights, key_period_size}`. + - These features are now provided by `periods.dateunit`. +- Deprecate `periods.intersect`. + - The feature has no replacement. +- Make `periods.parse_period` stricter. + - For example `2022-1` now fails. +- Refactor `periods.period.contains` as `__contains__`. + - For example `subperiod in period` is now possible. + +##### Structural changes + +- Transform `Period.date` from property to method. + - Now it has to be used as `period.date()` (note the parenthesis). +- Transform `Instant.date` from property to method. + - Now it has to be used as `instant.date()` (note the parenthesis). +- Rationalise the reference periods. + - Before, there was a definite list of reference periods. For example, + `period.first_month` or `period.n_2`. + - This has been simplified to allow users to build their own: + - `period.ago(unit: DateUnit, size: int = 1) -> Period`. + - `period.last(unit: DateUnit, size: int = 1) -> Period`. + - `period.first(unit: DateUnit) -> Period`. +- Rationalise date units. + - Before, usage of "month", YEAR, and so on was fairly inconsistent, and + providing a perfect hotbed for bugs to breed. + - This has been fixed by introducing a new `dateunit` module, which + provides a single source of truth for all date units. + - Note that if you used `periods.YEAR` and the like, there is nothing to + change in your code. + - However, strings like `"year"` or `"ETERNITY"` are no longer allowed (in + fact, date unit are int enums an no longer strings). + +#### Technical changes + +- Add typing to `openfisca_core.periods`. +- Fix `openfisca_core.periods` doctests. +- Document `openfisca_core.periods`. + +#### Bug fixes + +- Fixes incoherent dates. +- Fixes several race conditions. + # 38.0.0 [#989](https://github.com/openfisca/openfisca-core/pull/989) #### New Features @@ -35,7 +92,7 @@ #### Migration details -- Replace `some_period.start.period` and similar methods with `Period((unit, some_period.start, 1))`. +- Replace `some_period.start.period` and similar methods with `Period(unit, some_period.start, 1)`. # 36.0.0 [#1149](https://github.com/openfisca/openfisca-core/pull/1162) diff --git a/openfisca_core/data_storage/in_memory_storage.py b/openfisca_core/data_storage/in_memory_storage.py index bd40460a56..d04d3ec437 100644 --- a/openfisca_core/data_storage/in_memory_storage.py +++ b/openfisca_core/data_storage/in_memory_storage.py @@ -41,7 +41,7 @@ def delete(self, period = None): self._arrays = { period_item: value for period_item, value in self._arrays.items() - if not period.contains(period_item) + if period_item not in period } def get_known_periods(self): diff --git a/openfisca_core/data_storage/on_disk_storage.py b/openfisca_core/data_storage/on_disk_storage.py index 10d4696b58..ae886c492f 100644 --- a/openfisca_core/data_storage/on_disk_storage.py +++ b/openfisca_core/data_storage/on_disk_storage.py @@ -62,7 +62,7 @@ def delete(self, period = None): self._files = { period_item: value for period_item, value in self._files.items() - if not period.contains(period_item) + if period_item not in period } def get_known_periods(self): diff --git a/openfisca_core/holders/helpers.py b/openfisca_core/holders/helpers.py index 176f6b6f30..1d8d1a58f0 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__) @@ -28,7 +27,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: @@ -61,7 +60,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) @@ -74,7 +73,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 ae7e3fbcec..d74e81910f 100644 --- a/openfisca_core/holders/holder.py +++ b/openfisca_core/holders/holder.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional, Sequence, Union +from typing import Any, Sequence import os import warnings @@ -8,15 +8,11 @@ import numpy import psutil -from openfisca_core import ( - errors, - commons, - data_storage as storage, - indexed_enums as enums, - periods, - tools, - types, - ) +from openfisca_core import commons +from openfisca_core import data_storage as storage +from openfisca_core import errors +from openfisca_core import indexed_enums as enums +from openfisca_core import periods, tools from .memory_usage import MemoryUsage @@ -116,7 +112,7 @@ def get_memory_usage(self) -> MemoryUsage: >>> entity = entities.Entity("", "", "", "") >>> class MyVariable(variables.Variable): - ... definition_period = "year" + ... definition_period = periods.YEAR ... entity = entity ... value_type = int @@ -164,9 +160,9 @@ def get_known_periods(self): def set_input( self, - period: types.Period, - array: Union[numpy.ndarray, Sequence[Any]], - ) -> Optional[numpy.ndarray]: + period: periods.Period, + array: numpy.ndarray | Sequence[Any], + ) -> numpy.ndarray | None: """Set a Variable's array of values of a given Period. Args: @@ -187,7 +183,7 @@ def set_input( >>> entity = entities.Entity("", "", "", "") >>> class MyVariable(variables.Variable): - ... definition_period = "year" + ... definition_period = periods.YEAR ... entity = entity ... value_type = int @@ -211,13 +207,17 @@ def set_input( """ period = periods.period(period) + + if period is None: + raise ValueError(f"Invalid period value: {period}") + if period.unit == periods.ETERNITY and self.variable.definition_period != periods.ETERNITY: 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.', ]).format( self.variable.name, - self.variable.definition_period + str(self.variable.definition_period) ) raise errors.PeriodMismatchError( self.variable.name, @@ -265,10 +265,10 @@ def _set(self, period, value): raise ValueError('A period must be specified to set values, except for variables with periods.ETERNITY as as period_definition.') if (self.variable.definition_period != period.unit or period.size > 1): name = self.variable.name - period_size_adj = f'{period.unit}' if (period.size == 1) else f'{period.size}-{period.unit}s' + period_size_adj = f'{str(period.unit)}' if (period.size == 1) else f'{period.size}-{str(period.unit)}s' error_message = os.linesep.join([ f'Unable to set a value for variable "{name}" for {period_size_adj}-long period "{period}".', - f'"{name}" can only be set for one {self.variable.definition_period} at a time. Please adapt your input.', + f'"{name}" can only be set for one {str(self.variable.definition_period)} at a time. Please adapt your input.', f'If you are the maintainer of "{name}", you can consider adding it a set_input attribute to enable automatic period casting.' ]) diff --git a/openfisca_core/holders/tests/test_helpers.py b/openfisca_core/holders/tests/test_helpers.py index 6ba1e7a815..84e94bba02 100644 --- a/openfisca_core/holders/tests/test_helpers.py +++ b/openfisca_core/holders/tests/test_helpers.py @@ -3,7 +3,6 @@ from openfisca_core import holders, periods, tools from openfisca_core.entities import Entity from openfisca_core.holders import Holder -from openfisca_core.periods import Instant, Period from openfisca_core.populations import Population from openfisca_core.variables import Variable @@ -58,8 +57,8 @@ def test_set_input_dispatch_by_period( Income.definition_period = definition_unit income = Income() holder = Holder(income, population) - instant = Instant((2022, 1, 1)) - dispatch_period = Period((dispatch_unit, instant, 3)) + instant = periods.Instant(2022, 1, 1) + dispatch_period = periods.Period(dispatch_unit, instant, 3) holders.set_input_dispatch_by_period(holder, dispatch_period, values) total = sum(map(holder.get_array, holder.get_known_periods())) @@ -89,8 +88,8 @@ def test_set_input_divide_by_period( Income.definition_period = definition_unit income = Income() holder = Holder(income, population) - instant = Instant((2022, 1, 1)) - divide_period = Period((divide_unit, instant, 3)) + instant = periods.Instant(2022, 1, 1) + divide_period = periods.Period(divide_unit, instant, 3) holders.set_input_divide_by_period(holder, divide_period, values) last = holder.get_array(holder.get_known_periods()[-1]) diff --git a/openfisca_core/model_api.py b/openfisca_core/model_api.py index 8ccf5c2763..3c126215b6 100644 --- a/openfisca_core/model_api.py +++ b/openfisca_core/model_api.py @@ -1,39 +1,34 @@ from datetime import date # noqa: F401 -from numpy import ( # noqa: F401 - logical_not as not_, - maximum as max_, - minimum as min_, - round as round_, - select, - where, +from numpy import logical_not as not_ # noqa: F401 +from numpy import maximum as max_ # noqa: F401 +from numpy import minimum as min_ # noqa: F401 +from numpy import round as round_ # noqa: F401 +from numpy import select, where # noqa: F401 + +from openfisca_core import periods # noqa: F401 +from openfisca_core.commons import ( # noqa: F401 + apply_thresholds, + concat, + switch, ) - -from openfisca_core.commons import apply_thresholds, concat, switch # noqa: F401 - from openfisca_core.holders import ( # noqa: F401 set_input_dispatch_by_period, set_input_divide_by_period, ) - from openfisca_core.indexed_enums import Enum # noqa: F401 - from openfisca_core.parameters import ( # noqa: F401 + Bracket, load_parameter_file, + Parameter, ParameterNode, Scale, - Bracket, - Parameter, ValuesHistory, ) - -from openfisca_core.periods import DAY, MONTH, YEAR, ETERNITY, period # noqa: F401 from openfisca_core.populations import ADD, DIVIDE # noqa: F401 from openfisca_core.reforms import Reform # noqa: F401 - from openfisca_core.simulations import ( # noqa: F401 calculate_output_add, calculate_output_divide, ) - from openfisca_core.variables import Variable # noqa: F401 diff --git a/openfisca_core/parameters/helpers.py b/openfisca_core/parameters/helpers.py index 75d5a18b73..1c24fcb48f 100644 --- a/openfisca_core/parameters/helpers.py +++ b/openfisca_core/parameters/helpers.py @@ -63,7 +63,7 @@ def _parse_child(child_name, child, child_path): return parameters.Parameter(child_name, child, child_path) elif 'brackets' in child: return parameters.ParameterScale(child_name, child, child_path) - elif isinstance(child, dict) and all([periods.INSTANT_PATTERN.match(str(key)) for key in child.keys()]): + elif isinstance(child, dict) and all([periods.parse(str(key)) for key in child.keys()]): return parameters.Parameter(child_name, child, child_path) else: return parameters.ParameterNode(child_name, data = child, file_path = child_path) diff --git a/openfisca_core/parameters/parameter.py b/openfisca_core/parameters/parameter.py index ed2c9482a4..66ffbf1dc7 100644 --- a/openfisca_core/parameters/parameter.py +++ b/openfisca_core/parameters/parameter.py @@ -73,7 +73,7 @@ def __init__(self, name: str, data: dict, file_path: Optional[str] = None) -> No values_list = [] for instant_str in instants: - if not periods.INSTANT_PATTERN.match(instant_str): + if periods.parse(instant_str) is None: raise ParameterParsingError( "Invalid property '{}' in '{}'. Properties must be valid YYYY-MM-DD instants, such as 2017-01-15." .format(instant_str, self.name), @@ -126,7 +126,7 @@ def update(self, period = None, start = None, stop = None, value = None): if start is None: raise ValueError("You must provide either a start or a period") start_str = str(start) - stop_str = str(stop.offset(1, 'day')) if stop else None + stop_str = str(stop.offset(1, periods.DAY)) if stop else None old_values = self.values_list new_values = [] diff --git a/openfisca_core/periods/__init__.py b/openfisca_core/periods/__init__.py index 8acddd62c9..eb65ff46a8 100644 --- a/openfisca_core/periods/__init__.py +++ b/openfisca_core/periods/__init__.py @@ -1,45 +1,44 @@ -# Transitional imports to ensure non-breaking changes. -# Could be deprecated in the next major release. -# -# How imports are being used today: -# -# >>> from openfisca_core.module import symbol -# -# The previous example provokes cyclic dependency problems -# that prevent us from modularizing the different components -# of the library so to make them easier to test and to maintain. -# -# How could them be used after the next major release: -# -# >>> from openfisca_core import module -# >>> module.symbol() -# -# And for classes: -# -# >>> from openfisca_core.module import Symbol -# >>> Symbol() -# -# See: https://www.python.org/dev/peps/pep-0008/#imports - -from .config import ( # noqa: F401 - DAY, - MONTH, - YEAR, - ETERNITY, - INSTANT_PATTERN, - date_by_instant_cache, - str_by_instant_cache, - year_or_month_or_day_re, - ) - -from .helpers import ( # noqa: F401 - instant, - instant_date, - period, - key_period_size, - unit_weights, - unit_weight, - ) - -from .instant_ import Instant # noqa: F401 -from .period_ import Period # noqa: F401 +"""Transitional imports to ensure non-breaking changes. + +These imports could be deprecated in the next major release. + +Currently, imports are used in the following way:: + from openfisca_core.module import symbol + +This example causes cyclic dependency problems, which prevent us from +modularising the different components of the library and make them easier to +test and maintain. + +After the next major release, imports could be used in the following way:: + from openfisca_core import module + module.symbol() + +And for classes:: + from openfisca_core.module import Symbol + Symbol() + +.. seealso:: `PEP8#Imports`_ and `OpenFisca's Styleguide`_. + +.. _PEP8#Imports: + https://www.python.org/dev/peps/pep-0008/#imports + +.. _OpenFisca's Styleguide: + https://github.com/openfisca/openfisca-core/blob/master/STYLEGUIDE.md + +""" + +from ._dates import DateUnit +from .helpers import build_instant as instant +from .helpers import build_period as period +from .helpers import parse_instant_str as parse +from .instant_ import Instant +from .period_ import Period + +DAY, MONTH, YEAR, ETERNITY = tuple(DateUnit) +day, month, year, eternity = tuple(DateUnit) +days, months, years, _ = tuple(DateUnit) + +# Deprecated + +setattr(Period, "this_year", property(lambda self: self.first(YEAR))) # noqa: B010 +setattr(Period, "first_month", property(lambda self: self.first(MONTH))) # noqa: B010 diff --git a/openfisca_core/periods/_dates.py b/openfisca_core/periods/_dates.py new file mode 100644 index 0000000000..7b95daf909 --- /dev/null +++ b/openfisca_core/periods/_dates.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import NamedTuple + +import enum + +from ._errors import DateUnitValueError + + +class DateUnitMeta(enum.EnumMeta): + """Metaclass for ``DateUnit``.""" + + @property + def isoformat(self) -> int: + """Date units corresponding to the ISO format (day, month, and year). + + Returns: + A DateUnit representing ISO format units. + + Examples: + >>> DateUnit.isoformat + + + >>> DateUnit.DAY in DateUnit.isoformat + True + + >>> bool(DateUnit.DAY & DateUnit.isoformat) + True + + >>> DateUnit.ETERNITY in DateUnit.isoformat + False + + >>> bool(DateUnit.ETERNITY & DateUnit.isoformat) + False + + .. versionadded:: 39.0.0 + + """ + + return ~DateUnit.ETERNITY + + +class DateUnit(enum.IntFlag, metaclass = DateUnitMeta): + """The date units of a rule system. + + Examples: + >>> repr(DateUnit) + "" + + >>> repr(DateUnit.DAY) + 'day' + + >>> str(DateUnit.DAY) + 'day' + + >>> dict([(DateUnit.DAY, DateUnit.DAY.value)]) + {day: 1} + + >>> list(DateUnit) + [day, month, year, eternity] + + >>> len(DateUnit) + 4 + + >>> DateUnit["DAY"] + day + + >>> DateUnit(DateUnit.DAY) + day + + >>> DateUnit.DAY in DateUnit + True + + >>> bool(DateUnit.DAY & ~DateUnit.ETERNITY) + True + + >>> "DAY" in DateUnit + False + + >>> DateUnit.DAY == 1 + True + + >>> DateUnit.DAY.name + 'DAY' + + >>> DateUnit.DAY.value + 1 + + .. versionadded:: 39.0.0 + + """ + + #: The day unit. + DAY = enum.auto() + + #: The month unit. + MONTH = enum.auto() + + #: The year unit. + YEAR = enum.auto() + + #: A special unit to represent time-independent properties. + ETERNITY = enum.auto() + + def __repr__(self) -> str: + try: + return self.name.lower() + + except AttributeError: + return super().__repr__() + + def __str__(self) -> str: + try: + return self.name.lower() + + except AttributeError: + return super().__str__() + + @property + def plural(self) -> str: + """Returns the plural form of the date unit. + + Returns: + str: The plural form. + + Raises: + DateUnitValueError: When the date unit is not a ISO format unit. + + Examples: + >>> DateUnit.DAY.plural + 'days' + + >>> DateUnit.ETERNITY.plural + Traceback (most recent call last): + DateUnitValueError: 'eternity' is not a valid ISO format date unit. + + .. versionadded:: 39.0.0 + + """ + + if self & type(self).isoformat: + return str(self) + "s" + + raise DateUnitValueError(self) + + +class ISOFormat(NamedTuple): + """A tuple representing a date in ISO format""" + + #: The year of the parsed period. + year: int + + #: The month of the parsed period. + month: int + + #: The month of the parsed period. + day: int + + #: The unit of the parsed period, in binary. + unit: int + + #: The size of the parsed instant or period. + size: int diff --git a/openfisca_core/periods/_errors.py b/openfisca_core/periods/_errors.py new file mode 100644 index 0000000000..3f2e127413 --- /dev/null +++ b/openfisca_core/periods/_errors.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +LEARN_MORE = ( + "Learn more about legal period formats in OpenFisca: " + "." + ) + + +class DateUnitValueError(ValueError): + """Raised when a date unit's value is not valid.""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"'{str(value)}' is not a valid ISO format date unit. ISO format " + f"date units are any of: 'day', 'month', or 'year'. {LEARN_MORE}" + ) + + +class InstantFormatError(ValueError): + """Raised when an instant's format is not valid (ISO format).""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"'{str(value)}' is not a valid instant. Instants are described " + "using the 'YYYY-MM-DD' format, for instance '2015-06-15'. " + f"{LEARN_MORE}" + ) + + +class InstantTypeError(TypeError): + """Raised when an instant's type is not valid.""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"Invalid instant: '{str(value)}' of type {type(value)}, " + f"expecting an 'Instant', 'tuple', 'list', or 'str'. {LEARN_MORE}" + ) + + +class PeriodFormatError(ValueError): + """Raised when a period's format is not valid .""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"'{str(value)}' is not a valid period. Periods are described " + "using the 'unit:YYYY-MM-DD:size' format, for instance " + f"'day:2023-01-15:3'. {LEARN_MORE}" + ) + + +class PeriodTypeError(TypeError): + """Raised when a period's type is not valid.""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"Invalid period: '{str(value)}' of type {type(value)}, " + f"expecting a 'Period', 'tuple', 'list', or 'str. {LEARN_MORE}" + ) + + +class OffsetTypeError(TypeError): + """Raised when an offset's type is not valid.""" + + def __init__(self, value: Any) -> None: + super().__init__( + f"Invalid offset: '{str(value)}' of type {type(value)}, expecting " + f"an 'int'. {LEARN_MORE}" + ) diff --git a/openfisca_core/periods/config.py b/openfisca_core/periods/config.py deleted file mode 100644 index 6e0c698098..0000000000 --- a/openfisca_core/periods/config.py +++ /dev/null @@ -1,15 +0,0 @@ -import re -import typing - -DAY = 'day' -MONTH = 'month' -YEAR = 'year' -ETERNITY = 'eternity' - -# Matches "2015", "2015-01", "2015-01-01" -# Does not match "2015-13", "2015-12-32" -INSTANT_PATTERN = re.compile(r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$") - -date_by_instant_cache: typing.Dict = {} -str_by_instant_cache: typing.Dict = {} -year_or_month_or_day_re = re.compile(r'(18|19|20)\d{2}(-(0?[1-9]|1[0-2])(-([0-2]?\d|3[0-1]))?)?$') diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index e4f93e4edb..d57648057d 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -1,202 +1,395 @@ -from typing import Dict +from __future__ import annotations + +from typing import Any, Sequence import datetime -import os -from . import config +import pendulum +from pendulum.datetime import Date +from pendulum.parsing import ParserError + +from ._dates import DateUnit, ISOFormat +from ._errors import ( + InstantFormatError, + InstantTypeError, + PeriodFormatError, + PeriodTypeError, + ) from .instant_ import Instant from .period_ import Period +day, _month, year, eternity = tuple(DateUnit) + + +def build_instant(value: Any) -> Instant: + """Build a new instant, aka a triple of integers (year, month, day). + + Args: + value: An ``instant-like`` object. + + Returns: + An Instant. + + Raises: + InstantFormatError: When ``value`` is invalid, like "2021-32-13". + InstantTypeError: When ``value`` is None. + + Examples: + >>> build_instant(datetime.date(2021, 9, 16)) + Instant(year=2021, month=9, day=16) + + >>> build_instant(Instant(2021, 9, 16)) + Instant(year=2021, month=9, day=16) + + >>> build_instant("2021") + Instant(year=2021, month=1, day=1) -def instant(instant): - """Return a new instant, aka a triple of integers (year, month, day). + >>> build_instant(2021) + Instant(year=2021, month=1, day=1) - >>> 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)) + >>> build_instant((2021, 9)) + Instant(year=2021, month=9, day=1) + + >>> start = Instant(2021, 9, 16) + + >>> build_instant(Period(year, start, 1)) + Traceback (most recent call last): + InstantFormatError: 'year:2021-09' is not a valid instant. + + .. versionadded:: 39.0.0 - >>> instant(None) """ - if instant is None: - return None - if isinstance(instant, Instant): - return instant - if isinstance(instant, str): - if not config.INSTANT_PATTERN.match(instant): - raise ValueError("'{}' is not a valid instant. Instants are described using the 'YYYY-MM-DD' format, for instance '2015-06-15'.".format(instant)) - instant = Instant( - int(fragment) - for fragment in instant.split('-', 2)[:3] - ) - elif isinstance(instant, datetime.date): - instant = Instant((instant.year, instant.month, instant.day)) - elif isinstance(instant, int): - instant = (instant,) - elif isinstance(instant, list): - assert 1 <= len(instant) <= 3 - instant = tuple(instant) - elif isinstance(instant, Period): - instant = instant.start + + isoformat: ISOFormat | None + + if isinstance(value, Instant): + return value + + if isinstance(value, datetime.date): + return Instant(value.year, value.month, value.day) + + if isinstance(value, int): + isoformat = parse_int(value) + + elif isinstance(value, str): + isoformat = parse_instant_str(value) + + elif isinstance(value, (list, tuple)): + isoformat = parse_seq(value) + else: - assert isinstance(instant, tuple), instant - assert 1 <= len(instant) <= 3 - if len(instant) == 1: - return Instant((instant[0], 1, 1)) - if len(instant) == 2: - return Instant((instant[0], instant[1], 1)) - return Instant(instant) + raise InstantTypeError(value) + if isoformat is None: + raise InstantFormatError(value) -def instant_date(instant): - if instant is None: - return None - instant_date = config.date_by_instant_cache.get(instant) - if instant_date is None: - config.date_by_instant_cache[instant] = instant_date = datetime.date(*instant) - return instant_date + return Instant(isoformat.year, isoformat.month, isoformat.day) + + +def build_period(value: Any) -> Period: + """Build a new period, aka a triple (unit, start_instant, size). + + Args: + value: A ``period-like`` object. + + Returns: + A period. + + Raises: + PeriodFormatError: When arguments are invalid, like "2021-32-13". + PeriodTypeError: When ``value`` is not a ``period-like`` object. + + Examples: + >>> build_period(Period(year, Instant(2021, 1, 1), 1)) + Period(unit=year, start=Instant(year=2021, month=1, day=1), size=1) + + >>> build_period(Instant(2021, 1, 1)) + Period(unit=day, start=Instant(year=2021, month=1, day=1), size=1) + + >>> build_period(eternity) + Period(unit=eternity, start=Instant(year=1, month=1, day=1), size=1) + + >>> build_period(2021) + Period(unit=year, start=Instant(year=2021, month=1, day=1), size=1) + + >>> build_period("2014") + Period(unit=year, start=Instant(year=2014, month=1, day=1), size=1) + + >>> build_period("year:2014") + Period(unit=year, start=Instant(year=2014, month=1, day=1), size=1) + >>> build_period("month:2014-02") + Period(unit=month, start=Instant(year=2014, month=2, day=1), size=1) -def period(value) -> Period: - """Return a new period, aka a triple (unit, start_instant, size). + >>> build_period("year:2014-02") + Period(unit=year, start=Instant(year=2014, month=2, day=1), size=1) - >>> period('2014') - Period((YEAR, Instant((2014, 1, 1)), 1)) - >>> period('year:2014') - Period((YEAR, Instant((2014, 1, 1)), 1)) + >>> build_period("day:2014-02-02") + Period(unit=day, start=Instant(year=2014, month=2, day=2), size=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)) + >>> build_period("day:2014-02-02:3") + Period(unit=day, start=Instant(year=2014, month=2, day=2), size=3) - >>> period('year:2014-2') - Period((YEAR, Instant((2014, 2, 1)), 1)) """ + + if value in {eternity, eternity.name, eternity.name.lower()}: + return Period(eternity, build_instant(datetime.date.min), 1) + + if value is None or isinstance(value, DateUnit): + raise PeriodTypeError(value) + if isinstance(value, Period): return value 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:", - "." - ]) - raise ValueError(message) - - if value == 'ETERNITY' or value == config.ETERNITY: - return Period(('eternity', instant(datetime.date.min), float("inf"))) - - # check the type + return Period(day, value, 1) + if isinstance(value, int): - return Period((config.YEAR, Instant((value, 1, 1)), 1)) + return Period(year, Instant(value, 1, 1), 1) + if not isinstance(value, str): - raise_error(value) + raise PeriodFormatError(value) - # try to parse as a simple period - period = parse_simple_period(value) - if period is not None: - return period + # Try to parse as a complex period + isoformat = parse_period_str(value) - # complex period must have a ':' in their strings - if ":" not in value: - raise_error(value) + if isoformat is None: + raise PeriodFormatError(value) - components = value.split(':') + unit = DateUnit(isoformat.unit) - # left-most component must be a valid unit - unit = components[0] - if unit not in (config.DAY, config.MONTH, config.YEAR): - raise_error(value) + return Period(unit, build_instant(isoformat[:3]), isoformat.size) - # middle component must be a valid iso period - base_period = parse_simple_period(components[1]) - if not base_period: - raise_error(value) - # period like year:2015-03 have a size of 1 - if len(components) == 2: - size = 1 - # if provided, make sure the size is an integer - elif len(components) == 3: - try: - size = int(components[2]) - except ValueError: - raise_error(value) - # if there is more than 2 ":" in the string, the period is invalid - else: - raise_error(value) +def parse_int(value: int) -> ISOFormat | None: + """Parse an int respecting the ISO format. + + Args: + value: The integer to parse. + + Returns: + An ISOFormat object if ``value`` is valid. + None otherwise. - # reject ambiguous period such as month:2014 - if unit_weight(base_period.unit) > unit_weight(unit): - raise_error(value) + Examples: + >>> parse_int(1) + ISOFormat(year=1, month=1, day=1, unit=4, size=1) - return Period((unit, base_period.start, size)) + >>> parse_int(2023) + ISOFormat(year=2023, month=1, day=1, unit=4, size=1) + >>> parse_int(-1) + + >>> parse_int("2023") + + >>> parse_int(20231231) + + .. versionadded:: 39.0.0 -def key_period_size(period): """ - Defines a key in order to sort periods by length. It uses two aspects : first unit then size - :param period: an OpenFisca period - :return: a string + if not isinstance(value, int): + return None + + if not 1 <= len(str(value)) <= 4: + return None + + try: + if not 1 <= int(str(value)[:4]) < 10000: + return None - >>> key_period_size(period('2014')) - '2_1' - >>> key_period_size(period('2013')) - '2_1' - >>> key_period_size(period('2014-01')) - '1_1' + except ValueError: + return None + + return ISOFormat(value, 1, 1, 4, 1) + + +def parse_seq(value: Sequence[int]) -> ISOFormat | None: + """Parse a sequence of ints respecting the ISO format. + + Args: + value: A sequence of ints such as [2012, 3, 13]. + + Returns: + An ISOFormat object if ``value`` is valid. + None if ``value`` is not valid. + + Examples: + >>> parse_seq([2022]) + ISOFormat(year=2022, month=1, day=1, unit=4, size=1) + + >>> parse_seq([2022, 1]) + ISOFormat(year=2022, month=1, day=1, unit=2, size=1) + + >>> parse_seq([2022, 1, 1]) + ISOFormat(year=2022, month=1, day=1, unit=1, size=1) + + >>> parse_seq([-2022, 1, 1]) + + >>> parse_seq([2022, 13, 1]) + + >>> parse_seq([2022, 1, 32]) + + .. versionadded:: 39.0.0 """ - unit, start, size = period + if not isinstance(value, (list, tuple)): + return None - return '{}_{}'.format(unit_weight(unit), size) + if not value: + return None + if not 1 <= len(value) <= 3: + return None -def unit_weights() -> Dict[str, int]: - return { - config.DAY: 100, - config.MONTH: 200, - config.YEAR: 300, - config.ETERNITY: 400, - } + if not all(isinstance(unit, int) for unit in value): + return None + if not all(unit == abs(unit) for unit in value): + return None + + # We get the shape of the string (e.g. "2012-02" = 2) + shape = len(value) + + # We get the unit from the shape (e.g. 2 = "month") + unit = tuple(DateUnit)[3 - shape] + + while len(value) < 3: + value = (*value, 1) + + try: + # We parse the date + date = pendulum.date(*value) + + except ValueError: + return None + + if not isinstance(date, Date): + return None + + # We build the corresponding ISOFormat object + return ISOFormat(date.year, date.month, date.day, unit.value, 1) + + +def parse_instant_str(value: str) -> ISOFormat | None: + """Parse strings respecting the ISO format. + + Args: + value: A string such as such as "2012" or "2015-03". + + Returns: + An ISOFormat object if ``value`` is valid. + None if ``value`` is not valid. + + Examples: + >>> parse_instant_str("2022") + ISOFormat(year=2022, month=1, day=1, unit=4, size=1) + + >>> parse_instant_str("2022-02") + ISOFormat(year=2022, month=2, day=1, unit=2, size=1) + + >>> parse_instant_str("2022-02-13") + ISOFormat(year=2022, month=2, day=13, unit=1, size=1) + + >>> parse_instant_str(1000) + + >>> parse_instant_str("ETERNITY") + + .. versionadded:: 39.0.0 + + """ + + if not isinstance(value, str): + return None + + if not value: + return None + + # If it is a complex value, next! + if len(value.split(":")) != 1: + return None + + # If it's negative period, next! + if value[0] == "-": + return None + + try: + # We parse the date + date = pendulum.parse(value, exact = True, strict = True) + + except ParserError: + return None + + if not isinstance(date, Date): + return None + + # We get the shape of the string (e.g. "2012-02" = 2) + shape = len(value.split("-")) + + # We get the unit from the shape (e.g. 2 = "month") + unit = tuple(DateUnit)[3 - shape] + + # We build the corresponding ISOFormat object + return ISOFormat(date.year, date.month, date.day, unit.value, 1) + + +def parse_period_str(value: str) -> ISOFormat | None: + """Parse complex strings representing periods. + + Args: + value: A string such as such as "year:2012" or "month:2015-03:12". + + Returns: + An ISOFormat object if ``value`` is valid. + None if ``value`` is not valid. + + Examples: + >>> parse_period_str("year:2022") + ISOFormat(year=2022, month=1, day=1, unit=4, size=1) + + >>> parse_period_str("month:2022-02") + ISOFormat(year=2022, month=2, day=1, unit=2, size=1) + + >>> parse_period_str("day:2022-02-13:15") + ISOFormat(year=2022, month=2, day=13, unit=1, size=15) + + >>> parse_period_str("2022:3") + + >>> parse_period_str("ETERNITY") + + .. versionadded:: 39.0.0 + + """ + + if not isinstance(value, str): + return None + + if not value: + return None + + # If it is not a complex value, delegate! + if len(value.split(":")) == 1: + return parse_instant_str(value) + + first, second, *rest = value.split(":") + unit = DateUnit.__members__.get(first.upper()) + date = parse_instant_str(second) + + # If it is an invalid unit, next! + if unit is None: + return None + + # If it is an invalid date, next! + if date is None: + return None + + # If it has no size, we'll assume ``1`` + if not rest: + size = 1 + + else: + size = int(rest[0]) -def unit_weight(unit: str) -> int: - return unit_weights()[unit] + # We build the corresponding ISOFormat object + return ISOFormat(date.year, date.month, date.day, unit.value, size) diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index ad559e5e53..8e90640613 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -1,233 +1,175 @@ +from __future__ import annotations + +from typing import Callable, NamedTuple + import calendar -import datetime +import functools -from . import config +import pendulum +from pendulum.datetime import Date +from ._dates import DateUnit +from ._errors import DateUnitValueError, OffsetTypeError -class Instant(tuple): +day, month, year, _ = tuple(DateUnit) - 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__()) - def __str__(self): - """ - Transform instant to a string. +class Instant(NamedTuple): + """An instant in time (``year``, ``month``, ``day``). - >>> str(instant(2014)) - '2014-01-01' - >>> str(instant('2014-2')) - '2014-02-01' - >>> str(instant('2014-2-3')) - '2014-02-03' + An ``Instant`` represents the most atomic and indivisible + legislation's date unit. - """ - 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 + Current implementation considers this unit to be a day, so + ``instants`` can be thought of as "day dates". - @property - def date(self): - """ - Convert instant to a date. - - >>> instant(2014).date - datetime.date(2014, 1, 1) - >>> instant('2014-2').date - datetime.date(2014, 2, 1) - >>> instant('2014-2-3').date - datetime.date(2014, 2, 3) - """ - instant_date = config.date_by_instant_cache.get(self) - if instant_date is None: - config.date_by_instant_cache[self] = instant_date = datetime.date(*self) - return instant_date + Examples: + >>> instant = Instant(2021, 9, 13) - @property - def day(self): - """ - Extract day from instant. + ``Instants`` are represented as a ``tuple`` containing the date units: + + >>> repr(instant) + 'Instant(year=2021, month=9, day=13)' + + However, their user-friendly representation is as a date in the + ISO format: + + >>> str(instant) + '2021-09-13' + + Because ``Instants`` are ``tuples``, they are immutable, which allows + us to use them as keys in hashmaps: + + >>> {instant: (2021, 9, 13)} + {Instant(year=2021, month=9, day=13): (2021, 9, 13)} + + All the rest of the ``tuple`` protocols are inherited as well: - >>> instant(2014).day - 1 - >>> instant('2014-2').day - 1 - >>> instant('2014-2-3').day + >>> instant.year + 2021 + + >>> instant.year in instant + True + + >>> len(instant) 3 - """ - return self[2] - @property - def month(self): - """ - Extract month from instant. - - >>> instant(2014).month - 1 - >>> instant('2014-2').month - 2 - >>> instant('2014-2-3').month - 2 - """ - return self[1] + >>> instant == (2021, 9, 13) + True + + >>> instant > (2020, 9, 13) + True + + """ + + #: The year. + year: int + + #: The month. + month: int + + #: The day. + day: int + + @functools.lru_cache(maxsize = None) + def __str__(self) -> str: + return self.date().isoformat() + + @functools.lru_cache(maxsize = None) + def date(self) -> Date: + """The date representation of the ``Instant``. + + Example: + >>> instant = Instant(2021, 10, 1) + >>> instant.date() + Date(2021, 10, 1) + + Returns: + A date. - def offset(self, offset, unit): - """ - Increment (or decrement) the given instant with offset units. - - >>> instant(2014).offset(1, 'day') - Instant((2014, 1, 2)) - >>> instant(2014).offset(1, 'month') - Instant((2014, 2, 1)) - >>> instant(2014).offset(1, 'year') - Instant((2015, 1, 1)) - - >>> instant('2014-1-31').offset(1, 'day') - Instant((2014, 2, 1)) - >>> instant('2014-1-31').offset(1, 'month') - Instant((2014, 2, 28)) - >>> instant('2014-1-31').offset(1, 'year') - Instant((2015, 1, 31)) - - >>> instant('2011-2-28').offset(1, 'day') - Instant((2011, 3, 1)) - >>> instant('2011-2-28').offset(1, 'month') - Instant((2011, 3, 28)) - >>> instant('2012-2-29').offset(1, 'year') - Instant((2013, 2, 28)) - - >>> instant(2014).offset(-1, 'day') - Instant((2013, 12, 31)) - >>> instant(2014).offset(-1, 'month') - Instant((2013, 12, 1)) - >>> instant(2014).offset(-1, 'year') - Instant((2013, 1, 1)) - - >>> instant('2011-3-1').offset(-1, 'day') - Instant((2011, 2, 28)) - >>> instant('2011-3-31').offset(-1, 'month') - Instant((2011, 2, 28)) - >>> instant('2012-2-29').offset(-1, 'year') - Instant((2011, 2, 28)) - - >>> instant('2014-1-30').offset(3, 'day') - Instant((2014, 2, 2)) - >>> instant('2014-10-2').offset(3, 'month') - Instant((2015, 1, 2)) - >>> instant('2014-1-1').offset(3, 'year') - Instant((2017, 1, 1)) - - >>> instant(2014).offset(-3, 'day') - Instant((2013, 12, 29)) - >>> instant(2014).offset(-3, 'month') - Instant((2013, 10, 1)) - >>> instant(2014).offset(-3, 'year') - Instant((2011, 1, 1)) - - >>> instant(2014).offset('first-of', 'month') - Instant((2014, 1, 1)) - >>> instant('2014-2').offset('first-of', 'month') - Instant((2014, 2, 1)) - >>> instant('2014-2-3').offset('first-of', 'month') - Instant((2014, 2, 1)) - - >>> instant(2014).offset('first-of', 'year') - Instant((2014, 1, 1)) - >>> instant('2014-2').offset('first-of', 'year') - Instant((2014, 1, 1)) - >>> instant('2014-2-3').offset('first-of', 'year') - Instant((2014, 1, 1)) - - >>> instant(2014).offset('last-of', 'month') - Instant((2014, 1, 31)) - >>> instant('2014-2').offset('last-of', 'month') - Instant((2014, 2, 28)) - >>> instant('2012-2-3').offset('last-of', 'month') - Instant((2012, 2, 29)) - - >>> instant(2014).offset('last-of', 'year') - Instant((2014, 12, 31)) - >>> instant('2014-2').offset('last-of', 'year') - Instant((2014, 12, 31)) - >>> instant('2014-2-3').offset('last-of', 'year') - Instant((2014, 12, 31)) """ - year, month, day = self - assert unit in (config.DAY, config.MONTH, config.YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit)) - if offset == 'first-of': - if unit == config.MONTH: - day = 1 - elif unit == config.YEAR: - month = 1 - day = 1 - elif offset == 'last-of': - if unit == config.MONTH: - day = calendar.monthrange(year, month)[1] - elif unit == config.YEAR: - month = 12 - day = 31 - else: - assert isinstance(offset, int), 'Invalid offset: {} of type {}'.format(offset, type(offset)) - if unit == config.DAY: - day += offset - if offset < 0: - while day < 1: - month -= 1 - if month == 0: - year -= 1 - month = 12 - day += calendar.monthrange(year, month)[1] - elif offset > 0: - month_last_day = calendar.monthrange(year, month)[1] - while day > month_last_day: - month += 1 - if month == 13: - year += 1 - month = 1 - day -= month_last_day - month_last_day = calendar.monthrange(year, month)[1] - elif unit == config.MONTH: - month += offset - if offset < 0: - while month < 1: - year -= 1 - month += 12 - elif offset > 0: - while month > 12: - year += 1 - month -= 12 - month_last_day = calendar.monthrange(year, month)[1] - if day > month_last_day: - day = month_last_day - elif unit == config.YEAR: - year += offset - # Handle february month of leap year. - month_last_day = calendar.monthrange(year, month)[1] - if day > month_last_day: - day = month_last_day - - return self.__class__((year, month, day)) - - @property - def year(self): + + return pendulum.date(*self) + + def offset(self, offset: str | int, unit: DateUnit) -> Instant: + """Increments/decrements the given instant with offset units. + + Args: + offset: How much of ``unit`` to offset. + unit: What to offset. + + Returns: + A new Instant. + + Raises: + DateUnitValueError: When ``unit`` is not a date unit. + OffsetTypeError: When ``offset`` is of type ``int``. + + Examples: + >>> Instant(2020, 12, 31).offset("first-of", month) + Instant(year=2020, month=12, day=1) + + >>> Instant(2020, 1, 1).offset("last-of", year) + Instant(year=2020, month=12, day=31) + + >>> Instant(2020, 1, 1).offset(1, year) + Instant(year=2021, month=1, day=1) + + >>> Instant(2020, 1, 1).offset(-3, day) + Instant(year=2019, month=12, day=29) + """ - Extract year from instant. - - >>> instant(2014).year - 2014 - >>> instant('2014-2').year - 2014 - >>> instant('2014-2-3').year - 2014 + + if not isinstance(unit, DateUnit): + raise DateUnitValueError(unit) + + if not unit & DateUnit.isoformat: + raise DateUnitValueError(unit) + + if offset in {"first-of", "last-of"} and unit == day: + return self + + if offset == "first-of" and unit == month: + return Instant(self.year, self.month, 1) + + if offset == "first-of" and unit == year: + return Instant(self.year, 1, 1) + + if offset == "last-of" and unit == month: + monthrange = calendar.monthrange(self.year, self.month) + return Instant(self.year, self.month, monthrange[1]) + + if offset == "last-of" and unit == year: + return Instant(self.year, 12, 31) + + if not isinstance(offset, int): + raise OffsetTypeError(offset) + + date = self._add(unit.plural, offset) + + return Instant(date.year, date.month, date.day) + + def _add(self, unit: str, count: int) -> Date: + """Add ``count`` ``unit``s to a ``date``. + + Args: + unit: The unit to add. + count: The number of units to add. + + Returns: + A new Date. + + Examples: + >>> instant = Instant(2021, 10, 1) + >>> instant._add("months", 6) + Date(2022, 4, 1) + + .. versionadded:: 39.0.0 + """ - return self[0] + + fun: Callable[..., Date] = self.date().add + + new: Date = fun(**{unit: count}) + + return new diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 7de0459bdf..7ff4ec981e 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -1,489 +1,558 @@ from __future__ import annotations -import calendar +from typing import NamedTuple, Sequence -from . import config, helpers -from .instant_ import Instant +import datetime +from ._dates import DateUnit +from ._errors import DateUnitValueError +from .typing import Instant -class Period(tuple): - """ - Toolbox to handle date intervals. +day, month, year, eternity = tuple(DateUnit) +days, months, years, _ = tuple(DateUnit) + + +class Period(NamedTuple): + """Toolbox to handle date intervals. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2021, 9, 1) + >>> period = Period(year, start, 3) + + ``Periods`` are represented as a ``tuple`` containing the ``unit``, + an ``Instant`` and the ``size``: + + >>> repr(period) + 'Period(unit=year, start=Instant(year=2021, month=9, day=1), size=3)' + + Their user-friendly representation is as a date in the + ISO format, prefixed with the ``unit`` and suffixed with its ``size``: + + >>> str(period) + 'year:2021-09:3' + + However, you won't be able to use them as hashmaps keys. Because they + contain a nested data structure, they're not hashable: + + >>> {period: (2021, 9, 13)} + {Period(unit=year, start=Instant(year=2021, month=9, day=1), size=3):...} + + All the rest of the ``tuple`` protocols are inherited as well: + + >>> period.unit + year - 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.unit in period + True + >>> len(period) + 3 + + >>> period == Period(year, start, 3) + True + + >>> period > Period(year, start, 3) + False 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(Period, self).__repr__()) + #: Either ``year``, ``month``, ``day`` or ``eternity``. + unit: DateUnit - 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' - """ + #: The "instant" the Period starts at. + start: Instant - unit, start_instant, size = self - if unit == config.ETERNITY: - return 'ETERNITY' - year, month, day = start_instant - - # 1 year long period - if (unit == config.MONTH and size == 12 or unit == config.YEAR and size == 1): - if month == 1: - # civil year starting from january - return str(year) - else: - # rolling year - return '{}:{}-{:02d}'.format(config.YEAR, year, month) - # simple month - if unit == config.MONTH and size == 1: - return '{}-{:02d}'.format(year, month) - # several civil years - if unit == config.YEAR and month == 1: - return '{}:{}:{}'.format(unit, year, size) - - if unit == config.DAY: - if size == 1: - return '{}-{:02d}-{:02d}'.format(year, month, day) - else: - return '{}:{}-{:02d}-{:02d}:{}'.format(unit, year, month, day, size) - - # complex period - return '{}:{}-{:02d}:{}'.format(unit, year, month, size) + #: The amount of ``unit``, starting at ``start``, at least ``1``. + size: int - @property - def date(self): - assert self.size == 1, '"date" is undefined for a period of size > 1: {}'.format(self) - return self.start.date + def __str__(self) -> str: + """Transform period to a string. - @property - def days(self): - """ - 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 - """ - return (self.stop.date - self.start.date).days + 1 - - def intersection(self, start, stop): - if start is None and stop is None: - return self - period_start = self[1] - period_stop = self.stop - if start is None: - start = period_start - if stop is None: - stop = period_stop - if stop < period_start or period_stop < start: - return None - intersection_start = max(period_start, start) - intersection_stop = min(period_stop, stop) - if intersection_start == period_start and intersection_stop == period_stop: - return self - if intersection_start.day == 1 and intersection_start.month == 1 \ - and intersection_stop.day == 31 and intersection_stop.month == 12: - return self.__class__(( - 'year', - intersection_start, - intersection_stop.year - intersection_start.year + 1, - )) - if intersection_start.day == 1 and intersection_stop.day == calendar.monthrange(intersection_stop.year, - intersection_stop.month)[1]: - return self.__class__(( - 'month', - intersection_start, - ( - (intersection_stop.year - intersection_start.year) * 12 - + intersection_stop.month - - intersection_start.month - + 1 - ), - )) - return self.__class__(( - '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. + Returns: + A string representation of the period. Examples: + >>> from openfisca_core.periods import Instant - >>> period('2017').get_subperiods(MONTH) - >>> [period('2017-01'), period('2017-02'), ... period('2017-12')] + >>> jan = Instant(2021, 1, 1) + >>> feb = jan.offset(1, month) - >>> 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)) + >>> str(Period(year, jan, 1)) + '2021' - if unit == config.YEAR: - return [self.this_year.offset(i, config.YEAR) for i in range(self.size)] + >>> str(Period(year, feb, 1)) + 'year:2021-02:1' - if unit == config.MONTH: - return [self.first_month.offset(i, config.MONTH) for i in range(self.size_in_months)] + >>> str(Period(month, feb, 1)) + '2021-02' - if unit == config.DAY: - return [self.first_day.offset(i, config.DAY) for i in range(self.size_in_days)] + >>> str(Period(year, jan, 2)) + 'year:2021:2' - def offset(self, offset, unit = None): - """ - 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], self[1].offset(offset, self[0] if unit is None else unit), self[2])) + >>> str(Period(month, jan, 2)) + 'month:2021-01:2' + + >>> str(Period(month, jan, 12)) + 'month:2021-01:12' - def contains(self, other: Period) -> bool: """ - Returns ``True`` if the period contains ``other``. For instance, ``period(2015)`` contains ``period(2015-01)`` + + if self.unit == eternity: + return str(self.unit.name) + + string = f"{self.start.year:04d}" + + if self.unit == year and self.start.month > 1: + string = f"{string}-{self.start.month:02d}" + + if self.unit < year: + string = f"{string}-{self.start.month:02d}" + + if self.unit < month: + string = f"{string}-{self.start.day:02d}" + + if self.unit == year and self.start.month > 1: + return f"{str(self.unit)}:{string}:{self.size}" + + if self.size > 1: + return f"{str(self.unit)}:{string}:{self.size}" + + return string + + def __contains__(self, other: object) -> bool: + """Checks if a ``period`` contains another one. + + Args: + other: The other ``Period``. + + Returns: + True if ``other`` is contained, otherwise False. + + Example: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2021, 1, 1) + >>> period = Period(year, start, 1) + >>> sub_period = Period(month, start, 3) + + >>> sub_period in period + True + """ - return self.start <= other.start and self.stop >= other.stop + + if isinstance(other, Period): + return self.start <= other.start and self.stop >= other.stop + + return super().__contains__(other) @property - def size(self): - """ - Return the size of the period. + def stop(self) -> Instant: + """Last day of the ``Period`` as an ``Instant``. + + Returns: + An Instant. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2012, 2, 29) + + >>> Period(year, start, 2).stop + Instant(year=2014, month=2, day=27) + + >>> Period(month, start, 36).stop + Instant(year=2015, month=2, day=27) + + >>> Period(day, start, 1096).stop + Instant(year=2015, month=2, day=28) - >>> period('month', '2012-2-29', 4).size - 4 """ - return self[2] - @property - def size_in_months(self): + if self.unit == eternity: + return type(self.start)(1, 1, 1) + + return self.start.offset(self.size, self.unit).offset(-1, day) + + def date(self) -> datetime.date: + """The date representation of the ``period``'s' start date. + + Returns: + A datetime.date. + + Raises: + ValueError: If the period's size is greater than 1. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2021, 10, 1) + + >>> period = Period(year, start, 1) + >>> period.date() + Date(2021, 10, 1) + + >>> period = Period(year, start, 3) + >>> period.date() + Traceback (most recent call last): + ValueError: 'date' undefined for period size > 1: year:2021-10:3. + + .. versionchanged:: 39.0.0: + Made it a normal method instead of a property. + """ - Return the size of the period in months. - >>> period('month', '2012-2-29', 4).size_in_months - 4 - >>> period('year', '2012', 1).size_in_months - 12 + if self.size > 1: + raise ValueError(f"'date' undefined for period size > 1: {self}.") + + return self.start.date() + + def count(self, unit: DateUnit) -> int: + """The ``size`` of the ``Period`` in the given unit. + + Args: + unit: The unit to convert to. + + Returns: + An int. + + Raises: + ValueError: If the period's unit is not a day, a month or a year. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2021, 10, 1) + + >>> period = Period(year, start, 3) + >>> period.count(days) + 1096 + + >>> period = Period(month, start, 3) + >>> period.count(days) + 92 + + >>> period = Period(year, start, 3) + >>> period.count(months) + 36 + + >>> period = Period(day, start, 3) + >>> period.count(months) + Traceback (most recent call last): + ValueError: Cannot calculate number of months in a day. + + >>> period = Period(year, start, 3) + >>> period.count(years) + 3 + + >>> period = Period(month, start, 3) + >>> period.count(years) + Traceback (most recent call last): + ValueError: Cannot calculate number of years in a month. + + .. versionadded:: 39.0.0 + """ - 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])) - @property - def size_in_days(self): + if unit == self.unit: + return self.size + + if unit == day and self.unit in {month, year}: + delta: int = (self.stop.date() - self.start.date()).days + return delta + 1 + + if unit == month and self.unit == year: + return self.size * 12 + + raise ValueError( + f"Cannot calculate number of {unit.plural} in a " + f"{str(self.unit)}." + ) + + def first(self, unit: DateUnit) -> Period: + """A new month ``Period`` starting at the first of ``unit``. + + Args: + unit: The unit of the requested Period. + + Returns: + A Period. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2023, 1, 1) + + >>> period = Period(year, start, 3) + + >>> period.first(day) + Period(unit=day, start=Instant(year=2023, month=1, day=1), size=1) + + >>> period.first(month) + Period(unit=month, start=Instant(year=2023, month=1, day=1), size=1) + + >>> period.first(year) + Period(unit=year, start=Instant(year=2023, month=1, day=1), size=1) + + .. versionadded:: 39.0.0 + """ - Return the size of the period in days. - >>> period('month', '2012-2-29', 4).size_in_days - 28 - >>> period('year', '2012', 1).size_in_days - 366 + start: Instant = self.start.offset("first-of", unit) + + return Period(unit, start, 1) + + def come(self, size: int, unit: DateUnit) -> Period: + """The next ``unit``s ``size`` from ``Period.start``. + + Args: + size: The number of units ago. + unit: The unit of the requested Period. + + Returns: + A Period. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2023, 1, 1) + + >>> period = Period(year, start, 3) + + >>> period.come(1, day) + Period(unit=day, start=Instant(year=2023, month=1, day=2), size=1) + + >>> period.come(7, days) + Period(unit=day, start=Instant(year=2023, month=1, day=8), size=1) + + >>> period.come(1, month) + Period(unit=month, start=Instant(year=2023, month=2, day=1), size=1) + + >>> period.come(3, months) + Period(unit=month, start=Instant(year=2023, month=4, day=1), size=1) + + >>> period.come(1, year) + Period(unit=year, start=Instant(year=2024, month=1, day=1), size=1) + + >>> period.come(2, years) + Period(unit=year, start=Instant(year=2025, month=1, day=1), size=1) + + .. versionadded:: 39.0.0 + """ - unit, instant, length = self - 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 + start: Instant = self.first(unit).start - raise ValueError("Cannot calculate number of days in {0}".format(unit)) + return Period(unit, start, 1).offset(size) + + def ago(self, size: int, unit: DateUnit) -> Period: + """``size`` ``unit``s ago from ``Period.start``. + + Args: + size: The number of units ago. + unit: The unit of the requested Period. + + Returns: + A Period. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2020, 3, 31) + + >>> period = Period(year, start, 3) + + >>> period.ago(1, day) + Period(unit=day, start=Instant(year=2020, month=3, day=30), size=1) + + >>> period.ago(7, days) + Period(unit=day, start=Instant(year=2020, month=3, day=24), size=1) + + >>> period.ago(1, month) + Period(unit=month, start=Instant(year=2020, month=2, day=29), size=1) + + >>> period.ago(3, months) + Period(unit=month, start=Instant(year=2019, month=12, day=31), size=1) + + >>> period.ago(1, year) + Period(unit=year, start=Instant(year=2019, month=3, day=31), size=1) + + >>> period.ago(2, years) + Period(unit=year, start=Instant(year=2018, month=3, day=31), size=1) + + .. versionadded:: 39.0.0 - @property - def start(self) -> Instant: """ - Return the first day of the period as an Instant instance. - >>> period('month', '2012-2-29', 4).start - Instant((2012, 2, 29)) + start: Instant = self.start + + return Period(unit, start, 1).offset(-size) + + def until(self, size: int, unit: DateUnit) -> Period: + """Next ``unit`` ``size``s from ``Period.start``. + + Args: + size: The number of units to include in the Period. + unit: The unit of the requested Period. + + Returns: + A Period. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2023, 1, 1) + + >>> period = Period(year, start, 3) + + >>> period.until(1, day) + Period(unit=day, start=Instant(year=2023, month=1, day=1), size=1) + + >>> period.until(7, days) + Period(unit=day, start=Instant(year=2023, month=1, day=1), size=7) + + >>> period.until(1, month) + Period(unit=month, start=Instant(year=2023, month=1, day=1), size=1) + + >>> period.until(3, months) + Period(unit=month, start=Instant(year=2023, month=1, day=1), size=3) + + >>> period.until(1, year) + Period(unit=year, start=Instant(year=2023, month=1, day=1), size=1) + + >>> period.until(2, years) + Period(unit=year, start=Instant(year=2023, month=1, day=1), size=2) + + .. versionadded:: 39.0.0 + """ - return self[1] - @property - def stop(self) -> Instant: + start: Instant = self.first(unit).start + + return Period(unit, start, size) + + def last(self, size: int, unit: DateUnit) -> Period: + """Last ``size`` ``unit``s from ``Period.start``. + + Args: + size: The number of units to include in the Period. + unit: The unit of the requested Period. + + Returns: + A Period. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2023, 1, 1) + + >>> period = Period(year, start, 3) + + >>> period.last(1, day) + Period(unit=day, start=Instant(year=2022, month=12, day=31), size=1) + + >>> period.last(7, days) + Period(unit=day, start=Instant(year=2022, month=12, day=25), size=7) + + >>> period.last(1, month) + Period(unit=month, start=Instant(year=2022, month=12, day=1), size=1) + + >>> period.last(3, months) + Period(unit=month, start=Instant(year=2022, month=10, day=1), size=3) + + >>> period.last(1, year) + Period(unit=year, start=Instant(year=2022, month=1, day=1), size=1) + + >>> period.last(2, years) + Period(unit=year, start=Instant(year=2021, month=1, day=1), size=2) + + .. versionadded:: 39.0.0 + """ - 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)) + + start: Instant = self.ago(size, unit).start + + return Period(unit, start, size) + + def offset(self, offset: str | int, unit: DateUnit | None = None) -> Period: + """Increment (or decrement) the given period with offset units. + + Args: + offset: How much of ``unit`` to offset. + unit: What to offset. + + Returns: + Period: A new one. + + Examples: + >>> from openfisca_core.periods import Instant + + >>> start = Instant(2014, 2, 3) + + >>> Period(day, start, 1).offset("first-of", month) + Period(unit=day, start=Instant(year=2014, month=2, day=1), size=1) + + >>> Period(month, start, 4).offset("last-of", month) + Period(unit=month, start=Instant(year=2014, month=2, day=28), size=4) + + >>> start = Instant(2021, 1, 1) + + >>> Period(day, start, 365).offset(-3) + Period(unit=day, start=Instant(year=2020, month=12, day=29), size=365) + + >>> Period(day, start, 365).offset(1, year) + Period(unit=day, start=Instant(year=2022, month=1, day=1), size=365) + """ - unit, start_instant, size = self - year, month, day = start_instant - if unit == config.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] - 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)) - @property - def unit(self) -> str: - return self[0] + if unit is None: + unit = self.unit - # Reference periods + start: Instant = self.start.offset(offset, unit) - @property - def last_month(self) -> Period: - return self.first_month.offset(-1) + return Period(self.unit, start, self.size) - @property - def last_3_months(self) -> Period: - start: Instant = self.first_month.start - return self.__class__((config.MONTH, start, 3)).offset(-3) + def subperiods(self, unit: DateUnit) -> Sequence[Period]: + """Return the list of all the periods of unit ``unit``. - @property - def last_year(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)).offset(-1) + Args: + unit: A string representing period's ``unit``. - @property - def n_2(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)).offset(-2) + Returns: + A list of periods. - @property - def this_year(self) -> Period: - start: Instant = self.start.offset("first-of", config.YEAR) - return self.__class__((config.YEAR, start, 1)) + Raises: + DateUnitValueError: If the ``unit`` is not a valid date unit. + ValueError: If the period's unit is smaller than the given unit. - @property - def first_month(self) -> Period: - start: Instant = self.start.offset("first-of", config.MONTH) - return self.__class__((config.MONTH, start, 1)) + Examples: + >>> from openfisca_core.periods import Instant - @property - def first_day(self) -> Period: - return self.__class__((config.DAY, self.start, 1)) + >>> start = Instant(2021, 1, 1) + + >>> period = Period(year, start, 1) + >>> period.subperiods(month) + [Period(unit=month, start=Instant(year=2021, month=1, day=1), size=1), ...] + + >>> period = Period(year, start, 2) + >>> period.subperiods(year) + [Period(unit=year, start=Instant(year=2021, month=1, day=1), size=1), ...] + + .. versionchanged:: 39.0.0: + Renamed from ``get_subperiods`` to ``subperiods``. + + """ + + if self.unit < unit: + raise ValueError(f"Cannot subdivide {self.unit} into {unit}") + + if not unit & DateUnit.isoformat: + raise DateUnitValueError(unit) + + return [ + self.first(unit).offset(offset, unit) + for offset in range(self.count(unit)) + ] diff --git a/openfisca_core/periods/py.typed b/openfisca_core/periods/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/__init__.py b/openfisca_core/periods/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/test_builders.py b/openfisca_core/periods/tests/test_builders.py new file mode 100644 index 0000000000..553daf307c --- /dev/null +++ b/openfisca_core/periods/tests/test_builders.py @@ -0,0 +1,109 @@ +import datetime + +import pytest + +from openfisca_core import periods +from openfisca_core.periods import DateUnit, Instant, Period + +day, month, year, eternity = DateUnit + + +@pytest.mark.parametrize("arg, expected", [ + ["1000", Instant(1000, 1, 1)], + ["1000-01", Instant(1000, 1, 1)], + ["1000-01-01", Instant(1000, 1, 1)], + [1000, Instant(1000, 1, 1)], + [(1000,), Instant(1000, 1, 1)], + [(1000, 1), Instant(1000, 1, 1)], + [(1000, 1, 1), Instant(1000, 1, 1)], + [datetime.date(1, 1, 1), Instant(1, 1, 1)], + [Instant(1, 1, 1), Instant(1, 1, 1)], + ]) +def test_build_instant(arg, expected): + """Returns the expected ``Instant``.""" + + assert periods.instant(arg) == expected + + +@pytest.mark.parametrize("arg, error", [ + ["1000-0", ValueError], + ["1000-0-0", ValueError], + ["1000-01-0", ValueError], + ["1000-01-01-01", ValueError], + ["1000-01-1", ValueError], + ["1000-01-32", ValueError], + ["1000-1", ValueError], + ["1000-1-1", ValueError], + ["1000-13", ValueError], + ["month:1000", ValueError], + ["month:1000:1", ValueError], + ["year:1000-01-01", ValueError], + ["year:1000-01-01:1", ValueError], + ["year:1000-01-01:3", ValueError], + [None, TypeError], + [eternity, ValueError], + [year, ValueError], + [Period(day, Instant(1, 1, 1), 365), ValueError], + ]) +def test_build_instant_with_an_invalid_argument(arg, error): + """Raises ``ValueError`` when given an invalid argument.""" + + with pytest.raises(error): + periods.instant(arg) + + +@pytest.mark.parametrize("arg, expected", [ + ["1000", Period(year, Instant(1000, 1, 1), 1)], + ["1000-01", Period(month, Instant(1000, 1, 1), 1)], + ["1000-01-01", Period(day, Instant(1000, 1, 1), 1)], + ["1004-02-29", Period(day, Instant(1004, 2, 29), 1)], + ["ETERNITY", Period(eternity, Instant(1, 1, 1), 1)], + ["day:1000-01-01", Period(day, Instant(1000, 1, 1), 1)], + ["day:1000-01-01:3", Period(day, Instant(1000, 1, 1), 3)], + ["eternity", Period(eternity, Instant(1, 1, 1), 1)], + ["month:1000-01", Period(month, Instant(1000, 1, 1), 1)], + ["month:1000-01-01", Period(month, Instant(1000, 1, 1), 1)], + ["month:1000-01-01:3", Period(month, Instant(1000, 1, 1), 3)], + ["month:1000-01:3", Period(month, Instant(1000, 1, 1), 3)], + ["year:1000", Period(year, Instant(1000, 1, 1), 1)], + ["year:1000-01", Period(year, Instant(1000, 1, 1), 1)], + ["year:1000-01-01", Period(year, Instant(1000, 1, 1), 1)], + ["year:1000-01-01:3", Period(year, Instant(1000, 1, 1), 3)], + ["year:1000-01:3", Period(year, Instant(1000, 1, 1), 3)], + ["year:1000:3", Period(year, Instant(1000, 1, 1), 3)], + [1000, Period(year, Instant(1000, 1, 1), 1)], + [eternity, Period(eternity, Instant(1, 1, 1), 1)], + [Instant(1, 1, 1), Period(day, Instant(1, 1, 1), 1)], + [Period(day, Instant(1, 1, 1), 365), Period(day, Instant(1, 1, 1), 365)], + ["month:1000:1", Period(2, Instant(1000, 1, 1), 1)], + ["month:1000", Period(2, Instant(1000, 1, 1), 1)], + ["day:1000:1", Period(1, Instant(1000, 1, 1), 1)], + ["day:1000-01:1", Period(1, Instant(1000, 1, 1), 1)], + ["day:1000-01", Period(1, Instant(1000, 1, 1), 1)], + ]) +def test_build_period(arg, expected): + """Returns the expected ``Period``.""" + + assert periods.period(arg) == expected + + +@pytest.mark.parametrize("arg, error", [ + ["1000-0", ValueError], + ["1000-0-0", ValueError], + ["1000-01-01:1", ValueError], + ["1000-01:1", ValueError], + ["1000-1", ValueError], + ["1000-1-0", ValueError], + ["1000-1-1", ValueError], + ["1000-13", ValueError], + ["1000-2-31", ValueError], + ["1000:1", ValueError], + [None, TypeError], + [datetime.date(1, 1, 1), ValueError], + [year, TypeError], + ]) +def test_build_period_with_an_invalid_argument(arg, error): + """Raises ``ValueError`` when given an invalid argument.""" + + with pytest.raises(error): + periods.period(arg) diff --git a/openfisca_core/periods/tests/test_instant.py b/openfisca_core/periods/tests/test_instant.py new file mode 100644 index 0000000000..181a77194d --- /dev/null +++ b/openfisca_core/periods/tests/test_instant.py @@ -0,0 +1,30 @@ +import pytest + +from openfisca_core.periods import DateUnit, Instant + +day, month, year, eternity = DateUnit + + +@pytest.fixture +def instant(): + """Returns a ``Instant``.""" + + return Instant(2020, 2, 29) + + +@pytest.mark.parametrize("offset, unit, expected", [ + ["first-of", month, Instant(2020, 2, 1)], + ["first-of", year, Instant(2020, 1, 1)], + ["last-of", month, Instant(2020, 2, 29)], + ["last-of", year, Instant(2020, 12, 31)], + [-3, day, Instant(2020, 2, 26)], + [-3, month, Instant(2019, 11, 29)], + [-3, year, Instant(2017, 2, 28)], + [3, day, Instant(2020, 3, 3)], + [3, month, Instant(2020, 5, 29)], + [3, year, Instant(2023, 2, 28)], + ]) +def test_offset(instant, offset, unit, expected): + """Returns the expected ``Instant``.""" + + assert instant.offset(offset, unit) == expected diff --git a/openfisca_core/periods/tests/test_parsers.py b/openfisca_core/periods/tests/test_parsers.py new file mode 100644 index 0000000000..87f8f3c695 --- /dev/null +++ b/openfisca_core/periods/tests/test_parsers.py @@ -0,0 +1,175 @@ +import pytest + +from openfisca_core import periods +from openfisca_core.periods import DateUnit +from openfisca_core.periods import helpers as parsers + +year = DateUnit.YEAR + + +@pytest.mark.parametrize("arg, expected", [ + ["1", None], + ["1000", None], + ["1000-01", None], + ["1000-01-01", None], + ["1000-01-1", None], + ["1000-01-99", None], + ["1000-1", None], + ["1000-1-1", None], + ["999", None], + ["eternity", None], + ["first-of", None], + ["year:2021:7", None], + [(1, 1), None], + [(1, 1, 1), None], + [(1, 1, 1, 1), None], + [(1,), None], + [(2022, 1), None], + [(2022, 1, 1), None], + [(2022, 12), None], + [(2022, 12, 1), None], + [(2022, 12, 31), None], + [(2022, 12, 32), None], + [(2022, 13), None], + [(2022, 13, 31), None], + [(2022,), None], + [1, (1, 1, 1, 4, 1)], + [1., None], + [1000, (1000, 1, 1, 4, 1)], + [1000., None], + [year, None], + [{1, 1, 1, 1}, None], + [{1, 1, 1}, None], + [{1, 1}, None], + [{1, }, None], + ]) +def test_parse_iso_format_from_int(arg, expected): + """Returns an ``ISOFormat`` when given a valid ISO format int.""" + + assert parsers.parse_int(arg) == expected + + +@pytest.mark.parametrize("arg, expected", [ + ["1", None], + ["1000", None], + ["1000-01", None], + ["1000-01-01", None], + ["1000-01-1", None], + ["1000-01-99", None], + ["1000-1", None], + ["1000-1-1", None], + ["999", None], + ["eternity", None], + ["first-of", None], + ["year:2021:7", None], + [(1, 1), (1, 1, 1, 2, 1)], + [(1, 1, 1), (1, 1, 1, 1, 1)], + [(1, 1, 1, 1), None], + [(1,), (1, 1, 1, 4, 1)], + [(2022, 1), (2022, 1, 1, 2, 1)], + [(2022, 1, 1), (2022, 1, 1, 1, 1)], + [(2022, 12), (2022, 12, 1, 2, 1)], + [(2022, 12, 1), (2022, 12, 1, 1, 1)], + [(2022, 12, 31), (2022, 12, 31, 1, 1)], + [(2022, 12, 32), None], + [(2022, 13), None], + [(2022, 13, 31), None], + [(2022,), (2022, 1, 1, 4, 1)], + [1, None], + [1., None], + [1000, None], + [1000., None], + [year, None], + [{1, 1, 1, 1}, None], + [{1, 1, 1}, None], + [{1, 1}, None], + [{1}, None], + ]) +def test_parse_iso_format_from_seq(arg, expected): + """Returns an ``ISOFormat`` when given a valid ISO format sequence.""" + + assert parsers.parse_seq(arg) == expected + + +@pytest.mark.parametrize("arg, expected", [ + ["1", None], + ["1000", (1000, 1, 1, 4, 1)], + ["1000-01", (1000, 1, 1, 2, 1)], + ["1000-01-01", (1000, 1, 1, 1, 1)], + ["1000-01-1", None], + ["1000-01-99", None], + ["1000-1", None], + ["1000-1-1", None], + ["999", None], + ["eternity", None], + ["first-of", None], + ["year:2021:7", (2021, 1, 1, 4, 7)], + [(1, 1), None], + [(1, 1, 1), None], + [(1, 1, 1, 1), None], + [(1,), None], + [(2022, 1), None], + [(2022, 1, 1), None], + [(2022, 12), None], + [(2022, 12, 1), None], + [(2022, 12, 31), None], + [(2022, 12, 32), None], + [(2022, 13), None], + [(2022, 13, 31), None], + [(2022,), None], + [1, None], + [1., None], + [1000, None], + [1000., None], + [year, None], + [{1, 1, 1, 1}, None], + [{1, 1, 1}, None], + [{1, 1}, None], + [{1, }, None], + ]) +def test_parse_iso_format_from_complex_str(arg, expected): + """Returns an ``ISOFormat`` when given a valid complex period.""" + + assert parsers.parse_period_str(arg) == expected + + +@pytest.mark.parametrize("arg, expected", [ + ["1", None], + ["1000", (1000, 1, 1, 4, 1)], + ["1000-01", (1000, 1, 1, 2, 1)], + ["1000-01-01", (1000, 1, 1, 1, 1)], + ["1000-01-1", None], + ["1000-01-99", None], + ["1000-1", None], + ["1000-1-1", None], + ["999", None], + ["eternity", None], + ["first-of", None], + ["year:2021:7", None], + [(1, 1), None], + [(1, 1, 1), None], + [(1, 1, 1, 1), None], + [(1,), None], + [(2022, 1), None], + [(2022, 1, 1), None], + [(2022, 12), None], + [(2022, 12, 1), None], + [(2022, 12, 31), None], + [(2022, 12, 32), None], + [(2022, 13), None], + [(2022, 13, 31), None], + [(2022,), None], + [1, None], + [1., None], + [1000, None], + [1000., None], + [year, None], + [{1, 1, 1, 1}, None], + [{1, 1, 1}, None], + [{1, 1}, None], + [{1, }, None], + ]) +def test_parse_iso_format_from_str(arg, expected): + """Returns an ``ISOFormat`` when given a valid ISO format string.""" + + assert periods.parse(arg) == 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..4ca45f8764 --- /dev/null +++ b/openfisca_core/periods/tests/test_period.py @@ -0,0 +1,134 @@ +import pytest + +from openfisca_core.periods import DateUnit, Instant, Period + +day, month, year, eternity = DateUnit + + +@pytest.fixture +def instant(): + """Returns a ``Instant``.""" + + return Instant(2022, 12, 31) + + +@pytest.mark.parametrize("date_unit, instant, size, expected", [ + [month, Instant(2022, 1, 1), 12, "month:2022-01:12"], + [month, Instant(2022, 3, 1), 12, "month:2022-03:12"], + [year, Instant(2022, 1, 1), 1, "2022"], + [year, Instant(2022, 1, 1), 3, "year:2022:3"], + [year, Instant(2022, 1, 3), 3, "year:2022:3"], + [year, Instant(2022, 3, 1), 1, "year:2022-03:1"], + ]) +def test_str_with_years(date_unit, instant, size, expected): + """Returns the expected string.""" + + assert str(Period(date_unit, instant, size)) == expected + + +@pytest.mark.parametrize("date_unit, instant, size, expected", [ + [month, Instant(2022, 1, 1), 1, "2022-01"], + [month, Instant(2022, 1, 1), 3, "month:2022-01:3"], + [month, Instant(2022, 3, 1), 3, "month:2022-03:3"], + ]) +def test_str_with_months(date_unit, instant, size, expected): + """Returns the expected string.""" + + assert str(Period(date_unit, instant, size)) == expected + + +@pytest.mark.parametrize("date_unit, instant, size, expected", [ + [day, Instant(2022, 1, 1), 1, "2022-01-01"], + [day, Instant(2022, 1, 1), 3, "day:2022-01-01:3"], + [day, Instant(2022, 3, 1), 3, "day:2022-03-01:3"], + ]) +def test_str_with_days(date_unit, instant, size, expected): + """Returns the expected string.""" + + assert str(Period(date_unit, instant, size)) == expected + + +@pytest.mark.parametrize("period_unit, unit, start, cease, count", [ + [day, day, Instant(2022, 12, 31), Instant(2023, 1, 2), 3], + [month, day, Instant(2022, 12, 31), Instant(2023, 3, 30), 90], + [month, month, Instant(2022, 12, 1), Instant(2023, 2, 1), 3], + [year, day, Instant(2022, 12, 31), Instant(2025, 12, 30), 1096], + [year, month, Instant(2022, 12, 1), Instant(2025, 11, 1), 36], + [year, year, Instant(2022, 1, 1), Instant(2024, 1, 1), 3], + ]) +def test_subperiods(instant, period_unit, unit, start, cease, count): + """Returns the expected subperiods.""" + + period = Period(period_unit, instant, 3) + subperiods = period.subperiods(unit) + + assert len(subperiods) == count + assert subperiods[0] == Period(unit, start, 1) + assert subperiods[-1] == Period(unit, cease, 1) + + +@pytest.mark.parametrize("period_unit, offset, unit, expected", [ + [day, "first-of", month, Period(day, Instant(2022, 12, 1), 3)], + [day, "first-of", year, Period(day, Instant(2022, 1, 1), 3)], + [day, "last-of", month, Period(day, Instant(2022, 12, 31), 3)], + [day, "last-of", year, Period(day, Instant(2022, 12, 31), 3)], + [day, -3, year, Period(day, Instant(2019, 12, 31), 3)], + [day, 1, month, Period(day, Instant(2023, 1, 31), 3)], + [day, 3, day, Period(day, Instant(2023, 1, 3), 3)], + [month, "first-of", month, Period(month, Instant(2022, 12, 1), 3)], + [month, "first-of", year, Period(month, Instant(2022, 1, 1), 3)], + [month, "last-of", month, Period(month, Instant(2022, 12, 31), 3)], + [month, "last-of", year, Period(month, Instant(2022, 12, 31), 3)], + [month, -3, year, Period(month, Instant(2019, 12, 31), 3)], + [month, 1, month, Period(month, Instant(2023, 1, 31), 3)], + [month, 3, day, Period(month, Instant(2023, 1, 3), 3)], + [year, "first-of", month, Period(year, Instant(2022, 12, 1), 3)], + [year, "first-of", year, Period(year, Instant(2022, 1, 1), 3)], + [year, "last-of", month, Period(year, Instant(2022, 12, 31), 3)], + [year, "last-of", year, Period(year, Instant(2022, 12, 31), 3)], + [year, -3, year, Period(year, Instant(2019, 12, 31), 3)], + [year, 1, month, Period(year, Instant(2023, 1, 31), 3)], + [year, 3, day, Period(year, Instant(2023, 1, 3), 3)], + ]) +def test_offset(instant, period_unit, offset, unit, expected): + """Returns the expected ``Period``.""" + + period = Period(period_unit, instant, 3) + + assert period.offset(offset, unit) == expected + + +@pytest.mark.parametrize("date_unit, instant, size, expected", [ + [month, Instant(2012, 1, 3), 3, 3], + [month, Instant(2012, 2, 3), 1, 1], + [month, Instant(2022, 1, 3), 3, 3], + [month, Instant(2022, 12, 1), 1, 1], + [year, Instant(2012, 1, 1), 1, 12], + [year, Instant(2022, 1, 1), 2, 24], + [year, Instant(2022, 12, 1), 1, 12], + ]) +def test_day_size_in_months(date_unit, instant, size, expected): + """Returns the expected number of months.""" + + period = Period(date_unit, instant, size) + + assert period.count(month) == expected + + +@pytest.mark.parametrize("date_unit, instant, size, expected", [ + [day, Instant(2022, 12, 31), 1, 1], + [day, Instant(2022, 12, 31), 3, 3], + [month, Instant(2012, 1, 3), 3, 31 + 29 + 31], + [month, Instant(2012, 2, 3), 1, 29], + [month, Instant(2022, 1, 3), 3, 31 + 28 + 31], + [month, Instant(2022, 12, 1), 1, 31], + [year, Instant(2012, 1, 1), 1, 366], + [year, Instant(2022, 1, 1), 2, 730], + [year, Instant(2022, 12, 1), 1, 365], + ]) +def test_day_size_in_days(date_unit, instant, size, expected): + """Returns the expected number of days.""" + + period = Period(date_unit, instant, size) + + assert period.count(day) == expected diff --git a/openfisca_core/periods/typing.py b/openfisca_core/periods/typing.py new file mode 100644 index 0000000000..7c3fa467f1 --- /dev/null +++ b/openfisca_core/periods/typing.py @@ -0,0 +1,65 @@ +# pylint: disable=missing-class-docstring, missing-function-docstring + +from __future__ import annotations + +from typing import Any, Iterator, TypeVar +from typing_extensions import Protocol + +import abc + +from pendulum.datetime import Date + +_T = TypeVar("_T", covariant = True) +_U = TypeVar("_U", covariant = True) +_V = TypeVar("_V", covariant = True) +_Self = TypeVar("_Self") + + +class _Offsetable(Protocol[_T, _U, _V]): + @abc.abstractmethod + def __init__(self, *args: _T | _U | _V) -> None: + ... + + @abc.abstractmethod + def __iter__(self) -> Iterator[_T | _U | _V]: + ... + + @abc.abstractmethod + def __le__(self, other: Any) -> bool: + ... + + @abc.abstractmethod + def __ge__(self, other: Any) -> bool: + ... + + @property + @abc.abstractmethod + def year(self) -> int: + ... + + @property + @abc.abstractmethod + def month(self) -> int: + ... + + @property + @abc.abstractmethod + def day(self) -> int: + ... + + @abc.abstractmethod + def date(self) -> Date: + ... + + @abc.abstractmethod + def offset(self: _Self, offset: Any, unit: Any) -> _Self: + ... + + @abc.abstractmethod + def _add(self, unit: str, count: int) -> Date: + ... + + +Instant = _Offsetable[int, int, int] + +Period = _Offsetable[Any, Instant, int] diff --git a/openfisca_core/populations/population.py b/openfisca_core/populations/population.py index cb243aff70..51ca732556 100644 --- a/openfisca_core/populations/population.py +++ b/openfisca_core/populations/population.py @@ -10,7 +10,7 @@ from openfisca_core import periods, projectors from openfisca_core.holders import Holder, MemoryUsage from openfisca_core.projectors import Projector -from openfisca_core.types import Array, Entity, Period, Role, Simulation +from openfisca_core.types import Array, Entity, Role, Simulation from . import config @@ -75,9 +75,9 @@ def check_array_compatible_with_entity( def check_period_validity( self, variable_name: str, - period: Optional[Union[int, str, Period]], + period: periods.Period | int | str | None, ) -> None: - if isinstance(period, (int, str, Period)): + if isinstance(period, (int, str, periods.Period)): return None stack = traceback.extract_stack() @@ -93,7 +93,7 @@ def check_period_validity( def __call__( self, variable_name: str, - period: Optional[Union[int, str, Period]] = None, + period: periods.Period | int | str | None = None, options: Optional[Sequence[str]] = None, ) -> Optional[Array[float]]: """ @@ -265,8 +265,8 @@ def get_rank( class Calculate(NamedTuple): variable: str - period: Period - option: Optional[Sequence[str]] + period: periods.Period + option: Sequence[str] | None class MemoryUsageByVariable(TypedDict, total = False): diff --git a/openfisca_core/scripts/measure_performances.py b/openfisca_core/scripts/measure_performances.py index 5c4fced850..3a7ea4f96d 100644 --- a/openfisca_core/scripts/measure_performances.py +++ b/openfisca_core/scripts/measure_performances.py @@ -106,7 +106,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 (numpy.datetime64(period.date()) - birth).astype('timedelta64[Y]') class dom_tom(Variable): diff --git a/openfisca_core/simulations/simulation.py b/openfisca_core/simulations/simulation.py index ef1cbbd869..6404dfc181 100644 --- a/openfisca_core/simulations/simulation.py +++ b/openfisca_core/simulations/simulation.py @@ -1,5 +1,6 @@ from __future__ import annotations +from openfisca_core.types import Population, TaxBenefitSystem, Variable from typing import Dict, NamedTuple, Optional, Set import tempfile @@ -8,11 +9,17 @@ import numpy from openfisca_core import commons, periods -from openfisca_core.errors import CycleError, SpiralError, VariableNotFoundError +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.tracers import FullTracer, SimpleTracer, TracingParameterNodeAtInstant -from openfisca_core.types import Population, TaxBenefitSystem, Variable +from openfisca_core.tracers import ( + FullTracer, + SimpleTracer, + TracingParameterNodeAtInstant, + ) from openfisca_core.warnings import TempfileWarning @@ -95,7 +102,7 @@ def data_storage_dir(self): def calculate(self, variable_name: str, period): """Calculate ``variable_name`` for ``period``.""" - if period is not None and not isinstance(period, Period): + if period is not None and not isinstance(period, periods.Period): period = periods.period(period) self.tracer.record_calculation_start(variable_name, period) @@ -109,7 +116,7 @@ def calculate(self, variable_name: str, period): self.tracer.record_calculation_end() self.purge_cache_of_invalid_values() - def _calculate(self, variable_name: str, period: Period): + def _calculate(self, variable_name: str, period: periods.Period): """ Calculate the variable ``variable_name`` for the period ``period``, using the variable formula if it exists. @@ -167,12 +174,12 @@ def calculate_add(self, variable_name: str, period): if variable is None: raise VariableNotFoundError(variable_name, self.tax_benefit_system) - if period is not None and not isinstance(period, Period): + if period is not None and not isinstance(period, periods.Period): period = periods.period(period) # Check that the requested period matches definition_period - if periods.unit_weight(variable.definition_period) > periods.unit_weight(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( + if variable.definition_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.first(YEAR)'.".format( variable.name, period, variable.definition_period @@ -185,7 +192,7 @@ def calculate_add(self, variable_name: str, period): return sum( self.calculate(variable_name, sub_period) - for sub_period in period.get_subperiods(variable.definition_period) + for sub_period in period.subperiods(variable.definition_period) ) def calculate_divide(self, variable_name: str, period): @@ -196,7 +203,7 @@ def calculate_divide(self, variable_name: str, period): if variable is None: raise VariableNotFoundError(variable_name, self.tax_benefit_system) - if period is not None and not isinstance(period, Period): + if period is not None and not isinstance(period, periods.Period): period = periods.period(period) # Check that the requested period matches definition_period @@ -209,7 +216,7 @@ def calculate_divide(self, variable_name: str, period): raise ValueError("DIVIDE option can only be used for a one-year or a one-month requested period") if period.unit == periods.MONTH: - computation_period = period.this_year + computation_period = period.first(periods.YEAR) return self.calculate(variable_name, period = computation_period) / 12. elif period.unit == periods.YEAR: return self.calculate(variable_name, period) @@ -276,7 +283,7 @@ def _check_period_consistency(self, period, variable): )) if variable.definition_period == periods.YEAR and period.unit != periods.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( + 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.first(YEAR)'.".format( variable.name, period )) @@ -344,7 +351,7 @@ def get_array(self, variable_name: str, period): Unlike :meth:`.calculate`, this method *does not* trigger calculations and *does not* use any formula. """ - if period is not None and not isinstance(period, Period): + if period is not None and not isinstance(period, periods.Period): period = periods.period(period) return self.get_holder(variable_name).get_array(period) @@ -410,7 +417,7 @@ def get_known_periods(self, variable): >>> simulation.set_input('age', '2018-04', [12, 14]) >>> simulation.set_input('age', '2018-05', [13, 14]) >>> simulation.get_known_periods('age') - [Period((u'month', Instant((2018, 5, 1)), 1)), Period((u'month', Instant((2018, 4, 1)), 1))] + [periods.period((u'month', Instant(2018, 5, 1)), 1)), periods.period((u'month', Instant((2018, 4, 1)), 1)] """ return self.get_holder(variable).get_known_periods() @@ -439,7 +446,7 @@ def set_input(self, variable_name: str, period, value): raise VariableNotFoundError(variable_name, self.tax_benefit_system) period = periods.period(period) - if ((variable.end is not None) and (period.start.date > variable.end)): + if ((variable.end is not None) and (period.start.date() > variable.end)): return self.get_holder(variable_name).set_input(period, value) @@ -494,4 +501,4 @@ def clone(self, debug = False, trace = False): class Cache(NamedTuple): variable: str - period: Period + period: periods.Period diff --git a/openfisca_core/simulations/simulation_builder.py b/openfisca_core/simulations/simulation_builder.py index 0092ba8371..41c9e4f8da 100644 --- a/openfisca_core/simulations/simulation_builder.py +++ b/openfisca_core/simulations/simulation_builder.py @@ -413,7 +413,8 @@ def finalize_variables_init(self, population): buffer = self.input_buffer[variable_name] unsorted_periods = [periods.period(period_str) for period_str in self.input_buffer[variable_name].keys()] # We need to handle small periods first for set_input to work - sorted_periods = sorted(unsorted_periods, key = periods.key_period_size) + sorted_periods = sorted(unsorted_periods, key = lambda period: f"{period.unit}_{period.size}") + for period_value in sorted_periods: values = buffer[str(period_value)] # Hack to replicate the values in the persons entity @@ -422,7 +423,7 @@ def finalize_variables_init(self, population): variable = holder.variable # TODO - this duplicates the check in Simulation.set_input, but # fixing that requires improving Simulation's handling of entities - if (variable.end is None) or (period_value.start.date <= variable.end): + if (variable.end is None) or (period_value.start.date() <= variable.end): holder.set_input(period_value, array) def raise_period_mismatch(self, entity, json, e): diff --git a/openfisca_core/taxbenefitsystems/tax_benefit_system.py b/openfisca_core/taxbenefitsystems/tax_benefit_system.py index 9a8831269d..a21c96e0a1 100644 --- a/openfisca_core/taxbenefitsystems/tax_benefit_system.py +++ b/openfisca_core/taxbenefitsystems/tax_benefit_system.py @@ -1,28 +1,31 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Sequence, Union +import typing +from typing import Any, Dict, Optional, Sequence import copy import functools import glob import importlib -import importlib_metadata import inspect import logging import os import sys import traceback -import typing -from openfisca_core import commons, periods, variables +import importlib_metadata + +from openfisca_core import commons, periods, types, variables from openfisca_core.entities import Entity -from openfisca_core.errors import VariableNameConflictError, VariableNotFoundError +from openfisca_core.errors import ( + VariableNameConflictError, + VariableNotFoundError, + ) from openfisca_core.parameters import ParameterNode -from openfisca_core.periods import Instant, Period -from openfisca_core.populations import Population, GroupPopulation +from openfisca_core.populations import GroupPopulation, Population from openfisca_core.simulations import SimulationBuilder -from openfisca_core.types import ParameterNodeAtInstant from openfisca_core.variables import Variable +from openfisca_core.periods.typing import Instant log = logging.getLogger(__name__) @@ -43,7 +46,7 @@ class TaxBenefitSystem: person_entity: Entity _base_tax_benefit_system = None - _parameters_at_instant_cache: Dict[Instant, ParameterNodeAtInstant] = {} + _parameters_at_instant_cache: Dict[periods.Instant, types.ParameterNodeAtInstant] = {} person_key_plural = None preprocess_parameters = None baseline = None # Baseline tax-benefit system. Used only by reforms. Note: Reforms can be chained. @@ -342,7 +345,7 @@ def neutralize_variable(self, variable_name: str): def annualize_variable( self, variable_name: str, - period: Optional[Period] = None, + period: periods.Period | None = None, ) -> None: check: bool variable: Optional[Variable] @@ -385,8 +388,8 @@ def _get_baseline_parameters_at_instant(self, instant): @functools.lru_cache() def get_parameters_at_instant( self, - instant: Union[str, int, Period, Instant], - ) -> Optional[ParameterNodeAtInstant]: + instant: periods.Period | Instant | str | int, + ) -> Optional[types.ParameterNodeAtInstant]: """Get the parameters of the legislation at a given instant Args: @@ -398,19 +401,18 @@ def get_parameters_at_instant( """ key: Instant - msg: str - if isinstance(instant, Instant): + if isinstance(instant, periods.Instant): key = instant - elif isinstance(instant, Period): + elif isinstance(instant, periods.Period): key = instant.start elif isinstance(instant, (str, int)): key = periods.instant(instant) else: - msg = f"Expected an Instant (e.g. Instant((2017, 1, 1)) ). Got: {key}." + msg = f"Expected an Instant (e.g. Instant(2017, 1, 1) ). Got: {instant}." raise AssertionError(msg) if self.parameters is None: diff --git a/openfisca_core/tracers/full_tracer.py b/openfisca_core/tracers/full_tracer.py index 6638a789d4..d1a8a3a15f 100644 --- a/openfisca_core/tracers/full_tracer.py +++ b/openfisca_core/tracers/full_tracer.py @@ -1,17 +1,15 @@ from __future__ import annotations -import time -import typing +from numpy.typing import ArrayLike from typing import Dict, Iterator, List, Optional, Union -from .. import tracers +import time -if typing.TYPE_CHECKING: - from numpy.typing import ArrayLike +from openfisca_core import periods - from openfisca_core.periods import Period +from .. import tracers - Stack = List[Dict[str, Union[str, Period]]] +Stack = List[Dict[str, Union[str, periods.Period]]] class FullTracer: @@ -28,7 +26,7 @@ def __init__(self) -> None: def record_calculation_start( self, variable: str, - period: Period, + period: periods.Period, ) -> None: self._simple_tracer.record_calculation_start(variable, period) self._enter_calculation(variable, period) @@ -37,7 +35,7 @@ def record_calculation_start( def _enter_calculation( self, variable: str, - period: Period, + period: periods.Period, ) -> None: new_node = tracers.TraceNode( name = variable, @@ -56,7 +54,7 @@ def _enter_calculation( def record_parameter_access( self, parameter: str, - period: Period, + period: periods.Period, value: ArrayLike, ) -> None: diff --git a/openfisca_core/tracers/simple_tracer.py b/openfisca_core/tracers/simple_tracer.py index 2fa98c6582..61b40d2902 100644 --- a/openfisca_core/tracers/simple_tracer.py +++ b/openfisca_core/tracers/simple_tracer.py @@ -1,14 +1,11 @@ from __future__ import annotations -import typing +from numpy.typing import ArrayLike from typing import Dict, List, Union -if typing.TYPE_CHECKING: - from numpy.typing import ArrayLike +from openfisca_core import periods - from openfisca_core.periods import Period - - Stack = List[Dict[str, Union[str, Period]]] +Stack = List[Dict[str, Union[str, periods.Period]]] class SimpleTracer: @@ -18,7 +15,7 @@ class SimpleTracer: def __init__(self) -> None: self._stack = [] - def record_calculation_start(self, variable: str, period: Period) -> None: + def record_calculation_start(self, variable: str, period: periods.Period) -> None: self.stack.append({'name': variable, 'period': period}) def record_calculation_result(self, value: ArrayLike) -> None: diff --git a/openfisca_core/tracers/trace_node.py b/openfisca_core/tracers/trace_node.py index 93b630886c..0edf798482 100644 --- a/openfisca_core/tracers/trace_node.py +++ b/openfisca_core/tracers/trace_node.py @@ -1,30 +1,26 @@ from __future__ import annotations -import dataclasses -import typing - -if typing.TYPE_CHECKING: - import numpy +from numpy.typing import ArrayLike +from typing import List - from openfisca_core.indexed_enums import EnumArray - from openfisca_core.periods import Period +import dataclasses - Array = typing.Union[EnumArray, numpy.typing.ArrayLike] - Time = typing.Union[float, int] +from openfisca_core import periods +from openfisca_core.indexed_enums import EnumArray @dataclasses.dataclass class TraceNode: name: str - period: Period - parent: typing.Optional[TraceNode] = None - children: typing.List[TraceNode] = dataclasses.field(default_factory = list) - parameters: typing.List[TraceNode] = dataclasses.field(default_factory = list) - value: typing.Optional[Array] = None + period: periods.Period + parent: TraceNode | None = None + children: List[TraceNode] = dataclasses.field(default_factory = list) + parameters: List[TraceNode] = dataclasses.field(default_factory = list) + value: EnumArray | ArrayLike | None = None start: float = 0 end: float = 0 - def calculation_time(self, round_: bool = True) -> Time: + def calculation_time(self, round_: bool = True) -> float | int: result = self.end - self.start if round_: @@ -50,5 +46,5 @@ def append_child(self, node: TraceNode) -> None: self.children.append(node) @staticmethod - def round(time: Time) -> float: + def round(time: float | int) -> float: return float(f'{time:.4g}') # Keep only 4 significant figures diff --git a/openfisca_core/types/__init__.py b/openfisca_core/types/__init__.py index 699133aecb..12d4c3935c 100644 --- a/openfisca_core/types/__init__.py +++ b/openfisca_core/types/__init__.py @@ -11,10 +11,8 @@ * :attr:`.Entity` * :attr:`.Formula` * :attr:`.Holder` - * :attr:`.Instant` * :attr:`.ParameterNodeAtInstant` * :attr:`.Params` - * :attr:`.Period` * :attr:`.Population` * :attr:`.Role`, * :attr:`.Simulation`, @@ -49,19 +47,13 @@ # Official Public API -from ._data import ( # noqa: F401 - Array, - ArrayLike, - ) - +from ._data import Array, ArrayLike # noqa: F401 from ._domain import ( # noqa: F401 Entity, Formula, Holder, - Instant, ParameterNodeAtInstant, Params, - Period, Population, Role, Simulation, @@ -75,10 +67,8 @@ "Entity", "Formula", "Holder", - "Instant", "ParameterNodeAtInstant", "Params", - "Period", "Population", "Role", "Simulation", diff --git a/openfisca_core/types/_data.py b/openfisca_core/types/_data.py index ff7066d43a..fdc1533dda 100644 --- a/openfisca_core/types/_data.py +++ b/openfisca_core/types/_data.py @@ -1,8 +1,8 @@ from typing import Sequence, TypeVar, Union -from nptyping import types, NDArray as Array - import numpy +from nptyping import NDArray as Array +from nptyping import types T = TypeVar("T", bool, bytes, float, int, object, str) diff --git a/openfisca_core/types/_domain.py b/openfisca_core/types/_domain.py index 643f27964f..6b7ca4bb75 100644 --- a/openfisca_core/types/_domain.py +++ b/openfisca_core/types/_domain.py @@ -1,12 +1,13 @@ from __future__ import annotations -import numpy import typing_extensions -from typing import Any, Optional +from typing import Any from typing_extensions import Protocol import abc +import numpy + class Entity(Protocol): """Entity protocol.""" @@ -26,7 +27,7 @@ def check_variable_defined_for_entity(self, variable_name: Any) -> None: def get_variable( self, variable_name: Any, check_existence: Any = ..., - ) -> Optional[Any]: + ) -> Any | None: """Abstract method.""" @@ -37,7 +38,7 @@ class Formula(Protocol): def __call__( self, population: Population, - instant: Instant, + instant: Any, params: Params, ) -> numpy.ndarray: """Abstract method.""" @@ -55,10 +56,6 @@ def get_memory_usage(self) -> Any: """Abstract method.""" -class Instant(Protocol): - """Instant protocol.""" - - @typing_extensions.runtime_checkable class ParameterNodeAtInstant(Protocol): """ParameterNodeAtInstant protocol.""" @@ -68,21 +65,7 @@ class Params(Protocol): """Params protocol.""" @abc.abstractmethod - def __call__(self, instant: Instant) -> ParameterNodeAtInstant: - """Abstract method.""" - - -@typing_extensions.runtime_checkable -class Period(Protocol): - """Period protocol.""" - - @property - @abc.abstractmethod - def start(self) -> Any: - """Abstract method.""" - @property - @abc.abstractmethod - def unit(self) -> Any: + def __call__(self, instant: Any) -> ParameterNodeAtInstant: """Abstract method.""" @@ -119,7 +102,7 @@ def calculate_divide(self, variable_name: Any, period: Any) -> Any: """Abstract method.""" @abc.abstractmethod - def get_population(self, plural: Optional[Any]) -> Any: + def get_population(self, plural: Any | None) -> Any: """Abstract method.""" @@ -132,7 +115,7 @@ class TaxBenefitSystem(Protocol): def get_variable( self, variable_name: Any, check_existence: Any = ..., - ) -> Optional[Any]: + ) -> Any | None: """Abstract method.""" diff --git a/openfisca_core/variables/helpers.py b/openfisca_core/variables/helpers.py index 335a585498..a9e9fc2fc9 100644 --- a/openfisca_core/variables/helpers.py +++ b/openfisca_core/variables/helpers.py @@ -1,14 +1,15 @@ from __future__ import annotations -import sortedcontainers from typing import Optional -from openfisca_core.periods import Period +import sortedcontainers + +from openfisca_core import periods from .. import variables -def get_annualized_variable(variable: variables.Variable, annualization_period: Optional[Period] = None) -> variables.Variable: +def get_annualized_variable(variable: variables.Variable, annualization_period: Optional[periods.Period] = None) -> variables.Variable: """ Returns a clone of ``variable`` that is annualized for the period ``annualization_period``. When annualized, a variable's formula is only called for a January calculation, and the results for other months are assumed to be identical. @@ -17,8 +18,8 @@ def get_annualized_variable(variable: variables.Variable, annualization_period: def make_annual_formula(original_formula, annualization_period = None): def annual_formula(population, period, parameters): - if period.start.month != 1 and (annualization_period is None or annualization_period.contains(period)): - return population(variable.name, period.this_year.first_month) + if period.start.month != 1 and (annualization_period is None or period not in annualization_period): + return population(variable.name, period.first(periods.YEAR).first(periods.MONTH)) if original_formula.__code__.co_argcount == 2: return original_formula(population, period) return original_formula(population, period, parameters) diff --git a/openfisca_core/variables/variable.py b/openfisca_core/variables/variable.py index 1e9fce3083..4c1460d756 100644 --- a/openfisca_core/variables/variable.py +++ b/openfisca_core/variables/variable.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional, Union +from openfisca_core.types import Formula import datetime import inspect @@ -13,8 +13,6 @@ 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.types import Formula, Instant from . import config, helpers @@ -311,8 +309,8 @@ def get_introspection_data(cls, tax_benefit_system): def get_formula( self, - period: Union[Instant, Period, str, int] = None, - ) -> Optional[Formula]: + period: periods.Period | periods.Instant | str | int | None = None, + ) -> Formula | None: """Returns the formula to compute the variable at the given period. If no period is given and the variable has several formulas, the method @@ -332,15 +330,20 @@ def get_formula( if period is None: return self.formulas.peekitem(index = 0)[1] # peekitem gets the 1st key-value tuple (the oldest start_date and formula). Return the formula. - if isinstance(period, Period): + if isinstance(period, periods.Period): instant = period.start + else: try: instant = periods.period(period).start + except ValueError: instant = periods.instant(period) - if self.end and instant.date > self.end: + if instant is None: + return None + + if self.end and instant.date() > self.end: return None instant_str = str(instant) diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 115c6267bb..7d546e0937 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -17,6 +17,7 @@ check-style: $(shell git ls-files "*.py") ## Run linters to check for syntax and style errors in the doc. lint-doc: \ lint-doc-commons \ + lint-doc-periods \ lint-doc-types \ ; @@ -42,6 +43,7 @@ check-types: ## Run static type checkers for type errors (strict). lint-typing-strict: \ lint-typing-strict-commons \ + lint-typing-strict-periods \ lint-typing-strict-types \ ; diff --git a/openfisca_tasks/test_code.mk b/openfisca_tasks/test_code.mk index 63fdd4386a..6cffe10b17 100644 --- a/openfisca_tasks/test_code.mk +++ b/openfisca_tasks/test_code.mk @@ -31,10 +31,6 @@ test-code: test-core test-country test-extension ## Run openfisca-core tests. test-core: $(shell pytest --quiet --quiet --collect-only 2> /dev/null | cut -f 1 -d ":") @$(call print_help,$@:) - @pytest --quiet --capture=no --xdoctest --xdoctest-verbose=0 \ - openfisca_core/commons \ - openfisca_core/holders \ - openfisca_core/types @PYTEST_ADDOPTS="$${PYTEST_ADDOPTS} ${pytest_args}" \ coverage run -m \ ${openfisca} test $? \ diff --git a/openfisca_web_api/loader/variables.py b/openfisca_web_api/loader/variables.py index d9390fb3a2..35d982fa00 100644 --- a/openfisca_web_api/loader/variables.py +++ b/openfisca_web_api/loader/variables.py @@ -71,7 +71,7 @@ def build_variable(variable, country_package_metadata, tax_benefit_system): 'description': variable.label, 'valueType': VALUE_TYPES[variable.value_type]['formatted_value_type'], 'defaultValue': get_default_value(variable), - 'definitionPeriod': variable.definition_period.upper(), + 'definitionPeriod': variable.definition_period.__str__().upper(), 'entity': variable.entity.key, } diff --git a/setup.cfg b/setup.cfg index 467e3ede59..9e0140eed0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,13 +12,25 @@ [flake8] extend-ignore = D hang-closing = true -ignore = E128,E251,F403,F405,E501,RST301,W503,W504 +ignore = E128,E251,F403,F405,E501,E704,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 +per-file-ignores = */typing.py:D101,D102,E704, */__init__.py:F401 rst-directives = attribute, deprecated, seealso, versionadded, versionchanged rst-roles = any, attr, class, exc, func, meth, mod, obj strictness = short +[isort] +case_sensitive = true +group_by_package = true +include_trailing_comma = true +known_first_party = openfisca_core +known_openfisca = openfisca_country_template, openfisca_extension_template +known_typing = mypy*, *types*, *typing* +multi_line_output = 8 +py_version = 37 +sections = FUTURE,TYPING,STDLIB,THIRDPARTY,OPENFISCA,FIRSTPARTY,LOCALFOLDER + [pylint.message_control] disable = all enable = C0115,C0116,R0401 @@ -41,17 +53,14 @@ skip_empty = true addopts = --doctest-modules --disable-pytest-warnings --showlocals doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL NUMBER NORMALIZE_WHITESPACE python_files = **/*.py -testpaths = tests +testpaths = openfisca_core/commons openfisca_core/holders openfisca_core/periods openfisca_core/types tests [mypy] ignore_missing_imports = True install_types = True non_interactive = True -[mypy-openfisca_core.commons.tests.*] -ignore_errors = True - -[mypy-openfisca_core.holders.tests.*] +[mypy-openfisca_core.*.tests.*] ignore_errors = True [mypy-openfisca_core.scripts.*] diff --git a/setup.py b/setup.py index 2543121ac8..85b349713b 100644 --- a/setup.py +++ b/setup.py @@ -26,13 +26,15 @@ # functional and integration breaks caused by external code updates. general_requirements = [ + 'PyYAML >= 3.10', 'dpath >= 1.5.0, < 3.0.0', + 'importlib-metadata < 4.3.0', 'nptyping == 1.4.4', 'numexpr >= 2.7.0, <= 3.0', - 'numpy >= 1.11, < 1.21', + 'numpy >= 1.20, < 1.21', + 'pendulum >= 2.1.0, < 3.0.0', 'psutil >= 5.4.7, < 6.0.0', 'pytest >= 4.4.1, < 6.0.0', # For openfisca test - 'PyYAML >= 3.10', 'sortedcontainers == 2.2.2', 'typing-extensions >= 4.0.0, < 5.0.0', ] @@ -58,12 +60,11 @@ 'openapi-spec-validator >= 0.3.0', 'pycodestyle >= 2.8.0, < 2.9.0', 'pylint == 2.10.2', - 'xdoctest >= 1.0.0, < 2.0.0', ] + api_requirements setup( name = 'OpenFisca-Core', - version = '38.0.0', + version = '39.0.0', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [ diff --git a/tests/core/tax_scales/test_marginal_amount_tax_scale.py b/tests/core/tax_scales/test_marginal_amount_tax_scale.py index 7582d725b4..f1035d72ec 100644 --- a/tests/core/tax_scales/test_marginal_amount_tax_scale.py +++ b/tests/core/tax_scales/test_marginal_amount_tax_scale.py @@ -36,7 +36,7 @@ def test_calc(): # TODO: move, as we're testing Scale, not MarginalAmountTaxScale def test_dispatch_scale_type_on_creation(data): scale = parameters.Scale("amount_scale", data, "") - first_jan = periods.Instant((2017, 11, 1)) + first_jan = periods.instant((2017, 11, 1)) result = scale.get_at_instant(first_jan) diff --git a/tests/core/tax_scales/test_single_amount_tax_scale.py b/tests/core/tax_scales/test_single_amount_tax_scale.py index c5e6483a7d..fdb3522523 100644 --- a/tests/core/tax_scales/test_single_amount_tax_scale.py +++ b/tests/core/tax_scales/test_single_amount_tax_scale.py @@ -50,7 +50,7 @@ def test_to_dict(): # TODO: move, as we're testing Scale, not SingleAmountTaxScale def test_assign_thresholds_on_creation(data): scale = parameters.Scale("amount_scale", data, "") - first_jan = periods.Instant((2017, 11, 1)) + first_jan = periods.instant((2017, 11, 1)) scale_at_instant = scale.get_at_instant(first_jan) result = scale_at_instant.thresholds @@ -61,7 +61,7 @@ def test_assign_thresholds_on_creation(data): # TODO: move, as we're testing Scale, not SingleAmountTaxScale def test_assign_amounts_on_creation(data): scale = parameters.Scale("amount_scale", data, "") - first_jan = periods.Instant((2017, 11, 1)) + first_jan = periods.instant((2017, 11, 1)) scale_at_instant = scale.get_at_instant(first_jan) result = scale_at_instant.amounts @@ -72,7 +72,7 @@ def test_assign_amounts_on_creation(data): # TODO: move, as we're testing Scale, not SingleAmountTaxScale def test_dispatch_scale_type_on_creation(data): scale = parameters.Scale("amount_scale", data, "") - first_jan = periods.Instant((2017, 11, 1)) + first_jan = periods.instant((2017, 11, 1)) result = scale.get_at_instant(first_jan) diff --git a/tests/core/test_countries.py b/tests/core/test_countries.py index aeb4d762c7..a12202068a 100644 --- a/tests/core/test_countries.py +++ b/tests/core/test_countries.py @@ -51,7 +51,7 @@ def test_non_existing_variable(simulation): @pytest.mark.parametrize("simulation", [({}, PERIOD)], indirect = True) def test_calculate_variable_with_wrong_definition_period(simulation): - year = str(PERIOD.this_year) + year = str(PERIOD.first(periods.YEAR)) with pytest.raises(ValueError) as error: simulation.calculate("basic_income", year) @@ -71,7 +71,7 @@ def test_divide_option_on_month_defined_variable(simulation): @pytest.mark.parametrize("simulation", [({}, PERIOD)], indirect = True) def test_divide_option_with_complex_period(simulation): - quarter = PERIOD.last_3_months + quarter = PERIOD.last(3, periods.months) with pytest.raises(ValueError) as error: simulation.household("housing_tax", quarter, options = [populations.DIVIDE]) @@ -84,7 +84,7 @@ def test_divide_option_with_complex_period(simulation): def test_input_with_wrong_period(tax_benefit_system): - year = str(PERIOD.this_year) + year = str(PERIOD.first(periods.YEAR)) variables = {"basic_income": {year: 12000}} simulation_builder = SimulationBuilder() simulation_builder.set_default_period(PERIOD) diff --git a/tests/core/test_cycles.py b/tests/core/test_cycles.py index 1c4361ded2..e36e8bb647 100644 --- a/tests/core/test_cycles.py +++ b/tests/core/test_cycles.py @@ -44,7 +44,7 @@ class variable3(Variable): definition_period = periods.MONTH def formula(person, period): - return person('variable4', period.last_month) + return person('variable4', period.last(1, periods.month)) class variable4(Variable): @@ -64,7 +64,7 @@ class variable5(Variable): definition_period = periods.MONTH def formula(person, period): - variable6 = person('variable6', period.last_month) + variable6 = person('variable6', period.last(1, periods.month)) return 5 + variable6 @@ -96,7 +96,7 @@ class cotisation(Variable): def formula(person, period): if period.start.month == 12: - return 2 * person('cotisation', period.last_month) + return 2 * person('cotisation', period.last(1, periods.month)) else: return person.empty_array() + 1 @@ -128,7 +128,7 @@ def test_spirals_result_in_default_value(simulation, reference_period): def test_spiral_heuristic(simulation, reference_period): variable5 = simulation.calculate('variable5', period = reference_period) variable6 = simulation.calculate('variable6', period = reference_period) - variable6_last_month = simulation.calculate('variable6', reference_period.last_month) + variable6_last_month = simulation.calculate('variable6', reference_period.last(1, periods.month)) tools.assert_near(variable5, [11]) tools.assert_near(variable6, [11]) tools.assert_near(variable6_last_month, [11]) @@ -141,6 +141,6 @@ def test_spiral_cache(simulation, reference_period): def test_cotisation_1_level(simulation, reference_period): - month = reference_period.last_month + month = reference_period.last(1, periods.month) cotisation = simulation.calculate('cotisation', period = month) tools.assert_near(cotisation, [0]) diff --git a/tests/core/test_periods.py b/tests/core/test_periods.py deleted file mode 100644 index 2c125d527c..0000000000 --- a/tests/core/test_periods.py +++ /dev/null @@ -1,203 +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 1fa3a759b3..3b32e5d9e2 100644 --- a/tests/core/test_projectors.py +++ b/tests/core/test_projectors.py @@ -1,8 +1,10 @@ +import numpy + +from openfisca_core import periods +from openfisca_core.entities import build_entity +from openfisca_core.model_api import Enum, Variable 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 def test_shortcut_to_containing_entity_provided(): @@ -125,14 +127,14 @@ class household_enum_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household - definition_period = ETERNITY + definition_period = periods.ETERNITY class projected_enum_variable(Variable): value_type = Enum possible_values = enum default_value = enum.FIRST_OPTION entity = person - definition_period = ETERNITY + definition_period = periods.ETERNITY def formula(person, period): return person.household("household_enum_variable", period) @@ -194,7 +196,7 @@ class household_projected_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household - definition_period = ETERNITY + definition_period = periods.ETERNITY def formula(household, period): return household.value_from_first_person(household.members("person_enum_variable", period)) @@ -204,7 +206,7 @@ class person_enum_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = person - definition_period = ETERNITY + definition_period = periods.ETERNITY system.add_variables(household_projected_variable, person_enum_variable) @@ -275,14 +277,14 @@ class household_level_variable(Variable): possible_values = enum default_value = enum.FIRST_OPTION entity = household_entity - definition_period = ETERNITY + definition_period = periods.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 = periods.ETERNITY def formula(family, period): return family.household("household_level_variable", period) @@ -290,7 +292,7 @@ def formula(family, period): class decoded_projected_family_level_variable(Variable): value_type = str entity = family_entity - definition_period = ETERNITY + definition_period = periods.ETERNITY def formula(family, period): return family.household("household_level_variable", period).decode_to_str() diff --git a/tests/core/test_reforms.py b/tests/core/test_reforms.py index 8735cee18f..15fe78b3d6 100644 --- a/tests/core/test_reforms.py +++ b/tests/core/test_reforms.py @@ -2,12 +2,12 @@ import pytest -from openfisca_core import periods -from openfisca_core.periods import Instant -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 import periods from openfisca_core.model_api import * # noqa analysis:ignore +from openfisca_core.parameters import ParameterNode, ValuesHistory +from openfisca_core.tools import assert_near class goes_to_school(Variable): @@ -15,7 +15,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 = periods.MONTH class WithBasicIncomeNeutralized(Reform): @@ -216,7 +216,7 @@ class new_variable(Variable): value_type = int label = "Nouvelle variable introduite par la réforme" entity = Household - definition_period = MONTH + definition_period = periods.MONTH def formula(household, period): return household.empty_array() + 10 @@ -240,7 +240,7 @@ class new_dated_variable(Variable): value_type = int label = "Nouvelle variable introduite par la réforme" entity = Household - definition_period = MONTH + definition_period = periods.MONTH def formula_2010_01_01(household, period): return household.empty_array() + 10 @@ -263,7 +263,7 @@ def apply(self): def test_update_variable(make_simulation, tax_benefit_system): class disposable_income(Variable): - definition_period = MONTH + definition_period = periods.MONTH def formula_2018(household, period): return household.empty_array() + 10 @@ -294,7 +294,7 @@ def apply(self): def test_replace_variable(tax_benefit_system): class disposable_income(Variable): - definition_period = MONTH + definition_period = periods.MONTH entity = Person label = "Disposable income" value_type = float @@ -344,7 +344,7 @@ def apply(self): parameters_new_node = reform.parameters.children['new_node'] assert parameters_new_node is not None - instant = Instant((2013, 1, 1)) + instant = periods.instant((2013, 1, 1)) parameters_at_instant = reform.get_parameters_at_instant(instant) assert parameters_at_instant.new_node.new_param is True @@ -355,7 +355,7 @@ class some_variable(Variable): value_type = int entity = Person label = "Variable with many attributes" - definition_period = MONTH + definition_period = periods.MONTH set_input = set_input_divide_by_period calculate_output = calculate_output_add diff --git a/tests/core/variables/test_annualize.py b/tests/core/variables/test_annualize.py index 056fcfead3..4db394b761 100644 --- a/tests/core/variables/test_annualize.py +++ b/tests/core/variables/test_annualize.py @@ -15,7 +15,7 @@ def monthly_variable(): class monthly_variable(Variable): value_type = int entity = Person - definition_period = MONTH + definition_period = periods.MONTH def formula(person, period, parameters): variable.calculation_count += 1 @@ -47,7 +47,7 @@ def test_without_annualize(monthly_variable): yearly_sum = sum( person('monthly_variable', month) - for month in period.get_subperiods(MONTH) + for month in period.subperiods(periods.MONTH) ) assert monthly_variable.calculation_count == 11 @@ -62,7 +62,7 @@ def test_with_annualize(monthly_variable): yearly_sum = sum( person('monthly_variable', month) - for month in period.get_subperiods(MONTH) + for month in period.subperiods(periods.MONTH) ) assert monthly_variable.calculation_count == 0 @@ -77,7 +77,7 @@ def test_with_partial_annualize(monthly_variable): yearly_sum = sum( person('monthly_variable', month) - for month in period.get_subperiods(MONTH) + for month in period.subperiods(periods.MONTH) ) assert monthly_variable.calculation_count == 11 diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 0fcc67f0b7..a12abb0363 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -41,8 +41,8 @@ def check_response(client, data, expected_error_code, path_to_check, content_to_ ('{"persons": {"bob": {}}, "households": {"household": {"parents": ["unexpected_person_id"]}}}', client.BAD_REQUEST, 'households/household/parents', 'has not been declared in persons',), ('{"persons": {"bob": {}}, "households": {"household": {"parents": ["bob", "bob"]}}}', client.BAD_REQUEST, 'households/household/parents', 'has been declared more than once',), ('{"persons": {"bob": {}}, "households": {"household": {"parents": ["bob", {}]}}}', client.BAD_REQUEST, 'households/household/parents/1', 'Invalid type',), - ('{"persons": {"bob": {"salary": {"invalid period": 2000 }}}}', client.BAD_REQUEST, 'persons/bob/salary', 'Expected a period',), - ('{"persons": {"bob": {"salary": {"invalid period": null }}}}', client.BAD_REQUEST, 'persons/bob/salary', 'Expected a period',), + ('{"persons": {"bob": {"salary": {"invalid period": 2000 }}}}', client.BAD_REQUEST, 'persons/bob/salary', 'is not a valid period',), + ('{"persons": {"bob": {"salary": {"invalid period": null }}}}', client.BAD_REQUEST, 'persons/bob/salary', 'is not a valid period',), ('{"persons": {"bob": {"basic_income": {"2017": 2000 }}}, "households": {"household": {"parents": ["bob"]}}}', client.BAD_REQUEST, 'persons/bob/basic_income/2017', '"basic_income" can only be set for one month',), ('{"persons": {"bob": {"salary": {"ETERNITY": 2000 }}}, "households": {"household": {"parents": ["bob"]}}}', client.BAD_REQUEST, 'persons/bob/salary/ETERNITY', 'salary is only defined for months',), ('{"persons": {"alice": {}, "bob": {}, "charlie": {}}, "households": {"_": {"parents": ["alice", "bob", "charlie"]}}}', client.BAD_REQUEST, 'households/_/parents', 'at most 2 parents in a household',), @@ -268,7 +268,7 @@ def test_encoding_period_id(test_client): response_json = json.loads(response.data.decode('utf-8')) # In Python 3, there is no encoding issue. - if "Expected a period" not in str(response.data): + if "is not a valid period" not in str(response.data): message = "'à' is not a valid ASCII value." text = response_json['error'] assert message in text