diff --git a/openfisca_core/entities/__init__.py b/openfisca_core/entities/__init__.py index 9546773cb..927aad63d 100644 --- a/openfisca_core/entities/__init__.py +++ b/openfisca_core/entities/__init__.py @@ -1,40 +1,94 @@ -# 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 import module -# >>> module.Symbol() -# -# See: https://www.python.org/dev/peps/pep-0008/#imports - -from . import types -from .entity import Entity -from .group_entity import GroupEntity -from .helpers import build_entity, find_role -from .role import Role - -SingleEntity = Entity - -__all__ = [ - "Entity", - "SingleEntity", - "GroupEntity", - "Role", - "build_entity", - "find_role", - "types", -] +"""Provides a way of representing the entities of a rule system. + +Each rule system is comprised by legislation and regulations to be applied upon +"someone". In legal and economical terms, "someone" is referred to as people: +individuals, families, tax households, companies, and so on. + +People can be either human or non-human, that is a legal entity, also referred +to as a legal person. Human or non-human, a person is an atomic element of a +rule system: for example, in most legislations, a salary is invariably owed +to an indivual, and payroll taxes by a company, as a juridical person. In +OpenFisca, that atomic element is represented as an :class:`.Entity`. + +In other cases, legal and regulatory rules are defined for groups or clusters +of people: for example, income tax is usually due by a tax household, that is +a group of individuals. There may also be fiduciary entities where the members, +legal entities, are collectively liable for a property tax. In OpenFisca, those +cluster elements are represented as a :class:`.GroupEntity`. + +In the latter case, the atomic members of a given group may have a different +:class:`Role` in the context of a specific rule: for example, income tax +is due, in some legislations, by a tax household, where we find different +roles as the declarant, the spouse, the children, and so on… + +What's important is that each rule, or in OpenFisca, a :class:`.Variable`, +is defined either for an :class:`.Entity` or for a :class:`.GroupEntity`, +and in the latter case, the way the rule is going to be applied depends +on the attributes and roles of the members of the group. + +Finally, there is a distiction to be made between the "abstract" entities +described by in a rule system, for example an individual, as in "any" +individual, and an actual individual, like Mauko, Andrea, Mehdi, Seiko, +or José. + +This module provides tools for modelling the former. For the actual +"simulation" or "application" of any given :class:`.Variable` to a +concrete individual or group of individuals, see :class:`.Population` +and :class:`.GroupPopulation`. + +Official Public API: + * :class:`.Entity` + * :class:`.GroupEntity` + * :class:`.Role` + * :func:`.build_entity` + * :func:`.check_role_validity` + +Deprecated: + * :meth:`.Entity.set_tax_benefit_system` + * :meth:`.Entity.get_variable` + * :meth:`.Entity.check_variable_defined_for_entity` + * :meth:`.Entity.check_role_validity` + +Note: + The ``deprecated`` features are kept so as to give time to users to + migrate, and could be definitely removed from the codebase in a future + major release. + +Note: + How imports are being used today:: + + from openfisca_core.entities import * # Bad + from openfisca_core.entities.helpers import build_entity # Bad + from openfisca_core.entities.role import Role # Bad + + The previous examples provoke cyclic dependency problems, that prevents us + from modularizing the different components of the library, so as to make + them easier to test and to maintain. + + How could them be used after the next major release:: + + from openfisca_core import entities + from openfisca_core.entities import Role + + Role() # Good: import classes as publicly exposed + entities.build_entity() # Good: use functions as publicly exposed + + .. 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 + +""" + +# Official Public API + +from .entity import Entity # noqa: F401 +from .group_entity import GroupEntity # noqa: F401 +from .helpers import build_entity, check_role_validity # noqa: F401 +from .role import Role # noqa: F401 + +__all__ = ["Entity", "GroupEntity", "Role"] +__all__ = ["build_entity", "check_role_validity", *__all__] diff --git a/openfisca_core/entities/_role_builder.py b/openfisca_core/entities/_role_builder.py new file mode 100644 index 000000000..fbed5dbfd --- /dev/null +++ b/openfisca_core/entities/_role_builder.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Iterable, Optional, Sequence, Type + +from openfisca_core.types import HasPlural, RoleLike, SupportsRole + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class RoleBuilder: + """Builds roles & sub-roles from a given input. + + Attributes: + builder (:obj:`.Entity` or :obj:`.GroupEntity`): + A builder object. + buildee (:obj:`.Role`): + The objects to be built. + + Args: + builder: A builder object. + buildee: The objects to be built. + + Examples: + >>> from openfisca_core.entities import GroupEntity, Role + + >>> group_entity = GroupEntity( + ... "household", + ... "households", + ... "A household", + ... "All the people who live together in the same place.", + ... [] + ... ) + + >>> items = [{ + ... "key": "parent", + ... "subroles": ["first_parent", "second_parent"], + ... }] + + >>> builder = RoleBuilder(group_entity, Role) + + >>> repr(RoleBuilder) + "" + + >>> repr(builder) + ")>" + + >>> str(builder) + ")>" + + >>> builder(items) + (,) + + .. versionadded:: 35.7.0 + + """ + + __slots__ = ["builder", "buildee"] + builder: HasPlural + buildee: Type[SupportsRole] + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}({self.builder}, {self.buildee})>" + + def __call__(self, items: Iterable[RoleLike]) -> Sequence[SupportsRole]: + """Builds a sub/role for each item in ``items``. + + Args: + items: Role-like items, see :class:`.RoleLike`. + + Returns: + A :obj:`list` of :obj:`.Role`. + + .. versionadded:: 35.7.0 + + """ + + return tuple(self.build(item) for item in items) + + def build(self, item: RoleLike) -> SupportsRole: + """Builds a role from ``item``. + + Args: + item: A role-like item, see :class:`.RoleLike`. + + Returns: + :obj:`.Role`: A :obj:`.Role`. + + .. versionadded:: 35.7.0 + + """ + + role: SupportsRole + subroles: Optional[Iterable[str]] + + role = self.buildee(item, self.builder) + subroles = item.get("subroles", []) + + if subroles: + role.subroles = [ + self.build(RoleLike({"key": key, "max": 1})) for key in subroles + ] + role.max = len(role.subroles) + + return role diff --git a/openfisca_core/entities/_variable_proxy.py b/openfisca_core/entities/_variable_proxy.py new file mode 100644 index 000000000..7381ff90b --- /dev/null +++ b/openfisca_core/entities/_variable_proxy.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Any, Optional, Type +from typing_extensions import Protocol + +from openfisca_core.types import HasPlural, HasVariables, SupportsFormula + +import functools +import os + +DOC_URL = "https://openfisca.org/doc/coding-the-legislation" + +E = HasPlural +T = HasVariables +V = SupportsFormula + + +class Query(Protocol): + """A dummy class to "duck-check" :meth:`.TaxBenefitSystem.get_variable`.""" + + def __call__( + self, + __arg1: str, + __arg2: bool = False, + ) -> Optional["VariableProxy"]: + """See comment above.""" + + ... + + +class VariableProxy: + """A `descriptor`_ to find an :obj:`.Entity`'s :obj:`.Variable`. + + Attributes: + entity (:obj:`.Entity`, optional): + The :obj:`.Entity` ``owner`` of the descriptor. + tax_benefit_system (:obj:`.TaxBenefitSystem`, optional): + The :obj:`.Entity`'s :obj:`.TaxBenefitSystem`. + query (:meth:`.TaxBenefitSystem.get_variable`): + The method used to query the :obj:`.TaxBenefitSystem`. + + Examples: + >>> from openfisca_core.entities import Entity + >>> from openfisca_core.taxbenefitsystems import TaxBenefitSystem + >>> from openfisca_core.variables import Variable + + >>> entity = Entity( + ... "individual", + ... "individuals", + ... "An individual", + ... "The minimal legal entity on which a rule can be applied.", + ... ) + + >>> class Variable(Variable): + ... definition_period = "month" + ... value_type = float + ... entity = entity + + >>> tbs = TaxBenefitSystem([entity]) + >>> tbs.add_variable(Variable) + >> entity.tax_benefit_system = tbs + + >>> entity.variables.get("Variable") + <...Variable... + + >>> entity.variables.exists().get("Variable") + <...Variable... + + >>> entity.variables.isdefined().get("Variable") + <...Variable... + + .. _descriptor: https://docs.python.org/3/howto/descriptor.html + + .. versionadded:: 35.7.0 + + """ + + entity: Optional[E] = None + tax_benefit_system: Optional[T] = None + query: Query + + def __get__(self, entity: E, type: Type[E]) -> Optional[VariableProxy]: + """Binds :meth:`.TaxBenefitSystem.get_variable`.""" + + self.entity = entity + + self.tax_benefit_system = getattr( + self.entity, + "tax_benefit_system", + None, + ) + + if self.tax_benefit_system is None: + return None + + self.query = self.tax_benefit_system.get_variable + + return self + + def __set__(self, entity: E, value: Any) -> None: + NotImplemented + + def get(self, variable_name: str) -> Optional[V]: + """Runs the query for ``variable_name``, based on the options given. + + Args: + variable_name: The :obj:`.Variable` to be found. + + Returns: + :obj:`.Variable` or :obj:`None`: + :obj:`.Variable` when the :obj:`.Variable` exists. + :obj:`None` when the :attr:`.tax_benefit_system` is not set. + + Raises: + :exc:`.VariableNotFoundError`: When :obj:`.Variable` doesn't exist. + :exc:`.ValueError`: When the :obj:`.Variable` exists but is defined + for another :obj:`.Entity`. + + .. versionadded:: 35.7.0 + + """ + + if self.entity is None: + return NotImplemented + + return self.query(variable_name) + + def exists(self) -> VariableProxy: + """Sets ``check_existence`` to ``True``.""" + + self.query = functools.partial( + self.query, + check_existence=True, + ) + + return self + + def isdefined(self) -> VariableProxy: + """Checks that ``variable_name`` is defined for :attr:`.entity`.""" + + # We assume that we're also checking for existance. + self.exists() + + self.query = functools.partial( + self._isdefined, + self.query, + ) + + return self + + def _isdefined(self, query: Query, variable_name: str, **any: Any) -> Any: + variable = query(variable_name) + + if self.entity is None: + return None + + if variable is None: + return None + + if variable.entity is None: + return None + + if self.entity != variable.entity: + message = os.linesep.join( + [ + f"You tried to compute the variable '{variable_name}' for", + f"the entity '{self.entity.plural}'; however the variable", + f"'{variable_name}' is defined for the entity", + f"'{variable.entity.plural}'. Learn more about entities", + f"in our documentation: <{DOC_URL}/50_entities.html>.", + ] + ) + + raise ValueError(message) + + return variable diff --git a/openfisca_core/entities/entity.py b/openfisca_core/entities/entity.py index c00191816..570bfdcca 100644 --- a/openfisca_core/entities/entity.py +++ b/openfisca_core/entities/entity.py @@ -1,42 +1,287 @@ -from typing import ClassVar +from typing import Any, Iterator, Optional, Tuple +from openfisca_core.types import Descriptor, HasHolders, HasVariables, SupportsFormula + +import dataclasses import textwrap -from . import types as t -from ._core_entity import _CoreEntity +from openfisca_core.commons import deprecated + +from .. import entities +from ._variable_proxy import VariableProxy + +@dataclasses.dataclass +class Entity: + """Represents an entity on which calculations can be run. -class Entity(_CoreEntity): - """An entity (e.g. a person, a household) on which calculations can be run. + For example an individual, a company, etc. An :class:`.Entity` + represents an "abstract" atomic unit of the legislation, as in + "any individual", or "any company". + + Attributes: + key (:obj:`str`): + Key to identify the :class:`.Entity`. + plural (:obj:`str`): + The ``key``, pluralised. + label (:obj:`str`): + A summary description. + doc (:obj:`str`): + A full description, dedented. + is_person (:obj:`bool`): + Represents an individual? Defaults to True. Args: - key: A key to identify the ``Entity``. - plural: The ``key`` pluralised. + key: Key to identify the :class:`.Entity`. + plural: ``key``, pluralised. label: A summary description. doc: A full description. - """ + Examples: + >>> entity = Entity( + ... "individual", + ... "individuals", + ... "An individual", + ... "The minimal legal entity on which a rule might be applied.", + ... ) - #: A key to identify the ``Entity``. - key: t.EntityKey + >>> repr(Entity) + "" - #: The ``key`` pluralised. - plural: t.EntityPlural + >>> repr(entity) + '' - #: A summary description. - label: str + >>> str(entity) + 'individuals' + + >>> dict(entity) + {'key': 'individual', 'plural': 'individuals', 'label': 'An individ...} + + >>> list(entity) + [('key', 'individual'), ('plural', 'individuals'), ('label', 'An in...] + + >>> entity == entity + True + + >>> entity != entity + False + + :attr:`population` + + >>> from openfisca_core.populations import Population + + >>> entity.population = Population(entity) + >>> entity == entity.population.entity + True + + :attr:`tax_benefit_system` + + >>> from openfisca_core.taxbenefitsystems import TaxBenefitSystem + + >>> entity.tax_benefit_system = TaxBenefitSystem([entity]) + >>> entity in entity.tax_benefit_system.entities + True + + :attr:`.variables` - #: A full description. + >>> from openfisca_core.variables import Variable + + >>> class Variable(Variable): + ... definition_period = "month" + ... value_type = float + ... entity = entity + + >>> entity.variables.get("Variable") + + >>> entity.tax_benefit_system.add_variable(Variable) + <...Variable... + + >>> entity.variables.get("Variable") + <...Variable... + + >>> entity.variables.exists().get("Variable") + <...Variable... + + >>> entity.variables.isdefined().get("Variable") + <...Variable... + + .. versionchanged:: 35.7.0 + Hereafter :attr:`.variables` allows querying a :obj:`.TaxBenefitSystem` + for a :obj:`.Variable`. + + .. versionchanged:: 35.7.0 + Hereafter the equality of an :obj:`.Entity` is determined by its + data attributes. + + """ + + __slots__ = [ + "key", + "plural", + "label", + "doc", + "is_person", + "_population", + "_tax_benefit_system", + ] + + key: str + plural: str + label: str doc: str - #: Whether the ``Entity`` is a person or not. - is_person: ClassVar[bool] = True + @property + def population(self) -> Optional[HasHolders]: + """An :class:`.Entity` has one :class:`.Population`.""" + + return self._population + + @population.setter + def population(self, value: HasHolders) -> None: + self._population = value + + @property + def tax_benefit_system(self) -> Optional[HasVariables]: + """An :class:`.Entity` belongs to a :obj:`.TaxBenefitSystem`.""" + + return self._tax_benefit_system + + @tax_benefit_system.setter + def tax_benefit_system(self, value: HasVariables) -> None: + self._tax_benefit_system = value + + @property + def variables(self) -> Optional[VariableProxy]: + """An :class:`.Entity` has many :class:`Variables <.Variable>`.""" + + return self._variables + + _variables: Descriptor[VariableProxy] = dataclasses.field( + init=False, + compare=False, + default=VariableProxy(), + ) + + def __post_init__(self, *__: Any) -> None: + self.doc = textwrap.dedent(self.doc) + self.is_person = True + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}({self.key})>" + + def __str__(self) -> str: + return self.plural + + def __iter__(self) -> Iterator[Tuple[str, Any]]: + return ( + (item, self.__getattribute__(item)) + for item in self.__slots__ + if not item.startswith("_") + ) + + @deprecated(since="35.7.0", expires="the future") + def set_tax_benefit_system( + self, + tax_benefit_system: HasVariables, + ) -> None: + """Sets ``_tax_benefit_system``. + + Args: + tax_benefit_system: To query variables from. + + .. deprecated:: 35.7.0 + :meth:`.set_tax_benefit_system` has been deprecated and will be + removed in the future. The functionality is now provided by + :attr:`.tax_benefit_system`. + + """ + + self.tax_benefit_system = tax_benefit_system + + @deprecated(since="35.7.0", expires="the future") + def get_variable( + self, + variable_name: str, + check_existence: bool = False, + ) -> Optional[SupportsFormula]: + """Gets ``variable_name`` from ``variables``. + + Args: + variable_name: The variable to be found. + check_existence: Was the variable found? Defaults to False. + + Returns: + :obj:`.Variable` or :obj:`None`: + :obj:`.Variable` when the :obj:`.Variable` exists. + :obj:`None` when the :obj:`.Variable` doesn't exist. + + Raises: + :exc:`.VariableNotFoundError`: When ``check_existence`` is True and + the :obj:`.Variable` doesn't exist. + + .. deprecated:: 35.7.0 + :meth:`.get_variable` has been deprecated and will be + removed in the future. The functionality is now provided by + ``variables``. + + """ + + if self.variables is None: + return None + + if check_existence: + return self.variables.exists().get(variable_name) + + return self.variables.get(variable_name) + + @deprecated(since="35.7.0", expires="the future") + def check_variable_defined_for_entity( + self, + variable_name: str, + ) -> Optional[SupportsFormula]: + """Checks if ``variable_name`` is defined for ``self``. + + Args: + variable_name: The :obj:`.Variable` to be found. + + Returns: + :obj:`.Variable` or :obj:`None`: + :obj:`.Variable` when the :obj:`.Variable` exists. + :obj:`None` when the :attr:`.tax_benefit_system` is not set. + + Raises: + :exc:`.VariableNotFoundError`: When :obj:`.Variable` doesn't exist. + :exc:`.ValueError`: When the :obj:`.Variable` exists but is defined + for another :obj:`.Entity`. + + .. deprecated:: 35.7.0 + :meth:`.check_variable_defined_for_entity` has been deprecated and + will be removed in the future. The functionality is now provided by + ``variables``. + + """ + + if self.variables is None: + return None + + return self.variables.isdefined().get(variable_name) + + @staticmethod + @deprecated(since="35.7.0", expires="the future") + def check_role_validity(role: Any) -> None: + """Checks if ``role`` is an instance of :class:`.Role`. + + Args: + role: Any object. + + Returns: + :obj:`None`. - def __init__(self, key: str, plural: str, label: str, doc: str) -> None: - self.key = t.EntityKey(key) - self.plural = t.EntityPlural(plural) - self.label = label - self.doc = textwrap.dedent(doc) + .. deprecated:: 35.7.0 + :meth:`.check_role_validity` has been deprecated and will be + removed in the future. The functionality is now provided by + :func:`.entities.check_role_validity`. + """ -__all__ = ["Entity"] + return entities.check_role_validity(role) diff --git a/openfisca_core/entities/group_entity.py b/openfisca_core/entities/group_entity.py index 4b588567a..066273d87 100644 --- a/openfisca_core/entities/group_entity.py +++ b/openfisca_core/entities/group_entity.py @@ -1,81 +1,163 @@ -from __future__ import annotations +from typing import Any, Dict, Iterable, Optional, Sequence -from collections.abc import Iterable, Sequence -from typing import ClassVar +from openfisca_core.types import Builder, RoleLike, SupportsRole +import dataclasses import textwrap -from itertools import chain -from . import types as t -from ._core_entity import _CoreEntity +from ._role_builder import RoleBuilder +from .entity import Entity from .role import Role -class GroupEntity(_CoreEntity): - """Represents an entity containing several others with different roles. +@dataclasses.dataclass(repr=False) +class GroupEntity(Entity): + """Represents a :class:`.GroupEntity` on which calculations can be run. - A ``GroupEntity`` represents an ``Entity`` containing several other entities, - with different roles, and on which calculations can be run. + A :class:`.GroupEntity` is basically a group of people, and thus it is + composed of several :obj:`Entity` with different :obj:`Role` within the + group. For example a tax household, a family, a trust, etc. + + Attributes: + key (:obj:`str`): Key to identify the :class:`.GroupEntity`. + plural (:obj:`str`): The ``key``, pluralised. + label (:obj:`str`): A summary description. + doc (:obj:`str`): A full description, dedented. + is_person (:obj:`bool`): Represents an individual? Defaults to False. Args: - key: A key to identify the ``GroupEntity``. - plural: The ``key`` pluralised. + key: Key to identify the :class:`.GroupEntity`. + plural: ``key``, pluralised. label: A summary description. doc: A full description. - roles: The list of roles of the group entity. - containing_entities: The list of keys of group entities whose members - are guaranteed to be a superset of this group's entities. + roles: The list of :class:`.Role` of the :class:`.GroupEntity`. + + Examples: + >>> roles = [{ + ... "key": "parent", + ... "subroles": ["first_parent", "second_parent"], + ... }] + + >>> group_entity = GroupEntity( + ... "household", + ... "households", + ... "A household", + ... "All the people who live together in the same place.", + ... roles, + ... ) + + >>> repr(GroupEntity) + "" + + >>> repr(group_entity) + '' + + >>> str(group_entity) + 'households' + + >>> dict(group_entity) + {'key': 'household', 'plural': 'households', 'label': 'A household...} + + >>> list(group_entity) + [('key', 'household'), ('plural', 'households'), ('label', 'A hous...] + + >>> group_entity == group_entity + True + + >>> group_entity != group_entity + False + + :attr:`roles` + + >>> group_entity.roles + (,) + + >>> group_entity.PARENT + + + :attr:`flattened_roles` + + >>> group_entity.flattened_roles + (, ) + + >>> group_entity.FIRST_PARENT + + + .. versionchanged:: 35.7.0 + Hereafter :attr:`.variables` allows querying a :obj:`.TaxBenefitSystem` + for a :obj:`.Variable`. + + .. versionchanged:: 35.7.0 + Hereafter the equality of an :obj:`.GroupEntity` is determined by its + data attributes. """ - #: A key to identify the ``Entity``. - key: t.EntityKey - - #: The ``key`` pluralised. - plural: t.EntityPlural - - #: A summary description. - label: str - - #: A full description. - doc: str - - #: The list of roles of the ``GroupEntity``. - roles: Iterable[Role] - - #: Whether the entity is a person or not. - is_person: ClassVar[bool] = False - - def __init__( - self, - key: str, - plural: str, - label: str, - doc: str, - roles: Sequence[t.RoleParams], - containing_entities: Iterable[str] = (), - ) -> None: - self.key = t.EntityKey(key) - self.plural = t.EntityPlural(plural) - self.label = label - self.doc = textwrap.dedent(doc) - self.roles_description = roles - self.roles: Iterable[Role] = () - for role_description in roles: - role = Role(role_description, self) - setattr(self, role.key.upper(), role) - self.roles = (*self.roles, role) - if subroles := role_description.get("subroles"): - role.subroles = () - for subrole_key in subroles: - subrole = Role({"key": subrole_key, "max": 1}, self) - setattr(self, subrole.key.upper(), subrole) - role.subroles = (*role.subroles, subrole) - role.max = len(role.subroles) - self.flattened_roles = tuple( - chain.from_iterable(role.subroles or [role] for role in self.roles), + __slots__ = [ + "key", + "plural", + "label", + "doc", + "is_person", + "roles_description", + "_population", + "_tax_benefit_system", + "_roles", + "_roles_map", + "_flattened_roles", + ] + + __: dataclasses.InitVar[Iterable[RoleLike]] + + @property + def roles(self) -> Sequence[SupportsRole]: + """List of the roles of the group entity.""" + + return self._roles + + @roles.setter + def roles(self, roles: Sequence[SupportsRole]) -> None: + self._roles = roles + + @property + def flattened_roles(self) -> Sequence[SupportsRole]: + """:attr:`roles` flattened out.""" + + return self._flattened_roles + + @flattened_roles.setter + def flattened_roles(self, roles: Sequence[SupportsRole]) -> None: + self._flattened_roles = tuple( + array for role in roles for array in role.subroles or [role] ) - self.containing_entities = containing_entities + def __post_init__(self, *__: Iterable[RoleLike]) -> None: + self.doc = textwrap.dedent(self.doc) + self.is_person = False + + # Extract roles. + roles: Iterable[RoleLike] + roles = next(iter(__)) + + # Build roles & sub-roles. + builder: Builder[GroupEntity, SupportsRole, RoleLike] + builder = RoleBuilder(self, Role) + self.roles = builder(roles) + self.flattened_roles = self.roles + + # Assign role & sub-role attributes. + self._roles_map: Dict[str, SupportsRole] + self._roles_map = {} + + for role in (*self.roles, *self.flattened_roles): + self._roles_map.update({role.key.upper(): role}) + + # Useless step kept to avoid changing the signature. + self.roles_description: Optional[Iterable[RoleLike]] + self.roles_description = roles + + def __getattr__(self, attr: str) -> Any: + if attr.isupper(): + return self._roles_map[attr] -__all__ = ["GroupEntity"] + return self.__getattribute__(attr) diff --git a/openfisca_core/entities/helpers.py b/openfisca_core/entities/helpers.py index 146ab6d25..cf566f3df 100644 --- a/openfisca_core/entities/helpers.py +++ b/openfisca_core/entities/helpers.py @@ -1,9 +1,8 @@ -from __future__ import annotations +from typing import Any, Iterable, Optional -from collections.abc import Iterable, Sequence +from openfisca_core.types import HasPlural, RoleLike, SupportsRole -from . import types as t -from .entity import Entity as SingleEntity +from .entity import Entity from .group_entity import GroupEntity @@ -12,154 +11,78 @@ def build_entity( plural: str, label: str, doc: str = "", - roles: None | Sequence[t.RoleParams] = None, + roles: Optional[Iterable[RoleLike]] = None, is_person: bool = False, - *, - class_override: object = None, - containing_entities: Sequence[str] = (), -) -> t.SingleEntity | t.GroupEntity: - """Build an ``Entity`` or a ``GroupEntity``. + class_override: Optional[Any] = None, +) -> HasPlural: + """Builds an :class:`.Entity` or a :class:`.GroupEntity`. Args: - key: Key to identify the ``Entity`` or ``GroupEntity``. - plural: The ``key`` pluralised. + key: Key to identify the :class:`.Entity` or :class:`.GroupEntity`. + plural: ``key``, pluralised. label: A summary description. doc: A full description. - roles: A list of roles —if it's a ``GroupEntity``. + roles: A list of :class:`.Role`, if it's a :class:`.GroupEntity`. is_person: If is an individual, or not. class_override: ? - containing_entities: Keys of contained entities. Returns: - Entity: When ``is_person`` is ``True``. - GroupEntity: When ``is_person`` is ``False``. + :obj:`.Entity` or :obj:`.GroupEntity`: + :obj:`.Entity`: When ``is_person`` is True. + :obj:`.GroupEntity`: When ``is_person`` is False. Raises: - NotImplementedError: If ``roles`` is ``None``. + ValueError: If ``roles`` is not iterable. Examples: - >>> from openfisca_core import entities - - >>> entity = build_entity( + >>> build_entity( ... "syndicate", ... "syndicates", ... "Banks loaning jointly.", - ... roles=[], - ... containing_entities=(), - ... ) - >>> entity - GroupEntity(syndicate) + ... roles = [], + ... ) + >>> build_entity( ... "company", ... "companies", ... "A small or medium company.", - ... is_person=True, - ... ) - Entity(company) + ... is_person = True, + ... ) + - >>> role = entities.Role({"key": "key"}, entity) - - >>> build_entity( - ... "syndicate", - ... "syndicates", - ... "Banks loaning jointly.", - ... roles=[role], - ... ) - Traceback (most recent call last): - TypeError: 'Role' object is not subscriptable + .. versionchanged:: 35.7.0 + Instead of raising :exc:`TypeError` when ``roles`` is None, it does + now raise :exc:`ValueError` when ``roles`` is not iterable. """ if is_person: - return SingleEntity(key, plural, label, doc) + return Entity(key, plural, label, doc) - if roles is not None: - return GroupEntity( - key, - plural, - label, - doc, - roles, - containing_entities=containing_entities, - ) + if roles is not None and hasattr(roles, "__iter__"): + return GroupEntity(key, plural, label, doc, roles) - raise NotImplementedError + raise ValueError(f"Invalid value '{roles}' for 'roles', must be iterable.") -def find_role( - roles: Iterable[t.Role], - key: t.RoleKey, - *, - total: None | int = None, -) -> None | t.Role: - """Find a ``Role`` in a ``GroupEntity``. +def check_role_validity(role: Any) -> None: + """Checks if ``role`` is an instance of :class:`.Role`. Args: - roles: The roles to search. - key: The key of the role to find. - total: The ``max`` attribute of the role to find. + role: Any object. - Returns: - Role: The role if found - None: Else ``None``. + Raises: + ValueError: When ``role`` is not a :class:`.Role`. Examples: - >>> from openfisca_core.entities.types import RoleParams - - >>> principal = RoleParams( - ... key="principal", - ... label="Principal", - ... doc="Person focus of a calculation in a family context.", - ... max=1, - ... ) - - >>> partner = RoleParams( - ... key="partner", - ... plural="partners", - ... label="Partners", - ... doc="Persons partners of the principal.", - ... ) - - >>> parent = RoleParams( - ... key="parent", - ... plural="parents", - ... label="Parents", - ... doc="Persons parents of children of the principal", - ... subroles=["first_parent", "second_parent"], - ... ) - - >>> group_entity = build_entity( - ... key="family", - ... plural="families", - ... label="Family", - ... doc="A Family represents a collection of related persons.", - ... roles=[principal, partner, parent], - ... ) - - >>> find_role(group_entity.roles, "principal", total=1) - Role(principal) - - >>> find_role(group_entity.roles, "partner") - Role(partner) - - >>> find_role(group_entity.roles, "parent", total=2) - Role(parent) - - >>> find_role(group_entity.roles, "first_parent", total=1) - Role(first_parent) - - """ - for role in roles: - if role.subroles: - for subrole in role.subroles: - if (subrole.max == total) and (subrole.key == key): - return subrole - - if (role.max == total) and (role.key == key): - return role + >>> from openfisca_core.entities import Role + >>> role = Role({"key": "key"}, object()) + >>> check_role_validity(role) - return None + .. versionadded:: 35.7.0 + """ -__all__ = ["build_entity", "find_role"] + if role is not None and not isinstance(role, SupportsRole): + raise ValueError(f"{role} is not a valid role") diff --git a/openfisca_core/entities/role.py b/openfisca_core/entities/role.py index e687b2604..5fdea85bb 100644 --- a/openfisca_core/entities/role.py +++ b/openfisca_core/entities/role.py @@ -1,91 +1,92 @@ from __future__ import annotations -from collections.abc import Iterable +from typing import Any, Iterator, Optional, Sequence, Tuple -from . import types as t -from ._description import _Description +from openfisca_core.types import HasPlural, RoleLike, SupportsRole +import dataclasses +import textwrap -class Role: - """The role of an ``Entity`` within a ``GroupEntity``. - Each ``Entity`` related to a ``GroupEntity`` has a ``Role``. For example, - if you have a family, its roles could include a parent, a child, and so on. - Or if you have a tax household, its roles could include the taxpayer, a - spouse, several dependents, and the like. +@dataclasses.dataclass(init=False) +class Role: + """Role of an :class:`.Entity` within a :class:`.GroupEntity`. + + Each :class:`.Entity` related to a :class:`.GroupEntity` has a + :class:`.Role`. For example, if you have a family, its roles could include + a parent, a child, and so on. Or if you have a tax household, its roles + could include the taxpayer, a spouse, several dependents, and so on. + + Attributes: + entity (:obj:`.GroupEntity`): Entity the :class:`.Role` belongs to. + key (:obj:`str`): Key to identify the :class:`.Role`. + plural (:obj:`str`, optional): The ``key``, pluralised. + label (:obj:`str`, optional): A summary description. + doc (:obj:`str`): A full description, dedented. + max (:obj:`int`, optional): Max number of members. Defaults to None. + subroles (list, optional): The ``subroles``. Defaults to None. Args: - description: A description of the Role. - entity: The Entity to which the Role belongs. + description: A dictionary containing most of the attributes. + entity: :obj:`.Entity` the :class:`.Role` belongs to. Examples: - >>> from openfisca_core import entities - >>> entity = entities.GroupEntity("key", "plural", "label", "doc", []) - >>> role = entities.Role({"key": "parent"}, entity) + >>> description = { + ... "key": "parent", + ... "label": "Parents", + ... "plural": "parents", + ... "doc": "The one or two adults in charge of the household.", + ... "max": 2, + ... } + + >>> role = Role(description, object()) >>> repr(Role) "" >>> repr(role) - 'Role(parent)' + '' >>> str(role) - 'Role(parent)' + 'parent' - >>> {role} - {Role(parent)} + >>> dict(role) + {'entity': , 'key': 'parent', 'plural': 'parents'...} - >>> role.key - 'parent' + >>> list(role) + [('entity', ), ('key', 'parent'), ('plural', 'par...] + + >>> role == role + True + + >>> role != role + False """ - #: The ``GroupEntity`` the Role belongs to. - entity: t.GroupEntity - - #: A description of the ``Role``. - description: _Description - - #: Max number of members. - max: None | int = None - - #: A list of subroles. - subroles: None | Iterable[Role] = None - - @property - def key(self) -> t.RoleKey: - """A key to identify the ``Role``.""" - return t.RoleKey(self.description.key) - - @property - def plural(self) -> None | t.RolePlural: - """The ``key`` pluralised.""" - if (plural := self.description.plural) is None: - return None - return t.RolePlural(plural) - - @property - def label(self) -> None | str: - """A summary description.""" - return self.description.label - - @property - def doc(self) -> None | str: - """A full description, non-indented.""" - return self.description.doc - - def __init__(self, description: t.RoleParams, entity: t.GroupEntity) -> None: - self.description = _Description( - key=description["key"], - plural=description.get("plural"), - label=description.get("label"), - doc=description.get("doc"), - ) + __slots__ = ["entity", "key", "plural", "label", "doc", "max", "subroles"] + entity: HasPlural + key: str + plural: Optional[str] + label: Optional[str] + doc: str + max: Optional[int] + subroles: Optional[Sequence[SupportsRole]] + + def __init__(self, description: RoleLike, entity: HasPlural) -> None: self.entity = entity + self.key = description["key"] + self.plural = description.get("plural") + self.label = description.get("label") + self.doc = textwrap.dedent(str(description.get("doc", ""))) self.max = description.get("max") + self.subroles = None def __repr__(self) -> str: - return f"Role({self.key})" + return f"<{self.__class__.__name__}({self.key})>" + def __str__(self) -> str: + return self.key -__all__ = ["Role"] + def __iter__(self) -> Iterator[Tuple[str, Any]]: + return ((item, self.__getattribute__(item)) for item in self.__slots__) diff --git a/openfisca_core/entities/tests/test_entity.py b/openfisca_core/entities/tests/test_entity.py index b3cb813dd..dc2154287 100644 --- a/openfisca_core/entities/tests/test_entity.py +++ b/openfisca_core/entities/tests/test_entity.py @@ -1,10 +1,56 @@ -from openfisca_core import entities +import pytest +from openfisca_core.entities import Entity, Role +from openfisca_core.taxbenefitsystems import TaxBenefitSystem + + +@pytest.fixture +def entity(): + """An entity.""" + + return Entity("key", "label", "plural", "doc") + + +@pytest.fixture +def role(entity): + """A role.""" + + return Role({"key": "key"}, entity) + + +def test_init_when_doc_indented(): + """Dedents the ``doc`` attribute if it is passed at initialisation.""" -def test_init_when_doc_indented() -> None: - """De-indent the ``doc`` attribute if it is passed at initialisation.""" key = "\tkey" doc = "\tdoc" - entity = entities.Entity(key, "label", "plural", doc) + entity = Entity(key, "label", "plural", doc) assert entity.key == key - assert entity.doc == doc.lstrip() + assert entity.doc != doc + + +def test_set_tax_benefit_system_deprecation(entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + entity.set_tax_benefit_system(TaxBenefitSystem([entity])) + + +def test_check_role_validity_deprecation(entity, role): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + entity.check_role_validity(role) + + +def test_check_variable_defined_for_entity_deprecation(entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + entity.check_variable_defined_for_entity(object()) + + +def test_get_variable_deprecation(entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + entity.get_variable("variable") diff --git a/openfisca_core/entities/tests/test_group_entity.py b/openfisca_core/entities/tests/test_group_entity.py index 092c9d357..e27b07847 100644 --- a/openfisca_core/entities/tests/test_group_entity.py +++ b/openfisca_core/entities/tests/test_group_entity.py @@ -1,70 +1,65 @@ -from collections.abc import Mapping -from typing import Any - import pytest -from openfisca_core import entities +from openfisca_core.entities import Entity, GroupEntity +from openfisca_core.taxbenefitsystems import TaxBenefitSystem @pytest.fixture -def parent() -> str: - return "parent" +def roles(): + """A role-like.""" + + return [{"key": "parent", "subroles": ["first_parent", "second_parent"]}] @pytest.fixture -def uncle() -> str: - return "uncle" +def entity(): + """An entity.""" + + return Entity("key", "label", "plural", "doc") @pytest.fixture -def first_parent() -> str: - return "first_parent" +def group_entity(roles): + """A group entity.""" + return GroupEntity("key", "label", "plural", "doc", roles) -@pytest.fixture -def second_parent() -> str: - return "second_parent" +def test_init_when_doc_indented(): + """Dedents the ``doc`` attribute if it is passed at initialisation.""" -@pytest.fixture -def third_parent() -> str: - return "third_parent" + key = "\tkey" + doc = "\tdoc" + group_entity = GroupEntity(key, "label", "plural", doc, []) + assert group_entity.key == key + assert group_entity.doc != doc -@pytest.fixture -def role(parent: str, first_parent: str, third_parent: str) -> Mapping[str, Any]: - return {"key": parent, "subroles": {first_parent, third_parent}} +def test_set_tax_benefit_system_deprecation(entity, group_entity): + """Throws a deprecation warning when called.""" + tbs = TaxBenefitSystem([entity, group_entity]) -@pytest.fixture -def group_entity(role: Mapping[str, Any]) -> entities.GroupEntity: - return entities.GroupEntity("key", "label", "plural", "doc", (role,)) + with pytest.warns(DeprecationWarning): + group_entity.set_tax_benefit_system(tbs) -def test_init_when_doc_indented() -> None: - """De-indent the ``doc`` attribute if it is passed at initialisation.""" - key = "\tkey" - doc = "\tdoc" - group_entity = entities.GroupEntity(key, "label", "plural", doc, ()) - assert group_entity.key == key - assert group_entity.doc == doc.lstrip() - - -def test_group_entity_with_roles( - group_entity: entities.GroupEntity, - parent: str, - uncle: str, -) -> None: - """Assign a Role for each role-like passed as argument.""" - assert hasattr(group_entity, parent.upper()) - assert not hasattr(group_entity, uncle.upper()) - - -def test_group_entity_with_subroles( - group_entity: entities.GroupEntity, - first_parent: str, - second_parent: str, -) -> None: - """Assign a Role for each subrole-like passed as argument.""" - assert hasattr(group_entity, first_parent.upper()) - assert not hasattr(group_entity, second_parent.upper()) +def test_check_role_validity_deprecation(group_entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + group_entity.check_role_validity(group_entity.PARENT) + + +def test_check_variable_defined_for_entity_deprecation(group_entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + group_entity.check_variable_defined_for_entity(object()) + + +def test_get_variable_deprecation(group_entity): + """Throws a deprecation warning when called.""" + + with pytest.warns(DeprecationWarning): + group_entity.get_variable("variable") diff --git a/openfisca_core/entities/tests/test_helpers.py b/openfisca_core/entities/tests/test_helpers.py new file mode 100644 index 000000000..c6eedb122 --- /dev/null +++ b/openfisca_core/entities/tests/test_helpers.py @@ -0,0 +1,17 @@ +import pytest + +from openfisca_core import entities + + +def test_build_entity_without_roles(): + """Raises a ArgumentError when it's called without roles.""" + + with pytest.raises(ValueError): + entities.build_entity("", "", "", roles=None) + + +def test_check_role_validity_when_not_role(): + """Raises a ValueError when it gets an invalid role.""" + + with pytest.raises(ValueError): + entities.check_role_validity(object()) diff --git a/openfisca_core/entities/tests/test_role.py b/openfisca_core/entities/tests/test_role.py index 454d862c7..0953a8689 100644 --- a/openfisca_core/entities/tests/test_role.py +++ b/openfisca_core/entities/tests/test_role.py @@ -1,11 +1,11 @@ -from openfisca_core import entities +from openfisca_core.entities import Role -def test_init_when_doc_indented() -> None: - """De-indent the ``doc`` attribute if it is passed at initialisation.""" +def test_init_when_doc_indented(): + """Dedents the ``doc`` attribute if it is passed at initialisation.""" + key = "\tkey" doc = "\tdoc" - entity = entities.GroupEntity("key", "plural", "label", "doc", []) - role = entities.Role({"key": key, "doc": doc}, entity) + role = Role({"key": key, "doc": doc}, object()) assert role.key == key - assert role.doc == doc.lstrip() + assert role.doc != doc diff --git a/openfisca_core/entities/tests/test_variable_proxy.py b/openfisca_core/entities/tests/test_variable_proxy.py new file mode 100644 index 000000000..f9f571819 --- /dev/null +++ b/openfisca_core/entities/tests/test_variable_proxy.py @@ -0,0 +1,159 @@ +import pytest + +from openfisca_core.entities import Entity +from openfisca_core.errors import VariableNotFoundError +from openfisca_core.taxbenefitsystems import TaxBenefitSystem +from openfisca_core.variables import Variable + +from .._variable_proxy import VariableProxy + + +@pytest.fixture() +def variables(): + """A variable descriptor.""" + + return VariableProxy() + + +@pytest.fixture +def entity(): + """An entity.""" + + return Entity("", "individuals", "", "") + + +@pytest.fixture +def ThisVar(entity): + """A variable.""" + + return type( + "ThisVar", + (Variable,), + { + "definition_period": "month", + "value_type": float, + "entity": entity, + }, + ) + + +@pytest.fixture +def ThatVar(): + """Another variable.""" + + class ThatVar(Variable): + definition_period = "month" + value_type = float + entity = Entity("", "martians", "", "") + + return ThatVar + + +@pytest.fixture +def tbs(entity, ThisVar, ThatVar): + """A tax-benefit system.""" + + tbs = TaxBenefitSystem([entity]) + tbs.add_variable(ThisVar) + tbs.add_variable(ThatVar) + return tbs + + +def test_variables_without_variable_name(variables): + """Raises a TypeError when called without ``variable_name``.""" + + with pytest.raises(TypeError, match="'variable_name'"): + variables.get() + + +def test_variables_without_owner(variables): + """Returns NotImplemented when called without an ``owner``.""" + + assert variables.get("ThisVar") == NotImplemented + + +def test_variables_setter(entity): + """Raises AttributeError when tryng to set ``variables``.""" + + with pytest.raises(AttributeError, match="can't set attribute"): + entity.variables = object() + + +def test_variables_without_tax_benefit_system(entity): + """Returns None when called without a TaxBenefitSystem.""" + + assert not entity.variables + + +def test_variables_when_exists(entity, tbs, ThisVar): + """Returns the variable when it exists.""" + + entity.tax_benefit_system = tbs + variable = entity.variables.get("ThisVar") + assert isinstance(variable, ThisVar) + + +def test_variables_when_doesnt_exist(entity, tbs): + """Returns None when it does not.""" + + entity.tax_benefit_system = tbs + assert not entity.variables.get("OtherVar") + + +def test_variables_when_exists_and_check_exists(entity, tbs, ThisVar): + """Raises a VariableNotFoundError when checking for existance.""" + + entity.tax_benefit_system = tbs + variable = entity.variables.exists().get("ThisVar") + assert isinstance(variable, ThisVar) + + +def test_variables_when_doesnt_exist_and_check_exists(entity, tbs): + """Raises a VariableNotFoundError when checking for existance.""" + + entity.tax_benefit_system = tbs + + with pytest.raises(VariableNotFoundError, match="'OtherVar'"): + entity.variables.exists().get("OtherVar") + + +def test_variables_when_exists_and_defined_for(entity, tbs, ThisVar): + """Returns the variable when it exists and defined for the entity.""" + + entity.tax_benefit_system = tbs + variable = entity.variables.isdefined().get("ThisVar") + assert isinstance(variable, ThisVar) + + +def test_variables_when_exists_and_not_defined_for(entity, tbs): + """Raises a ValueError when it exists but defined for another var.""" + + entity.tax_benefit_system = tbs + + with pytest.raises(ValueError, match="'martians'"): + entity.variables.isdefined().get("ThatVar") + + +def test_variables_when_doesnt_exist_and_check_defined_for(entity, tbs): + """Raises a VariableNotFoundError when it doesn't exist.""" + + entity.tax_benefit_system = tbs + + with pytest.raises(VariableNotFoundError, match="'OtherVar'"): + entity.variables.isdefined().get("OtherVar") + + +def test_variables_composition(entity, tbs): + """Conditions can be composed.""" + + entity.tax_benefit_system = tbs + + assert entity.variables.exists().isdefined().get("ThisVar") + + +def test_variables_permutation(entity, tbs): + """Conditions can be permuted.""" + + entity.tax_benefit_system = tbs + + assert entity.variables.isdefined().exists().get("ThisVar") diff --git a/openfisca_core/indexed_enums/__init__.py b/openfisca_core/indexed_enums/__init__.py index 70e2d3549..2668d8852 100644 --- a/openfisca_core/indexed_enums/__init__.py +++ b/openfisca_core/indexed_enums/__init__.py @@ -11,14 +11,9 @@ """ -from . import types from .config import ENUM_ARRAY_DTYPE from .enum import Enum from .enum_array import EnumArray +from . import types -__all__ = [ - "ENUM_ARRAY_DTYPE", - "Enum", - "EnumArray", - "types", -] +__all__ = ["ENUM_ARRAY_DTYPE", "EnumArray", "Enum", "types"] diff --git a/openfisca_core/indexed_enums/enum.py b/openfisca_core/indexed_enums/enum.py index d76c9f9e2..7ee766bd1 100644 --- a/openfisca_core/indexed_enums/enum.py +++ b/openfisca_core/indexed_enums/enum.py @@ -1,10 +1,11 @@ from __future__ import annotations + import numpy -from . import types as t -from .config import ENUM_ARRAY_DTYPE from .enum_array import EnumArray +from .config import ENUM_ARRAY_DTYPE +from . import types as t class Enum(t.Enum): @@ -13,6 +14,67 @@ class Enum(t.Enum): Its items have an :any:`int` index. This is useful and performant when running simulations on large populations. + Examples: + >>> class Housing(Enum): + ... OWNER = "Owner" + ... TENANT = "Tenant" + ... FREE_LODGER = "Free lodger" + ... HOMELESS = "Homeless" + + >>> repr(Housing) + "" + + >>> repr(Housing.TENANT) + '' + + >>> str(Housing.TENANT) + 'Housing.TENANT' + + >>> dict([(Housing.TENANT, Housing.TENANT.value)]) + {: 'Tenant'} + + >>> list(Housing) + [, , ...] + + >>> Housing["TENANT"] + + + >>> Housing("Tenant") + + + >>> Housing.TENANT in Housing + True + + >>> len(Housing) + 4 + + >>> Housing.TENANT == Housing.TENANT + True + + >>> Housing.TENANT != Housing.TENANT + False + + >>> Housing.TENANT > Housing.TENANT + False + + >>> Housing.TENANT < Housing.TENANT + False + + >>> Housing.TENANT >= Housing.TENANT + True + + >>> Housing.TENANT <= Housing.TENANT + True + + >>> Housing.TENANT.index + 1 + + >>> Housing.TENANT.name + 'TENANT' + + >>> Housing.TENANT.value + 'Tenant' + """ #: The ``index`` of the ``Enum`` member. @@ -28,12 +90,67 @@ def __init__(self, *__args: object, **__kwargs: object) -> None: *__args: Positional arguments. **__kwargs: Keyword arguments. + Examples: + >>> MyEnum = Enum("MyEnum", "foo bar") + >>> MyEnum.bar.index + 1 + + >>> class MyEnum(Enum): + ... foo = b"foo" + ... bar = b"bar" + + >>> MyEnum.bar.index + 1 + + >>> array = numpy.array([[1, 2], [3, 4]]) + >>> array[MyEnum.bar.index] + array([3, 4]) + """ self.index = len(self._member_names_) - #: Bypass the slow :any:`~enum.Enum.__eq__` method. - __eq__ = object.__eq__ + def __repr__(self) -> str: + return f"<{self.__class__.__name__}.{self.name}({self.value})>" + + def __str__(self) -> str: + return f"{self.__class__.__name__}.{self.name}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index == other.index + + def __ne__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index != other.index + + def __lt__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index < other.index + + def __le__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index <= other.index + + def __gt__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index > other.index + + def __ge__(self, other: object) -> bool: + if not isinstance(other, Enum): + return NotImplemented + + return self.index >= other.index #: :meth:`.__hash__` must also be defined so as to stay hashable. __hash__ = object.__hash__ @@ -51,49 +168,102 @@ def encode( Returns: EnumArray: An ``EnumArray`` with the encoded input values. - For instance: + Examples: + >>> class MyEnum(Enum): + ... foo = b"foo" + ... bar = b"bar" + + # EnumArray + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, MyEnum) + >>> MyEnum.encode(enum_array) + ])> + + # ArrayTipe[Enum] + + >>> array = numpy.array([MyEnum.bar]) + >>> enum_array = MyEnum.encode(array) + >>> enum_array[0] == MyEnum.bar.index + True + + # ArrayType[bytes] + + >>> array = numpy.array([b"bar"]) + >>> enum_array = MyEnum.encode(array) + >>> enum_array[0] == MyEnum.bar.index + True + + # ArrayType[int] - >>> string_identifier_array = asarray(["free_lodger", "owner"]) - >>> encoded_array = HousingOccupancyStatus.encode(string_identifier_array) - >>> encoded_array[0] - 2 # Encoded value + >>> array = numpy.array([1]) + >>> enum_array = MyEnum.encode(array) + >>> enum_array[0] == MyEnum.bar.index + True + + # ArrayType[str] + + >>> array = numpy.array(["bar"]) + >>> enum_array = MyEnum.encode(array) + >>> enum_array[0] == MyEnum.bar.index + True + + .. versionchanged:: 35.8.0 + Fixed a bug when encoding :class:`bytes` arrays, now they're casted + to :obj:`str` prior to encoding. + + .. versionchanged:: 35.8.0 + Fixed a bug when encoding scalar arrays of :class:`.Enum` items, + now they're encoded as expected. + + .. seealso:: + :meth:`.EnumArray.decode` for decoding. - >>> free_lodger = HousingOccupancyStatus.free_lodger - >>> owner = HousingOccupancyStatus.owner - >>> enum_item_array = asarray([free_lodger, owner]) - >>> encoded_array = HousingOccupancyStatus.encode(enum_item_array) - >>> encoded_array[0] - 2 # Encoded value """ + + conditions: list[t.Array[t.DTypeBool]] + choices: list[int] + if isinstance(array, EnumArray): + return array - # String array - if isinstance(array, numpy.ndarray) and array.dtype.kind in {"U", "S"}: - array = numpy.select( - [array == item.name for item in cls], - [item.index for item in cls], - ).astype(ENUM_ARRAY_DTYPE) - - # Enum items arrays - elif isinstance(array, numpy.ndarray) and array.dtype.kind == "O": - # Ensure we are comparing the comparable. The problem this fixes: - # On entering this method "cls" will generally come from - # variable.possible_values, while the array values may come from - # directly importing a module containing an Enum class. However, - # variables (and hence their possible_values) are loaded by a call - # to load_module, which gives them a different identity from the - # ones imported in the usual way. - # - # So, instead of relying on the "cls" passed in, we use only its - # name to check that the values in the array, if non-empty, are of - # the right type. - if len(array) > 0 and cls.__name__ is array[0].__class__.__name__: - cls = array[0].__class__ - - array = numpy.select( - [array == item for item in cls], - [item.index for item in cls], - ).astype(ENUM_ARRAY_DTYPE) + if ( + isinstance(array, numpy.ndarray) + and array.size > 0 + and isinstance(array.take(0), (Enum, bytes, str)) + ): + + if numpy.issubdtype(array.dtype, bytes): + + array = array.astype(str) + + if numpy.issubdtype(array.dtype, str): + + conditions = [array == item.name for item in cls] + + if numpy.issubdtype(array.dtype, cls): + # Ensure we are comparing the comparable. + # + # The problem this fixes: + # + # On entering this method "cls" will generally come from + # variable.possible_values, while the array values may come + # from directly importing a module containing an Enum class. + # + # However, variables (and hence their possible_values) are + # loaded by a call to load_module, which gives them a different + # identity from the ones imported in the usual way. + # + # So, instead of relying on the "cls" passed in, we use only + # its name to check that the values in the array, if non-empty, + # are of the right type. + + cls = array.take(0).__class__ + conditions = [array == item for item in cls] + + choices = [item.index for item in cls] + + array = numpy.select(conditions, choices).astype(ENUM_ARRAY_DTYPE) return EnumArray(array, cls) diff --git a/openfisca_core/indexed_enums/enum_array.py b/openfisca_core/indexed_enums/enum_array.py index a1479d5b8..14a106636 100644 --- a/openfisca_core/indexed_enums/enum_array.py +++ b/openfisca_core/indexed_enums/enum_array.py @@ -1,58 +1,189 @@ from __future__ import annotations -from typing import Any, NoReturn -from typing_extensions import Self +from typing import Any, NoReturn, Optional, Type, Union + +from openfisca_core.types import ArrayLike, ArrayType, SupportsEncode import numpy -from . import types as t +class EnumArray(numpy.ndarray): + """:class:`numpy.ndarray` subclass representing an array of :class:`.Enum`. + + :class:`EnumArrays <.EnumArray>` are encoded as :class:`int` arrays to + improve performance. + + Note: + Subclassing `numpy.ndarray` is a little tricky™. To read more about the + :meth:`.__new__` and :meth:`.__array_finalize__` methods below, see + `Subclassing ndarray`_. + + Examples: + >>> from openfisca_core.indexed_enums import Enum + >>> from openfisca_core.variables import Variable + + >>> class Housing(Enum): + ... OWNER = "Owner" + ... TENANT = "Tenant" + ... FREE_LODGER = "Free lodger" + ... HOMELESS = "Homeless" + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, Housing) + + >>> repr(EnumArray) + "" + + >>> repr(enum_array) + '])>' + + >>> str(enum_array) + "['TENANT']" + + >>> list(enum_array) + [1] + + >>> enum_array[0] + 1 + + >>> enum_array[0] in enum_array + True + + >>> len(enum_array) + 1 + + >>> enum_array = EnumArray(list(Housing), Housing) + >>> enum_array[Housing.TENANT.index] + + + >>> class OccupancyStatus(Variable): + ... value_type = Enum + ... possible_values = Housing + + >>> EnumArray(array, OccupancyStatus.possible_values) + ])> -class EnumArray(t.EnumArray): - """NumPy array subclass representing an array of enum items. - EnumArrays are encoded as ``int`` arrays to improve performance + .. _Subclassing ndarray: + https://numpy.org/doc/stable/user/basics.subclassing.html + """ - # 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. def __new__( cls, - input_array: t.Array[t.DTypeEnum], - possible_values: None | type[t.Enum] = None, - ) -> Self: + input_array: ArrayType[int], + possible_values: Optional[Type[SupportsEncode]] = None, + ) -> EnumArray: + """See comment above.""" + + obj: EnumArray obj = numpy.asarray(input_array).view(cls) obj.possible_values = possible_values return obj - # See previous comment - def __array_finalize__(self, obj: numpy.int32 | None) -> None: + def __array_finalize__(self, obj: Optional[ArrayType[int]]) -> None: + """See comment above.""" + if obj is None: return self.possible_values = getattr(obj, "possible_values", None) - def __eq__(self, other: object) -> bool: - # When comparing to an item of self.possible_values, use the item index - # to speed up the comparison. + def __repr__(self) -> str: + return f"<{self.__class__.__name__}({str(self.decode())})>" + + def __str__(self) -> str: + return str(self.decode_to_str()) + + def __eq__(self, other: Any) -> Union[ArrayType[bool], bool]: + """Compare equality with the item index. + + When comparing to an item of :attr:`.possible_values`, use the item + index to speed up the comparison. + + Whenever possible, use :meth:`numpy.ndarray.view` so that the result is + a classic :obj:`numpy.ndarray`, not an :obj:`.EnumArray`. + + Args: + other: Another object to compare to. + + Returns: + True, False, or a boolean :class:`numpy.ndarray`. + + Examples: + >>> from openfisca_core.indexed_enums import Enum + + >>> class MyEnum(Enum): + ... FOO = b"foo" + ... BAR = b"bar" + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, MyEnum) + + >>> enum_array == 1 + array([ True]) + + >>> enum_array == [1] + array([ True]) + + >>> enum_array == [2] + array([False]) + + >>> enum_array == "1" + False + + >>> enum_array == None + array([False]) + + """ + if other.__class__.__name__ is self.possible_values.__name__: - # Use view(ndarray) so that the result is a classic ndarray, not an - # EnumArray. return self.view(numpy.ndarray) == other.index return self.view(numpy.ndarray) == other - def __ne__(self, other: object) -> bool: + def __ne__(self, other: Any) -> Union[ArrayType[bool], bool]: + """Inequality… + + Args: + other: Another object to compare to. + + Returns: + True, False, or a boolean :class:`numpy.ndarray`. + + Examples: + >>> from openfisca_core.indexed_enums import Enum + + >>> class MyEnum(Enum): + ... FOO = b"foo" + ... BAR = b"bar" + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, MyEnum) + + >>> enum_array != 1 + array([False]) + + >>> enum_array != [1] + array([False]) + + >>> enum_array != [2] + array([ True]) + + >>> enum_array != "1" + True + + >>> enum_array != None + array([ True]) + + """ + return numpy.logical_not(self == other) def _forbidden_operation(self, other: Any) -> NoReturn: - msg = ( - "Forbidden operation. The only operations allowed on EnumArrays " - "are '==' and '!='." - ) raise TypeError( - msg, + "Forbidden operation. The only operations allowed on " + f"{self.__class__.__name__}s are '==' and '!='.", ) __add__ = _forbidden_operation @@ -64,42 +195,52 @@ def _forbidden_operation(self, other: Any) -> NoReturn: __and__ = _forbidden_operation __or__ = _forbidden_operation - def decode(self) -> numpy.object_: - """Return the array of enum items corresponding to self. + def decode(self) -> ArrayLike[SupportsEncode]: + """Decodes itself to a normal array. - For instance: + Returns: + The enum items of the :obj:`.EnumArray`. - >>> enum_array = household("housing_occupancy_status", period) - >>> enum_array[0] - >>> 2 # Encoded value - >>> enum_array.decode()[0] - + Examples: + >>> from openfisca_core.indexed_enums import Enum + + >>> class MyEnum(Enum): + ... FOO = b"foo" + ... BAR = b"bar" + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, MyEnum) + >>> enum_array.decode() + array([], dtype=object) - Decoded value: enum item """ + return numpy.select( [self == item.index for item in self.possible_values], list(self.possible_values), ) - def decode_to_str(self) -> numpy.str_: - """Return the array of string identifiers corresponding to self. + def decode_to_str(self) -> ArrayType[str]: + """Decodes itself to an array of strings. - For instance: + Returns: + The string identifiers of the :obj:`.EnumArray`. + + Examples: + >>> from openfisca_core.indexed_enums import Enum + + >>> class MyEnum(Enum): + ... FOO = b"foo" + ... BAR = b"bar" + + >>> array = numpy.array([1]) + >>> enum_array = EnumArray(array, MyEnum) + >>> enum_array.decode_to_str() + array(['BAR']...) - >>> enum_array = household("housing_occupancy_status", period) - >>> enum_array[0] - >>> 2 # Encoded value - >>> enum_array.decode_to_str()[0] - 'free_lodger' # String identifier """ + return numpy.select( [self == item.index for item in self.possible_values], [item.name for item in self.possible_values], ) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.decode()!s})" - - def __str__(self) -> str: - return str(self.decode_to_str()) diff --git a/openfisca_core/indexed_enums/tests/__init__.py b/openfisca_core/indexed_enums/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openfisca_core/indexed_enums/tests/test_enum.py b/openfisca_core/indexed_enums/tests/test_enum.py new file mode 100644 index 000000000..953a5e121 --- /dev/null +++ b/openfisca_core/indexed_enums/tests/test_enum.py @@ -0,0 +1,153 @@ +import numpy + +from openfisca_core.indexed_enums import Enum + + +class MyEnum(Enum): + """An enum…""" + + foo = b"foo" + bar = b"bar" + + +# ArrayLike["Enum"] + + +def test_enum_encode_with_enum_scalar_array(): + """Encodes when called with an enum scalar array.""" + + array = numpy.array(MyEnum.bar) + enum_array = MyEnum.encode(array) + assert enum_array == MyEnum.bar.index + + +def test_enum_encode_with_enum_sequence(): + """Does not encode when called with an enum sequence.""" + + sequence = list(MyEnum) + enum_array = MyEnum.encode(sequence) + assert enum_array[0] != MyEnum.bar.index + + +def test_enum_encode_with_enum_scalar(): + """Does not encode when called with an enum scalar.""" + + scalar = MyEnum.bar + enum_array = MyEnum.encode(scalar) + assert enum_array != MyEnum.bar.index + + +# ArrayLike[bytes] + + +def test_enum_encode_with_bytes_scalar_array(): + """Encodes when called with a bytes scalar array.""" + + array = numpy.array(b"bar") + enum_array = MyEnum.encode(array) + assert enum_array == MyEnum.bar.index + + +def test_enum_encode_with_bytes_sequence(): + """Does not encode when called with a bytes sequence.""" + + sequence = bytearray(b"bar") + enum_array = MyEnum.encode(sequence) + assert enum_array[0] != MyEnum.bar.index + + +def test_enum_encode_with_bytes_scalar(): + """Does not encode when called with a bytes scalar.""" + + scalar = b"bar" + enum_array = MyEnum.encode(scalar) + assert enum_array != MyEnum.bar.index + + +# ArrayLike[int] + + +def test_enum_encode_with_int_scalar_array(): + """Does not encode when called with an int scalar array (noop).""" + + array = numpy.array(1) + enum_array = MyEnum.encode(array) + assert enum_array == MyEnum.bar.index + + +def test_enum_encode_with_int_sequence(): + """Does not encode when called with an int sequence (noop).""" + + sequence = range(1, 2) + enum_array = MyEnum.encode(sequence) + assert enum_array[0] == MyEnum.bar.index + + +def test_enum_encode_with_int_scalar(): + """Does not encode when called with an int scalar (noop).""" + + scalar = 1 + enum_array = MyEnum.encode(scalar) + assert enum_array == MyEnum.bar.index + + +# ArrayLike[str] + + +def test_enum_encode_with_str_scalar_array(): + """Encodes when called with a str scalar array.""" + + array = numpy.array("bar") + enum_array = MyEnum.encode(array) + assert enum_array == MyEnum.bar.index + + +def test_enum_encode_with_str_sequence(): + """Does not encode when called with a str sequence.""" + + sequence = tuple(("bar",)) + enum_array = MyEnum.encode(sequence) + assert enum_array[0] != MyEnum.bar.index + + +def test_enum_encode_with_str_scalar(): + """Does not encode when called with a str scalar.""" + + scalar = "bar" + enum_array = MyEnum.encode(scalar) + assert enum_array != MyEnum.bar.index + + +# Unsupported encodings + + +def test_enum_encode_with_any_array(): + """Does not encode when called with unsupported types (noop).""" + + array = numpy.array([{"foo": "bar"}]) + enum_array = MyEnum.encode(array) + assert enum_array[0] == {"foo": "bar"} + + +def test_enum_encode_with_any_scalar_array(): + """Does not encode when called with unsupported types (noop).""" + + array = numpy.array(1.5) + enum_array = MyEnum.encode(array) + assert enum_array == 1.5 + + +def test_enum_encode_with_any_sequence(): + """Does not encode when called with unsupported types (noop).""" + + sequence = memoryview(b"bar") + enum_array = MyEnum.encode(sequence) + assert enum_array[0] == sequence[0] + + +def test_enum_encode_with_anything(): + """Does not encode when called with unsupported types (noop).""" + + anything = {object()} + enum_array = MyEnum.encode(anything) + assert enum_array == anything diff --git a/openfisca_core/indexed_enums/tests/test_enum_array.py b/openfisca_core/indexed_enums/tests/test_enum_array.py new file mode 100644 index 000000000..5ccc5256f --- /dev/null +++ b/openfisca_core/indexed_enums/tests/test_enum_array.py @@ -0,0 +1,37 @@ +import numpy +import pytest + +from openfisca_core.indexed_enums import Enum, EnumArray + + +class MyEnum(Enum): + """An enum…""" + + FOO = b"foo" + BAR = b"bar" + + +@pytest.fixture +def enum_array(): + """An enum array…""" + + return EnumArray([numpy.array(1)], MyEnum) + + +def test_enum_array_eq_operation(enum_array): + """The equality operation is permitted.""" + + assert enum_array == EnumArray([numpy.array(1)], MyEnum) + + +def test_enum_array_ne_operation(enum_array): + """The non-equality operation is permitted.""" + + assert enum_array != EnumArray([numpy.array(0)], MyEnum) + + +def test_enum_array_any_other_operation(enum_array): + """Only equality and non-equality operations are permitted.""" + + with pytest.raises(TypeError, match="Forbidden operation."): + enum_array * 1 diff --git a/openfisca_core/indexed_enums/types.py b/openfisca_core/indexed_enums/types.py index d69eb098a..e90f5b00e 100644 --- a/openfisca_core/indexed_enums/types.py +++ b/openfisca_core/indexed_enums/types.py @@ -1,3 +1,3 @@ -from openfisca_core.types import Array, DTypeEnum, Enum, EnumArray +from openfisca_core.types import Array, DTypeBool, Enum -__all__ = ["Array", "DTypeEnum", "Enum", "EnumArray"] +__all__ = ["Array", "DTypeBool", "Enum"] diff --git a/openfisca_core/types/protocols/builder.py b/openfisca_core/types/protocols/builder.py new file mode 100644 index 000000000..7120daa53 --- /dev/null +++ b/openfisca_core/types/protocols/builder.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Iterable, Sequence, Type, TypeVar +from typing_extensions import Protocol + +import abc + +RT = TypeVar("RT", covariant=True) +ET = TypeVar("ET", covariant=True) +EL = TypeVar("EL", contravariant=True) + + +class Builder(Protocol[RT, ET, EL]): + """Base type for any model implementing a builder protocol. + + Type-checking against abstractions rather than implementations helps in + (a) decoupling the codebse, thanks to structural subtyping, and + (b) documenting/enforcing the blueprints of the different OpenFisca models. + + .. versionadded:: 35.7.0 + + """ + + @abc.abstractmethod + def __init__(self, __builder: RT, __buildee: Type[ET]) -> None: ... + + @abc.abstractmethod + def __call__(self, __items: Iterable[EL]) -> Sequence[ET]: + """A concrete builder implements :meth:`.__call__`.""" + + ... + + @abc.abstractmethod + def build(self, __item: EL) -> ET: + """A concrete builder implements :meth:`.build`.""" + + ... diff --git a/openfisca_core/types/protocols/descriptor.py b/openfisca_core/types/protocols/descriptor.py new file mode 100644 index 000000000..aa5406960 --- /dev/null +++ b/openfisca_core/types/protocols/descriptor.py @@ -0,0 +1,20 @@ +from typing import Any, Optional, Type, TypeVar +from typing_extensions import Protocol + +T = TypeVar("T", covariant=True) + + +class Descriptor(Protocol[T]): + """Base type for any model implementing a descriptor protocol. + + Type-checking against abstractions rather than implementations helps in + (a) decoupling the codebse, thanks to structural subtyping, and + (b) documenting/enforcing the blueprints of the different OpenFisca models. + + .. versionadded:: 35.7.0 + + """ + + def __get__(self, __instance: Any, __owner: Type[Any]) -> Optional[T]: ... + + def __set__(self, __instance: Any, __value: Any) -> None: ...