Skip to content

Refactor subroles #1196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 41.2.0 [#1196](https://github.com/openfisca/openfisca-core/pull/1196)

#### New features

- Introduce `SubRole`.
- Allows for building subroles in an explicit manner.

#### Discussion

Currently, roles and subroles coexist and are used interchangeably, as
instances of the `Role` data model. This is very confusing, as the only thing
they have in common is the `key` attribute, shared with every other data model
in the `entities` module. By declaring `SubRole` as an independent data model
from `Role`, we can make the distinction between the two clearer, improving
readability, maintenance, debugging, and eventual extensibility of both.

### 41.1.2 [#1192](https://github.com/openfisca/openfisca-core/pull/1192)

#### Technical changes
Expand All @@ -21,12 +37,12 @@

- Make `Role` explicitly hashable.
- Details:
- By introducing `__eq__`, naturally `Role` became unhashable, because
equality was calculated based on a property of `Role`
(`role.key == another_role.key`), and no longer structurally
(`"1" == "1"`).
- This changeset removes `__eq__`, as `Role` is being used downstream as a
hashable object, and adds a test to ensure `Role`'s hashability.
- By introducing `__eq__`, naturally `Role` became unhashable, because
equality was calculated based on a property of `Role`
(`role.key == another_role.key`), and no longer structurally
(`"1" == "1"`).
- This changeset removes `__eq__`, as `Role` is being used downstream as a
hashable object, and adds a test to ensure `Role`'s hashability.

### 41.0.2 [#1194](https://github.com/openfisca/openfisca-core/pull/1194)

Expand Down
5 changes: 3 additions & 2 deletions openfisca_core/entities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
# See: https://www.python.org/dev/peps/pep-0008/#imports

from . import typing
from ._actions import build_entity
from ._subrole import SubRole
from .entity import Entity
from .group_entity import GroupEntity
from .helpers import build_entity
from .role import Role

__all__ = ["Entity", "GroupEntity", "Role", "build_entity", "typing"]
__all__ = ["Entity", "GroupEntity", "Role", "SubRole", "build_entity", "typing"]
81 changes: 81 additions & 0 deletions openfisca_core/entities/_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Actions related to the entities context."""

from __future__ import annotations

from collections.abc import Iterable, Mapping
from typing import Any

from .entity import Entity
from .group_entity import GroupEntity


def build_entity(
key: str,
plural: str,
label: str,
doc: str = "",
roles: Iterable[Mapping[str, Any]] | None = None,
is_person: bool = False,
class_override: Any | None = None,
containing_entities: Iterable[str] = (),
) -> Entity | GroupEntity:
"""Build an Entity` or GroupEntity.

Args:
key (str): Key to identify the Entity or GroupEntity.
plural (str): ``key``, pluralised.
label (str): A summary description.
doc (str): A full description.
roles (list) : A list of Role, if it's a GroupEntity.
is_person (bool): If is an individual, or not.
class_override: ?
containing_entities (list): Keys of contained entities.

Returns:
Entity or GroupEntity:
Entity: When ``is_person`` is True.
GroupEntity: When ``is_person`` is False.

Raises:
ValueError: If ``roles`` is not an Iterable.

Examples:
>>> from openfisca_core import entities

>>> 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 = entities.Role({"key": "key"}, object())

>>> build_entity(
... "syndicate",
... "syndicates",
... "Banks loaning jointly.",
... roles = role,
... )
Traceback (most recent call last):
ValueError: Invalid value 'Role(key)' for 'roles', must be an iterable.

"""

if is_person:
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 an iterable.")
54 changes: 54 additions & 0 deletions openfisca_core/entities/_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

import dataclasses
import textwrap


@dataclasses.dataclass(frozen=True)
class Description:
"""A description.

Examples:
>>> data = {
... "key": "parent",
... "label": "Parents",
... "plural": "parents",
... "doc": "\t\t\tThe one/two adults in charge of the household.",
... }

>>> description = Description(**data)

>>> repr(Description)
"<class 'openfisca_core.entities._description.Description'>"

>>> repr(description)
"Description(key='parent', plural='parents', label='Parents', ...)"

>>> str(description)
"Description(key='parent', plural='parents', label='Parents', ...)"

>>> {description}
{Description(key='parent', plural='parents', label='Parents', doc=...}

>>> description.key
'parent'

.. versionadded:: 41.0.1

"""

#: A key to identify an entity.
key: str

#: The ``key``, pluralised.
plural: str | None = None

#: A summary description.
label: str | None = None

#: A full description, non-indented.
doc: str | None = None

def __post_init__(self) -> None:
if self.doc is not None:
object.__setattr__(self, "doc", textwrap.dedent(self.doc))
71 changes: 71 additions & 0 deletions openfisca_core/entities/_subrole.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

import dataclasses

from .typing import Entity, GroupEntity, Role


@dataclasses.dataclass(frozen=True)
class SubRole:
"""The sub-role of a Role.

Each Role can be composed of one or several SubRole. For example, if you
have a Role "parent", its sub-roles could include "mother" and "father".

Attributes:
role (Role): The Role the SubRole belongs to.
key (str): A key to identify the SubRole.
max (int): Max number of members.

Args:
role (Role): The Role the SubRole belongs to.
key (str): A key to identify the SubRole.

Examples:
>>> from openfisca_core import entities

>>> entity = entities.GroupEntity("person", "", "", "", {})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The group entity is not clear here.

>>> role = entities.Role({"key": "sorority"}, entity)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sorority example is not very clear to me.

>>> subrole = SubRole(role, "sister")

>>> repr(SubRole)
"<class 'openfisca_core.entities._subrole.SubRole'>"

>>> repr(subrole)
"SubRole(role=Role(sorority), key='sister', max=1)"

>>> str(subrole)
"SubRole(role=Role(sorority), key='sister', max=1)"

>>> {subrole}
{SubRole(role=Role(sorority), key='sister', max=1)}

>>> subrole.entity.key
'person'

>>> subrole.role.key
'sorority'

>>> subrole.key
'sister'

>>> subrole.max
1

.. versionadded:: 41.2.0

"""

#: An id to identify the Role the SubRole belongs to.
role: Role

#: A key to identify the SubRole.
key: str

#: Max number of members.
max: int = 1

@property
def entity(self) -> Entity | GroupEntity:
"""The Entity the SubRole transitively belongs to."""
return self.role.entity
98 changes: 87 additions & 11 deletions openfisca_core/entities/entity.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,110 @@
from __future__ import annotations

from openfisca_core.types import TaxBenefitSystem, Variable
from typing import Any, Optional

import os
import textwrap

from ._description import Description
from ._subrole import SubRole
from .role import Role


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:
description (Description): A description of the Entity.
is_person (bool): Represents an individual? Defaults to True.

Args:
key (str): Key to identify the Entity.
plural (str): ``key``, pluralised.
label (str): A summary description.
doc (str): 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)
"<class 'openfisca_core.entities.entity.Entity'>"

>>> repr(entity)
'Entity(individual)'

>>> str(entity)
'Entity(individual)'

>>> {entity}
{Entity(individual)}

"""
Represents an entity (e.g. a person, a household, etc.) on which calculations can be run.
"""

#: A description of the Entity.
description: Description

#: Whether it represents an individual or not.
is_person: bool = True

#: The TaxBenefitSystem of the Entity, if defined.
_tax_benefit_system: TaxBenefitSystem | None = None

@property
def key(self) -> str:
"""A key to identify the Entity."""
return self.description.key

@property
def plural(self) -> str | None:
"""The ``key``, pluralised."""
return self.description.plural

@property
def label(self) -> str | None:
"""A summary description."""
return self.description.label

@property
def doc(self) -> str | None:
"""A full description, non-indented."""
return self.description.doc

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.description = Description(key, plural, label, doc)
self.is_person = True
self._tax_benefit_system = None

def set_tax_benefit_system(self, tax_benefit_system: TaxBenefitSystem):
def set_tax_benefit_system(self, tax_benefit_system: TaxBenefitSystem) -> None:
self._tax_benefit_system = tax_benefit_system

def check_role_validity(self, role: Any) -> None:
if role is not None and not isinstance(role, Role):
@staticmethod
def check_role_validity(role: Any) -> None:
if role is None:
return None

if not isinstance(role, (Role, SubRole)):
raise ValueError(f"{role} is not a valid role")

def get_variable(
self,
variable_name: str,
check_existence: bool = False,
) -> Optional[Variable]:
if self._tax_benefit_system is None:
message = (
f"The entity '{self}' has no TaxBenefitSystem defined yet.",
"You should call 'set_tax_benefit_system()' first.",
)
raise ValueError(os.linesep.join(message))

return self._tax_benefit_system.get_variable(variable_name, check_existence)

def check_variable_defined_for_entity(self, variable_name: str) -> None:
Expand All @@ -52,3 +125,6 @@ def check_variable_defined_for_entity(self, variable_name: str) -> None:
"<https://openfisca.org/doc/coding-the-legislation/50_entities.html>.",
)
raise ValueError(os.linesep.join(message))

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.key})"
Loading