diff --git a/CHANGELOG.md b/CHANGELOG.md index fed09d73c6..f6a7f9b5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +### 35.5.4 [#1033](https://github.com/openfisca/openfisca-core/pull/1033) + +#### Bug Fixes + +- Fix doctests of the commons module + +#### Dependencies + +- `darglint`, `flake8-docstrings`, & `pylint` + - For automatic docstring linting & validation. + ### 35.5.3 [#1020](https://github.com/openfisca/openfisca-core/pull/1020) #### Technical changes diff --git a/openfisca_core/commons/formulas.py b/openfisca_core/commons/formulas.py index 4fadc1b518..5b49387fde 100644 --- a/openfisca_core/commons/formulas.py +++ b/openfisca_core/commons/formulas.py @@ -5,17 +5,15 @@ def apply_thresholds(input, thresholds, choices): """ Return one of the choices depending on the input position compared to thresholds, for each input. - >>> apply_thresholds(np.array([4]), [5, 7], [10, 15, 20]) - array([10]) - >>> apply_thresholds(np.array([5]), [5, 7], [10, 15, 20]) - array([10]) - >>> apply_thresholds(np.array([6]), [5, 7], [10, 15, 20]) - array([15]) - >>> apply_thresholds(np.array([8]), [5, 7], [10, 15, 20]) - array([20]) - >>> apply_thresholds(np.array([10]), [5, 7, 9], [10, 15, 20]) - array([0]) + Examples: + >>> input = numpy.array([4, 5, 6, 7, 8]) + >>> thresholds = [5, 7] + >>> choices = [10, 15, 20] + >>> apply_thresholds(input, thresholds, choices) + array([10, 10, 15, 15, 20]) + """ + condlist = [input <= threshold for threshold in thresholds] if len(condlist) == len(choices) - 1: # If a choice is provided for input > highest threshold, last condition must be true to return it. @@ -26,6 +24,16 @@ def apply_thresholds(input, thresholds, choices): def concat(this, that): + """ + + Examples: + >>> this = ["this", "that"] + >>> that = numpy.array([1, 2.5]) + >>> concat(this, that) + array(['this1.0', 'that2.5']...) + + """ + if isinstance(this, numpy.ndarray) and not numpy.issubdtype(this.dtype, numpy.str): this = this.astype('str') if isinstance(that, numpy.ndarray) and not numpy.issubdtype(that.dtype, numpy.str): @@ -39,10 +47,14 @@ def switch(conditions, value_by_condition): Reproduces a switch statement: given an array of conditions, return an array of the same size replacing each condition item by the corresponding given value. - Example: - >>> switch(np.array([1, 1, 1, 2]), {1: 80, 2: 90}) + Examples: + >>> conditions = numpy.array([1, 1, 1, 2]) + >>> value_by_condition = {1: 80, 2: 90} + >>> switch(conditions, value_by_condition) array([80, 80, 80, 90]) + ''' + assert len(value_by_condition) > 0, \ "switch must be called with at least one value" condlist = [ diff --git a/openfisca_core/commons/misc.py b/openfisca_core/commons/misc.py index eb2bc7372c..5dd54f70c5 100644 --- a/openfisca_core/commons/misc.py +++ b/openfisca_core/commons/misc.py @@ -2,7 +2,23 @@ def empty_clone(original): - """Create a new empty instance of the same class of the original object.""" + """Create a new empty instance of the same class of the original object. + + Examples: + >>> Foo = type("Foo", (list,), {}) + >>> foo = Foo([1, 2, 3]) + >>> foo + [1, 2, 3] + + >>> bar = empty_clone(foo) + >>> bar + [] + + >>> isinstance(bar, Foo) + True + + """ + class Dummy(original.__class__): def __init__(self) -> None: pass @@ -15,6 +31,24 @@ def __init__(self) -> None: def stringify_array(array: numpy.ndarray) -> str: """ Generate a clean string representation of a NumPY array. + + Examples: + >>> import numpy + >>> stringify_array(None) + 'None' + + >>> array = numpy.array([10, 20.]) + >>> stringify_array(array) + '[10.0, 20.0]' + + >>> array = numpy.array(["10", "Twenty"]) + >>> stringify_array(array) + '[10, Twenty]' + + >>> array = numpy.array([list, dict(), stringify_array]) + >>> stringify_array(array) + "[, {}, >> target = numpy.array([1, 2, 3]) + >>> varying = [2, 2, 2] + >>> trim = [-1, .25] + >>> average_rate(target, varying, trim) + array([ nan, 0. , -0.5]) + ''' + average_rate = 1 - target / varying if trim is not None: average_rate = numpy.where(average_rate <= max(trim), average_rate, numpy.nan) @@ -18,6 +27,17 @@ def average_rate(target = None, varying = None, trim = None): def marginal_rate(target = None, varying = None, trim = None): + """ + + Examples: + >>> target = numpy.array([1, 2, 3]) + >>> varying = numpy.array([1, 2, 4]) + >>> trim = [.25, .75] + >>> marginal_rate(target, varying, trim) + array([nan, 0.5]) + + """ + # target: numerator, varying: denominator marginal_rate = 1 - (target[:-1] - target[1:]) / (varying[:-1] - varying[1:]) if trim is not None: diff --git a/openfisca_core/commons/tests/__init__.py b/openfisca_core/commons/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/commons/tests/test_dummy.py b/openfisca_core/commons/tests/test_dummy.py new file mode 100644 index 0000000000..d4ecec3842 --- /dev/null +++ b/openfisca_core/commons/tests/test_dummy.py @@ -0,0 +1,10 @@ +import pytest + +from openfisca_core.commons import Dummy + + +def test_dummy_deprecation(): + """Dummy throws a deprecation warning when instantiated.""" + + with pytest.warns(DeprecationWarning): + assert Dummy() diff --git a/openfisca_core/commons/tests/test_formulas.py b/openfisca_core/commons/tests/test_formulas.py new file mode 100644 index 0000000000..f05725cb80 --- /dev/null +++ b/openfisca_core/commons/tests/test_formulas.py @@ -0,0 +1,81 @@ +import numpy +import pytest +from numpy.testing import assert_array_equal + +from openfisca_core import commons + + +def test_apply_thresholds_when_several_inputs(): + """Makes a choice for any given input.""" + + input_ = numpy.array([4, 5, 6, 7, 8, 9, 10]) + thresholds = [5, 7, 9] + choices = [10, 15, 20, 25] + + result = commons.apply_thresholds(input_, thresholds, choices) + + assert_array_equal(result, [10, 10, 15, 15, 20, 20, 25]) + + +def test_apply_thresholds_when_too_many_thresholds(): + """Raises an AssertionError when thresholds > choices.""" + + input_ = numpy.array([6]) + thresholds = [5, 7, 9, 11] + choices = [10, 15, 20] + + with pytest.raises(AssertionError): + assert commons.apply_thresholds(input_, thresholds, choices) + + +def test_apply_thresholds_when_too_many_choices(): + """Raises an AssertionError when thresholds < choices - 1.""" + + input_ = numpy.array([6]) + thresholds = [5, 7] + choices = [10, 15, 20, 25] + + with pytest.raises(AssertionError): + assert commons.apply_thresholds(input_, thresholds, choices) + + +def test_concat_when_this_is_array_not_str(): + """Casts ``this`` to ``str`` when it is a numpy array other than string.""" + + this = numpy.array([1, 2]) + that = numpy.array(["la", "o"]) + + result = commons.concat(this, that) + + assert_array_equal(result, ["1la", "2o"]) + + +def test_concat_when_that_is_array_not_str(): + """Casts ``that`` to ``str`` when it is a numpy array other than string.""" + + this = numpy.array(["ho", "cha"]) + that = numpy.array([1, 2]) + + result = commons.concat(this, that) + + assert_array_equal(result, ["ho1", "cha2"]) + + +def test_concat_when_args_not_str_array_like(): + """Raises a TypeError when args are not a string array-like object.""" + + this = (1, 2) + that = (3, 4) + + with pytest.raises(TypeError): + commons.concat(this, that) + + +def test_switch_when_values_are_empty(): + """Raises an AssertionError when the values are empty.""" + + conditions = [1, 1, 1, 2] + value_by_condition = {} + + with pytest.raises(AssertionError): + assert commons.switch(conditions, value_by_condition) diff --git a/openfisca_core/commons/tests/test_rates.py b/openfisca_core/commons/tests/test_rates.py new file mode 100644 index 0000000000..e603a05241 --- /dev/null +++ b/openfisca_core/commons/tests/test_rates.py @@ -0,0 +1,26 @@ +import numpy +from numpy.testing import assert_array_equal + +from openfisca_core import commons + + +def test_average_rate_when_varying_is_zero(): + """Yields infinity when the varying gross income crosses zero.""" + + target = numpy.array([1, 2, 3]) + varying = [0, 0, 0] + + result = commons.average_rate(target, varying) + + assert_array_equal(result, [- numpy.inf, - numpy.inf, - numpy.inf]) + + +def test_marginal_rate_when_varying_is_zero(): + """Yields infinity when the varying gross income crosses zero.""" + + target = numpy.array([1, 2, 3]) + varying = numpy.array([0, 0, 0]) + + result = commons.marginal_rate(target, varying) + + assert_array_equal(result, [numpy.inf, numpy.inf]) diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk index 8be8dd71ce..a2fe4b03a0 100644 --- a/openfisca_tasks/lint.mk +++ b/openfisca_tasks/lint.mk @@ -1,5 +1,5 @@ ## Lint the codebase. -lint: check-syntax-errors check-style check-types +lint: check-syntax-errors check-style lint-doc check-types @$(call print_pass,$@:) ## Compile python files to check for syntax errors. @@ -14,10 +14,34 @@ check-style: $(shell git ls-files "*.py") @flake8 $? @$(call print_pass,$@:) +## Run linters to check for syntax and style errors in the doc. +lint-doc: \ + lint-doc-commons \ + lint-doc-types \ + ; + +## Run linters to check for syntax and style errors in the doc. +lint-doc-%: + @## These checks are exclusively related to doc/strings/test. + @## + @## They can be integrated into setup.cfg once all checks pass. + @## The reason they're here is because otherwise we wouldn't be + @## able to integrate documentation improvements incrementally. + @## + @## D101: Each class has to have at least one doctest. + @## D102: Each public method has to have at least one doctest. + @## D103: Each public function has to have at least one doctest. + @## DARXXX: https://github.com/terrencepreilly/darglint#error-codes. + @## + @$(call print_help,$(subst $*,%,$@:)) + @flake8 --select=D101,D102,D103,DAR openfisca_core/$* + @pylint openfisca_core/$* + @$(call print_pass,$@:) + ## Run static type checkers for type errors. -check-types: openfisca_core openfisca_web_api +check-types: @$(call print_help,$@:) - @mypy $? + @mypy --package openfisca_core --package openfisca_web_api @$(call print_pass,$@:) ## Run code formatters to correct style errors. diff --git a/openfisca_tasks/test_code.mk b/openfisca_tasks/test_code.mk index 8a24ad69b1..ffa87efbc4 100644 --- a/openfisca_tasks/test_code.mk +++ b/openfisca_tasks/test_code.mk @@ -19,7 +19,7 @@ test-code: test-core test-country test-extension ## Run openfisca-core tests. test-core: $(shell git ls-files "tests/*.py") @$(call print_help,$@:) - @PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=openfisca_core ${pytest_args}" \ + @PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=openfisca_core --cov=openfisca_web_api ${pytest_args}" \ openfisca test $? \ ${openfisca_args} @$(call print_pass,$@:) diff --git a/openfisca_tasks/test_doc.mk b/openfisca_tasks/test_doc.mk index fb7249c99e..bce952fe81 100644 --- a/openfisca_tasks/test_doc.mk +++ b/openfisca_tasks/test_doc.mk @@ -62,14 +62,17 @@ test-doc-checkout: } \ || git pull --ff-only origin master ; \ } 1> /dev/null + @$(call print_pass,$@:) ## Install doc dependencies. test-doc-install: @$(call print_help,$@:) @pip install --requirement doc/requirements.txt 1> /dev/null @pip install --editable .[dev] --upgrade 1> /dev/null + @$(call print_pass,$@:) ## Dry-build the doc. test-doc-build: @$(call print_help,$@:) @sphinx-build -M dummy doc/source doc/build -n -q -W + @$(call print_pass,$@:) diff --git a/setup.cfg b/setup.cfg index 4f98591eeb..75004da81c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,23 +1,41 @@ -; E128/133: We prefer hang-closing visual indents -; E251: We prefer `function(x = 1)` over `function(x=1)` -; E501: We do not enforce a maximum line length -; F403/405: We ignore * imports -; W503/504: We break lines before binary operators (Knuth's style) +; DXXX: We do not (yet) check docstrings (see https://www.pydocstyle.org/en/2.1.1/error_codes.html#grouping). +; DAR101: We do not (yet) document class/function attributes/arguments. +; DAR201: We do not (yet) document method/function returns. +; E128/133: We prefer hang-closing visual indents. +; E251: We prefer `function(x = 1)` over `function(x=1)`. +; E501: We do not enforce a maximum line length. +; F403/405: We ignore * imports. +; W503/504: We break lines before binary operators (Knuth's style). [flake8] -hang-closing = true -ignore = E128,E251,F403,F405,E501,W503,W504 -in-place = true -rst-roles = any, class, exc, meth, obj -rst-directives = attribute +hang-closing = true +extend-ignore = D,DAR101,DAR201 +ignore = E128,E251,F403,F405,E501,W503,W504 +in-place = true +include-in-doctest = openfisca_core/commons openfisca_core/types +rst-directives = attribute, deprecated, seealso, versionadded, versionchanged +rst-roles = any, attr, class, exc, func, meth, obj +strictness = short + +; C0116: We document public functions. +; R0401: We avoid cyclic imports —required for unit/doc tests. + +[pylint.message_control] +disable = all +enable = C0116,R0401 +score = no [tool:pytest] -addopts = --showlocals --doctest-modules --disable-pytest-warnings -testpaths = tests -python_files = **/*.py +addopts = --cov-report=term-missing:skip-covered --doctest-modules --disable-pytest-warnings --showlocals +doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL NUMBER NORMALIZE_WHITESPACE +python_files = **/*.py +testpaths = openfisca_core/commons tests [mypy] ignore_missing_imports = True +[mypy-openfisca_core.commons.tests.*] +ignore_errors = True + [mypy-openfisca_core.scripts.*] ignore_errors = True diff --git a/setup.py b/setup.py index 1935635464..66b49c1377 100644 --- a/setup.py +++ b/setup.py @@ -7,36 +7,39 @@ general_requirements = [ 'dpath >= 1.5.0, < 2.0.0', - 'pytest >= 4.4.1, < 6.0.0', # For openfisca test + 'numexpr >= 2.7.0, <= 3.0', 'numpy >= 1.11, < 1.21', 'psutil >= 5.4.7, < 6.0.0', + 'pytest >= 4.4.1, < 6.0.0', # For openfisca test 'PyYAML >= 3.10', 'sortedcontainers == 2.2.2', - 'numexpr >= 2.7.0, <= 3.0', ] api_requirements = [ - 'werkzeug >= 1.0.0, < 2.0.0', 'flask == 1.1.2', 'flask-cors == 3.0.10', 'gunicorn >= 20.0.0, < 21.0.0', + 'werkzeug >= 1.0.0, < 2.0.0', ] dev_requirements = [ 'autopep8 >= 1.4.0, < 1.6.0', + 'darglint == 1.8.0', 'flake8 >= 3.9.0, < 4.0.0', 'flake8-bugbear >= 19.3.0, < 20.0.0', + 'flake8-docstrings == 1.6.0', 'flake8-print >= 3.1.0, < 4.0.0', 'flake8-rst-docstrings < 1.0.0', - 'pytest-cov >= 2.6.1, < 3.0.0', 'mypy >= 0.701, < 0.800', 'openfisca-country-template >= 3.10.0, < 4.0.0', - 'openfisca-extension-template >= 1.2.0rc0, < 2.0.0' + 'openfisca-extension-template >= 1.2.0rc0, < 2.0.0', + 'pylint == 2.10.2', + 'pytest-cov >= 2.6.1, < 3.0.0', ] + api_requirements setup( name = 'OpenFisca-Core', - version = '35.5.3', + version = '35.5.4', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [ diff --git a/tests/core/test_commons.py b/tests/core/test_commons.py deleted file mode 100644 index ddbf30e5a9..0000000000 --- a/tests/core/test_commons.py +++ /dev/null @@ -1,36 +0,0 @@ -import numpy - -from openfisca_core import commons - -import pytest - - -def test_dummy(): - with pytest.warns(DeprecationWarning): - result = commons.Dummy() - assert result - - -def test_empty_clone(): - dummy_class = type("Dummmy", (), {}) - dummy = dummy_class() - - result = commons.empty_clone(dummy) - - assert type(result) == dummy_class - - -def test_stringify_array(): - array = numpy.array([10, 20]) - - result = commons.stringify_array(array) - - assert result == "[10, 20]" - - -def test_stringify_array_when_none(): - array = None - - result = commons.stringify_array(array) - - assert result == "None" diff --git a/tests/core/test_formula_helpers.py b/tests/core/test_formula_helpers.py deleted file mode 100644 index 51bc2a2e20..0000000000 --- a/tests/core/test_formula_helpers.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy -import pytest - -from openfisca_core.formula_helpers import apply_thresholds as apply_thresholds -from openfisca_core.tools import assert_near - - -def test_apply_thresholds_with_too_many_thresholds(): - input = numpy.array([10]) - thresholds = [5, 4] - choice_list = [10] - with pytest.raises(AssertionError): - return apply_thresholds(input, thresholds, choice_list) - - -def test_apply_thresholds_with_too_few_thresholds(): - input = numpy.array([10]) - thresholds = [5] - choice_list = [10, 15, 20] - with pytest.raises(AssertionError): - return apply_thresholds(input, thresholds, choice_list) - - -def test_apply_thresholds(): - input = numpy.array([4, 5, 6, 7, 8]) - thresholds = [5, 7] - choice_list = [10, 15, 20] - result = apply_thresholds(input, thresholds, choice_list) - assert_near(result, [10, 10, 15, 15, 20]) - - -def test_apply_thresholds_with_as_many_thresholds_than_choices(): - input = numpy.array([4, 6, 8]) - thresholds = [5, 7] - choice_list = [10, 20] - result = apply_thresholds(input, thresholds, choice_list) - assert_near(result, [10, 20, 0]) - - -def test_apply_thresholds_with_variable_threshold(): - input = numpy.array([1000, 1000, 1000]) - thresholds = [numpy.array([500, 1500, 1000])] # Only one thresold, but varies with the person - choice_list = [True, False] # True if input <= threshold, false otherwise - result = apply_thresholds(input, thresholds, choice_list) - assert_near(result, [False, True, True]) diff --git a/tests/core/test_rates.py b/tests/core/test_rates.py deleted file mode 100644 index 8ab2170954..0000000000 --- a/tests/core/test_rates.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy - -from openfisca_core.rates import average_rate - - -def test_average_rate(): - '''Compute the average tax rate when the gross income is never zero''' - target = numpy.array([1, 2, 3]) - result = average_rate(target, varying = 2) - expected = numpy.array([.5, 0, -.5]) - numpy.testing.assert_equal(result, expected) - - -def test_average_rate_when_varying_is_zero(): - '''Compute the average tax rate when the varying gross income cross zero (yields infinity)''' - target = numpy.array([1, 2, 3]) - result = average_rate(target, varying = 0) - assert numpy.isinf(result[0]).all()