Skip to content

Commit adcc161

Browse files
authored
refactor: add typing to & do maintenance of periods (#1223)
2 parents 7d451a1 + e68657e commit adcc161

File tree

22 files changed

+935
-464
lines changed

22 files changed

+935
-464
lines changed

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
# Changelog
22

3+
# 42.0.0 [#1223](https://github.com/openfisca/openfisca-core/pull/1223)
4+
5+
#### Breaking changes
6+
7+
- Changes to `eternity` instants and periods
8+
- Eternity instants are now `<Instant(-1, -1, -1)>` instead of
9+
`<Instant(inf, inf, inf)>`
10+
- Eternity periods are now `<Period(('eternity', <Instant(-1, -1, -1)>, -1))>`
11+
instead of `<Period(('eternity', <Instant(inf, inf, inf)>, inf))>`
12+
- The reason is to avoid mixing data types: `inf` is a float, periods and
13+
instants are integers. Mixed data types make memory optimisations impossible.
14+
- Migration should be straightforward. If you have a test that checks for
15+
`inf`, you should update it to check for `-1` or use the `is_eternal` method.
16+
- `periods.instant` no longer returns `None`
17+
- Now, it raises `periods.InstantError`
18+
19+
#### New features
20+
21+
- Introduce `Instant.eternity()`
22+
- This behaviour was duplicated across
23+
- Now it is encapsulated in a single method
24+
- Introduce `Instant.is_eternal` and `Period.is_eternal`
25+
- These methods check if the instant or period are eternity (`bool`).
26+
- Now `periods.instant` parses also ISO calendar strings (weeks)
27+
- For instance, `2022-W01` is now a valid input
28+
29+
#### Technical changes
30+
31+
- Update `pendulum`
32+
- Reduce code complexity
33+
- Remove run-time type-checks
34+
- Add typing to the periods module
35+
336
### 41.5.7 [#1225](https://github.com/openfisca/openfisca-core/pull/1225)
437

538
#### Technical changes

openfisca_core/commons/misc.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ def empty_clone(original: object) -> object:
3030
3131
"""
3232

33+
def __init__(_: object) -> None: ...
34+
3335
Dummy = type(
3436
"Dummy",
3537
(original.__class__,),
36-
{"__init__": lambda _: None},
38+
{"__init__": __init__},
3739
)
3840

3941
new = Dummy()
@@ -69,6 +71,7 @@ def stringify_array(array: None | t.Array[numpy.generic]) -> str:
6971
"[<class 'list'>, {}, <function stringify_array...]"
7072
7173
"""
74+
7275
if array is None:
7376
return "None"
7477

openfisca_core/entities/py.typed

Whitespace-only changes.

openfisca_core/periods/__init__.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,59 @@
2121
#
2222
# See: https://www.python.org/dev/peps/pep-0008/#imports
2323

24-
from .config import ( # noqa: F401
25-
DAY,
26-
ETERNITY,
24+
from . import types
25+
from ._errors import InstantError, ParserError, PeriodError
26+
from .config import (
2727
INSTANT_PATTERN,
28-
MONTH,
29-
WEEK,
30-
WEEKDAY,
31-
YEAR,
3228
date_by_instant_cache,
3329
str_by_instant_cache,
3430
year_or_month_or_day_re,
3531
)
36-
from .date_unit import DateUnit # noqa: F401
37-
from .helpers import ( # noqa: F401
32+
from .date_unit import DateUnit
33+
from .helpers import (
3834
instant,
3935
instant_date,
4036
key_period_size,
4137
period,
4238
unit_weight,
4339
unit_weights,
4440
)
45-
from .instant_ import Instant # noqa: F401
46-
from .period_ import Period # noqa: F401
41+
from .instant_ import Instant
42+
from .period_ import Period
43+
44+
WEEKDAY = DateUnit.WEEKDAY
45+
WEEK = DateUnit.WEEK
46+
DAY = DateUnit.DAY
47+
MONTH = DateUnit.MONTH
48+
YEAR = DateUnit.YEAR
49+
ETERNITY = DateUnit.ETERNITY
50+
ISOFORMAT = DateUnit.isoformat
51+
ISOCALENDAR = DateUnit.isocalendar
52+
53+
__all__ = [
54+
"DAY",
55+
"DateUnit",
56+
"ETERNITY",
57+
"INSTANT_PATTERN",
58+
"ISOCALENDAR",
59+
"ISOFORMAT",
60+
"Instant",
61+
"InstantError",
62+
"MONTH",
63+
"ParserError",
64+
"Period",
65+
"PeriodError",
66+
"WEEK",
67+
"WEEKDAY",
68+
"YEAR",
69+
"date_by_instant_cache",
70+
"instant",
71+
"instant_date",
72+
"key_period_size",
73+
"period",
74+
"str_by_instant_cache",
75+
"types",
76+
"unit_weight",
77+
"unit_weights",
78+
"year_or_month_or_day_re",
79+
]

openfisca_core/periods/_errors.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pendulum.parsing.exceptions import ParserError
2+
3+
4+
class InstantError(ValueError):
5+
"""Raised when an invalid instant-like is provided."""
6+
7+
def __init__(self, value: str) -> None:
8+
msg = (
9+
f"'{value}' is not a valid instant string. Instants are described "
10+
"using either the 'YYYY-MM-DD' format, for instance '2015-06-15', "
11+
"or the 'YYYY-Www-D' format, for instance '2015-W24-1'."
12+
)
13+
super().__init__(msg)
14+
15+
16+
class PeriodError(ValueError):
17+
"""Raised when an invalid period-like is provided."""
18+
19+
def __init__(self, value: str) -> None:
20+
msg = (
21+
"Expected a period (eg. '2017', 'month:2017-01', 'week:2017-W01-1:3', "
22+
f"...); got: '{value}'. Learn more about legal period formats in "
23+
"OpenFisca: <https://openfisca.org/doc/coding-the-legislation/35_periods.html#periods-in-simulations>."
24+
)
25+
super().__init__(msg)
26+
27+
28+
__all__ = ["InstantError", "ParserError", "PeriodError"]

openfisca_core/periods/_parsers.py

Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,92 @@
1-
from typing import Optional
1+
"""To parse periods and instants from strings."""
22

3-
import re
3+
from __future__ import annotations
4+
5+
import datetime
46

57
import pendulum
6-
from pendulum.datetime import Date
7-
from pendulum.parsing import ParserError
88

9+
from . import types as t
10+
from ._errors import InstantError, ParserError, PeriodError
911
from .date_unit import DateUnit
1012
from .instant_ import Instant
1113
from .period_ import Period
1214

13-
invalid_week = re.compile(r".*(W[1-9]|W[1-9]-[0-9]|W[0-5][0-9]-0)$")
1415

16+
def parse_instant(value: str) -> t.Instant:
17+
"""Parse a string into an instant.
18+
19+
Args:
20+
value (str): The string to parse.
21+
22+
Returns:
23+
An InstantStr.
24+
25+
Raises:
26+
InstantError: When the string is not a valid ISO Calendar/Format.
27+
ParserError: When the string couldn't be parsed.
28+
29+
Examples:
30+
>>> parse_instant("2022")
31+
Instant((2022, 1, 1))
32+
33+
>>> parse_instant("2022-02")
34+
Instant((2022, 2, 1))
35+
36+
>>> parse_instant("2022-W02-7")
37+
Instant((2022, 1, 16))
1538
16-
def _parse_period(value: str) -> Optional[Period]:
39+
>>> parse_instant("2022-W013")
40+
Traceback (most recent call last):
41+
openfisca_core.periods._errors.InstantError: '2022-W013' is not a va...
42+
43+
>>> parse_instant("2022-02-29")
44+
Traceback (most recent call last):
45+
pendulum.parsing.exceptions.ParserError: Unable to parse string [202...
46+
47+
"""
48+
49+
if not isinstance(value, t.InstantStr):
50+
raise InstantError(str(value))
51+
52+
date = pendulum.parse(value, exact=True)
53+
54+
if not isinstance(date, datetime.date):
55+
msg = f"Unable to parse string [{value}]"
56+
raise ParserError(msg)
57+
58+
return Instant((date.year, date.month, date.day))
59+
60+
61+
def parse_period(value: str) -> t.Period:
1762
"""Parses ISO format/calendar periods.
1863
1964
Such as "2012" or "2015-03".
2065
2166
Examples:
22-
>>> _parse_period("2022")
67+
>>> parse_period("2022")
2368
Period((<DateUnit.YEAR: 'year'>, Instant((2022, 1, 1)), 1))
2469
25-
>>> _parse_period("2022-02")
70+
>>> parse_period("2022-02")
2671
Period((<DateUnit.MONTH: 'month'>, Instant((2022, 2, 1)), 1))
2772
28-
>>> _parse_period("2022-W02-7")
73+
>>> parse_period("2022-W02-7")
2974
Period((<DateUnit.WEEKDAY: 'weekday'>, Instant((2022, 1, 16)), 1))
3075
3176
"""
32-
# If it's a complex period, next!
33-
if len(value.split(":")) != 1:
34-
return None
3577

36-
# Check for a non-empty string.
37-
if not (value and isinstance(value, str)):
38-
raise AttributeError
78+
try:
79+
instant = parse_instant(value)
3980

40-
# If it's negative, next!
41-
if value[0] == "-":
42-
raise ValueError
81+
except InstantError as error:
82+
raise PeriodError(value) from error
4383

44-
# If it's an invalid week, next!
45-
if invalid_week.match(value):
46-
raise ParserError
47-
48-
unit = _parse_unit(value)
49-
date = pendulum.parse(value, exact=True)
50-
51-
if not isinstance(date, Date):
52-
raise ValueError
53-
54-
instant = Instant((date.year, date.month, date.day))
84+
unit = parse_unit(value)
5585

5686
return Period((unit, instant, 1))
5787

5888

59-
def _parse_unit(value: str) -> DateUnit:
89+
def parse_unit(value: str) -> t.DateUnit:
6090
"""Determine the date unit of a date string.
6191
6292
Args:
@@ -66,32 +96,26 @@ def _parse_unit(value: str) -> DateUnit:
6696
A DateUnit.
6797
6898
Raises:
69-
ValueError when no DateUnit can be determined.
99+
InstantError: when no DateUnit can be determined.
70100
71101
Examples:
72-
>>> _parse_unit("2022")
102+
>>> parse_unit("2022")
73103
<DateUnit.YEAR: 'year'>
74104
75-
>>> _parse_unit("2022-W03-01")
105+
>>> parse_unit("2022-W03-1")
76106
<DateUnit.WEEKDAY: 'weekday'>
77107
78108
"""
79-
length = len(value.split("-"))
80-
isweek = value.find("W") != -1
81109

82-
if length == 1:
83-
return DateUnit.YEAR
110+
if not isinstance(value, t.InstantStr):
111+
raise InstantError(str(value))
84112

85-
if length == 2:
86-
if isweek:
87-
return DateUnit.WEEK
113+
length = len(value.split("-"))
88114

89-
return DateUnit.MONTH
115+
if isinstance(value, t.ISOCalendarStr):
116+
return DateUnit.isocalendar[-length]
90117

91-
if length == 3:
92-
if isweek:
93-
return DateUnit.WEEKDAY
118+
return DateUnit.isoformat[-length]
94119

95-
return DateUnit.DAY
96120

97-
raise ValueError
121+
__all__ = ["parse_instant", "parse_period", "parse_unit"]

openfisca_core/periods/config.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import re
22

3-
from .date_unit import DateUnit
3+
import pendulum
44

5-
WEEKDAY = DateUnit.WEEKDAY
6-
WEEK = DateUnit.WEEK
7-
DAY = DateUnit.DAY
8-
MONTH = DateUnit.MONTH
9-
YEAR = DateUnit.YEAR
10-
ETERNITY = DateUnit.ETERNITY
5+
from . import types as t
116

127
# Matches "2015", "2015-01", "2015-01-01"
138
# Does not match "2015-13", "2015-12-32"
149
INSTANT_PATTERN = re.compile(
1510
r"^\d{4}(-(0[1-9]|1[012]))?(-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))?$",
1611
)
1712

18-
date_by_instant_cache: dict = {}
19-
str_by_instant_cache: dict = {}
13+
date_by_instant_cache: dict[t.Instant, pendulum.Date] = {}
14+
str_by_instant_cache: dict[t.Instant, t.InstantStr] = {}
2015
year_or_month_or_day_re = re.compile(
2116
r"(18|19|20)\d{2}(-(0?[1-9]|1[0-2])(-([0-2]?\d|3[0-1]))?)?$",
2217
)
18+
19+
20+
__all__ = ["INSTANT_PATTERN", "date_by_instant_cache", "str_by_instant_cache"]

0 commit comments

Comments
 (0)