diff --git a/CHANGELOG.md b/CHANGELOG.md index ade8610231..6095896937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +### 41.5.8 [#1220](https://github.com/openfisca/openfisca-core/pull/1221) + +#### Technical changes + +- Fix doc & type definitions in the projectors module + +### 41.5.7 [#1224](https://github.com/openfisca/openfisca-core/pull/1224) + +#### Technical changes + +- Fix doc & type definitions in the enums module + +### 41.5.6 [#1223](https://github.com/openfisca/openfisca-core/pull/1223) + +#### Technical changes + +- Update `pendulum' +- Remove run-time type-checks +- Add typing to the periods module + ### 41.5.5 [#1220](https://github.com/openfisca/openfisca-core/pull/1220) #### Technical changes diff --git a/openfisca_core/commons/formulas.py b/openfisca_core/commons/formulas.py index bce9206938..96fd0101ed 100644 --- a/openfisca_core/commons/formulas.py +++ b/openfisca_core/commons/formulas.py @@ -3,14 +3,14 @@ import numpy -from openfisca_core import types as t +from . import types as t def apply_thresholds( - input: t.Array[numpy.float_], + input: t.Array[t.ArrayFloat], thresholds: t.ArrayLike[float], choices: t.ArrayLike[float], -) -> t.Array[numpy.float_]: +) -> t.Array[t.ArrayBool]: """Makes a choice based on an input and thresholds. From a list of ``choices``, this function selects one of these values @@ -39,7 +39,7 @@ def apply_thresholds( """ - condlist: list[Union[t.Array[numpy.bool_], bool]] + condlist: list[Union[t.Array[t.ArrayBool], bool]] condlist = [input <= threshold for threshold in thresholds] if len(condlist) == len(choices) - 1: @@ -58,8 +58,8 @@ def apply_thresholds( def concat( - this: Union[t.Array[numpy.str_], t.ArrayLike[str]], - that: Union[t.Array[numpy.str_], t.ArrayLike[str]], + this: Union[t.Array[t.ArrayStr], t.ArrayLike[str]], + that: Union[t.Array[t.ArrayStr], t.ArrayLike[str]], ) -> t.Array[numpy.str_]: """Concatenates the values of two arrays. @@ -89,9 +89,9 @@ def concat( def switch( - conditions: t.Array[numpy.float_], + conditions: t.Array[t.ArrayFloat], value_by_condition: Mapping[float, float], -) -> t.Array[numpy.float_]: +) -> t.Array[t.ArrayFloat]: """Mimicks a switch statement. Given an array of conditions, returns an array of the same size, diff --git a/openfisca_core/commons/misc.py b/openfisca_core/commons/misc.py index 342bbbe5fb..ae66bec33d 100644 --- a/openfisca_core/commons/misc.py +++ b/openfisca_core/commons/misc.py @@ -1,7 +1,5 @@ from typing import Optional, TypeVar -import numpy - from openfisca_core import types as t T = TypeVar("T") @@ -45,7 +43,7 @@ def empty_clone(original: T) -> T: return new -def stringify_array(array: Optional[t.Array[numpy.generic]]) -> str: +def stringify_array(array: Optional[t.Array[t.ArrayAny]]) -> str: """Generates a clean string representation of a numpy array. Args: diff --git a/openfisca_core/commons/rates.py b/openfisca_core/commons/rates.py index 6df1f1fee2..ca34c06479 100644 --- a/openfisca_core/commons/rates.py +++ b/openfisca_core/commons/rates.py @@ -1,15 +1,15 @@ from typing import Optional -from openfisca_core.types import Array, ArrayLike - import numpy +from . import types as t + def average_rate( - target: Array[numpy.float_], - varying: ArrayLike[float], - trim: Optional[ArrayLike[float]] = None, -) -> Array[numpy.float_]: + target: t.Array[t.ArrayFloat], + varying: t.ArrayLike[float], + trim: Optional[t.ArrayLike[float]] = None, +) -> t.Array[t.ArrayFloat]: """Computes the average rate of a target net income. Given a ``target`` net income, and according to the ``varying`` gross @@ -41,7 +41,7 @@ def average_rate( """ - average_rate: Array[numpy.float_] + average_rate: t.Array[t.ArrayFloat] average_rate = 1 - target / varying @@ -62,10 +62,10 @@ def average_rate( def marginal_rate( - target: Array[numpy.float_], - varying: Array[numpy.float_], - trim: Optional[ArrayLike[float]] = None, -) -> Array[numpy.float_]: + target: t.Array[t.ArrayFloat], + varying: t.Array[t.ArrayFloat], + trim: Optional[t.ArrayLike[float]] = None, +) -> t.Array[t.ArrayFloat]: """Computes the marginal rate of a target net income. Given a ``target`` net income, and according to the ``varying`` gross @@ -97,7 +97,7 @@ def marginal_rate( """ - marginal_rate: Array[numpy.float_] + marginal_rate: t.Array[t.ArrayFloat] marginal_rate = +1 - (target[:-1] - target[1:]) / (varying[:-1] - varying[1:]) diff --git a/openfisca_core/commons/types.py b/openfisca_core/commons/types.py new file mode 100644 index 0000000000..8d02a39f44 --- /dev/null +++ b/openfisca_core/commons/types.py @@ -0,0 +1,6 @@ +from openfisca_core.types import Array as Array # noqa: F401 +from openfisca_core.types import ArrayAny as ArrayAny # noqa: F401 +from openfisca_core.types import ArrayBool as ArrayBool # noqa: F401 +from openfisca_core.types import ArrayFloat as ArrayFloat # noqa: F401 +from openfisca_core.types import ArrayLike as ArrayLike # noqa: F401 +from openfisca_core.types import ArrayStr as ArrayStr # noqa: F401 diff --git a/openfisca_core/indexed_enums/__init__.py b/openfisca_core/indexed_enums/__init__.py index 874a7e1f9b..03958426b9 100644 --- a/openfisca_core/indexed_enums/__init__.py +++ b/openfisca_core/indexed_enums/__init__.py @@ -21,6 +21,7 @@ # # See: https://www.python.org/dev/peps/pep-0008/#imports -from .config import ENUM_ARRAY_DTYPE # noqa: F401 -from .enum import Enum # noqa: F401 -from .enum_array import EnumArray # noqa: F401 +from .enum import Enum +from .enum_array import EnumArray + +__all__ = ["Enum", "EnumArray"] diff --git a/openfisca_core/indexed_enums/config.py b/openfisca_core/indexed_enums/config.py deleted file mode 100644 index f7da69b847..0000000000 --- a/openfisca_core/indexed_enums/config.py +++ /dev/null @@ -1,3 +0,0 @@ -import numpy - -ENUM_ARRAY_DTYPE = numpy.int16 diff --git a/openfisca_core/indexed_enums/enum.py b/openfisca_core/indexed_enums/enum.py index 7957ced3a2..4869e5ba5b 100644 --- a/openfisca_core/indexed_enums/enum.py +++ b/openfisca_core/indexed_enums/enum.py @@ -1,16 +1,12 @@ from __future__ import annotations -from typing import Union - -import enum - import numpy -from .config import ENUM_ARRAY_DTYPE +from . import types as t from .enum_array import EnumArray -class Enum(enum.Enum): +class Enum(t.Enum): """ Enum based on `enum34 `_, whose items have an index. @@ -21,7 +17,7 @@ def __init__(self, name: str) -> None: # When the enum item is initialized, self._member_names_ contains the # names of the previously initialized items, so its length is the index # of this item. - self.index = len(self._member_names_) + self.index = len(self._member_names_) # type: ignore[attr-defined] # Bypass the slow Enum.__eq__ __eq__ = object.__eq__ @@ -33,13 +29,14 @@ def __init__(self, name: str) -> None: @classmethod def encode( cls, - array: Union[ - EnumArray, - numpy.int_, - numpy.float_, - numpy.object_, - ], - ) -> EnumArray: + array: ( + t.EnumArray + | t.Array[t.ArrayBytes] + | t.Array[t.ArrayEnum] + | t.Array[t.ArrayInt] + | t.Array[t.ArrayStr] + ), + ) -> t.EnumArray: """ Encode a string numpy array, an enum item numpy array, or an int numpy array into an :any:`EnumArray`. See :any:`EnumArray.decode` for @@ -65,6 +62,7 @@ def encode( >>> encoded_array[0] 2 # Encoded value """ + if isinstance(array, EnumArray): return array @@ -73,7 +71,7 @@ def encode( array = numpy.select( [array == item.name for item in cls], [item.index for item in cls], - ).astype(ENUM_ARRAY_DTYPE) + ).astype(t.ArrayEnum) # Enum items arrays elif isinstance(array, numpy.ndarray) and array.dtype.kind == "O": @@ -94,6 +92,7 @@ def encode( array = numpy.select( [array == item for item in cls], [item.index for item in cls], - ).astype(ENUM_ARRAY_DTYPE) + ).astype(t.ArrayEnum) + array = numpy.asarray(array, dtype=t.ArrayEnum) return EnumArray(array, cls) diff --git a/openfisca_core/indexed_enums/enum_array.py b/openfisca_core/indexed_enums/enum_array.py index 2742719ada..de6f83df4c 100644 --- a/openfisca_core/indexed_enums/enum_array.py +++ b/openfisca_core/indexed_enums/enum_array.py @@ -1,15 +1,14 @@ from __future__ import annotations -import typing -from typing import Any, NoReturn, Optional, Type +from typing import NoReturn, overload +from typing_extensions import TypeGuard import numpy -if typing.TYPE_CHECKING: - from openfisca_core.indexed_enums import Enum +from . import types as t -class EnumArray(numpy.ndarray): +class EnumArray(t.EnumArray): """ NumPy array subclass representing an array of enum items. @@ -19,51 +18,86 @@ class EnumArray(numpy.ndarray): # Subclassing ndarray is a little tricky. # To read more about the two following methods, see: # https://docs.scipy.org/doc/numpy-1.13.0/user/basics.subclassing.html#slightly-more-realistic-example-attribute-added-to-existing-array. + + #: Enum type of the array items. + possible_values: None | type[t.Enum] = None + def __new__( cls, - input_array: numpy.int_, - possible_values: Optional[Type[Enum]] = None, + input_array: t.Array[t.ArrayEnum], + possible_values: None | type[t.Enum] = None, ) -> EnumArray: obj = numpy.asarray(input_array).view(cls) obj.possible_values = possible_values return obj # See previous comment - def __array_finalize__(self, obj: Optional[numpy.int_]) -> None: + def __array_finalize__(self, obj: None | t.EnumArray | t.Array[t.ArrayAny]) -> None: if obj is None: - return + return None + if isinstance(obj, EnumArray): + self.possible_values = obj.possible_values + return None + + @overload # type: ignore[override] + def __eq__(self, other: None | t.Enum | type[t.Enum]) -> t.Array[t.ArrayBool]: + ... + + @overload + def __eq__(self, other: object) -> t.Array[t.ArrayBool] | bool: + ... + + def __eq__(self, other: object) -> t.Array[t.ArrayBool] | bool: + boolean_array: t.Array[t.ArrayBool] + boolean: bool - self.possible_values = getattr(obj, "possible_values", None) + if self.possible_values is None: + return NotImplemented + + view: t.Array[t.ArrayEnum] = self.view(numpy.ndarray) + + if other is None or self._is_an_enum_type(other): + boolean_array = view == other + return boolean_array - def __eq__(self, other: Any) -> bool: # When comparing to an item of self.possible_values, use the item index # to speed up the comparison. - if other.__class__.__name__ is self.possible_values.__name__: + if self._is_an_enum(other): # Use view(ndarray) so that the result is a classic ndarray, not an # EnumArray. - return self.view(numpy.ndarray) == other.index + boolean_array = view == other.index + return boolean_array + + boolean = view == other + return boolean - return self.view(numpy.ndarray) == other + @overload # type: ignore[override] + def __ne__(self, other: None | t.Enum | type[t.Enum]) -> t.Array[t.ArrayBool]: + ... - def __ne__(self, other: Any) -> bool: + @overload + def __ne__(self, other: object) -> t.Array[t.ArrayBool] | bool: + ... + + def __ne__(self, other: object) -> t.Array[t.ArrayBool] | bool: return numpy.logical_not(self == other) - def _forbidden_operation(self, other: Any) -> NoReturn: + def _forbidden_operation(self, other: object) -> NoReturn: raise TypeError( "Forbidden operation. The only operations allowed on EnumArrays " "are '==' and '!='.", ) - __add__ = _forbidden_operation - __mul__ = _forbidden_operation - __lt__ = _forbidden_operation - __le__ = _forbidden_operation - __gt__ = _forbidden_operation - __ge__ = _forbidden_operation - __and__ = _forbidden_operation - __or__ = _forbidden_operation + __add__ = _forbidden_operation # type: ignore[assignment] + __mul__ = _forbidden_operation # type: ignore[assignment] + __lt__ = _forbidden_operation # type: ignore[assignment] + __le__ = _forbidden_operation # type: ignore[assignment] + __gt__ = _forbidden_operation # type: ignore[assignment] + __ge__ = _forbidden_operation # type: ignore[assignment] + __and__ = _forbidden_operation # type: ignore[assignment] + __or__ = _forbidden_operation # type: ignore[assignment] - def decode(self) -> numpy.object_: + def decode(self) -> t.Array[t.ArrayEnum]: """ Return the array of enum items corresponding to self. @@ -77,12 +111,16 @@ def decode(self) -> numpy.object_: Decoded value: enum item """ + + if self.possible_values is None: + raise NotImplementedError + return numpy.select( [self == item.index for item in self.possible_values], - list(self.possible_values), + [item for item in self.possible_values], # type: ignore[misc] ) - def decode_to_str(self) -> numpy.str_: + def decode_to_str(self) -> t.Array[t.ArrayStr]: """ Return the array of string identifiers corresponding to self. @@ -94,6 +132,10 @@ def decode_to_str(self) -> numpy.str_: >>> enum_array.decode_to_str()[0] 'free_lodger' # String identifier """ + + if self.possible_values is None: + raise NotImplementedError + return numpy.select( [self == item.index for item in self.possible_values], [item.name for item in self.possible_values], @@ -104,3 +146,21 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self.decode_to_str()) + + def _is_an_enum(self, other: object) -> TypeGuard[t.Enum]: + if self.possible_values is None: + raise NotImplementedError + + return ( + not hasattr(other, "__name__") + and other.__class__.__name__ is self.possible_values.__name__ + ) + + def _is_an_enum_type(self, other: object) -> TypeGuard[type[t.Enum]]: + if self.possible_values is None: + raise NotImplementedError + + return ( + hasattr(other, "__name__") + and other.__name__ is self.possible_values.__name__ + ) diff --git a/openfisca_core/indexed_enums/py.typed b/openfisca_core/indexed_enums/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/indexed_enums/types.py b/openfisca_core/indexed_enums/types.py new file mode 100644 index 0000000000..57657b8c56 --- /dev/null +++ b/openfisca_core/indexed_enums/types.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from openfisca_core.types import Array as Array +from openfisca_core.types import ArrayAny as ArrayAny # noqa: F401 +from openfisca_core.types import ArrayBool as ArrayBool # noqa: F401 +from openfisca_core.types import ArrayBytes as ArrayBytes # noqa: F401 +from openfisca_core.types import ArrayEnum as ArrayEnum +from openfisca_core.types import ArrayInt as ArrayInt # noqa: F401 +from openfisca_core.types import ArrayStr as ArrayStr # noqa: F401 + +import abc +import enum + +# Indexed enums + + +class Enum(enum.Enum): + index: int + + +class EnumArray(Array[ArrayEnum], metaclass=abc.ABCMeta): + ... diff --git a/openfisca_core/periods/__init__.py b/openfisca_core/periods/__init__.py index 4669c7ff4f..1f2e68acaf 100644 --- a/openfisca_core/periods/__init__.py +++ b/openfisca_core/periods/__init__.py @@ -21,20 +21,15 @@ # # See: https://www.python.org/dev/peps/pep-0008/#imports -from .config import ( # noqa: F401 - DAY, - ETERNITY, +from . import types +from .config import ( INSTANT_PATTERN, - MONTH, - WEEK, - WEEKDAY, - YEAR, date_by_instant_cache, str_by_instant_cache, year_or_month_or_day_re, ) -from .date_unit import DateUnit # noqa: F401 -from .helpers import ( # noqa: F401 +from .date_unit import DateUnit +from .helpers import ( instant, instant_date, key_period_size, @@ -42,5 +37,39 @@ unit_weight, unit_weights, ) -from .instant_ import Instant # noqa: F401 -from .period_ import Period # noqa: F401 +from .instant_ import Instant +from .period_ import Period + +WEEKDAY = DateUnit.WEEKDAY +WEEK = DateUnit.WEEK +DAY = DateUnit.DAY +MONTH = DateUnit.MONTH +YEAR = DateUnit.YEAR +ETERNITY = DateUnit.ETERNITY +ISOFORMAT = DateUnit.isoformat +ISOCALENDAR = DateUnit.isocalendar + +__all__ = [ + "INSTANT_PATTERN", + "date_by_instant_cache", + "str_by_instant_cache", + "year_or_month_or_day_re", + "DateUnit", + "instant", + "instant_date", + "key_period_size", + "period", + "unit_weight", + "unit_weights", + "Instant", + "Period", + "WEEKDAY", + "WEEK", + "DAY", + "MONTH", + "YEAR", + "ETERNITY", + "ISOFORMAT", + "ISOCALENDAR", + "types", +] diff --git a/openfisca_core/periods/_parsers.py b/openfisca_core/periods/_parsers.py index 64b2077831..0342cc71ed 100644 --- a/openfisca_core/periods/_parsers.py +++ b/openfisca_core/periods/_parsers.py @@ -6,6 +6,7 @@ from pendulum.datetime import Date from pendulum.parsing import ParserError +from . import types as t from .date_unit import DateUnit from .instant_ import Instant from .period_ import Period @@ -13,7 +14,7 @@ invalid_week = re.compile(r".*(W[1-9]|W[1-9]-[0-9]|W[0-5][0-9]-0)$") -def _parse_period(value: str) -> Optional[Period]: +def _parse_period(value: str) -> Optional[t.Period]: """Parses ISO format/calendar periods. Such as "2012" or "2015-03". @@ -57,7 +58,7 @@ def _parse_period(value: str) -> Optional[Period]: return Period((unit, instant, 1)) -def _parse_unit(value: str) -> DateUnit: +def _parse_unit(value: str) -> t.DateUnit: """Determine the date unit of a date string. Args: diff --git a/openfisca_core/periods/config.py b/openfisca_core/periods/config.py index 17807160e4..3b4c0a7eb0 100644 --- a/openfisca_core/periods/config.py +++ b/openfisca_core/periods/config.py @@ -1,13 +1,8 @@ import re -from .date_unit import DateUnit +from pendulum.datetime import Date -WEEKDAY = DateUnit.WEEKDAY -WEEK = DateUnit.WEEK -DAY = DateUnit.DAY -MONTH = DateUnit.MONTH -YEAR = DateUnit.YEAR -ETERNITY = DateUnit.ETERNITY +from . import types as t # Matches "2015", "2015-01", "2015-01-01" # Does not match "2015-13", "2015-12-32" @@ -15,8 +10,8 @@ r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$" ) -date_by_instant_cache: dict = {} -str_by_instant_cache: dict = {} +date_by_instant_cache: dict[t.Instant, Date] = {} +str_by_instant_cache: dict[t.Instant, t.InstantStr] = {} year_or_month_or_day_re = re.compile( r"(18|19|20)\d{2}(-(0?[1-9]|1[0-2])(-([0-2]?\d|3[0-1]))?)?$" ) diff --git a/openfisca_core/periods/date_unit.py b/openfisca_core/periods/date_unit.py index a813211495..13d93f8a24 100644 --- a/openfisca_core/periods/date_unit.py +++ b/openfisca_core/periods/date_unit.py @@ -4,10 +4,12 @@ from strenum import StrEnum +from . import types as t + class DateUnitMeta(EnumMeta): @property - def isoformat(self) -> tuple[DateUnit, ...]: + def isoformat(self) -> tuple[t.DateUnit, ...]: """Creates a :obj:`tuple` of ``key`` with isoformat items. Returns: @@ -28,7 +30,7 @@ def isoformat(self) -> tuple[DateUnit, ...]: return DateUnit.DAY, DateUnit.MONTH, DateUnit.YEAR @property - def isocalendar(self) -> tuple[DateUnit, ...]: + def isocalendar(self) -> tuple[t.DateUnit, ...]: """Creates a :obj:`tuple` of ``key`` with isocalendar items. Returns: @@ -96,6 +98,11 @@ class DateUnit(StrEnum, metaclass=DateUnitMeta): """ + def __contains__(self, other: object) -> bool: + if isinstance(other, str): + return super().__contains__(other) + return NotImplemented + WEEKDAY = "weekday" WEEK = "week" DAY = "day" diff --git a/openfisca_core/periods/helpers.py b/openfisca_core/periods/helpers.py index 2ce4e0cd35..ce27c54d0d 100644 --- a/openfisca_core/periods/helpers.py +++ b/openfisca_core/periods/helpers.py @@ -1,4 +1,6 @@ -from typing import NoReturn, Optional +from __future__ import annotations + +from typing import NoReturn, Optional, overload import datetime import os @@ -7,12 +9,23 @@ from pendulum.parsing import ParserError from . import _parsers, config +from . import types as t from .date_unit import DateUnit from .instant_ import Instant from .period_ import Period -def instant(instant) -> Optional[Instant]: +@overload +def instant(instant: None) -> None: + ... + + +@overload +def instant(instant: object) -> Instant: + ... + + +def instant(instant: object | None) -> t.Instant | None: """Build a new instant, aka a triple of integers (year, month, day). Args: @@ -49,6 +62,8 @@ def instant(instant) -> Optional[Instant]: """ + result: t.Instant | tuple[int, ...] + if instant is None: return None if isinstance(instant, Instant): @@ -58,27 +73,28 @@ def instant(instant) -> Optional[Instant]: raise ValueError( f"'{instant}' is not a valid instant. Instants are described using the 'YYYY-MM-DD' format, for instance '2015-06-15'." ) - instant = Instant(int(fragment) for fragment in instant.split("-", 2)[:3]) + result = Instant(int(fragment) for fragment in instant.split("-", 2)[:3]) elif isinstance(instant, datetime.date): - instant = Instant((instant.year, instant.month, instant.day)) + result = Instant((instant.year, instant.month, instant.day)) elif isinstance(instant, int): - instant = (instant,) + result = (instant,) elif isinstance(instant, list): assert 1 <= len(instant) <= 3 - instant = tuple(instant) + result = tuple(instant) elif isinstance(instant, Period): - instant = instant.start + result = instant.start else: assert isinstance(instant, tuple), instant assert 1 <= len(instant) <= 3 - if len(instant) == 1: - return Instant((instant[0], 1, 1)) - if len(instant) == 2: - return Instant((instant[0], instant[1], 1)) - return Instant(instant) + result = instant + if len(result) == 1: + return Instant((result[0], 1, 1)) + if len(result) == 2: + return Instant((result[0], result[1], 1)) + return Instant(result) -def instant_date(instant: Optional[Instant]) -> Optional[datetime.date]: +def instant_date(instant: Optional[t.Instant]) -> Optional[datetime.date]: """Returns the date representation of an :class:`.Instant`. Args: @@ -105,7 +121,7 @@ def instant_date(instant: Optional[Instant]) -> Optional[datetime.date]: return instant_date -def period(value) -> Period: +def period(value: object) -> t.Period: """Build a new period, aka a triple (unit, start_instant, size). Args: @@ -125,7 +141,7 @@ def period(value) -> Period: Period((, Instant((2021, 1, 1)), 1)) >>> period(DateUnit.ETERNITY) - Period((, Instant((1, 1, 1)), inf)) + Period((, Instant((1, 1, 1)), 0)) >>> period(2021) Period((, Instant((2021, 1, 1)), 1)) @@ -164,15 +180,9 @@ def period(value) -> Period: return Period((DateUnit.DAY, instant(value), 1)) # We return an "eternity-period", for example - # ``, inf))>``. + # ``, 0))>``. if str(value).lower() == DateUnit.ETERNITY: - return Period( - ( - DateUnit.ETERNITY, - instant(datetime.date.min), - float("inf"), - ) - ) + return Period.eternity() # For example ``2021`` gives # ``, 1))>``. @@ -181,7 +191,7 @@ def period(value) -> Period: # Up to this point, if ``value`` is not a :obj:`str`, we desist. if not isinstance(value, str): - _raise_error(value) + _raise_error(str(value)) # There can't be empty strings. if not value: @@ -267,7 +277,7 @@ def _raise_error(value: str) -> NoReturn: raise ValueError(message) -def key_period_size(period: Period) -> str: +def key_period_size(period: t.Period) -> str: """Define a key in order to sort periods by length. It uses two aspects: first, ``unit``, then, ``size``. @@ -291,12 +301,10 @@ def key_period_size(period: Period) -> str: """ - unit, start, size = period - - return f"{unit_weight(unit)}_{size}" + return f"{unit_weight(period.unit)}_{period.size}" -def unit_weights() -> dict[str, int]: +def unit_weights() -> dict[t.DateUnit, int]: """Assign weights to date units. Examples: @@ -315,7 +323,7 @@ def unit_weights() -> dict[str, int]: } -def unit_weight(unit: str) -> int: +def unit_weight(unit: t.DateUnit) -> int: """Retrieves a specific date unit weight. Examples: diff --git a/openfisca_core/periods/instant_.py b/openfisca_core/periods/instant_.py index 9d0893ba41..5eed50c452 100644 --- a/openfisca_core/periods/instant_.py +++ b/openfisca_core/periods/instant_.py @@ -1,10 +1,14 @@ +from __future__ import annotations + import pendulum +from pendulum.datetime import Date from . import config +from . import types as t from .date_unit import DateUnit -class Instant(tuple): +class Instant(tuple[int, int, int]): """An instant in time (year, month, day). An :class:`.Instant` represents the most atomic and indivisible @@ -13,10 +17,6 @@ class Instant(tuple): Current implementation considers this unit to be a day, so :obj:`instants <.Instant>` can be thought of as "day dates". - Args: - (tuple(tuple(int, int, int))): - The ``year``, ``month``, and ``day``, accordingly. - Examples: >>> instant = Instant((2021, 9, 13)) @@ -78,35 +78,51 @@ class Instant(tuple): """ - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({super().__repr__()})" - def __str__(self): + def __str__(self) -> t.InstantStr: instant_str = config.str_by_instant_cache.get(self) if instant_str is None: - config.str_by_instant_cache[self] = instant_str = self.date.isoformat() + instant_str = t.InstantStr(self.date.isoformat()) + config.str_by_instant_cache[self] = instant_str return instant_str + def __lt__(self, other: object) -> bool: + if isinstance(other, Instant): + return super().__lt__(other) + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, Instant): + return super().__le__(other) + return NotImplemented + @property - def date(self): + def date(self) -> Date: instant_date = config.date_by_instant_cache.get(self) if instant_date is None: - config.date_by_instant_cache[self] = instant_date = pendulum.date(*self) + instant_date = pendulum.date(*self) + config.date_by_instant_cache[self] = instant_date return instant_date @property - def day(self): + def day(self) -> int: return self[2] @property - def month(self): + def month(self) -> int: return self[1] - def offset(self, offset, unit): + @property + def year(self) -> int: + return self[0] + + def offset(self, offset: str | int, unit: t.DateUnit) -> t.Instant | None: """Increments/decrements the given instant with offset units. Args: @@ -193,6 +209,9 @@ def offset(self, offset, unit): date = date.add(days=offset) return self.__class__((date.year, date.month, date.day)) - @property - def year(self): - return self[0] + return None + + @classmethod + def eternity(cls) -> t.Instant: + """Return an eternity instant.""" + return cls((1, 1, 1)) diff --git a/openfisca_core/periods/period_.py b/openfisca_core/periods/period_.py index 11a7b671b4..39884d9002 100644 --- a/openfisca_core/periods/period_.py +++ b/openfisca_core/periods/period_.py @@ -1,22 +1,19 @@ from __future__ import annotations -import typing from collections.abc import Sequence import calendar import datetime -import pendulum +from pendulum.datetime import Date from . import helpers +from . import types as t from .date_unit import DateUnit from .instant_ import Instant -if typing.TYPE_CHECKING: - from pendulum.datetime import Date - -class Period(tuple): +class Period(tuple[t.DateUnit, t.Instant, int]): """Toolbox to handle date intervals. A :class:`.Period` is a triple (``unit``, ``start``, ``size``). @@ -124,11 +121,11 @@ class Period(tuple): def __repr__(self) -> str: return f"{self.__class__.__name__}({super().__repr__()})" - def __str__(self) -> str: + def __str__(self) -> t.PeriodStr: unit, start_instant, size = self if unit == DateUnit.ETERNITY: - return unit.upper() + return t.PeriodStr(unit.upper()) # ISO format date units. f_year, month, day = start_instant @@ -140,58 +137,58 @@ def __str__(self) -> str: if unit == DateUnit.MONTH and size == 12 or unit == DateUnit.YEAR and size == 1: if month == 1: # civil year starting from january - return str(f_year) + return t.PeriodStr(str(f_year)) else: # rolling year - return f"{DateUnit.YEAR}:{f_year}-{month:02d}" + return t.PeriodStr(f"{DateUnit.YEAR}:{f_year}-{month:02d}") # simple month if unit == DateUnit.MONTH and size == 1: - return f"{f_year}-{month:02d}" + return t.PeriodStr(f"{f_year}-{month:02d}") # several civil years if unit == DateUnit.YEAR and month == 1: - return f"{unit}:{f_year}:{size}" + return t.PeriodStr(f"{unit}:{f_year}:{size}") if unit == DateUnit.DAY: if size == 1: - return f"{f_year}-{month:02d}-{day:02d}" + return t.PeriodStr(f"{f_year}-{month:02d}-{day:02d}") else: - return f"{unit}:{f_year}-{month:02d}-{day:02d}:{size}" + return t.PeriodStr(f"{unit}:{f_year}-{month:02d}-{day:02d}:{size}") # 1 week if unit == DateUnit.WEEK and size == 1: if week < 10: - return f"{c_year}-W0{week}" + return t.PeriodStr(f"{c_year}-W0{week}") - return f"{c_year}-W{week}" + return t.PeriodStr(f"{c_year}-W{week}") # several weeks if unit == DateUnit.WEEK and size > 1: if week < 10: - return f"{unit}:{c_year}-W0{week}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W0{week}:{size}") - return f"{unit}:{c_year}-W{week}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W{week}:{size}") # 1 weekday if unit == DateUnit.WEEKDAY and size == 1: if week < 10: - return f"{c_year}-W0{week}-{weekday}" + return t.PeriodStr(f"{c_year}-W0{week}-{weekday}") - return f"{c_year}-W{week}-{weekday}" + return t.PeriodStr(f"{c_year}-W{week}-{weekday}") # several weekdays if unit == DateUnit.WEEKDAY and size > 1: if week < 10: - return f"{unit}:{c_year}-W0{week}-{weekday}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W0{week}-{weekday}:{size}") - return f"{unit}:{c_year}-W{week}-{weekday}:{size}" + return t.PeriodStr(f"{unit}:{c_year}-W{week}-{weekday}:{size}") # complex period - return f"{unit}:{f_year}-{month:02d}:{size}" + return t.PeriodStr(f"{unit}:{f_year}-{month:02d}:{size}") @property - def unit(self) -> str: + def unit(self) -> t.DateUnit: """The ``unit`` of the ``Period``. Example: @@ -205,7 +202,7 @@ def unit(self) -> str: return self[0] @property - def start(self) -> Instant: + def start(self) -> t.Instant: """The ``Instant`` at which the ``Period`` starts. Example: @@ -322,7 +319,12 @@ def size_in_days(self) -> int: """ if self.unit in (DateUnit.YEAR, DateUnit.MONTH): - last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + last = self.start.offset(self.size, self.unit) + if last is None: + raise NotImplementedError + last_day = last.offset(-1, DateUnit.DAY) + if last_day is None: + raise NotImplementedError return (last_day.date - self.start.date).days + 1 if self.unit == DateUnit.WEEK: @@ -334,7 +336,7 @@ def size_in_days(self) -> int: raise ValueError(f"Can't calculate number of days in a {self.unit}.") @property - def size_in_weeks(self): + def size_in_weeks(self) -> int: """The ``size`` of the ``Period`` in weeks. Examples: @@ -353,14 +355,14 @@ def size_in_weeks(self): if self.unit == DateUnit.YEAR: start = self.start.date cease = start.add(years=self.size) - delta = pendulum.period(start, cease) - return delta.as_interval().weeks + delta = start.diff(cease) + return delta.in_weeks() if self.unit == DateUnit.MONTH: start = self.start.date cease = start.add(months=self.size) - delta = pendulum.period(start, cease) - return delta.as_interval().weeks + delta = start.diff(cease) + return delta.in_weeks() if self.unit == DateUnit.WEEK: return self.size @@ -368,7 +370,7 @@ def size_in_weeks(self): raise ValueError(f"Can't calculate number of weeks in a {self.unit}.") @property - def size_in_weekdays(self): + def size_in_weekdays(self) -> int: """The ``size`` of the ``Period`` in weekdays. Examples: @@ -387,8 +389,13 @@ def size_in_weekdays(self): if self.unit == DateUnit.YEAR: return self.size_in_weeks * 7 - if self.unit in DateUnit.MONTH: - last_day = self.start.offset(self.size, self.unit).offset(-1, DateUnit.DAY) + if DateUnit.MONTH in self.unit: + last = self.start.offset(self.size, self.unit) + if last is None: + raise NotImplementedError + last_day = last.offset(-1, DateUnit.DAY) + if last_day is None: + raise NotImplementedError return (last_day.date - self.start.date).days + 1 if self.unit == DateUnit.WEEK: @@ -400,11 +407,13 @@ def size_in_weekdays(self): raise ValueError(f"Can't calculate number of weekdays in a {self.unit}.") @property - def days(self): + def days(self) -> int: """Same as ``size_in_days``.""" return (self.stop.date - self.start.date).days + 1 - def intersection(self, start, stop): + def intersection( + self, start: t.Instant | None, stop: t.Instant | None + ) -> t.Period | None: if start is None and stop is None: return self period_start = self[1] @@ -457,7 +466,7 @@ def intersection(self, start, stop): ) ) - def get_subperiods(self, unit: DateUnit) -> Sequence[Period]: + def get_subperiods(self, unit: t.DateUnit) -> Sequence[t.Period]: """Return the list of periods of unit ``unit`` contained in self. Examples: @@ -502,7 +511,7 @@ def get_subperiods(self, unit: DateUnit) -> Sequence[Period]: raise ValueError(f"Cannot subdivide {self.unit} into {unit}") - def offset(self, offset, unit=None): + def offset(self, offset: str | int, unit: t.DateUnit | None = None) -> t.Period: """Increment (or decrement) the given period with offset units. Examples: @@ -676,15 +685,22 @@ def offset(self, offset, unit=None): """ + start: None | t.Instant = self[1].offset( + offset, self[0] if unit is None else unit + ) + + if start is None: + raise NotImplementedError + return self.__class__( ( self[0], - self[1].offset(offset, self[0] if unit is None else unit), + start, self[2], ) ) - def contains(self, other: Period) -> bool: + def contains(self, other: t.Period) -> bool: """Returns ``True`` if the period contains ``other``. For instance, ``period(2015)`` contains ``period(2015-01)``. @@ -694,7 +710,7 @@ def contains(self, other: Period) -> bool: return self.start <= other.start and self.stop >= other.stop @property - def stop(self) -> Instant: + def stop(self) -> t.Instant: """Return the last day of the period as an Instant instance. Examples: @@ -731,7 +747,7 @@ def stop(self) -> Instant: year, month, day = start_instant if unit == DateUnit.ETERNITY: - return Instant((float("inf"), float("inf"), float("inf"))) + return Instant.eternity() elif unit == DateUnit.YEAR: date = start_instant.date.add(years=size, days=-1) @@ -755,67 +771,82 @@ def stop(self) -> Instant: # Reference periods @property - def last_week(self) -> Period: + def last_week(self) -> t.Period: return self.first_week.offset(-1) @property - def last_fortnight(self) -> Period: - start: Instant = self.first_week.start + def last_fortnight(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 1)).offset(-2) @property - def last_2_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_2_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 2)).offset(-2) @property - def last_26_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_26_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 26)).offset(-26) @property - def last_52_weeks(self) -> Period: - start: Instant = self.first_week.start + def last_52_weeks(self) -> t.Period: + start: t.Instant = self.first_week.start return self.__class__((DateUnit.WEEK, start, 52)).offset(-52) @property - def last_month(self) -> Period: + def last_month(self) -> t.Period: return self.first_month.offset(-1) @property - def last_3_months(self) -> Period: - start: Instant = self.first_month.start + def last_3_months(self) -> t.Period: + start: t.Instant = self.first_month.start return self.__class__((DateUnit.MONTH, start, 3)).offset(-3) @property - def last_year(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def last_year(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)).offset(-1) @property - def n_2(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def n_2(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)).offset(-2) @property - def this_year(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.YEAR) + def this_year(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.YEAR) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.YEAR, start, 1)) @property - def first_month(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.MONTH) + def first_month(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.MONTH) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.MONTH, start, 1)) @property - def first_day(self) -> Period: + def first_day(self) -> t.Period: return self.__class__((DateUnit.DAY, self.start, 1)) @property - def first_week(self) -> Period: - start: Instant = self.start.offset("first-of", DateUnit.WEEK) + def first_week(self) -> t.Period: + start: None | t.Instant = self.start.offset("first-of", DateUnit.WEEK) + if start is None: + raise NotImplementedError return self.__class__((DateUnit.WEEK, start, 1)) @property - def first_weekday(self) -> Period: + def first_weekday(self) -> t.Period: return self.__class__((DateUnit.WEEKDAY, self.start, 1)) + + @classmethod + def eternity(cls) -> t.Period: + """Return an eternity period.""" + return cls((DateUnit.ETERNITY, Instant.eternity(), 0)) diff --git a/openfisca_core/periods/py.typed b/openfisca_core/periods/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/periods/tests/helpers/test_helpers.py b/openfisca_core/periods/tests/helpers/test_helpers.py index bb409323d1..1a351ce835 100644 --- a/openfisca_core/periods/tests/helpers/test_helpers.py +++ b/openfisca_core/periods/tests/helpers/test_helpers.py @@ -47,9 +47,19 @@ def test_instant_date_with_an_invalid_argument(arg, error): [Period((DateUnit.MONTH, Instant((1, 1, 1)), 12)), "200_12"], [Period((DateUnit.YEAR, Instant((1, 1, 1)), 2)), "300_2"], [Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 1)), "400_1"], - [(DateUnit.DAY, None, 1), "100_1"], - [(DateUnit.MONTH, None, -1000), "200_-1000"], ], ) def test_key_period_size(arg, expected): assert periods.key_period_size(arg) == expected + + +@pytest.mark.parametrize( + "arg, error", + [ + [(DateUnit.DAY, None, 1), AttributeError], + [(DateUnit.MONTH, None, -1000), AttributeError], + ], +) +def test_key_period_size_when_an_invalid_argument(arg, error): + with pytest.raises(error): + periods.key_period_size(arg) diff --git a/openfisca_core/periods/tests/helpers/test_period.py b/openfisca_core/periods/tests/helpers/test_period.py index 7d50abe102..33aaac2e2b 100644 --- a/openfisca_core/periods/tests/helpers/test_period.py +++ b/openfisca_core/periods/tests/helpers/test_period.py @@ -9,11 +9,11 @@ @pytest.mark.parametrize( "arg, expected", [ - ["eternity", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))], - ["ETERNITY", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf")))], + ["eternity", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 0))], + ["ETERNITY", Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 0))], [ DateUnit.ETERNITY, - Period((DateUnit.ETERNITY, Instant((1, 1, 1)), float("inf"))), + Period((DateUnit.ETERNITY, Instant((1, 1, 1)), 0)), ], [datetime.date(1, 1, 1), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))], [Instant((1, 1, 1)), Period((DateUnit.DAY, Instant((1, 1, 1)), 1))], diff --git a/openfisca_core/periods/types.py b/openfisca_core/periods/types.py new file mode 100644 index 0000000000..26a6e096d0 --- /dev/null +++ b/openfisca_core/periods/types.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import NewType, Protocol + +from pendulum.datetime import Date + +from openfisca_core import types as t + +# New types. + +#: For example "2000-01". +InstantStr = NewType("InstantStr", str) + +#: For example "1:2000-01-01:day". +PeriodStr = NewType("PeriodStr", str) + + +# Periods + + +class DateUnit(t.DateUnit, Protocol): + ... + + +class Instant(t.Instant, Protocol): + @property + def year(self) -> int: + ... + + @property + def month(self) -> int: + ... + + @property + def day(self) -> int: + ... + + @property + def date(self) -> Date: + ... + + def __lt__(self, other: object, /) -> bool: + ... + + def __le__(self, other: object, /) -> bool: + ... + + def offset(self, offset: str | int, unit: DateUnit) -> Instant | None: + ... + + +class Period(t.Period, Protocol): + @property + def size(self) -> int: + ... + + @property + def start(self) -> Instant: + ... + + @property + def stop(self) -> Instant: + ... + + def offset(self, offset: str | int, unit: DateUnit | None = None) -> Period: + ... diff --git a/openfisca_core/populations/population.py b/openfisca_core/populations/population.py index e3ef6b209a..4cdd7abcff 100644 --- a/openfisca_core/populations/population.py +++ b/openfisca_core/populations/population.py @@ -85,7 +85,7 @@ def check_period_validity( variable_name: str, period: Optional[Union[int, str, Period]], ) -> None: - if isinstance(period, (int, str, Period)): + if isinstance(period, (int, str, periods.Period)): return None stack = traceback.extract_stack() diff --git a/openfisca_core/projectors/__init__.py b/openfisca_core/projectors/__init__.py index 28776e3cf9..9582510828 100644 --- a/openfisca_core/projectors/__init__.py +++ b/openfisca_core/projectors/__init__.py @@ -21,7 +21,7 @@ # # See: https://www.python.org/dev/peps/pep-0008/#imports -from . import typing +from . import types from .entity_to_person_projector import EntityToPersonProjector from .first_person_to_entity_projector import FirstPersonToEntityProjector from .helpers import get_projector_from_shortcut, projectable @@ -35,5 +35,5 @@ "projectable", "Projector", "UniqueRoleToEntityProjector", - "typing", + "types", ] diff --git a/openfisca_core/projectors/entity_to_person_projector.py b/openfisca_core/projectors/entity_to_person_projector.py index ca6245a1f7..392fda08a1 100644 --- a/openfisca_core/projectors/entity_to_person_projector.py +++ b/openfisca_core/projectors/entity_to_person_projector.py @@ -4,7 +4,7 @@ class EntityToPersonProjector(Projector): """For instance person.family.""" - def __init__(self, entity, parent=None): + def __init__(self, entity, parent=None) -> None: self.reference_entity = entity self.parent = parent diff --git a/openfisca_core/projectors/first_person_to_entity_projector.py b/openfisca_core/projectors/first_person_to_entity_projector.py index 4b4e7b7994..d986460cdc 100644 --- a/openfisca_core/projectors/first_person_to_entity_projector.py +++ b/openfisca_core/projectors/first_person_to_entity_projector.py @@ -4,7 +4,7 @@ class FirstPersonToEntityProjector(Projector): """For instance famille.first_person.""" - def __init__(self, entity, parent=None): + def __init__(self, entity, parent=None) -> None: self.target_entity = entity self.reference_entity = entity.members self.parent = parent diff --git a/openfisca_core/projectors/helpers.py b/openfisca_core/projectors/helpers.py index b3b7e6f2d3..d3d8a21c7b 100644 --- a/openfisca_core/projectors/helpers.py +++ b/openfisca_core/projectors/helpers.py @@ -1,12 +1,10 @@ from __future__ import annotations -from collections.abc import Mapping - -from openfisca_core.types import GroupEntity, Role, SingleEntity +from collections.abc import Iterable, Mapping from openfisca_core import entities, projectors -from .typing import GroupPopulation, Population +from . import types as t def projectable(function): @@ -19,10 +17,10 @@ def projectable(function): def get_projector_from_shortcut( - population: Population | GroupPopulation, + population: t.CorePopulation, shortcut: str, - parent: projectors.Projector | None = None, -) -> projectors.Projector | None: + parent: t.Projector | None = None, +) -> t.Projector | None: """Get a projector from a shortcut. Projectors are used to project an invidividual Population's or a @@ -46,7 +44,7 @@ def get_projector_from_shortcut( of a specific Simulation and TaxBenefitSystem. Args: - population (Population | GroupPopulation): Where to project from. + population (SinglePopulation | GroupPopulation): Where to project from. shortcut (str): Where to project to. parent: ??? @@ -110,12 +108,10 @@ def get_projector_from_shortcut( """ - entity: SingleEntity | GroupEntity = population.entity + entity: t.CoreEntity = population.entity if isinstance(entity, entities.Entity): - populations: Mapping[ - str, Population | GroupPopulation - ] = population.simulation.populations + populations: Mapping[str, t.CorePopulation] = population.simulation.populations if shortcut not in populations.keys(): return None @@ -126,7 +122,8 @@ def get_projector_from_shortcut( return projectors.FirstPersonToEntityProjector(population, parent) if isinstance(entity, entities.GroupEntity): - role: Role | None = entities.find_role(entity.roles, shortcut, total=1) + roles: Iterable[t.Role] = entity.roles + role: t.Role | None = entities.find_role(roles, shortcut, total=1) if role is not None: return projectors.UniqueRoleToEntityProjector(population, role, parent) diff --git a/openfisca_core/projectors/types.py b/openfisca_core/projectors/types.py new file mode 100644 index 0000000000..e145cc9a15 --- /dev/null +++ b/openfisca_core/projectors/types.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Protocol + +from openfisca_core import types as t + +# Entities + + +class CoreEntity(t.CoreEntity, Protocol): + ... + + +class Role(t.Role, Protocol): + ... + + +# Projectors + + +class Projector(Protocol): + ... + + +# Populations + + +class CorePopulation(t.CorePopulation, Protocol): + @property + def entity(self) -> CoreEntity: + ... + + @property + def simulation(self) -> Simulation: + ... + + +# Simulations + + +class Simulation(t.Simulation, Protocol): + @property + def populations(self) -> Mapping[str, CorePopulation]: + ... diff --git a/openfisca_core/projectors/typing.py b/openfisca_core/projectors/typing.py deleted file mode 100644 index 186f90e30c..0000000000 --- a/openfisca_core/projectors/typing.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Protocol - -from openfisca_core.types import GroupEntity, SingleEntity - - -class Population(Protocol): - @property - def entity(self) -> SingleEntity: - ... - - @property - def simulation(self) -> Simulation: - ... - - -class GroupPopulation(Protocol): - @property - def entity(self) -> GroupEntity: - ... - - @property - def simulation(self) -> Simulation: - ... - - -class Simulation(Protocol): - @property - def populations(self) -> Mapping[str, Population | GroupPopulation]: - ... diff --git a/openfisca_core/projectors/unique_role_to_entity_projector.py b/openfisca_core/projectors/unique_role_to_entity_projector.py index fed2f249ca..c565484339 100644 --- a/openfisca_core/projectors/unique_role_to_entity_projector.py +++ b/openfisca_core/projectors/unique_role_to_entity_projector.py @@ -4,7 +4,7 @@ class UniqueRoleToEntityProjector(Projector): """For instance famille.declarant_principal.""" - def __init__(self, entity, role, parent=None): + def __init__(self, entity, role, parent=None) -> None: self.target_entity = entity self.reference_entity = entity.members self.parent = parent diff --git a/openfisca_core/simulations/simulation.py b/openfisca_core/simulations/simulation.py index 93becda960..c4525525d0 100644 --- a/openfisca_core/simulations/simulation.py +++ b/openfisca_core/simulations/simulation.py @@ -2,7 +2,7 @@ from typing import Dict, Mapping, NamedTuple, Optional, Set -from openfisca_core.types import Population, TaxBenefitSystem, Variable +from openfisca_core.types import SinglePopulation, TaxBenefitSystem, Variable import tempfile import warnings @@ -19,13 +19,13 @@ class Simulation: """ tax_benefit_system: TaxBenefitSystem - populations: Dict[str, Population] + populations: Dict[str, SinglePopulation] invalidated_caches: Set[Cache] def __init__( self, tax_benefit_system: TaxBenefitSystem, - populations: Mapping[str, Population], + populations: Mapping[str, SinglePopulation], ): """ This constructor is reserved for internal use; see :any:`SimulationBuilder`, @@ -531,7 +531,7 @@ def set_input(self, variable_name: str, period, value): return self.get_holder(variable_name).set_input(period, value) - def get_variable_population(self, variable_name: str) -> Population: + def get_variable_population(self, variable_name: str) -> SinglePopulation: variable: Optional[Variable] variable = self.tax_benefit_system.get_variable( @@ -543,7 +543,9 @@ def get_variable_population(self, variable_name: str) -> Population: return self.populations[variable.entity.key] - def get_population(self, plural: Optional[str] = None) -> Optional[Population]: + def get_population( + self, plural: Optional[str] = None + ) -> Optional[SinglePopulation]: return next( ( population @@ -556,7 +558,7 @@ def get_population(self, plural: Optional[str] = None) -> Optional[Population]: def get_entity( self, plural: Optional[str] = None, - ) -> Optional[Population]: + ) -> Optional[SinglePopulation]: population = self.get_population(plural) return population and population.entity diff --git a/openfisca_core/types.py b/openfisca_core/types.py index b34a555434..fcb7f4898d 100644 --- a/openfisca_core/types.py +++ b/openfisca_core/types.py @@ -1,20 +1,48 @@ from __future__ import annotations -import typing_extensions -from collections.abc import Sequence +from collections.abc import Iterable, Sequence, Sized from numpy.typing import NDArray -from typing import Any, TypeVar +from typing import Any, TypeVar, Union from typing_extensions import Protocol, TypeAlias -import abc - import numpy +# Type aliases + +#: Type for arrays of any type. +ArrayAny: TypeAlias = numpy.generic + +#: Type for arrays of booleans. +ArrayBool: TypeAlias = numpy.bool_ + +#: Type for arrays of bytes. +ArrayBytes: TypeAlias = numpy.bytes_ + +#: Type for arrays of dates. +ArrayDate: TypeAlias = numpy.datetime64 + +#: Type for arrays of enums. +ArrayEnum: TypeAlias = numpy.int16 + +#: Type for arrays of floats. +ArrayFloat: TypeAlias = numpy.float32 + +#: Type for arrays of integers. +ArrayInt: TypeAlias = numpy.int32 + +#: Type for arrays of Python objects. +ArrayObject: TypeAlias = numpy.object_ + +#: Type for arrays of strings. +ArrayStr: TypeAlias = numpy.str_ + +#: Generic numpy type an array can have. N = TypeVar("N", bound=numpy.generic, covariant=True) #: Type representing an numpy array. Array: TypeAlias = NDArray[N] +#: Generic type a sequence can have. L = TypeVar("L") #: Type representing an array-like object. @@ -26,6 +54,12 @@ #: Type variable representing a value. A = TypeVar("A", covariant=True) +#: Generic covariant type. +T_cov = TypeVar("T_cov", covariant=True) + +#: Generic contravariant type. +T_con = TypeVar("T_con", contravariant=True) + # Entities @@ -34,15 +68,12 @@ class CoreEntity(Protocol): key: Any plural: Any - @abc.abstractmethod def check_role_validity(self, role: Any) -> None: ... - @abc.abstractmethod def check_variable_defined_for_entity(self, variable_name: Any) -> None: ... - @abc.abstractmethod def get_variable( self, variable_name: Any, @@ -73,11 +104,9 @@ def key(self) -> str: class Holder(Protocol): - @abc.abstractmethod def clone(self, population: Any) -> Holder: ... - @abc.abstractmethod def get_memory_usage(self) -> Any: ... @@ -85,7 +114,6 @@ def get_memory_usage(self) -> Any: # Parameters -@typing_extensions.runtime_checkable class ParameterNodeAtInstant(Protocol): ... @@ -93,51 +121,57 @@ class ParameterNodeAtInstant(Protocol): # Periods -class Instant(Protocol): - ... +class Indexable(Protocol[T_cov]): + def __getitem__(self, index: int, /) -> T_cov: + ... -@typing_extensions.runtime_checkable -class Period(Protocol): - @property - @abc.abstractmethod - def start(self) -> Any: +class DateUnit(Protocol): + def __contains__(self, other: object, /) -> bool: ... + +class Instant(Indexable[int], Iterable[int], Sized, Protocol): + ... + + +class Period(Indexable[Union[DateUnit, Instant, int]], Protocol): @property - @abc.abstractmethod - def unit(self) -> Any: + def unit(self) -> DateUnit: ... # Populations -class Population(Protocol): +class CorePopulation(Protocol): + ... + + +class SinglePopulation(CorePopulation, Protocol): entity: Any - @abc.abstractmethod def get_holder(self, variable_name: Any) -> Any: ... +class GroupPopulation(CorePopulation, Protocol): + ... + + # Simulations class Simulation(Protocol): - @abc.abstractmethod def calculate(self, variable_name: Any, period: Any) -> Any: ... - @abc.abstractmethod def calculate_add(self, variable_name: Any, period: Any) -> Any: ... - @abc.abstractmethod def calculate_divide(self, variable_name: Any, period: Any) -> Any: ... - @abc.abstractmethod def get_population(self, plural: Any | None) -> Any: ... @@ -148,7 +182,6 @@ def get_population(self, plural: Any | None) -> Any: class TaxBenefitSystem(Protocol): person_entity: Any - @abc.abstractmethod def get_variable( self, variable_name: Any, @@ -165,10 +198,9 @@ class Variable(Protocol): class Formula(Protocol): - @abc.abstractmethod def __call__( self, - population: Population, + population: GroupPopulation, instant: Instant, params: Params, ) -> Array[Any]: @@ -176,6 +208,5 @@ def __call__( class Params(Protocol): - @abc.abstractmethod def __call__(self, instant: Instant) -> ParameterNodeAtInstant: ... diff --git a/openfisca_core/variables/config.py b/openfisca_core/variables/config.py index 54270145bf..90faa7d127 100644 --- a/openfisca_core/variables/config.py +++ b/openfisca_core/variables/config.py @@ -1,27 +1,25 @@ import datetime -import numpy - -from openfisca_core import indexed_enums -from openfisca_core.indexed_enums import Enum +from openfisca_core import indexed_enums as enums +from openfisca_core import types as t VALUE_TYPES = { bool: { - "dtype": numpy.bool_, + "dtype": t.ArrayBool, "default": False, "json_type": "boolean", "formatted_value_type": "Boolean", "is_period_size_independent": True, }, int: { - "dtype": numpy.int32, + "dtype": t.ArrayInt, "default": 0, "json_type": "integer", "formatted_value_type": "Int", "is_period_size_independent": False, }, float: { - "dtype": numpy.float32, + "dtype": t.ArrayFloat, "default": 0, "json_type": "number", "formatted_value_type": "Float", @@ -34,8 +32,8 @@ "formatted_value_type": "String", "is_period_size_independent": True, }, - Enum: { - "dtype": indexed_enums.ENUM_ARRAY_DTYPE, + enums.Enum: { + "dtype": t.ArrayEnum, "json_type": "string", "formatted_value_type": "String", "is_period_size_independent": True, diff --git a/openfisca_core/variables/variable.py b/openfisca_core/variables/variable.py index e70c0d05d9..1b073a1bdc 100644 --- a/openfisca_core/variables/variable.py +++ b/openfisca_core/variables/variable.py @@ -2,8 +2,6 @@ from typing import Optional, Union -from openfisca_core.types import Formula, Instant - import datetime import re import textwrap @@ -12,6 +10,7 @@ import sortedcontainers from openfisca_core import periods, tools +from openfisca_core import types as t from openfisca_core.entities import Entity, GroupEntity from openfisca_core.indexed_enums import Enum, EnumArray from openfisca_core.periods import DateUnit, Period @@ -374,8 +373,8 @@ def get_introspection_data(cls): def get_formula( self, - period: Union[Instant, Period, str, int] = None, - ) -> Optional[Formula]: + period: Union[t.Instant, t.Period, str, int] = None, + ) -> Optional[t.Formula]: """Returns the formula to compute the variable at the given period. If no period is given and the variable has several formulas, the method @@ -389,7 +388,7 @@ def get_formula( """ - instant: Optional[Instant] + instant: Optional[t.Instant] if not self.formulas: return None diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 445abba10b..4d1f3e0977 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -42,6 +42,9 @@ check-types: @mypy \ openfisca_core/commons \ openfisca_core/entities \ + openfisca_core/indexed_enums \ + openfisca_core/periods \ + openfisca_core/projectors \ openfisca_core/types.py @$(call print_pass,$@:) diff --git a/setup.py b/setup.py index cca107bee8..9aa018fca8 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ "dpath >=2.1.4, <3.0", "numexpr >=2.8.4, <3.0", "numpy >=1.24.2, <1.25", - "pendulum >=2.1.2, <3.0.0", + "pendulum >=3.0.0, <4.0.0", "psutil >=5.9.4, <6.0", "pytest >=7.2.2, <8.0", "sortedcontainers >=2.4.0, <3.0", @@ -67,7 +67,7 @@ setup( name="OpenFisca-Core", - version="41.5.5", + version="41.5.8", author="OpenFisca Team", author_email="contact@openfisca.org", classifiers=[