Skip to content

Commit 443bb44

Browse files
authored
Re-extend NumPy compatibility to v1.20
Merge pull request #1010 from openfisca/numpy-typing
2 parents aefc897 + c7be456 commit 443bb44

26 files changed

Lines changed: 693 additions & 233 deletions

.circleci/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ jobs:
3333
name: Run Core tests
3434
command: env PYTEST_ADDOPTS="--exitfirst" make test
3535

36+
- run:
37+
name: Check NumPy typing against latest 3 minor versions
38+
command: for i in {1..3}; do VERSION=$(.circleci/get-numpy-version.py prev) && pip install numpy==$VERSION && make check-types; done
39+
3640
- persist_to_workspace:
3741
root: .
3842
paths:

.circleci/get-numpy-version.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#! /usr/bin/env python
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
import typing
8+
from packaging import version
9+
from typing import NoReturn, Union
10+
11+
import numpy
12+
13+
if typing.TYPE_CHECKING:
14+
from packaging.version import LegacyVersion, Version
15+
16+
17+
def prev() -> NoReturn:
18+
release = _installed().release
19+
20+
if release is None:
21+
sys.exit(os.EX_DATAERR)
22+
23+
major, minor, _ = release
24+
25+
if minor == 0:
26+
sys.exit(os.EX_DATAERR)
27+
28+
minor -= 1
29+
print(f"{major}.{minor}.0") # noqa: T001
30+
sys.exit(os.EX_OK)
31+
32+
33+
def _installed() -> Union[LegacyVersion, Version]:
34+
return version.parse(numpy.__version__)
35+
36+
37+
if __name__ == "__main__":
38+
globals()[sys.argv[1]]()

CHANGELOG.md

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

3+
## 35.4.0 [#1010](https://github.com/openfisca/openfisca-core/pull/1010)
4+
5+
#### Technical changes
6+
7+
- Update dependencies (_as in 35.3.7_).
8+
- Extend NumPy compatibility to v1.20 to support M1 processors.
9+
10+
- Make NumPy's type-checking compatible with 1.17.0+
11+
- NumPy introduced their `typing` module since 1.20.0
12+
- Previous type hints relying on `annotations` will henceforward no longer work
13+
- This changes ensure that type hints are always legal for the last four minor NumPy versions
14+
315
### 35.3.8 [#1014](https://github.com/openfisca/openfisca-core/pull/1014)
416

517
#### Bug fix

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ This package contains the core features of OpenFisca, which are meant to be used
1717

1818
OpenFisca runs on Python 3.7. More recent versions should work, but are not tested.
1919

20+
OpenFisca also relies strongly on NumPy. Last four minor versions should work, but only latest/stable is tested.
21+
2022
## Installation
2123

2224
If you're developing your own country package, you don't need to explicitly install OpenFisca-Core. It just needs to appear [in your package dependencies](https://github.com/openfisca/openfisca-france/blob/18.2.1/setup.py#L53).
@@ -49,6 +51,18 @@ To run a single test:
4951
pytest tests/core/test_parameters.py -k test_parameter_for_period
5052
```
5153

54+
## Types
55+
56+
This repository relies on MyPy for optional dynamic & static type checking.
57+
58+
As NumPy introduced the `typing` module in 1.20.0, to ensure type hints do not break the code at runtime, we run the checker against the last four minor NumPy versions.
59+
60+
Type checking is already run with `make test`. To run the type checker alone:
61+
62+
```sh
63+
make check-types
64+
```
65+
5266
## Style
5367

5468
This repository adheres to a [certain coding style](STYLEGUIDE.md), and we invite you to follow it for your contributions to be integrated promptly.

STYLEGUIDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ Whenever possible we should expect:
4242
# Yes
4343

4444
import copy
45+
import typing
4546
from typing import List
4647

4748
import numpy
48-
from numpy.typing import ArrayLike
4949

5050
from openfisca_country_template import entities
5151

@@ -54,6 +54,10 @@ from openfisca_core.variables import Variable
5454

5555
from . import Something
5656

57+
if typing.TYPE_CHECKING:
58+
from numpy.typing import ArrayLike
59+
60+
5761
def do(this: List) -> ArrayLike:
5862
that = copy.deepcopy(this)
5963
array = numpy.ndarray(that)

openfisca_core/indexed_enums/enum.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import enum
4-
import typing
4+
from typing import Union
55

66
import numpy
77

@@ -10,37 +10,41 @@
1010

1111
class Enum(enum.Enum):
1212
"""
13-
Enum based on `enum34 <https://pypi.python.org/pypi/enum34/>`_, whose items have an
14-
index.
13+
Enum based on `enum34 <https://pypi.python.org/pypi/enum34/>`_, whose items
14+
have an index.
1515
"""
1616

1717
# Tweak enums to add an index attribute to each enum item
1818
def __init__(self, name: str) -> None:
19-
# When the enum item is initialized, self._member_names_ contains the names of
20-
# the previously initialized items, so its length is the index of this item.
19+
# When the enum item is initialized, self._member_names_ contains the
20+
# names of the previously initialized items, so its length is the index
21+
# of this item.
2122
self.index = len(self._member_names_)
2223

2324
# Bypass the slow Enum.__eq__
2425
__eq__ = object.__eq__
2526

26-
# In Python 3, __hash__ must be defined if __eq__ is defined to stay hashable.
27+
# In Python 3, __hash__ must be defined if __eq__ is defined to stay
28+
# hashable.
2729
__hash__ = object.__hash__
2830

2931
@classmethod
3032
def encode(
3133
cls,
32-
array: typing.Union[
34+
array: Union[
3335
EnumArray,
34-
numpy.ndarray[int],
35-
numpy.ndarray[str],
36-
numpy.ndarray[Enum],
36+
numpy.int_,
37+
numpy.float_,
38+
numpy.object_,
3739
],
3840
) -> EnumArray:
3941
"""
40-
Encode a string numpy array, an enum item numpy array, or an int numpy array
41-
into an :any:`EnumArray`. See :any:`EnumArray.decode` for decoding.
42+
Encode a string numpy array, an enum item numpy array, or an int numpy
43+
array into an :any:`EnumArray`. See :any:`EnumArray.decode` for
44+
decoding.
4245
43-
:param ndarray array: Array of string identifiers, or of enum items, to encode.
46+
:param ndarray array: Array of string identifiers, or of enum items, to
47+
encode.
4448
4549
:returns: An :any:`EnumArray` encoding the input array values.
4650
:rtype: :any:`EnumArray`
@@ -59,24 +63,31 @@ def encode(
5963
>>> encoded_array[0]
6064
2 # Encoded value
6165
"""
62-
if type(array) is EnumArray:
66+
if isinstance(array, EnumArray):
6367
return array
6468

65-
if array.dtype.kind in {'U', 'S'}: # String array
69+
# String array
70+
if isinstance(array, numpy.ndarray) and \
71+
array.dtype.kind in {'U', 'S'}:
6672
array = numpy.select(
6773
[array == item.name for item in cls],
6874
[item.index for item in cls],
6975
).astype(config.ENUM_ARRAY_DTYPE)
7076

71-
elif array.dtype.kind == 'O': # Enum items arrays
77+
# Enum items arrays
78+
elif isinstance(array, numpy.ndarray) and \
79+
array.dtype.kind == 'O':
7280
# Ensure we are comparing the comparable. The problem this fixes:
7381
# On entering this method "cls" will generally come from
74-
# variable.possible_values, while the array values may come from directly
75-
# importing a module containing an Enum class. However, variables (and
76-
# hence their possible_values) are loaded by a call to load_module, which
77-
# gives them a different identity from the ones imported in the usual way.
78-
# So, instead of relying on the "cls" passed in, we use only its name to
79-
# check that the values in the array, if non-empty, are of the right type.
82+
# variable.possible_values, while the array values may come from
83+
# directly importing a module containing an Enum class. However,
84+
# variables (and hence their possible_values) are loaded by a call
85+
# to load_module, which gives them a different identity from the
86+
# ones imported in the usual way.
87+
#
88+
# So, instead of relying on the "cls" passed in, we use only its
89+
# name to check that the values in the array, if non-empty, are of
90+
# the right type.
8091
if len(array) > 0 and cls.__name__ is array[0].__class__.__name__:
8192
cls = array[0].__class__
8293

openfisca_core/indexed_enums/enum_array.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import typing
4+
from typing import Any, NoReturn, Optional, Type
45

56
import numpy
67

@@ -20,37 +21,37 @@ class EnumArray(numpy.ndarray):
2021
# https://docs.scipy.org/doc/numpy-1.13.0/user/basics.subclassing.html#slightly-more-realistic-example-attribute-added-to-existing-array.
2122
def __new__(
2223
cls,
23-
input_array: numpy.ndarray[int],
24-
possible_values: typing.Optional[typing.Type[Enum]] = None,
24+
input_array: numpy.int_,
25+
possible_values: Optional[Type[Enum]] = None,
2526
) -> EnumArray:
2627
obj = numpy.asarray(input_array).view(cls)
2728
obj.possible_values = possible_values
2829
return obj
2930

3031
# See previous comment
31-
def __array_finalize__(self, obj: typing.Optional[numpy.ndarray[int]]) -> None:
32+
def __array_finalize__(self, obj: Optional[numpy.int_]) -> None:
3233
if obj is None:
3334
return
3435

3536
self.possible_values = getattr(obj, "possible_values", None)
3637

37-
def __eq__(self, other: typing.Any) -> bool:
38-
# When comparing to an item of self.possible_values, use the item index to
39-
# speed up the comparison.
38+
def __eq__(self, other: Any) -> bool:
39+
# When comparing to an item of self.possible_values, use the item index
40+
# to speed up the comparison.
4041
if other.__class__.__name__ is self.possible_values.__name__:
4142
# Use view(ndarray) so that the result is a classic ndarray, not an
4243
# EnumArray.
4344
return self.view(numpy.ndarray) == other.index
4445

4546
return self.view(numpy.ndarray) == other
4647

47-
def __ne__(self, other: typing.Any) -> bool:
48+
def __ne__(self, other: Any) -> bool:
4849
return numpy.logical_not(self == other)
4950

50-
def _forbidden_operation(self, other: typing.Any) -> typing.NoReturn:
51+
def _forbidden_operation(self, other: Any) -> NoReturn:
5152
raise TypeError(
52-
"Forbidden operation. The only operations allowed on EnumArrays are "
53-
"'==' and '!='.",
53+
"Forbidden operation. The only operations allowed on EnumArrays "
54+
"are '==' and '!='.",
5455
)
5556

5657
__add__ = _forbidden_operation
@@ -62,7 +63,7 @@ def _forbidden_operation(self, other: typing.Any) -> typing.NoReturn:
6263
__and__ = _forbidden_operation
6364
__or__ = _forbidden_operation
6465

65-
def decode(self) -> numpy.ndarray[Enum]:
66+
def decode(self) -> numpy.object_:
6667
"""
6768
Return the array of enum items corresponding to self.
6869
@@ -72,14 +73,16 @@ def decode(self) -> numpy.ndarray[Enum]:
7273
>>> enum_array[0]
7374
>>> 2 # Encoded value
7475
>>> enum_array.decode()[0]
75-
<HousingOccupancyStatus.free_lodger: 'Free lodger'> # Decoded value : enum item
76+
<HousingOccupancyStatus.free_lodger: 'Free lodger'>
77+
78+
Decoded value: enum item
7679
"""
7780
return numpy.select(
7881
[self == item.index for item in self.possible_values],
7982
list(self.possible_values),
8083
)
8184

82-
def decode_to_str(self) -> numpy.ndarray[str]:
85+
def decode_to_str(self) -> numpy.str_:
8386
"""
8487
Return the array of string identifiers corresponding to self.
8588

openfisca_core/taxscales/abstract_rate_tax_scale.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,36 @@
33
import typing
44
import warnings
55

6-
import numpy
7-
86
from openfisca_core.taxscales import RateTaxScaleLike
97

8+
if typing.TYPE_CHECKING:
9+
import numpy
10+
11+
NumericalArray = typing.Union[numpy.int_, numpy.float_]
12+
1013

1114
class AbstractRateTaxScale(RateTaxScaleLike):
1215
"""
13-
Base class for various types of rate-based tax scales: marginal rate, linear
14-
average rate...
16+
Base class for various types of rate-based tax scales: marginal rate,
17+
linear average rate...
1518
"""
1619

17-
def __init__(self, name: typing.Optional[str] = None, option = None, unit = None) -> None:
20+
def __init__(
21+
self, name: typing.Optional[str] = None,
22+
option: typing.Any = None,
23+
unit: typing.Any = None,
24+
) -> None:
1825
message = [
19-
"The 'AbstractRateTaxScale' class has been deprecated since version",
20-
"34.7.0, and will be removed in the future.",
26+
"The 'AbstractRateTaxScale' class has been deprecated since",
27+
"version 34.7.0, and will be removed in the future.",
2128
]
29+
2230
warnings.warn(" ".join(message), DeprecationWarning)
23-
super(AbstractRateTaxScale, self).__init__(name, option, unit)
31+
super().__init__(name, option, unit)
2432

2533
def calc(
2634
self,
27-
tax_base: typing.Union[numpy.ndarray[int], numpy.ndarray[float]],
35+
tax_base: NumericalArray,
2836
right: bool,
2937
) -> typing.NoReturn:
3038
raise NotImplementedError(

0 commit comments

Comments
 (0)