diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d95ddaa3..9844ee02b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 35.8.0 [#1034](https://github.com/openfisca/openfisca-core/pull/1034) + +#### Documentation + +- Complete docs, doctests, and typing of the entities module + ### 35.7.1 [#1075](https://github.com/openfisca/openfisca-core/pull/1075) #### Bug fix diff --git a/openfisca_core/commons/__init__.py b/openfisca_core/commons/__init__.py index b3b5d8cbb2..241a3bcbec 100644 --- a/openfisca_core/commons/__init__.py +++ b/openfisca_core/commons/__init__.py @@ -16,8 +16,8 @@ * :class:`.Dummy` Note: - The ``deprecated`` imports are transitional, in order to ensure non-breaking - changes, and could be removed from the codebase in the next + The ``deprecated`` imports are transitional, in order to ensure + non-breaking changes, and could be removed from the codebase in the next major release. Note: @@ -32,7 +32,7 @@ from modularizing the different components of the library, which would make them easier to test and to maintain. - How they could be used in a future release: + How they could be used in a future release:: from openfisca_core import commons from openfisca_core.commons import deprecated diff --git a/openfisca_core/commons/formulas.py b/openfisca_core/commons/formulas.py index 6a90622147..86636f1bc1 100644 --- a/openfisca_core/commons/formulas.py +++ b/openfisca_core/commons/formulas.py @@ -1,8 +1,10 @@ +from __future__ import annotations + from typing import Any, Dict, Sequence, TypeVar +from openfisca_core.typing import ArrayLike, ArrayType import numpy -from openfisca_core.types import ArrayLike, ArrayType T = TypeVar("T") diff --git a/openfisca_core/commons/misc.py b/openfisca_core/commons/misc.py index dd05cea11b..2461b9afa2 100644 --- a/openfisca_core/commons/misc.py +++ b/openfisca_core/commons/misc.py @@ -1,6 +1,7 @@ -from typing import TypeVar +from __future__ import annotations -from openfisca_core.types import ArrayType +from typing import TypeVar +from openfisca_core.typing import ArrayType T = TypeVar("T") diff --git a/openfisca_core/commons/rates.py b/openfisca_core/commons/rates.py index d682824207..196f2072ea 100644 --- a/openfisca_core/commons/rates.py +++ b/openfisca_core/commons/rates.py @@ -1,9 +1,10 @@ +from __future__ import annotations + from typing import Optional +from openfisca_core.typing import ArrayLike, ArrayType import numpy -from openfisca_core.types import ArrayLike, ArrayType - def average_rate( target: ArrayType[float], diff --git a/openfisca_core/entities/__init__.py b/openfisca_core/entities/__init__.py index 15b38e2a5c..57cdc7d220 100644 --- a/openfisca_core/entities/__init__.py +++ b/openfisca_core/entities/__init__.py @@ -1,27 +1,95 @@ -# 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 .helpers import build_entity # noqa: F401 -from .role import Role # noqa: F401 -from .entity import Entity # noqa: F401 -from .group_entity import GroupEntity # noqa: F401 +"""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 distinction to be made between the "abstract" entities +described 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.check_role_validity` + +Note: + The ``deprecated`` imports are transitional, in order to ensure + non-breaking changes, and could be removed from the codebase in the next + 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 prevent us + from modularizing the different components of the library, which would make + them easier to test and to maintain. + + How they could be used in a future 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 .helpers import ( # noqa: F401 + Entity, + GroupEntity, + Role, + build_entity, + check_role_validity, + ) + +__all__ = ["Entity", "GroupEntity", "Role"] +__all__ = ["build_entity", "check_role_validity", *__all__] diff --git a/openfisca_core/entities/_variable_proxy.py b/openfisca_core/entities/_variable_proxy.py new file mode 100644 index 0000000000..0d5c8948c1 --- /dev/null +++ b/openfisca_core/entities/_variable_proxy.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import Any, Optional, Type +from typing_extensions import Protocol +from openfisca_core.typing import ( + EntityProtocol, + TaxBenefitSystemProtocol, + VariableProtocol, + ) + +import functools +import os + +doc_url = "https://openfisca.org/doc/coding-the-legislation" + + +class _Query(Protocol): + """A dummy class to duck-type :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: The :obj:`.Entity` ``owner`` of the descriptor. + tax_benefit_system: The :obj:`.Entity`'s :obj:`.TaxBenefitSystem`. + query: 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.8.0 + + """ + + entity: Optional[EntityProtocol] = None + tax_benefit_system: Optional[TaxBenefitSystemProtocol] = None + query: _Query + + def __get__( + self, + entity: EntityProtocol, + type: Type[EntityProtocol], + ) -> 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: EntityProtocol, value: Any) -> None: + NotImplemented + + def get(self, variable_name: str) -> Optional[VariableProtocol]: + """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.8.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 existence. + 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 c0da47f9bc..0732f7a846 100644 --- a/openfisca_core/entities/entity.py +++ b/openfisca_core/entities/entity.py @@ -1,40 +1,269 @@ -import os +from __future__ import annotations + +from typing import Any, Optional +from openfisca_core.typing import ( + RoleProtocol, + TaxBenefitSystemProtocol, + VariableProtocol, + ) + +import dataclasses import textwrap -from openfisca_core.entities import Role +from ._variable_proxy import _VariableProxy +@dataclasses.dataclass class Entity: + """Represents an entity 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: Key to identify the :class:`.Entity`. + plural: The ``key``, pluralised. + label: A summary description. + doc: A full description, dedented. + is_person: Represents an individual. Defaults to True. + + Args: + 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", + ... "\t\t\tThe minimal legal entity on which a rule might be a...", + ... ) + + >>> repr(Entity) + "" + + >>> repr(entity) + 'Entity(individual)' + + >>> str(entity) + 'individuals' + + .. versionchanged:: 35.8.0 + Added documentation, doctests, and typing. + """ - Represents an entity (e.g. a person, a household, etc.) on which calculations can be run. - """ - def __init__(self, key, plural, label, doc): + __slots__ = tuple(( + "_tax_benefit_system", + "doc", + "is_person", + "key", + "label", + "plural", + )) + + key: str + plural: str + label: str + doc: str + is_person: bool + _variables: _VariableProxy = dataclasses.field( + init = False, + compare = False, + default = _VariableProxy(), + ) + + def __init__(self, key: str, plural: str, label: str, doc: str) -> None: self.key = key self.label = label self.plural = plural self.doc = textwrap.dedent(doc) self.is_person = True - self._tax_benefit_system = None - - def set_tax_benefit_system(self, tax_benefit_system): - self._tax_benefit_system = tax_benefit_system - - def check_role_validity(self, role): - if role is not None and not type(role) == Role: - raise ValueError("{} is not a valid role".format(role)) - - def get_variable(self, variable_name, check_existence = False): - return self._tax_benefit_system.get_variable(variable_name, check_existence) - - def check_variable_defined_for_entity(self, variable_name): - variable_entity = self.get_variable(variable_name, check_existence = True).entity - # Should be this: - # if variable_entity is not self: - if variable_entity.key != self.key: - message = os.linesep.join([ - "You tried to compute the variable '{0}' for the entity '{1}';".format(variable_name, self.plural), - "however the variable '{0}' is defined for '{1}'.".format(variable_name, variable_entity.plural), - "Learn more about entities in our documentation:", - "."]) - raise ValueError(message) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.key})" + + def __str__(self) -> str: + return self.plural + + @property + def tax_benefit_system(self) -> Optional[TaxBenefitSystemProtocol]: + """An :obj:`.Entity` belongs to a :obj:`.TaxBenefitSystem`.""" + + return self._tax_benefit_system + + @tax_benefit_system.setter + def tax_benefit_system(self, value: TaxBenefitSystemProtocol) -> None: + self._tax_benefit_system = value + + def set_tax_benefit_system( + self, + tax_benefit_system: TaxBenefitSystemProtocol, + ) -> None: + """Sets ``_tax_benefit_system``. + + Args: + tax_benefit_system: To query variables from. + + .. deprecated:: 35.8.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 + + @property + def variables(self) -> Optional[_VariableProxy]: + """An :class:`.Entity` has many :class:`Variables <.Variable>`.""" + + return self._variables + + @staticmethod + def check_role_validity(role: Any) -> None: + """Checks if ``role`` is an instance of :class:`.Role`. + + Args: + role: Any object. + + Raises: + ValueError: When ``role`` is not a :class:`.Role`. + + .. deprecated:: 35.8.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`. + + """ + + if role is not None and not isinstance(role, RoleProtocol): + raise ValueError(f"{role} is not a valid role") + + def get_variable( + self, + variable_name: str, + check_existence: bool = False, + ) -> Optional[VariableProtocol]: + """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. + + Examples: + >>> from openfisca_core.taxbenefitsystems import TaxBenefitSystem + >>> from openfisca_core.variables import Variable + + >>> this = Entity("this", "", "", "") + >>> that = Entity("that", "", "", "") + + >>> this.get_variable("foo") + + >>> this.get_variable("foo", check_existence = True) + + >>> this.tax_benefit_system = TaxBenefitSystem([that]) + + >>> this.get_variable("foo") + + >>> this.get_variable("foo", check_existence = True) + Traceback (most recent call last): + VariableNotFoundError: You tried to calculate or to set a value... + + >>> class foo(Variable): + ... definition_period = "month" + ... value_type = float + ... entity = that + + >>> this.tax_benefit_system.add_variable(foo) + + + >>> this.get_variable("foo") + + + .. versionchanged:: 35.8.0 + Added documentation, doctests, and typing. + + """ + + if self.variables is None: + return None + + if check_existence: + return self.variables.exists().get(variable_name) + + return self.variables.get(variable_name) + + def check_variable_defined_for_entity( + self, + variable_name: str, + ) -> Optional[VariableProtocol]: + """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`. + + Examples: + >>> from openfisca_core.taxbenefitsystems import TaxBenefitSystem + >>> from openfisca_core.variables import Variable + + >>> this = Entity("this", "", "", "") + >>> that = Entity("that", "", "", "") + + >>> this.check_variable_defined_for_entity("foo") + + >>> this.tax_benefit_system = TaxBenefitSystem([that]) + >>> this.check_variable_defined_for_entity("foo") + Traceback (most recent call last): + VariableNotFoundError: You tried to calculate or to set a value... + + >>> class foo(Variable): + ... definition_period = "month" + ... value_type = float + ... entity = that + + >>> this.tax_benefit_system.add_variable(foo) + + + >>> this.check_variable_defined_for_entity("foo") + Traceback (most recent call last): + ValueError: You tried to compute the variable 'foo' for the enti... + + >>> foo.entity = this + >>> this.tax_benefit_system.update_variable(foo) + + + >>> this.check_variable_defined_for_entity("foo") + + + .. versionchanged:: 35.8.0 + Added documentation, doctests, and typing. + + """ + + if self.variables is None: + return None + + return self.variables.isdefined().get(variable_name) diff --git a/openfisca_core/entities/group_entity.py b/openfisca_core/entities/group_entity.py index 0d58acc6ba..660c1b90fa 100644 --- a/openfisca_core/entities/group_entity.py +++ b/openfisca_core/entities/group_entity.py @@ -1,6 +1,16 @@ -from openfisca_core.entities import Entity, Role +from __future__ import annotations +from typing import Any, Iterable, Mapping, Sequence +from openfisca_core.typing import RoleProtocol, RoleSchema +import dataclasses +from itertools import chain + +from .entity import Entity +from .role import Role + + +@dataclasses.dataclass class GroupEntity(Entity): """Represents an entity containing several others with different roles. @@ -8,14 +18,79 @@ class GroupEntity(Entity): several other :class:`.Entity` with different :class:`.Role`, and on which calculations can be run. + Attributes: + key: + Key to identify the :class:`.GroupEntity`. + plural: + The ``key``, pluralised. + label: + A summary description. + doc: + A full description, dedented. + is_person: + Represents an individual? Defaults to False. + roles: + List of the roles of the group entity. + flattened_roles: + ``roles`` flattened out. + containing_entities: + Keys of :obj:`.GroupEntity` whose members are guaranteed to be a + superset of this group's entities. + Args: - key: A key to identify the group entity. - plural: The ``key``, pluralised. - label: A summary description. - doc: A full description. - roles: The list of :class:`.Role` 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. + key: + Key to identify the :class:`.GroupEntity`. + plural: + The ``key``, pluralised. + label: + A summary description. + doc: + A full description, dedented. + roles: + The list of :class:`.Role` of the :class:`.GroupEntity`. + containing_entities: + The list of keys of :obj:`.GroupEntity` whose members are + guaranteed to be a superset of this group's entities. + + Examples: + >>> family_roles = [{ + ... "key": "parent", + ... "subroles": ["first_parent", "second_parent"], + ... }] + + >>> family = GroupEntity( + ... "family", + ... "families", + ... "A family", + ... "\t\t\tAll the people somehow related living together.", + ... family_roles, + ... ) + + >>> household_roles = [{ + ... "key": "partners", + ... "subroles": ["first_partner", "second_partner"], + ... }] + + >>> household = GroupEntity( + ... "household", + ... "households", + ... "A household", + ... "\t\t\tAll the people who live together in the same place.", + ... household_roles, + ... (family.key,), + ... ) + + >>> repr(GroupEntity) + "" + + >>> repr(household) + 'GroupEntity(household)' + + >>> str(household) + 'households' + + .. versionchanged:: 35.8.0 + Added documentation, doctests, and typing. .. versionchanged:: 35.7.0 Added ``containing_entities``, that allows the defining of group @@ -23,21 +98,151 @@ class GroupEntity(Entity): """ - def __init__(self, key, plural, label, doc, roles, containing_entities = ()): + __slots__ = tuple(( + "_cached_roles", + "_tax_benefit_system", + "containing_entities", + "doc", + "flattened_roles", + "is_person", + "key", + "label", + "plural", + "roles", + "roles_description", + )) + + is_person: bool + roles: Sequence[RoleProtocol] + containing_entities: Sequence[str] + flattened_roles: Sequence[RoleProtocol] + roles_description: Sequence[RoleSchema] + _cached_roles: Mapping[str, RoleProtocol] + + def __init__( + self, + key: str, + plural: str, + label: str, + doc: str, + roles: Sequence[RoleSchema], + containing_entities: Sequence[str] = (), + ): + super().__init__(key, plural, label, doc) - self.roles_description = roles - self.roles = [] - for role_description in roles: - role = Role(role_description, self) - setattr(self, role.key.upper(), role) - self.roles.append(role) - if role_description.get('subroles'): - role.subroles = [] - for subrole_key in role_description['subroles']: - subrole = Role({'key': subrole_key, 'max': 1}, self) - setattr(self, subrole.key.upper(), subrole) - role.subroles.append(subrole) - role.max = len(role.subroles) - self.flattened_roles = sum([role2.subroles or [role2] for role2 in self.roles], []) self.is_person = False self.containing_entities = containing_entities + self.roles = tuple(_build_role(self, desc) for desc in roles) + self.flattened_roles = _flatten_roles(self.roles) + self.roles_description = roles + self._cached_roles = _cache_roles((*self.roles, *self.flattened_roles)) + + def __getattr__(self, attr: str) -> Any: + if attr.isupper(): + role = self._cached_roles.get(attr) + + if role is not None: + return role + + return self.__getattribute__(attr) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.key})" + + def __str__(self) -> str: + return self.plural + + +def _build_role(entity: GroupEntity, description: RoleSchema) -> Role: + """Build roles & sub-roles. + + Args: + entity: A :obj:`.GroupEntity`. + description: A :obj:`dict` to build a :obj:`.Role`. + + Returns: + Role. + + Examples: + >>> description = {"key": "key"} + >>> _build_role(object(), description) + Role(key) + + >>> _build_role(object(), {}) + Traceback (most recent call last): + KeyError: 'key' + + .. versionadded:: 35.8.0 + + """ + + role = Role(description, entity) + subroles = description.get("subroles", ()) + + if subroles: + role.subroles = () + + for key in subroles: + subrole = Role({"key": key, "max": 1}, entity) + role.subroles = (*role.subroles, subrole) + + role.max = len(role.subroles) + + return role + + +def _flatten_roles(roles: Sequence[RoleProtocol]) -> Sequence[RoleProtocol]: + """Flattens roles' subroles to a sequence. + + Args: + roles: A list of :obj:`.Role`. + + Returns: + A list of subroles. + + Examples: + >>> _flatten_roles([]) + () + + >>> role = Role({"key": "key"}, object()) + >>> _flatten_roles([role]) + (Role(key),) + + >>> role.subroles = role, + >>> _flatten_roles([role]) + (Role(key),) + + >>> subrole = Role({"key": "llave"}, object()) + >>> role.subroles += subrole, + >>> _flatten_roles([role]) + (Role(key), Role(llave)) + + .. versionadded:: 35.8.0 + + """ + + tree: Iterable[Iterable[RoleProtocol]] + tree = [role.subroles or [role] for role in roles] + + return tuple(chain.from_iterable(tree)) + + +def _cache_roles(roles: Sequence[RoleProtocol]) -> Mapping[str, RoleProtocol]: + """Create a cached dictionary of :obj:`.Role`. + + Args: + roles: The :obj:`.Role` to cache. + + Returns: + Dictionary with the cached :obj:`.Role`. + + Examples: + >>> role = Role({"key": "key"}, object()) + >>> _cache_roles([role]) + {'KEY': Role(key)} + + .. versionadded:: 35.8.0 + + """ + + return {role.key.upper(): role for role in roles} diff --git a/openfisca_core/entities/helpers.py b/openfisca_core/entities/helpers.py index 86d7bb6a6b..9825795581 100644 --- a/openfisca_core/entities/helpers.py +++ b/openfisca_core/entities/helpers.py @@ -1,8 +1,110 @@ -from openfisca_core import entities +from __future__ import annotations +from typing import Any, Optional, Sequence +from openfisca_core.typing import EntityProtocol, RoleSchema + +from .entity import Entity +from .group_entity import GroupEntity +from .role import Role + + +def build_entity( + key: str, + plural: str, + label: str, + doc: str = "", + roles: Optional[Sequence[RoleSchema]] = None, + is_person: bool = False, + class_override: Optional[Any] = None, + containing_entities: Sequence[str] = (), + ) -> EntityProtocol: + """Builds an :class:`.Entity` or a :class:`.GroupEntity`. + + Args: + 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 :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: + :obj:`.Entity` or :obj:`.GroupEntity`: + :obj:`.Entity`: When ``is_person`` is True. + :obj:`.GroupEntity`: When ``is_person`` is False. + + Raises: + ValueError: If ``roles`` is not a sequence. + + Examples: + >>> build_entity( + ... "syndicate", + ... "syndicates", + ... "Banks loaning jointly.", + ... roles = [], + ... containing_entities = (), + ... ) + GroupEntity(syndicate) + + >>> build_entity( + ... "company", + ... "companies", + ... "A small or medium company.", + ... is_person = True, + ... ) + Entity(company) + + >>> role = Role({"key": "key"}, object()) + >>> build_entity( + ... "syndicate", + ... "syndicates", + ... "Banks loaning jointly.", + ... roles = role, + ... ) + Traceback (most recent call last): + ValueError: Invalid value 'key' for 'roles', must be a list. + + .. versionchanged:: 35.8.0 + Instead of raising :exc:`TypeError` when ``roles`` is None, it does + now raise :exc:`ValueError` when ``roles`` is not iterable. + + .. versionchanged:: 35.7.0 + Builder accepts a ``containing_entities`` attribute, that allows the + defining of group entities which entirely contain other group entities. + + """ -def build_entity(key, plural, label, doc = "", roles = None, is_person = False, class_override = None, containing_entities = ()): if is_person: - return entities.Entity(key, plural, label, doc) - else: - return entities.GroupEntity(key, plural, label, doc, roles, containing_entities = containing_entities) + return Entity(key, plural, label, doc) + + if isinstance(roles, (list, tuple)): + return GroupEntity(key, plural, label, doc, roles, containing_entities) + + raise ValueError(f"Invalid value '{roles}' for 'roles', must be a list.") + + +def check_role_validity(role: Any) -> None: + """Checks if ``role`` is an instance of :class:`.Role`. + + Args: + role: Any object. + + Raises: + ValueError: When ``role`` is not a :class:`.Role`. + + Examples: + >>> role = Role({"key": "key"}, object()) + >>> check_role_validity(role) + + >>> check_role_validity("hey!") + Traceback (most recent call last): + ValueError: hey! is not a valid role + + .. versionadded:: 35.8.0 + + """ + + if role is not None and not isinstance(role, Role): + raise ValueError(f"{role} is not a valid role") diff --git a/openfisca_core/entities/role.py b/openfisca_core/entities/role.py index ea815ed513..f4adee63c2 100644 --- a/openfisca_core/entities/role.py +++ b/openfisca_core/entities/role.py @@ -1,16 +1,85 @@ +from __future__ import annotations + +from typing import Optional, Sequence +from openfisca_core.typing import GroupEntityProtocol, RoleProtocol, RoleSchema + +import dataclasses import textwrap +@dataclasses.dataclass 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: Entity the :class:`.Role` belongs to. + key: Key to identify the :class:`.Role`. + plural: The ``key``, pluralised. + label: A summary description. + doc: A full description, dedented. + max: Max number of members. Defaults to None. + subroles: The ``subroles``. Defaults to None. + + Args: + description: A dictionary containing most of the attributes. + entity: :obj:`.Entity` the :class:`.Role` belongs to. + + Examples: + >>> description = { + ... "key": "parent", + ... "label": "Parents", + ... "plural": "parents", + ... "doc": "\t\t\tThe one/two adults in charge of the household.", + ... "max": 2, + ... } + + >>> role = Role(description, object()) + + >>> repr(Role) + "" + + >>> repr(role) + 'Role(parent)' + + >>> str(role) + 'parent' + + .. versionchanged:: 35.8.0 + Added documentation, doctests, and typing. + + """ + + __slots__ = "entity", "key", "plural", "label", "doc", "max", "subroles" + + entity: GroupEntityProtocol + key: str + plural: Optional[str] + label: Optional[str] + doc: Optional[str] + max: Optional[int] + subroles: Optional[Sequence[RoleProtocol]] + + def __init__( + self, + description: RoleSchema, + entity: GroupEntityProtocol, + ) -> None: - def __init__(self, description, entity): self.entity = entity self.key = description['key'] - self.label = description.get('label') self.plural = description.get('plural') - self.doc = textwrap.dedent(description.get('doc', "")) + self.label = description.get('label') + self.doc = textwrap.dedent(str(description.get('doc', ""))) self.max = description.get('max') self.subroles = None - def __repr__(self): - return "Role({})".format(self.key) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.key})" + + def __str__(self) -> str: + return self.key diff --git a/openfisca_core/entities/tests/__init__.py b/openfisca_core/entities/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/entities/tests/test_entity.py b/openfisca_core/entities/tests/test_entity.py new file mode 100644 index 0000000000..122a030c8c --- /dev/null +++ b/openfisca_core/entities/tests/test_entity.py @@ -0,0 +1,11 @@ +from openfisca_core.entities import Entity + + +def test_init_when_doc_indented(): + """Unindents the ``doc`` attribute if it is passed at initialisation.""" + + key = "\tkey" + doc = "\tdoc" + entity = Entity(key, "label", "plural", doc) + assert entity.key == key + assert entity.doc != doc diff --git a/openfisca_core/entities/tests/test_group_entity.py b/openfisca_core/entities/tests/test_group_entity.py new file mode 100644 index 0000000000..c4530efc14 --- /dev/null +++ b/openfisca_core/entities/tests/test_group_entity.py @@ -0,0 +1,45 @@ +import pytest + +from openfisca_core.entities import GroupEntity + + +@pytest.fixture +def roles(): + """A role-like.""" + + return [{"key": "parent", "subroles": ["first_parent", "second_parent"]}] + + +@pytest.fixture +def group_entity(roles): + """A group entity.""" + + return GroupEntity("key", "label", "plural", "doc", roles) + + +def test_init_when_doc_indented(): + """Unindents the ``doc`` attribute if it is passed at initialisation.""" + + key = "\tkey" + doc = "\tdoc" + group_entity = GroupEntity(key, "label", "plural", doc, []) + assert group_entity.key == key + assert group_entity.doc != doc + + +def test_group_entity_with_roles(group_entity): + """Assigns a :obj:`.Role` for each role-like passed as argument.""" + + assert group_entity.PARENT + + with pytest.raises(AttributeError): + assert group_entity.CHILD + + +def test_group_entity_with_subroles(group_entity): + """Assigns a :obj:`.Role` for each subrole-like passed as argument.""" + + assert group_entity.FIRST_PARENT + + with pytest.raises(AttributeError): + assert group_entity.FIRST_CHILD diff --git a/openfisca_core/entities/tests/test_role.py b/openfisca_core/entities/tests/test_role.py new file mode 100644 index 0000000000..8a24b3f61e --- /dev/null +++ b/openfisca_core/entities/tests/test_role.py @@ -0,0 +1,11 @@ +from openfisca_core.entities import Role + + +def test_init_when_doc_indented(): + """Unindents the ``doc`` attribute if it is passed at initialisation.""" + + key = "\tkey" + doc = "\tdoc" + role = Role({"key": key, "doc": doc}, object()) + assert role.key == key + 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 0000000000..ae6c720dd2 --- /dev/null +++ b/openfisca_core/entities/tests/test_variable_proxy.py @@ -0,0 +1,153 @@ +import pytest + +from openfisca_core.entities import Entity +from openfisca_core.entities._variable_proxy import _VariableProxy +from openfisca_core.errors import VariableNotFoundError +from openfisca_core.taxbenefitsystems import TaxBenefitSystem +from openfisca_core.variables import Variable + + +@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): + """Another 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_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 existence.""" + + 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 existence.""" + + 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/types/__init__.py b/openfisca_core/types/__init__.py deleted file mode 100644 index e14cfea65d..0000000000 --- a/openfisca_core/types/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Data types and protocols used by OpenFisca Core. - -The type definitions included in this sub-package are intented for -contributors, to help them better understand and document contracts -and expected behaviours. - -Official Public API: - * ``ArrayLike`` - * :attr:`.ArrayType` - -Note: - How imports are being used today:: - - from openfisca_core.types import * # Bad - from openfisca_core.types.data_types.arrays import ArrayLike # 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.types import ArrayLike - - ArrayLike # Good: import types 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 .data_types import ( # noqa: F401 - ArrayLike, - ArrayType, - ) - -__all__ = ["ArrayLike", "ArrayType"] diff --git a/openfisca_core/types/data_types/__init__.py b/openfisca_core/types/data_types/__init__.py deleted file mode 100644 index 6dd38194e3..0000000000 --- a/openfisca_core/types/data_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .arrays import ArrayLike, ArrayType # noqa: F401 diff --git a/openfisca_core/typing/__init__.py b/openfisca_core/typing/__init__.py new file mode 100644 index 0000000000..cc20997c5c --- /dev/null +++ b/openfisca_core/typing/__init__.py @@ -0,0 +1,72 @@ +"""Data types and protocols used by OpenFisca Core. + +The type definitions included in this sub-package are intented for +contributors, to help them better understand and document contracts +and expected behaviours. + +Official Public API: + * :data:`.ArrayLike` + * :attr:`.ArrayType` + * :class:`.EntityProtocol` + * :class:`.FormulaProtocol` + * :class:`.GroupEntityProtocol` + * :class:`.RoleProtocol` + * :class:`.TaxBenefitSystemProtocol` + * :class:`.GroupEntityProtocol` + * :class:`.RoleProtocol` + * :class:`.TaxBenefitSystemProtocol` + * :class:`.VariableProtocol` + * :class:`.RoleSchema` + +Note: + How imports are being used today:: + + from openfisca_core.typing import * # Bad + from openfisca_core.typing._types import ArrayLike # 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.typing import ArrayLike + + ArrayLike # Good: import types 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 ._types import ( # noqa: F401 + ArrayLike, + ArrayType, + ) + +__all__ = ["ArrayLike", "ArrayType"] + +from ._protocols import ( # noqa: F401 + EntityProtocol, + FormulaProtocol, + GroupEntityProtocol, + RoleProtocol, + TaxBenefitSystemProtocol, + VariableProtocol, + ) + +__all__ = ["EntityProtocol", "GroupEntityProtocol", "RoleProtocol", *__all__] +__all__ = ["FormulaProtocol", "TaxBenefitSystemProtocol", *__all__] +__all__ = ["VariableProtocol", *__all__] + +from ._schemas import RoleSchema # noqa: F401 + +__all__ = ["RoleSchema", *__all__] diff --git a/openfisca_core/typing/_protocols.py b/openfisca_core/typing/_protocols.py new file mode 100644 index 0000000000..b1af51ef44 --- /dev/null +++ b/openfisca_core/typing/_protocols.py @@ -0,0 +1,72 @@ +# pylint: disable=missing-function-docstring + +from __future__ import annotations + +from typing import Any, Optional, Sequence +from typing_extensions import Protocol, runtime_checkable + +import abc + + +class EntityProtocol(Protocol): + """Duck-type for entities. + + .. versionadded:: 35.8.0 + + """ + + key: str + plural: str + + +class FormulaProtocol(Protocol): + """Duck-type for formulas. + + .. versionadded:: 35.8.0 + + """ + + +class GroupEntityProtocol(EntityProtocol, Protocol): + """Duck-type for group entities. + + .. versionadded:: 35.8.0 + + """ + + roles: Sequence[RoleProtocol] + roles_description: Sequence[Any] + flattened_roles: Sequence[RoleProtocol] + + +@runtime_checkable +class RoleProtocol(Protocol): + """Duck-type for roles. + + .. versionadded:: 35.8.0 + + """ + + key: str + max: Optional[int] + subroles: Optional[Sequence[RoleProtocol]] + + +class TaxBenefitSystemProtocol(Protocol): + """Duck-type for tax-benefit systems. + + .. versionadded:: 35.8.0 + + """ + + @abc.abstractmethod + def get_variable(self, __arg1: str, __arg2: bool = False) -> Optional[Any]: + ... + + +class VariableProtocol(Protocol): + """Duck-type for variables. + + .. versionadded:: 35.8.0 + + """ diff --git a/openfisca_core/typing/_schemas.py b/openfisca_core/typing/_schemas.py new file mode 100644 index 0000000000..186e2ef083 --- /dev/null +++ b/openfisca_core/typing/_schemas.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Optional, Sequence +from typing_extensions import TypedDict + + +class RoleSchema(TypedDict, total = False): + """Data-schema for :class:`.Role`. + + .. versionadded:: 35.8.0 + + """ + + key: str + plural: Optional[str] + label: Optional[str] + doc: Optional[str] + max: Optional[int] + subroles: Optional[Sequence[str]] diff --git a/openfisca_core/types/data_types/arrays.py b/openfisca_core/typing/_types.py similarity index 90% rename from openfisca_core/types/data_types/arrays.py rename to openfisca_core/typing/_types.py index 5cfef639c5..943bfbe568 100644 --- a/openfisca_core/types/data_types/arrays.py +++ b/openfisca_core/typing/_types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Sequence, TypeVar, Union from nptyping import types, NDArray as ArrayType @@ -34,10 +36,13 @@ Todo: * Refactor once numpy version >= 1.21 is used. -.. versionadded:: 35.5.0 +.. versionchanged:: 35.8.0 + Moved to :mod:`.openfisca_core.typing` .. versionchanged:: 35.6.0 - Moved to :mod:`.types` + Moved to ``openfisca_core.types`` + +.. versionadded:: 35.5.0 .. _mypy: https://mypy.readthedocs.io/en/stable/ diff --git a/openfisca_core/variables/variable.py b/openfisca_core/variables/variable.py index 61a5d9274f..acfeb9fe70 100644 --- a/openfisca_core/variables/variable.py +++ b/openfisca_core/variables/variable.py @@ -309,7 +309,7 @@ def get_formula(self, period = None): If no period is given and the variable has several formula, return the oldest formula. :returns: Formula used to compute the variable - :rtype: .Formula + :rtype: callable """ diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 115c6267bb..191d48305f 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -17,7 +17,8 @@ check-style: $(shell git ls-files "*.py") ## Run linters to check for syntax and style errors in the doc. lint-doc: \ lint-doc-commons \ - lint-doc-types \ + lint-doc-entities \ + lint-doc-typing \ ; ## Run linters to check for syntax and style errors in the doc. @@ -42,7 +43,8 @@ check-types: ## Run static type checkers for type errors (strict). lint-typing-strict: \ lint-typing-strict-commons \ - lint-typing-strict-types \ + lint-typing-strict-entities \ + lint-typing-strict-typing \ ; ## Run static type checkers for type errors (strict). diff --git a/setup.cfg b/setup.cfg index bb3ff50fc5..add99164c5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,9 +14,10 @@ extend-ignore = D hang-closing = true ignore = E128,E251,F403,F405,E501,RST301,W503,W504 in-place = true -include-in-doctest = openfisca_core/commons openfisca_core/types +include-in-doctest = openfisca_core/commons openfisca_core/entities openfisca_core/typing +per-file-ignores = openfisca_core/typing/_protocols.py:D102 rst-directives = attribute, deprecated, seealso, versionadded, versionchanged -rst-roles = any, attr, class, exc, func, meth, obj +rst-roles = any, attr, class, data, exc, func, meth, obj strictness = short [pylint.message_control] @@ -41,7 +42,7 @@ skip_empty = true addopts = --doctest-modules --disable-pytest-warnings --showlocals doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL NUMBER NORMALIZE_WHITESPACE python_files = **/*.py -testpaths = openfisca_core/commons openfisca_core/types tests +testpaths = openfisca_core/commons openfisca_core/entities openfisca_core/typing tests [mypy] ignore_missing_imports = True @@ -51,5 +52,8 @@ non_interactive = True [mypy-openfisca_core.commons.tests.*] ignore_errors = True +[mypy-openfisca_core.entities.tests.*] +ignore_errors = True + [mypy-openfisca_core.scripts.*] ignore_errors = True diff --git a/setup.py b/setup.py index 36e30a751e..6b4ef20dcf 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ setup( name = 'OpenFisca-Core', - version = '35.7.1', + version = '35.8.0', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [